From 051bae5dd2957205b6b9eebab47b324e4d69d1b7 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Sun, 23 Feb 2025 12:35:25 -0500 Subject: [PATCH] Settings store --- pkg/database/mocks/Store.go | 155 ++++++++++++++++++++++++++++++ pkg/database/querier.go | 3 + pkg/database/settings.sql.go | 42 ++++++++ pkg/rbac/entities/entities.go | 7 ++ pkg/rbac/policy/policy.go | 9 ++ pkg/server/server.go | 4 + pkg/settings/settings.go | 131 +++++++++++++++++++++++++ sql/postgres/queries/settings.sql | 11 +++ 8 files changed, 362 insertions(+) create mode 100644 pkg/database/settings.sql.go create mode 100644 pkg/settings/settings.go create mode 100644 sql/postgres/queries/settings.sql diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index 34021cd..adf3ff7 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -947,6 +947,53 @@ func (_c *Store_DeleteIncident_Call) RunAndReturn(run func(context.Context, uuid return _c } +// DeleteSetting provides a mock function with given fields: ctx, name +func (_m *Store) DeleteSetting(ctx context.Context, name string) error { + ret := _m.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for DeleteSetting") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_DeleteSetting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSetting' +type Store_DeleteSetting_Call struct { + *mock.Call +} + +// DeleteSetting is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *Store_Expecter) DeleteSetting(ctx interface{}, name interface{}) *Store_DeleteSetting_Call { + return &Store_DeleteSetting_Call{Call: _e.mock.On("DeleteSetting", ctx, name)} +} + +func (_c *Store_DeleteSetting_Call) Run(run func(ctx context.Context, name string)) *Store_DeleteSetting_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Store_DeleteSetting_Call) Return(_a0 error) *Store_DeleteSetting_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_DeleteSetting_Call) RunAndReturn(run func(context.Context, string) error) *Store_DeleteSetting_Call { + _c.Call.Return(run) + return _c +} + // DeleteShare provides a mock function with given fields: ctx, id func (_m *Store) DeleteShare(ctx context.Context, id string) error { ret := _m.Called(ctx, id) @@ -1989,6 +2036,65 @@ func (_c *Store_GetIncidentTalkgroups_Call) RunAndReturn(run func(context.Contex return _c } +// GetSetting provides a mock function with given fields: ctx, name +func (_m *Store) GetSetting(ctx context.Context, name string) ([]byte, error) { + ret := _m.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetSetting") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) ([]byte, error)); ok { + return rf(ctx, name) + } + if rf, ok := ret.Get(0).(func(context.Context, string) []byte); ok { + r0 = rf(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetSetting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSetting' +type Store_GetSetting_Call struct { + *mock.Call +} + +// GetSetting is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *Store_Expecter) GetSetting(ctx interface{}, name interface{}) *Store_GetSetting_Call { + return &Store_GetSetting_Call{Call: _e.mock.On("GetSetting", ctx, name)} +} + +func (_c *Store_GetSetting_Call) Run(run func(ctx context.Context, name string)) *Store_GetSetting_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Store_GetSetting_Call) Return(_a0 []byte, _a1 error) *Store_GetSetting_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetSetting_Call) RunAndReturn(run func(context.Context, string) ([]byte, error)) *Store_GetSetting_Call { + _c.Call.Return(run) + return _c +} + // GetShare provides a mock function with given fields: ctx, id func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) { ret := _m.Called(ctx, id) @@ -3804,6 +3910,55 @@ func (_c *Store_SetCallTranscript_Call) RunAndReturn(run func(context.Context, u return _c } +// SetSetting provides a mock function with given fields: ctx, name, updatedBy, value +func (_m *Store) SetSetting(ctx context.Context, name string, updatedBy *int32, value []byte) error { + ret := _m.Called(ctx, name, updatedBy, value) + + if len(ret) == 0 { + panic("no return value specified for SetSetting") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, *int32, []byte) error); ok { + r0 = rf(ctx, name, updatedBy, value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_SetSetting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSetting' +type Store_SetSetting_Call struct { + *mock.Call +} + +// SetSetting is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - updatedBy *int32 +// - value []byte +func (_e *Store_Expecter) SetSetting(ctx interface{}, name interface{}, updatedBy interface{}, value interface{}) *Store_SetSetting_Call { + return &Store_SetSetting_Call{Call: _e.mock.On("SetSetting", ctx, name, updatedBy, value)} +} + +func (_c *Store_SetSetting_Call) Run(run func(ctx context.Context, name string, updatedBy *int32, value []byte)) *Store_SetSetting_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(*int32), args[3].([]byte)) + }) + return _c +} + +func (_c *Store_SetSetting_Call) Return(_a0 error) *Store_SetSetting_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_SetSetting_Call) RunAndReturn(run func(context.Context, string, *int32, []byte) error) *Store_SetSetting_Call { + _c.Call.Return(run) + return _c +} + // SetTalkgroupTags provides a mock function with given fields: ctx, tags, systemID, tGID func (_m *Store) SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error { ret := _m.Called(ctx, tags, systemID, tGID) diff --git a/pkg/database/querier.go b/pkg/database/querier.go index f2c7e35..fd039be 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -26,6 +26,7 @@ type Querier interface { DeleteAPIKey(ctx context.Context, apiKey string) error DeleteCall(ctx context.Context, id uuid.UUID) error DeleteIncident(ctx context.Context, id uuid.UUID) error + DeleteSetting(ctx context.Context, name string) error DeleteShare(ctx context.Context, id string) error DeleteSystem(ctx context.Context, id int) error DeleteTalkgroup(ctx context.Context, systemID int32, tGID int32) error @@ -43,6 +44,7 @@ type Querier interface { GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error) GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) + GetSetting(ctx context.Context, name string) ([]byte, error) GetShare(ctx context.Context, id string) (Share, error) GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error) GetSharesPCount(ctx context.Context, owner *int32) (int64, error) @@ -71,6 +73,7 @@ type Querier interface { RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error) SetAppPrefs(ctx context.Context, appName string, prefs []byte, uid int) error SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error + SetSetting(ctx context.Context, name string, updatedBy *int32, value []byte) error SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults diff --git a/pkg/database/settings.sql.go b/pkg/database/settings.sql.go new file mode 100644 index 0000000..db15296 --- /dev/null +++ b/pkg/database/settings.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: settings.sql + +package database + +import ( + "context" +) + +const deleteSetting = `-- name: DeleteSetting :exec +DELETE FROM settings WHERE name = $1 +` + +func (q *Queries) DeleteSetting(ctx context.Context, name string) error { + _, err := q.db.Exec(ctx, deleteSetting, name) + return err +} + +const getSetting = `-- name: GetSetting :one +SELECT value FROM settings WHERE name = $1 +` + +func (q *Queries) GetSetting(ctx context.Context, name string) ([]byte, error) { + row := q.db.QueryRow(ctx, getSetting, name) + var value []byte + err := row.Scan(&value) + return value, err +} + +const setSetting = `-- name: SetSetting :exec +INSERT INTO settings (name, updated_by, value) VALUES ($1, $2, $3) + ON CONFLICT (name) DO UPDATE SET + value = $3, + updated_by = $2 +` + +func (q *Queries) SetSetting(ctx context.Context, name string, updatedBy *int32, value []byte) error { + _, err := q.db.Exec(ctx, setSetting, name, updatedBy, value) + return err +} diff --git a/pkg/rbac/entities/entities.go b/pkg/rbac/entities/entities.go index 62481a0..bccbf2c 100644 --- a/pkg/rbac/entities/entities.go +++ b/pkg/rbac/entities/entities.go @@ -23,6 +23,7 @@ const ( ResourceShare = "Share" ResourceAPIKey = "APIKey" ResourceCallStats = "CallStats" + ResourceSetting = "Setting" ActionRead = "read" ActionCreate = "create" @@ -97,3 +98,9 @@ func (s *SystemServiceSubject) String() string { func (s *SystemServiceSubject) GetRoles() []string { return []string{RoleSystem} } + +func IsSystemService(sub Subject) bool { + _, is := sub.(*SystemServiceSubject) + + return is +} diff --git a/pkg/rbac/policy/policy.go b/pkg/rbac/policy/policy.go index b6d8634..1c8c524 100644 --- a/pkg/rbac/policy/policy.go +++ b/pkg/rbac/policy/policy.go @@ -50,6 +50,9 @@ var Policy = &restrict.PolicyDefinition{ entities.ResourceCallStats: { &restrict.Permission{Action: entities.ActionRead}, }, + entities.ResourceSetting: { + &restrict.Permission{Action: entities.ActionRead}, + }, }, }, entities.RoleSubmitter: { @@ -108,6 +111,12 @@ var Policy = &restrict.PolicyDefinition{ &restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Action: entities.ActionDelete}, }, + entities.ResourceSetting: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionDelete}, + }, }, }, entities.RoleSystem: { diff --git a/pkg/server/server.go b/pkg/server/server.go index ec8d07b..b9c3497 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -20,6 +20,7 @@ import ( "dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/rest" "dynatron.me/x/stillbox/pkg/services" + "dynatron.me/x/stillbox/pkg/settings" "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/sources" @@ -57,6 +58,7 @@ type Server struct { share shares.Service rbac rbac.RBAC stats stats.Stats + settings settings.Store } func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { @@ -110,6 +112,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { incidents: incstore.NewStore(), rbac: rbacSvc, stats: statsSvc, + settings: settings.New(), } if cfg.DB.Partition.Enabled { @@ -176,6 +179,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context { ctx = shares.CtxWithStore(ctx, s.share) ctx = rbac.CtxWithRBAC(ctx, s.rbac) ctx = stats.CtxWithStats(ctx, s.stats) + ctx = settings.CtxWithStore(ctx, s.settings) return ctx } diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go new file mode 100644 index 0000000..b029cff --- /dev/null +++ b/pkg/settings/settings.go @@ -0,0 +1,131 @@ +package settings + +import ( + "context" + "encoding/json" + "errors" + + "dynatron.me/x/stillbox/internal/cache" + "dynatron.me/x/stillbox/pkg/database" + "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "dynatron.me/x/stillbox/pkg/services" + "dynatron.me/x/stillbox/pkg/users" +) + +var ( + ErrNoSetting = errors.New("no such setting") +) + +type Store interface { + // Get gets a setting and unmarshals it into dst. + Get(ctx context.Context, name string, dest interface{}) error + + // Set sets a setting. + Set(ctx context.Context, name string, val interface{}) error + + // Delete removes a setting. + Delete(ctx context.Context, name string) error +} + +type postgresStore struct { + c cache.Cache[string, []byte] +} + +type storeCtxKey string + +const StoreCtxKey storeCtxKey = "store" + +func CtxWithStore(ctx context.Context, s Store) context.Context { + return services.WithValue(ctx, StoreCtxKey, s) +} + +func FromCtx(ctx context.Context) Store { + s, ok := services.Value(ctx, StoreCtxKey).(Store) + if !ok { + panic("no settings store in context") + } + + return s +} + +func New() *postgresStore { + s := &postgresStore{ + c: cache.New[string, []byte](), + } + + return s +} + +func (s *postgresStore) Get(ctx context.Context, name string, dest interface{}) error { + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionRead)) + if err != nil { + return err + } + + ci, has := s.c.Get(name) + if !has { + db := database.FromCtx(ctx) + + ci, err = db.GetSetting(ctx, name) + if err != nil { + if database.IsNoRows(err) { + return ErrNoSetting + } + + return err + } + + s.c.Set(name, ci) + } + + return json.Unmarshal(ci, dest) +} + +func (s *postgresStore) Set(ctx context.Context, name string, val interface{}) error { + subj, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionCreate, entities.ActionUpdate)) + if err != nil { + return err + } + + b, err := json.Marshal(val) + if err != nil { + return err + } + + var uid *int32 + switch u := subj.(type) { + case *entities.SystemServiceSubject: + // uid remains null + case *users.User: + u, err := users.FromSubject(subj) + if err != nil { + return err + } + + uid = u.ID.Int32Ptr() + default: + return rbac.ErrBadSubject + } + + db := database.FromCtx(ctx) + + err = db.SetSetting(ctx, name, uid, b) + if err != nil { + return err + } + + s.c.Set(name, b) + + return nil +} + +func (s *postgresStore) Delete(ctx context.Context, name string) error { + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionDelete)) + if err != nil { + return err + } + + s.c.Delete(name) + return database.FromCtx(ctx).DeleteSetting(ctx, name) +} diff --git a/sql/postgres/queries/settings.sql b/sql/postgres/queries/settings.sql new file mode 100644 index 0000000..e4f99cf --- /dev/null +++ b/sql/postgres/queries/settings.sql @@ -0,0 +1,11 @@ +-- name: GetSetting :one +SELECT value FROM settings WHERE name = @name; + +-- name: SetSetting :exec +INSERT INTO settings (name, updated_by, value) VALUES (@name, @updated_by, @value) + ON CONFLICT (name) DO UPDATE SET + value = @value, + updated_by = @updated_by; + +-- name: DeleteSetting :exec +DELETE FROM settings WHERE name = @name;