diff --git a/pkg/alerting/alerting.go b/pkg/alerting/alerting.go index d78a0cf..e580aed 100644 --- a/pkg/alerting/alerting.go +++ b/pkg/alerting/alerting.go @@ -14,7 +14,7 @@ import ( "dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/notify" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" @@ -124,7 +124,7 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte // Go is the alerting loop. It does not start a goroutine. func (as *alerter) Go(ctx context.Context) { - ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "alerter"}) + ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"}) err := as.startBackfill(ctx) if err != nil { diff --git a/pkg/auth/apikey.go b/pkg/auth/apikey.go index f88c8f9..7b11b1f 100644 --- a/pkg/auth/apikey.go +++ b/pkg/auth/apikey.go @@ -7,7 +7,7 @@ import ( "time" "dynatron.me/x/stillbox/pkg/database" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "github.com/google/uuid" "github.com/rs/zerolog/log" @@ -16,10 +16,10 @@ import ( type apiKeyAuth interface { // CheckAPIKey validates the provided key and returns the API owner's users.UserID. // An error is returned if validation fails for any reason. - CheckAPIKey(ctx context.Context, key string) (rbac.Subject, error) + CheckAPIKey(ctx context.Context, key string) (entities.Subject, error) } -func (a *Auth) CheckAPIKey(ctx context.Context, key string) (rbac.Subject, error) { +func (a *Auth) CheckAPIKey(ctx context.Context, key string) (entities.Subject, error) { keyUuid, err := uuid.Parse(key) if err != nil { log.Error().Str("apikey", key).Msg("cannot parse key") diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index 97867b1..896918c 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -10,7 +10,7 @@ import ( "golang.org/x/crypto/bcrypt" "dynatron.me/x/stillbox/pkg/database" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/users" "github.com/go-chi/chi/v5" @@ -104,7 +104,7 @@ func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler { return } - ctx = rbac.CtxWithSubject(ctx, sub) + ctx = entities.CtxWithSubject(ctx, sub) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/pkg/calls/call.go b/pkg/calls/call.go index 090fa4f..3e7ab4c 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -8,7 +8,7 @@ import ( "dynatron.me/x/stillbox/internal/audio" "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/pb" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/users" @@ -57,6 +57,7 @@ type Call struct { Audio []byte `json:"audio,omitempty" relayOut:"audio,omitempty" filenameField:"AudioName"` AudioName string `json:"audioName,omitempty" relayOut:"audioName,omitempty"` AudioType string `json:"audioType,omitempty" relayOut:"audioType,omitempty"` + AudioURL *string `json:"audioURL,omitempty" relayOut:"audioURL,omitempty"` Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"` DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"` Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"` @@ -75,7 +76,7 @@ type Call struct { } func (c *Call) GetResourceName() string { - return rbac.ResourceCall + return entities.ResourceCall } func (c *Call) String() string { diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go index 218113d..85aed7a 100644 --- a/pkg/calls/callstore/store.go +++ b/pkg/calls/callstore/store.go @@ -3,6 +3,7 @@ package callstore import ( "context" "fmt" + "time" "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/jsontypes" @@ -10,6 +11,7 @@ import ( "dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/users" @@ -28,6 +30,9 @@ type Store interface { // CallAudio returns a CallAudio struct CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) + // Call returns the call's metadata. + Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) + // Calls gets paginated Calls. Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error) } @@ -81,7 +86,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams { } func (s *store) AddCall(ctx context.Context, call *calls.Call) error { - _, err := rbac.Check(ctx, call, rbac.WithActions(rbac.ActionCreate)) + _, err := rbac.Check(ctx, call, rbac.WithActions(entities.ActionCreate)) if err != nil { return err } @@ -119,7 +124,7 @@ func (s *store) AddCall(ctx context.Context, call *calls.Call) error { } func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceCall), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -139,6 +144,43 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, }, nil } +func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) { + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead)) + if err != nil { + return nil, err + } + + db := database.FromCtx(ctx) + + c, err := db.GetCall(ctx, id) + if err != nil { + return nil, err + } + + var sub *users.UserID + if c.Submitter != nil { + sub = common.PtrTo(users.UserID(*c.Submitter)) + } + + return &calls.Call{ + ID: c.ID, + Submitter: sub, + System: c.System, + Talkgroup: c.Talkgroup, + DateTime: c.CallDate.Time, + AudioName: common.ZeroIfNil(c.AudioName), + AudioType: common.ZeroIfNil(c.AudioType), + AudioURL: c.AudioUrl, + Duration: calls.CallDuration(time.Duration(common.ZeroIfNil(c.Duration)) * time.Millisecond), + Frequency: c.Frequency, + Frequencies: c.Frequencies, + Patches: c.Patches, + TalkgroupLabel: c.TGLabel, + TalkgroupGroup: c.TGGroup, + TGAlphaTag: c.TGAlphaTag, + }, nil +} + type CallsParams struct { common.Pagination Direction *common.SortDirection `json:"dir"` @@ -152,7 +194,7 @@ type CallsParams struct { } func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) { - _, err = rbac.Check(ctx, rbac.UseResource(rbac.ResourceCall), rbac.WithActions(rbac.ActionRead)) + _, err = rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead)) if err != nil { return nil, 0, err } @@ -215,7 +257,7 @@ func (s *store) Delete(ctx context.Context, id uuid.UUID) error { return err } - _, err = rbac.Check(ctx, &callOwn, rbac.WithActions(rbac.ActionDelete)) + _, err = rbac.Check(ctx, &callOwn, rbac.WithActions(entities.ActionDelete)) if err != nil { return err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 61dc344..63a4f78 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,17 +16,18 @@ type Configuration struct { } type Config struct { - BaseURL jsontypes.URL `yaml:"baseURL"` - DB DB `yaml:"db"` - CORS CORS `yaml:"cors"` - Auth Auth `yaml:"auth"` - Alerting Alerting `yaml:"alerting"` - Log []Logger `yaml:"log"` - Listen string `yaml:"listen"` - Public bool `yaml:"public"` - RateLimit RateLimit `yaml:"rateLimit"` - Notify Notify `yaml:"notify"` - Relay []Relay `yaml:"relay"` + BaseURL jsontypes.URL `yaml:"baseURL"` + DumpRoutes bool `yaml:"dumpRoutes"` + DB DB `yaml:"db"` + CORS CORS `yaml:"cors"` + Auth Auth `yaml:"auth"` + Alerting Alerting `yaml:"alerting"` + Log []Logger `yaml:"log"` + Listen string `yaml:"listen"` + Public bool `yaml:"public"` + RateLimit RateLimit `yaml:"rateLimit"` + Notify Notify `yaml:"notify"` + Relay []Relay `yaml:"relay"` } type Auth struct { diff --git a/pkg/database/calls.sql.go b/pkg/database/calls.sql.go index a26efe8..f86c2df 100644 --- a/pkg/database/calls.sql.go +++ b/pkg/database/calls.sql.go @@ -164,6 +164,71 @@ func (q *Queries) DeleteCall(ctx context.Context, id uuid.UUID) error { return err } +const getCall = `-- name: GetCall :one +SELECT + id, + submitter, + system, + talkgroup, + call_date, + audio_name, + audio_type, + audio_url, + duration, + frequency, + frequencies, + patches, + tg_label, + tg_alpha_tag, + tg_group, + source +FROM calls +WHERE id = $1 +` + +type GetCallRow struct { + ID uuid.UUID `json:"id"` + Submitter *int32 `json:"submitter"` + System int `json:"system"` + Talkgroup int `json:"talkgroup"` + CallDate pgtype.Timestamptz `json:"call_date"` + AudioName *string `json:"audio_name"` + AudioType *string `json:"audio_type"` + AudioUrl *string `json:"audio_url"` + Duration *int32 `json:"duration"` + Frequency int `json:"frequency"` + Frequencies []int `json:"frequencies"` + Patches []int `json:"patches"` + TGLabel *string `json:"tg_label"` + TGAlphaTag *string `json:"tg_alpha_tag"` + TGGroup *string `json:"tg_group"` + Source int `json:"source"` +} + +func (q *Queries) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) { + row := q.db.QueryRow(ctx, getCall, id) + var i GetCallRow + err := row.Scan( + &i.ID, + &i.Submitter, + &i.System, + &i.Talkgroup, + &i.CallDate, + &i.AudioName, + &i.AudioType, + &i.AudioUrl, + &i.Duration, + &i.Frequency, + &i.Frequencies, + &i.Patches, + &i.TGLabel, + &i.TGAlphaTag, + &i.TGGroup, + &i.Source, + ) + return i, err +} + const getCallAudioByID = `-- name: GetCallAudioByID :one SELECT c.call_date, diff --git a/pkg/database/incidents.sql.go b/pkg/database/incidents.sql.go index c39e6ca..b233955 100644 --- a/pkg/database/incidents.sql.go +++ b/pkg/database/incidents.sql.go @@ -40,6 +40,21 @@ func (q *Queries) AddToIncident(ctx context.Context, incidentID uuid.UUID, callI return err } +const callInIncident = `-- name: CallInIncident :one +SELECT EXISTS + (SELECT 1 FROM incidents_calls ic + WHERE + ic.incident_id = $1 AND + ic.call_id = $2) +` + +func (q *Queries) CallInIncident(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID) (bool, error) { + row := q.db.QueryRow(ctx, callInIncident, incidentID, callID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + const createIncident = `-- name: CreateIncident :one INSERT INTO incidents ( id, diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index 11f1ae2..ad4ad0a 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -278,6 +278,64 @@ func (_c *Store_BulkSetTalkgroupTags_Call) RunAndReturn(run func(context.Context return _c } +// CallInIncident provides a mock function with given fields: ctx, incidentID, callID +func (_m *Store) CallInIncident(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID) (bool, error) { + ret := _m.Called(ctx, incidentID, callID) + + if len(ret) == 0 { + panic("no return value specified for CallInIncident") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID) (bool, error)); ok { + return rf(ctx, incidentID, callID) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID) bool); ok { + r0 = rf(ctx, incidentID, callID) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID) error); ok { + r1 = rf(ctx, incidentID, callID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_CallInIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CallInIncident' +type Store_CallInIncident_Call struct { + *mock.Call +} + +// CallInIncident is a helper method to define mock.On call +// - ctx context.Context +// - incidentID uuid.UUID +// - callID uuid.UUID +func (_e *Store_Expecter) CallInIncident(ctx interface{}, incidentID interface{}, callID interface{}) *Store_CallInIncident_Call { + return &Store_CallInIncident_Call{Call: _e.mock.On("CallInIncident", ctx, incidentID, callID)} +} + +func (_c *Store_CallInIncident_Call) Run(run func(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID)) *Store_CallInIncident_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID), args[2].(uuid.UUID)) + }) + return _c +} + +func (_c *Store_CallInIncident_Call) Return(_a0 bool, _a1 error) *Store_CallInIncident_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_CallInIncident_Call) RunAndReturn(run func(context.Context, uuid.UUID, uuid.UUID) (bool, error)) *Store_CallInIncident_Call { + _c.Call.Return(run) + return _c +} + // CleanupSweptCalls provides a mock function with given fields: ctx, rangeStart, rangeEnd func (_m *Store) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) { ret := _m.Called(ctx, rangeStart, rangeEnd) @@ -1348,6 +1406,63 @@ func (_c *Store_GetAppPrefs_Call) RunAndReturn(run func(context.Context, string, return _c } +// GetCall provides a mock function with given fields: ctx, id +func (_m *Store) GetCall(ctx context.Context, id uuid.UUID) (database.GetCallRow, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetCall") + } + + var r0 database.GetCallRow + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (database.GetCallRow, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) database.GetCallRow); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(database.GetCallRow) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetCall_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCall' +type Store_GetCall_Call struct { + *mock.Call +} + +// GetCall is a helper method to define mock.On call +// - ctx context.Context +// - id uuid.UUID +func (_e *Store_Expecter) GetCall(ctx interface{}, id interface{}) *Store_GetCall_Call { + return &Store_GetCall_Call{Call: _e.mock.On("GetCall", ctx, id)} +} + +func (_c *Store_GetCall_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_GetCall_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID)) + }) + return _c +} + +func (_c *Store_GetCall_Call) Return(_a0 database.GetCallRow, _a1 error) *Store_GetCall_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetCall_Call) RunAndReturn(run func(context.Context, uuid.UUID) (database.GetCallRow, error)) *Store_GetCall_Call { + _c.Call.Return(run) + return _c +} + // GetCallAudioByID provides a mock function with given fields: ctx, id func (_m *Store) GetCallAudioByID(ctx context.Context, id uuid.UUID) (database.GetCallAudioByIDRow, error) { ret := _m.Called(ctx, id) @@ -1693,6 +1808,65 @@ func (_c *Store_GetIncidentOwner_Call) RunAndReturn(run func(context.Context, uu return _c } +// GetIncidentTalkgroups provides a mock function with given fields: ctx, incidentID +func (_m *Store) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]database.GetIncidentTalkgroupsRow, error) { + ret := _m.Called(ctx, incidentID) + + if len(ret) == 0 { + panic("no return value specified for GetIncidentTalkgroups") + } + + var r0 []database.GetIncidentTalkgroupsRow + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) ([]database.GetIncidentTalkgroupsRow, error)); ok { + return rf(ctx, incidentID) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) []database.GetIncidentTalkgroupsRow); ok { + r0 = rf(ctx, incidentID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.GetIncidentTalkgroupsRow) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, incidentID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetIncidentTalkgroups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIncidentTalkgroups' +type Store_GetIncidentTalkgroups_Call struct { + *mock.Call +} + +// GetIncidentTalkgroups is a helper method to define mock.On call +// - ctx context.Context +// - incidentID uuid.UUID +func (_e *Store_Expecter) GetIncidentTalkgroups(ctx interface{}, incidentID interface{}) *Store_GetIncidentTalkgroups_Call { + return &Store_GetIncidentTalkgroups_Call{Call: _e.mock.On("GetIncidentTalkgroups", ctx, incidentID)} +} + +func (_c *Store_GetIncidentTalkgroups_Call) Run(run func(ctx context.Context, incidentID uuid.UUID)) *Store_GetIncidentTalkgroups_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID)) + }) + return _c +} + +func (_c *Store_GetIncidentTalkgroups_Call) Return(_a0 []database.GetIncidentTalkgroupsRow, _a1 error) *Store_GetIncidentTalkgroups_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetIncidentTalkgroups_Call) RunAndReturn(run func(context.Context, uuid.UUID) ([]database.GetIncidentTalkgroupsRow, error)) *Store_GetIncidentTalkgroups_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) diff --git a/pkg/database/models.go b/pkg/database/models.go index 5080c95..585f860 100644 --- a/pkg/database/models.go +++ b/pkg/database/models.go @@ -85,6 +85,7 @@ type Share struct { ID string `json:"id,omitempty"` EntityType string `json:"entity_type,omitempty"` EntityID uuid.UUID `json:"entity_id,omitempty"` + EntityDate pgtype.Timestamptz `json:"entity_date,omitempty"` Owner int `json:"owner,omitempty"` Expiration pgtype.Timestamptz `json:"expiration,omitempty"` } diff --git a/pkg/database/partman/partman.go b/pkg/database/partman/partman.go index d19683b..391e284 100644 --- a/pkg/database/partman/partman.go +++ b/pkg/database/partman/partman.go @@ -13,7 +13,7 @@ import ( "dynatron.me/x/stillbox/internal/isoweek" "dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/database" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -135,7 +135,7 @@ func New(db database.Store, cfg config.Partition) (*partman, error) { var _ PartitionManager = (*partman)(nil) func (pm *partman) Go(ctx context.Context) { - ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "partman"}) + ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "partman"}) tick := time.NewTicker(CheckInterval) select { diff --git a/pkg/database/querier.go b/pkg/database/querier.go index d0a8553..d2cd4a9 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -16,6 +16,7 @@ type Querier interface { AddCall(ctx context.Context, arg AddCallParams) error AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) error + CallInIncident(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID) (bool, error) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error) CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error) @@ -32,12 +33,14 @@ type Querier interface { GetAPIKey(ctx context.Context, apiKey string) (GetAPIKeyRow, error) GetAllTalkgroupTags(ctx context.Context) ([]string, error) GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error) + GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) GetDatabaseSize(ctx context.Context) (string, error) GetIncident(ctx context.Context, id uuid.UUID) (Incident, error) 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) GetShare(ctx context.Context, id string) (Share, error) GetSystemName(ctx context.Context, systemID int) (string, error) GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error) diff --git a/pkg/database/share.sql.go b/pkg/database/share.sql.go index b7b76a3..fbd2829 100644 --- a/pkg/database/share.sql.go +++ b/pkg/database/share.sql.go @@ -17,15 +17,17 @@ INSERT INTO shares ( id, entity_type, entity_id, + entity_date, owner, expiration -) VALUES ($1, $2, $3, $4, $5) +) VALUES ($1, $2, $3, $4, $5, $6) ` type CreateShareParams struct { ID string `json:"id"` EntityType string `json:"entity_type"` EntityID uuid.UUID `json:"entity_id"` + EntityDate pgtype.Timestamptz `json:"entity_date"` Owner int `json:"owner"` Expiration pgtype.Timestamptz `json:"expiration"` } @@ -35,6 +37,7 @@ func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) error arg.ID, arg.EntityType, arg.EntityID, + arg.EntityDate, arg.Owner, arg.Expiration, ) @@ -55,6 +58,7 @@ SELECT id, entity_type, entity_id, + entity_date, owner, expiration FROM shares @@ -68,6 +72,7 @@ func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) { &i.ID, &i.EntityType, &i.EntityID, + &i.EntityDate, &i.Owner, &i.Expiration, ) diff --git a/pkg/database/talkgroups.sql.go b/pkg/database/talkgroups.sql.go index bc378e5..8023101 100644 --- a/pkg/database/talkgroups.sql.go +++ b/pkg/database/talkgroups.sql.go @@ -10,6 +10,7 @@ import ( "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/alerting/rules" + "github.com/google/uuid" ) const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one @@ -117,6 +118,40 @@ func (q *Queries) GetAllTalkgroupTags(ctx context.Context) ([]string, error) { return items, nil } +const getIncidentTalkgroups = `-- name: GetIncidentTalkgroups :many +SELECT DISTINCT + c.system, + c.talkgroup +FROM incidents_calls ic +JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date) +WHERE ic.incident_id = $1 +` + +type GetIncidentTalkgroupsRow struct { + System int `json:"system"` + Talkgroup int `json:"talkgroup"` +} + +func (q *Queries) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) { + rows, err := q.db.Query(ctx, getIncidentTalkgroups, incidentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetIncidentTalkgroupsRow + for rows.Next() { + var i GetIncidentTalkgroupsRow + if err := rows.Scan(&i.System, &i.Talkgroup); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getSystemName = `-- name: GetSystemName :one SELECT name FROM systems WHERE id = $1 ` diff --git a/pkg/incidents/incident.go b/pkg/incidents/incident.go index b48f152..896c083 100644 --- a/pkg/incidents/incident.go +++ b/pkg/incidents/incident.go @@ -5,7 +5,7 @@ import ( "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/calls" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/users" "github.com/google/uuid" ) @@ -23,7 +23,7 @@ type Incident struct { } func (inc *Incident) GetResourceName() string { - return rbac.ResourceIncident + return entities.ResourceIncident } type IncidentCall struct { diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index 1ba52fa..8aa284b 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -10,6 +10,8 @@ import ( "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/users" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -46,9 +48,18 @@ type Store interface { // DeleteIncident deletes an incident. DeleteIncident(ctx context.Context, id uuid.UUID) error + + // Owner returns an incident with only the owner filled out. + Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) + + // CallIn returns whether an incident is in an call + CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) + + // TGsIn returns the talkgroups referenced by an incident as a map, primary for rbac use. + TGsIn(ctx context.Context, inc uuid.UUID) (talkgroups.PresenceMap, error) } -type store struct { +type postgresStore struct { } type storeCtxKey string @@ -69,10 +80,10 @@ func FromCtx(ctx context.Context) Store { } func NewStore() Store { - return &store{} + return &postgresStore{} } -func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*incidents.Incident, error) { +func (s *postgresStore) CreateIncident(ctx context.Context, inc incidents.Incident) (*incidents.Incident, error) { user, err := users.UserCheck(ctx, new(incidents.Incident), "create") if err != nil { return nil, err @@ -131,13 +142,13 @@ func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*in return &inc, nil } -func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID, addCallIDs []uuid.UUID, notes []byte, removeCallIDs []uuid.UUID) error { - inc, err := s.getIncidentOwner(ctx, incidentID) +func (s *postgresStore) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID, addCallIDs []uuid.UUID, notes []byte, removeCallIDs []uuid.UUID) error { + inc, err := s.Owner(ctx, incidentID) if err != nil { return err } - _, err = rbac.Check(ctx, &inc, rbac.WithActions(rbac.ActionUpdate)) + _, err = rbac.Check(ctx, &inc, rbac.WithActions(entities.ActionUpdate)) if err != nil { return err } @@ -169,8 +180,8 @@ func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID }, pgx.TxOptions{}) } -func (s *store) Incidents(ctx context.Context, p IncidentsParams) (incs []Incident, totalCount int, err error) { - _, err = rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(rbac.ActionRead)) +func (s *postgresStore) Incidents(ctx context.Context, p IncidentsParams) (incs []Incident, totalCount int, err error) { + _, err = rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(entities.ActionRead)) if err != nil { return nil, 0, err } @@ -274,8 +285,8 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall { return r } -func (s *store) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) { - _, err := rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(rbac.ActionRead)) +func (s *postgresStore) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) { + _, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -325,13 +336,13 @@ func (uip UpdateIncidentParams) toDBUIP(id uuid.UUID) database.UpdateIncidentPar } } -func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) { - ckinc, err := s.getIncidentOwner(ctx, id) +func (s *postgresStore) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) { + ckinc, err := s.Owner(ctx, id) if err != nil { return nil, err } - _, err = rbac.Check(ctx, &ckinc, rbac.WithActions(rbac.ActionUpdate)) + _, err = rbac.Check(ctx, &ckinc, rbac.WithActions(entities.ActionUpdate)) if err != nil { return nil, err } @@ -348,13 +359,13 @@ func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncide return &inc, nil } -func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error { - inc, err := s.getIncidentOwner(ctx, id) +func (s *postgresStore) DeleteIncident(ctx context.Context, id uuid.UUID) error { + inc, err := s.Owner(ctx, id) if err != nil { return err } - _, err = rbac.Check(ctx, &inc, rbac.WithActions(rbac.ActionDelete)) + _, err = rbac.Check(ctx, &inc, rbac.WithActions(entities.ActionDelete)) if err != nil { return err } @@ -362,11 +373,39 @@ func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error { return database.FromCtx(ctx).DeleteIncident(ctx, id) } -func (s *store) UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID, notes []byte) error { +func (s *postgresStore) UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID, notes []byte) error { return database.FromCtx(ctx).UpdateCallIncidentNotes(ctx, notes, incidentID, callID) } -func (s *store) getIncidentOwner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) { +func (s *postgresStore) Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) { owner, err := database.FromCtx(ctx).GetIncidentOwner(ctx, id) return incidents.Incident{ID: id, Owner: users.UserID(owner)}, err } + +func (s *postgresStore) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) { + db := database.FromCtx(ctx) + return db.CallInIncident(ctx, inc, call) +} + +func (s *postgresStore) TGsIn(ctx context.Context, id uuid.UUID) (talkgroups.PresenceMap, error) { + _, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(entities.ActionRead)) + if err != nil { + return nil, err + } + + db := database.FromCtx(ctx) + tgs, err := db.GetIncidentTalkgroups(ctx, id) + if err != nil { + return nil, err + } + + m := make(talkgroups.PresenceMap, len(tgs)) + for _, t := range tgs { + m.Put(talkgroups.ID{ + System: uint32(t.System), + Talkgroup: uint32(t.Talkgroup), + }) + } + + return m, nil +} diff --git a/pkg/nexus/nexus.go b/pkg/nexus/nexus.go index fcfe056..84d4020 100644 --- a/pkg/nexus/nexus.go +++ b/pkg/nexus/nexus.go @@ -6,7 +6,7 @@ import ( "dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/pb" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "github.com/rs/zerolog/log" ) @@ -39,7 +39,7 @@ func New() *Nexus { } func (n *Nexus) Go(ctx context.Context) { - ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "nexus"}) + ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "nexus"}) for { select { case call, ok := <-n.callCh: diff --git a/pkg/rbac/entities/entities.go b/pkg/rbac/entities/entities.go new file mode 100644 index 0000000..eca0217 --- /dev/null +++ b/pkg/rbac/entities/entities.go @@ -0,0 +1,79 @@ +package entities + +import ( + "context" + + "github.com/el-mike/restrict/v2" +) + +const ( + RoleUser = "User" + RoleSubmitter = "Submitter" + RoleAdmin = "Admin" + RoleSystem = "System" + RolePublic = "Public" + RoleShareGuest = "ShareGuest" + + ResourceCall = "Call" + ResourceIncident = "Incident" + ResourceTalkgroup = "Talkgroup" + ResourceAlert = "Alert" + ResourceShare = "Share" + ResourceAPIKey = "APIKey" + + ActionRead = "read" + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionShare = "share" +) + +func SubjectFrom(ctx context.Context) Subject { + sub, ok := ctx.Value(SubjectCtxKey).(Subject) + if ok { + return sub + } + + return new(PublicSubject) +} + +type Subject interface { + restrict.Subject + GetName() string +} + +func CtxWithSubject(ctx context.Context, sub Subject) context.Context { + return context.WithValue(ctx, SubjectCtxKey, sub) +} + +type subjectContextKey string + +const SubjectCtxKey subjectContextKey = "sub" + +type Resource interface { + restrict.Resource +} + +type PublicSubject struct { + RemoteAddr string +} + +func (s *PublicSubject) GetName() string { + return "PUBLIC:" + s.RemoteAddr +} + +func (s *PublicSubject) GetRoles() []string { + return []string{RolePublic} +} + +type SystemServiceSubject struct { + Name string +} + +func (s *SystemServiceSubject) GetName() string { + return "SYSTEM:" + s.Name +} + +func (s *SystemServiceSubject) GetRoles() []string { + return []string{RoleSystem} +} diff --git a/pkg/rbac/mocks/RBAC.go b/pkg/rbac/mocks/RBAC.go index d7de98b..6735737 100644 --- a/pkg/rbac/mocks/RBAC.go +++ b/pkg/rbac/mocks/RBAC.go @@ -5,9 +5,11 @@ package mocks import ( context "context" - rbac "dynatron.me/x/stillbox/pkg/rbac" + entities "dynatron.me/x/stillbox/pkg/rbac/entities" mock "github.com/stretchr/testify/mock" + rbac "dynatron.me/x/stillbox/pkg/rbac" + restrict "github.com/el-mike/restrict/v2" ) @@ -25,7 +27,7 @@ func (_m *RBAC) EXPECT() *RBAC_Expecter { } // Check provides a mock function with given fields: ctx, res, opts -func (_m *RBAC) Check(ctx context.Context, res restrict.Resource, opts ...rbac.CheckOption) (rbac.Subject, error) { +func (_m *RBAC) Check(ctx context.Context, res restrict.Resource, opts ...rbac.CheckOption) (entities.Subject, error) { _va := make([]interface{}, len(opts)) for _i := range opts { _va[_i] = opts[_i] @@ -39,16 +41,16 @@ func (_m *RBAC) Check(ctx context.Context, res restrict.Resource, opts ...rbac.C panic("no return value specified for Check") } - var r0 rbac.Subject + var r0 entities.Subject var r1 error - if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) (rbac.Subject, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) (entities.Subject, error)); ok { return rf(ctx, res, opts...) } - if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) rbac.Subject); ok { + if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) entities.Subject); ok { r0 = rf(ctx, res, opts...) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(rbac.Subject) + r0 = ret.Get(0).(entities.Subject) } } @@ -88,12 +90,12 @@ func (_c *RBAC_Check_Call) Run(run func(ctx context.Context, res restrict.Resour return _c } -func (_c *RBAC_Check_Call) Return(_a0 rbac.Subject, _a1 error) *RBAC_Check_Call { +func (_c *RBAC_Check_Call) Return(_a0 entities.Subject, _a1 error) *RBAC_Check_Call { _c.Call.Return(_a0, _a1) return _c } -func (_c *RBAC_Check_Call) RunAndReturn(run func(context.Context, restrict.Resource, ...rbac.CheckOption) (rbac.Subject, error)) *RBAC_Check_Call { +func (_c *RBAC_Check_Call) RunAndReturn(run func(context.Context, restrict.Resource, ...rbac.CheckOption) (entities.Subject, error)) *RBAC_Check_Call { _c.Call.Return(run) return _c } diff --git a/pkg/rbac/policy/conditions.go b/pkg/rbac/policy/conditions.go new file mode 100644 index 0000000..91334c0 --- /dev/null +++ b/pkg/rbac/policy/conditions.go @@ -0,0 +1,196 @@ +package policy + +import ( + "context" + "errors" + "fmt" + "reflect" + + "dynatron.me/x/stillbox/pkg/incidents/incstore" + "dynatron.me/x/stillbox/pkg/talkgroups" + + "github.com/el-mike/restrict/v2" + "github.com/google/uuid" +) + +const ( + SubmitterEqualConditionType = "SUBMITTER_EQUAL" + InMapConditionType = "IN_MAP" + CallInIncidentConditionType = "CALL_IN_INCIDENT" + TGInIncidentConditionType = "TG_IN_INCIDENT" +) + +type TGInIncidentCondition struct { + ID string `json:"name,omitempty" yaml:"name,omitempty"` + TG *restrict.ValueDescriptor `json:"tg" yaml:"tg"` + Incident *restrict.ValueDescriptor `json:"incident" yaml:"incident"` +} + +func (*TGInIncidentCondition) Type() string { + return TGInIncidentConditionType +} + +func (c *TGInIncidentCondition) Check(r *restrict.AccessRequest) error { + tgVID, err := c.TG.GetValue(r) + if err != nil { + return err + } + + incVID, err := c.Incident.GetValue(r) + if err != nil { + return err + } + + ctx, hasCtx := r.Context["ctx"].(context.Context) + if !hasCtx { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("no context provided")) + } + + incID, isUUID := incVID.(uuid.UUID) + if !isUUID { + return restrict.NewConditionNotSatisfiedError(c, r, errors.New("incident ID is not UUID")) + } + + tgID, isTGID := tgVID.(talkgroups.ID) + if !isTGID { + return restrict.NewConditionNotSatisfiedError(c, r, errors.New("tg ID is not TGID")) + } + + // XXX: this should instead come from the access request context, for better reuse upstream + tgm, err := incstore.FromCtx(ctx).TGsIn(ctx, incID) + if err != nil { + return restrict.NewConditionNotSatisfiedError(c, r, err) + } + + if !tgm.Has(tgID) { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`tg "%v" not in incident "%v"`, tgID, incID)) + } + + return nil +} + +type CallInIncidentCondition struct { + ID string `json:"name,omitempty" yaml:"name,omitempty"` + Call *restrict.ValueDescriptor `json:"call" yaml:"call"` + Incident *restrict.ValueDescriptor `json:"incident" yaml:"incident"` +} + +func (*CallInIncidentCondition) Type() string { + return CallInIncidentConditionType +} + +func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error { + callVID, err := c.Call.GetValue(r) + if err != nil { + return err + } + + incVID, err := c.Incident.GetValue(r) + if err != nil { + return err + } + + ctx, hasCtx := r.Context["ctx"].(context.Context) + if !hasCtx { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("no context provided")) + } + + incID, isUUID := incVID.(uuid.UUID) + if !isUUID { + return restrict.NewConditionNotSatisfiedError(c, r, errors.New("incident ID is not UUID")) + } + + callID, isUUID := callVID.(uuid.UUID) + if !isUUID { + return restrict.NewConditionNotSatisfiedError(c, r, errors.New("call ID is not UUID")) + } + + inCall, err := incstore.FromCtx(ctx).CallIn(ctx, incID, callID) + if err != nil { + return restrict.NewConditionNotSatisfiedError(c, r, err) + } + + if !inCall { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`call "%v" not in incident "%v"`, callID, incID)) + } + + return nil +} + +type SubmitterEqualCondition struct { + ID string `json:"name,omitempty" yaml:"name,omitempty"` + Left *restrict.ValueDescriptor `json:"left" yaml:"left"` + Right *restrict.ValueDescriptor `json:"right" yaml:"right"` +} + +func (s *SubmitterEqualCondition) Type() string { + return SubmitterEqualConditionType +} + +func (c *SubmitterEqualCondition) Check(r *restrict.AccessRequest) error { + left, err := c.Left.GetValue(r) + if err != nil { + return err + } + + right, err := c.Right.GetValue(r) + if err != nil { + return err + } + + lVal := reflect.ValueOf(left) + rVal := reflect.ValueOf(right) + + // deref Left. this is the difference between us and EqualCondition + for lVal.Kind() == reflect.Pointer { + lVal = lVal.Elem() + } + + if !lVal.IsValid() || !reflect.DeepEqual(rVal.Interface(), lVal.Interface()) { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("values \"%v\" and \"%v\" are not equal", left, right)) + } + + return nil +} + +func SubmitterEqualConditionFactory() restrict.Condition { + return new(SubmitterEqualCondition) +} + +type InMapCondition[K comparable, V any] struct { + ID string `json:"name,omitempty" yaml:"name,omitempty"` + Key *restrict.ValueDescriptor `json:"key" yaml:"key"` + Map *restrict.ValueDescriptor `json:"map" yaml:"map"` +} + +func (s *InMapCondition[K, V]) Type() string { + return SubmitterEqualConditionType +} + +func InMapConditionFactory[K comparable, V any]() restrict.Condition { + return new(InMapCondition[K, V]) +} + +func (c *InMapCondition[K, V]) Check(r *restrict.AccessRequest) error { + cKey, err := c.Key.GetValue(r) + if err != nil { + return err + } + + cMap, err := c.Map.GetValue(r) + if err != nil { + return err + } + + key := cKey.(K) + + if _, in := cMap.(map[K]V)[key]; !in { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("key '%v' not in map", key)) + } + + return nil +} + +func init() { + _ = restrict.RegisterConditionFactory(SubmitterEqualConditionType, SubmitterEqualConditionFactory) +} diff --git a/pkg/rbac/policy/policy.go b/pkg/rbac/policy/policy.go new file mode 100644 index 0000000..16798a6 --- /dev/null +++ b/pkg/rbac/policy/policy.go @@ -0,0 +1,243 @@ +package policy + +import ( + "dynatron.me/x/stillbox/pkg/rbac/entities" + + "github.com/el-mike/restrict/v2" +) + +const ( + PresetUpdateOwn = "updateOwn" + PresetDeleteOwn = "deleteOwn" + PresetReadShared = "readShared" + PresetReadSharedInMap = "readSharedInMap" + PresetShareOwn = "shareOwn" + + PresetUpdateSubmitter = "updateSubmitter" + PresetDeleteSubmitter = "deleteSubmitter" + PresetShareSubmitter = "shareSubmitter" + PresetReadInSharedIncident = "readInSharedIncident" +) + +var Policy = &restrict.PolicyDefinition{ + Roles: restrict.Roles{ + entities.RoleUser: { + Description: "An authenticated user", + Grants: restrict.GrantsMap{ + entities.ResourceIncident: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Preset: PresetUpdateOwn}, + &restrict.Permission{Preset: PresetDeleteOwn}, + &restrict.Permission{Preset: PresetShareOwn}, + }, + entities.ResourceCall: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Preset: PresetUpdateSubmitter}, + &restrict.Permission{Preset: PresetDeleteSubmitter}, + &restrict.Permission{Action: entities.ActionShare}, + }, + entities.ResourceTalkgroup: { + &restrict.Permission{Action: entities.ActionRead}, + }, + entities.ResourceShare: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Preset: PresetUpdateOwn}, + &restrict.Permission{Preset: PresetDeleteOwn}, + }, + }, + }, + entities.RoleSubmitter: { + Description: "A role that can submit calls", + Grants: restrict.GrantsMap{ + entities.ResourceCall: { + &restrict.Permission{Action: entities.ActionCreate}, + }, + entities.ResourceTalkgroup: { + // for learning TGs + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Action: entities.ActionUpdate}, + }, + }, + }, + entities.RoleShareGuest: { + Description: "Someone who has a valid share link", + Grants: restrict.GrantsMap{ + entities.ResourceCall: { + &restrict.Permission{Preset: PresetReadShared}, + &restrict.Permission{Preset: PresetReadInSharedIncident}, + }, + entities.ResourceIncident: { + &restrict.Permission{Preset: PresetReadShared}, + }, + entities.ResourceTalkgroup: { + &restrict.Permission{Action: entities.ActionRead}, + }, + }, + }, + entities.RoleAdmin: { + Parents: []string{entities.RoleUser}, + Grants: restrict.GrantsMap{ + entities.ResourceIncident: { + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionDelete}, + &restrict.Permission{Action: entities.ActionShare}, + }, + entities.ResourceCall: { + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionDelete}, + &restrict.Permission{Action: entities.ActionShare}, + }, + entities.ResourceTalkgroup: { + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Action: entities.ActionDelete}, + }, + }, + }, + entities.RoleSystem: { + Parents: []string{entities.RoleSystem}, + }, + entities.RolePublic: { + /* + Grants: restrict.GrantsMap{ + entities.ResourceShare: { + &restrict.Permission{Action: entities.ActionRead}, + }, + }, + */ + }, + }, + PermissionPresets: restrict.PermissionPresets{ + PresetUpdateOwn: &restrict.Permission{ + Action: entities.ActionUpdate, + Conditions: restrict.Conditions{ + &restrict.EqualCondition{ + ID: "isOwner", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "Owner", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "ID", + }, + }, + }, + }, + PresetDeleteOwn: &restrict.Permission{ + Action: entities.ActionDelete, + Conditions: restrict.Conditions{ + &restrict.EqualCondition{ + ID: "isOwner", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "Owner", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "ID", + }, + }, + }, + }, + PresetShareOwn: &restrict.Permission{ + Action: entities.ActionShare, + Conditions: restrict.Conditions{ + &restrict.EqualCondition{ + ID: "isOwner", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "Owner", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "ID", + }, + }, + }, + }, + PresetUpdateSubmitter: &restrict.Permission{ + Action: entities.ActionUpdate, + Conditions: restrict.Conditions{ + &SubmitterEqualCondition{ + ID: "isSubmitter", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "Submitter", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "ID", + }, + }, + }, + }, + PresetDeleteSubmitter: &restrict.Permission{ + Action: entities.ActionDelete, + Conditions: restrict.Conditions{ + &SubmitterEqualCondition{ + ID: "isSubmitter", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "Submitter", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "ID", + }, + }, + }, + }, + PresetShareSubmitter: &restrict.Permission{ + Action: entities.ActionShare, + Conditions: restrict.Conditions{ + &SubmitterEqualCondition{ + ID: "isSubmitter", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "Submitter", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "ID", + }, + }, + }, + }, + PresetReadShared: &restrict.Permission{ + Action: entities.ActionRead, + Conditions: restrict.Conditions{ + &restrict.EqualCondition{ + ID: "isOwner", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "ID", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "EntityID", + }, + }, + }, + }, + PresetReadInSharedIncident: &restrict.Permission{ + Action: entities.ActionRead, + Conditions: restrict.Conditions{ + &CallInIncidentCondition{ + ID: "callInIncident", + Call: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "ID", + }, + Incident: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "EntityID", + }, + }, + }, + }, + }, +} diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index b626f34..58da816 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -3,53 +3,17 @@ package rbac import ( "context" "errors" - "fmt" - "reflect" + + "dynatron.me/x/stillbox/pkg/rbac/entities" "github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2/adapters" ) -const ( - RoleUser = "User" - RoleSubmitter = "Submitter" - RoleAdmin = "Admin" - RoleSystem = "System" - RolePublic = "Public" - RoleShareGuest = "ShareGuest" - - ResourceCall = "Call" - ResourceIncident = "Incident" - ResourceTalkgroup = "Talkgroup" - ResourceAlert = "Alert" - ResourceShare = "Share" - ResourceAPIKey = "APIKey" - - ActionRead = "read" - ActionCreate = "create" - ActionUpdate = "update" - ActionDelete = "delete" - - PresetUpdateOwn = "updateOwn" - PresetDeleteOwn = "deleteOwn" - PresetReadShared = "readShared" - - PresetUpdateSubmitter = "updateSubmitter" - PresetDeleteSubmitter = "deleteSubmitter" -) - var ( ErrBadSubject = errors.New("bad subject in token") ) -type subjectContextKey string - -const SubjectCtxKey subjectContextKey = "sub" - -func CtxWithSubject(ctx context.Context, sub Subject) context.Context { - return context.WithValue(ctx, SubjectCtxKey, sub) -} - func ErrAccessDenied(err error) *restrict.AccessDeniedError { if accessErr, ok := err.(*restrict.AccessDeniedError); ok { return accessErr @@ -58,15 +22,6 @@ func ErrAccessDenied(err error) *restrict.AccessDeniedError { return nil } -func SubjectFrom(ctx context.Context) Subject { - sub, ok := ctx.Value(SubjectCtxKey).(Subject) - if ok { - return sub - } - - return new(PublicSubject) -} - type rbacCtxKey string const RBACCtxKey rbacCtxKey = "rbac" @@ -88,176 +43,6 @@ var ( ErrNotAuthorized = errors.New("not authorized") ) -var policy = &restrict.PolicyDefinition{ - Roles: restrict.Roles{ - RoleUser: { - Description: "An authenticated user", - Grants: restrict.GrantsMap{ - ResourceIncident: { - &restrict.Permission{Action: ActionRead}, - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Preset: PresetUpdateOwn}, - &restrict.Permission{Preset: PresetDeleteOwn}, - }, - ResourceCall: { - &restrict.Permission{Action: ActionRead}, - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Preset: PresetUpdateSubmitter}, - &restrict.Permission{Preset: PresetDeleteSubmitter}, - }, - ResourceTalkgroup: { - &restrict.Permission{Action: ActionRead}, - }, - ResourceShare: { - &restrict.Permission{Action: ActionRead}, - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Preset: PresetUpdateOwn}, - &restrict.Permission{Preset: PresetDeleteOwn}, - }, - }, - }, - RoleSubmitter: { - Description: "A role that can submit calls", - Grants: restrict.GrantsMap{ - ResourceCall: { - &restrict.Permission{Action: ActionCreate}, - }, - ResourceTalkgroup: { - // for learning TGs - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Action: ActionUpdate}, - }, - }, - }, - RoleShareGuest: { - Description: "Someone who has a valid share link", - Grants: restrict.GrantsMap{ - ResourceCall: { - &restrict.Permission{Preset: PresetReadShared}, - }, - ResourceIncident: { - &restrict.Permission{Preset: PresetReadShared}, - }, - ResourceTalkgroup: { - &restrict.Permission{Action: ActionRead}, - }, - }, - }, - RoleAdmin: { - Parents: []string{RoleUser}, - Grants: restrict.GrantsMap{ - ResourceIncident: { - &restrict.Permission{Action: ActionUpdate}, - &restrict.Permission{Action: ActionDelete}, - }, - ResourceCall: { - &restrict.Permission{Action: ActionUpdate}, - &restrict.Permission{Action: ActionDelete}, - }, - ResourceTalkgroup: { - &restrict.Permission{Action: ActionUpdate}, - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Action: ActionDelete}, - }, - }, - }, - RoleSystem: { - Parents: []string{RoleSystem}, - }, - RolePublic: { - /* - Grants: restrict.GrantsMap{ - ResourceShare: { - &restrict.Permission{Action: ActionRead}, - }, - }, - */ - }, - }, - PermissionPresets: restrict.PermissionPresets{ - PresetUpdateOwn: &restrict.Permission{ - Action: ActionUpdate, - Conditions: restrict.Conditions{ - &restrict.EqualCondition{ - ID: "isOwner", - Left: &restrict.ValueDescriptor{ - Source: restrict.ResourceField, - Field: "Owner", - }, - Right: &restrict.ValueDescriptor{ - Source: restrict.SubjectField, - Field: "ID", - }, - }, - }, - }, - PresetDeleteOwn: &restrict.Permission{ - Action: ActionDelete, - Conditions: restrict.Conditions{ - &restrict.EqualCondition{ - ID: "isOwner", - Left: &restrict.ValueDescriptor{ - Source: restrict.ResourceField, - Field: "Owner", - }, - Right: &restrict.ValueDescriptor{ - Source: restrict.SubjectField, - Field: "ID", - }, - }, - }, - }, - PresetUpdateSubmitter: &restrict.Permission{ - Action: ActionUpdate, - Conditions: restrict.Conditions{ - &SubmitterEqualCondition{ - ID: "isSubmitter", - Left: &restrict.ValueDescriptor{ - Source: restrict.ResourceField, - Field: "Submitter", - }, - Right: &restrict.ValueDescriptor{ - Source: restrict.SubjectField, - Field: "ID", - }, - }, - }, - }, - PresetDeleteSubmitter: &restrict.Permission{ - Action: ActionDelete, - Conditions: restrict.Conditions{ - &SubmitterEqualCondition{ - ID: "isSubmitter", - Left: &restrict.ValueDescriptor{ - Source: restrict.ResourceField, - Field: "Submitter", - }, - Right: &restrict.ValueDescriptor{ - Source: restrict.SubjectField, - Field: "ID", - }, - }, - }, - }, - PresetReadShared: &restrict.Permission{ - Action: ActionRead, - Conditions: restrict.Conditions{ - &restrict.EqualCondition{ - ID: "isOwner", - Left: &restrict.ValueDescriptor{ - Source: restrict.ContextField, - Field: "Owner", - }, - Right: &restrict.ValueDescriptor{ - Source: restrict.SubjectField, - Field: "ID", - }, - }, - }, - }, - }, -} - type checkOptions struct { actions []string context restrict.Context @@ -281,17 +66,8 @@ func UseResource(rsc string) restrict.Resource { return restrict.UseResource(rsc) } -type Subject interface { - restrict.Subject - GetName() string -} - -type Resource interface { - restrict.Resource -} - type RBAC interface { - Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (Subject, error) + Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (entities.Subject, error) } type rbac struct { @@ -299,8 +75,8 @@ type rbac struct { access *restrict.AccessManager } -func New() (*rbac, error) { - adapter := adapters.NewInMemoryAdapter(policy) +func New(pol *restrict.PolicyDefinition) (*rbac, error) { + adapter := adapters.NewInMemoryAdapter(pol) polMan, err := restrict.NewPolicyManager(adapter, true) if err != nil { return nil, err @@ -314,18 +90,24 @@ func New() (*rbac, error) { } // Check is a convenience function to pull the RBAC instance out of ctx and Check. -func Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (Subject, error) { +func Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (entities.Subject, error) { return FromCtx(ctx).Check(ctx, res, opts...) } -func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (Subject, error) { - sub := SubjectFrom(ctx) +func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (entities.Subject, error) { + sub := entities.SubjectFrom(ctx) o := checkOptions{} for _, opt := range opts { opt(&o) } + if o.context == nil { + o.context = make(restrict.Context) + } + + o.context["ctx"] = ctx + req := &restrict.AccessRequest{ Subject: sub, Resource: res, @@ -335,87 +117,3 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp return sub, r.access.Authorize(req) } - -type ShareLinkGuest struct { - ShareID string -} - -func (s *ShareLinkGuest) GetName() string { - return "SHARE:" + s.ShareID -} - -func (s *ShareLinkGuest) GetRoles() []string { - return []string{RoleShareGuest} -} - -type PublicSubject struct { - RemoteAddr string -} - -func (s *PublicSubject) GetName() string { - return "PUBLIC:" + s.RemoteAddr -} - -func (s *PublicSubject) GetRoles() []string { - return []string{RolePublic} -} - -type SystemServiceSubject struct { - Name string -} - -func (s *SystemServiceSubject) GetName() string { - return "SYSTEM:" + s.Name -} - -func (s *SystemServiceSubject) GetRoles() []string { - return []string{RoleSystem} -} - -const ( - SubmitterEqualConditionType = "SUBMITTER_EQUAL" -) - -type SubmitterEqualCondition struct { - ID string `json:"name,omitempty" yaml:"name,omitempty"` - Left *restrict.ValueDescriptor `json:"left" yaml:"left"` - Right *restrict.ValueDescriptor `json:"right" yaml:"right"` -} - -func (s *SubmitterEqualCondition) Type() string { - return SubmitterEqualConditionType -} - -func (c *SubmitterEqualCondition) Check(r *restrict.AccessRequest) error { - left, err := c.Left.GetValue(r) - if err != nil { - return err - } - - right, err := c.Right.GetValue(r) - if err != nil { - return err - } - - lVal := reflect.ValueOf(left) - rVal := reflect.ValueOf(right) - - // deref Left. this is the difference between us and EqualCondition - for lVal.Kind() == reflect.Pointer { - lVal = lVal.Elem() - } - - if !lVal.IsValid() || !reflect.DeepEqual(rVal.Interface(), lVal.Interface()) { - return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("values \"%v\" and \"%v\" are not equal", left, right)) - } - - return nil -} - -func SubmitterEqualConditionFactory() restrict.Condition { - return new(SubmitterEqualCondition) -} - -func init() { - restrict.RegisterConditionFactory(SubmitterEqualConditionType, SubmitterEqualConditionFactory) -} diff --git a/pkg/rbac/rbac_test.go b/pkg/rbac/rbac_test.go index 582732d..4ffbd73 100644 --- a/pkg/rbac/rbac_test.go +++ b/pkg/rbac/rbac_test.go @@ -10,6 +10,8 @@ import ( "dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/users" "github.com/el-mike/restrict/v2" @@ -20,8 +22,8 @@ import ( func TestRBAC(t *testing.T) { tests := []struct { name string - subject rbac.Subject - resource rbac.Resource + subject entities.Subject + resource entities.Resource action string expectErr error }{ @@ -32,7 +34,7 @@ func TestRBAC(t *testing.T) { IsAdmin: true, }, resource: &talkgroups.Talkgroup{}, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: nil, }, { @@ -45,7 +47,7 @@ func TestRBAC(t *testing.T) { Name: "test incident", Owner: 4, }, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: nil, }, { @@ -57,7 +59,7 @@ func TestRBAC(t *testing.T) { Name: "test incident", Owner: 4, }, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: errors.New(`access denied for Action: "update" on Resource: "Incident"`), }, { @@ -69,7 +71,7 @@ func TestRBAC(t *testing.T) { Name: "test incident", Owner: 2, }, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: nil, }, { @@ -81,7 +83,7 @@ func TestRBAC(t *testing.T) { Name: "test incident", Owner: 6, }, - action: rbac.ActionDelete, + action: entities.ActionDelete, expectErr: errors.New(`access denied for Action: "delete" on Resource: "Incident"`), }, { @@ -93,7 +95,7 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: common.PtrTo(users.UserID(4)), }, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: nil, }, { @@ -104,7 +106,7 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: common.PtrTo(users.UserID(4)), }, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`), }, { @@ -115,7 +117,7 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: common.PtrTo(users.UserID(2)), }, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: nil, }, { @@ -126,7 +128,7 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: nil, }, - action: rbac.ActionUpdate, + action: entities.ActionUpdate, expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`), }, { @@ -137,15 +139,83 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: common.PtrTo(users.UserID(6)), }, - action: rbac.ActionDelete, + action: entities.ActionDelete, expectErr: errors.New(`access denied for Action: "delete" on Resource: "Call"`), }, + { + name: "user share call not submitter", + subject: &users.User{ + ID: 2, + }, + resource: &calls.Call{ + Submitter: common.PtrTo(users.UserID(6)), + }, + action: entities.ActionShare, + expectErr: nil, + }, + { + name: "user share call admin", + subject: &users.User{ + ID: 2, + IsAdmin: true, + }, + resource: &calls.Call{ + Submitter: common.PtrTo(users.UserID(6)), + }, + action: entities.ActionShare, + expectErr: nil, + }, + { + name: "user share call submitter", + subject: &users.User{ + ID: 6, + }, + resource: &calls.Call{ + Submitter: common.PtrTo(users.UserID(6)), + }, + action: entities.ActionShare, + expectErr: nil, + }, + { + name: "user share incident not owner", + subject: &users.User{ + ID: 2, + }, + resource: &incidents.Incident{ + Owner: users.UserID(6), + }, + action: entities.ActionShare, + expectErr: errors.New(`access denied for Action: "share" on Resource: "Incident"`), + }, + { + name: "user share incident admin", + subject: &users.User{ + ID: 2, + IsAdmin: true, + }, + resource: &incidents.Incident{ + Owner: users.UserID(6), + }, + action: entities.ActionShare, + expectErr: nil, + }, + { + name: "user share incident owner", + subject: &users.User{ + ID: 6, + }, + resource: &incidents.Incident{ + Owner: users.UserID(6), + }, + action: entities.ActionShare, + expectErr: nil, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - ctx := rbac.CtxWithSubject(context.Background(), tc.subject) - rb, err := rbac.New() + ctx := entities.CtxWithSubject(context.Background(), tc.subject) + rb, err := rbac.New(policy.Policy) require.NoError(t, err) sub, err := rb.Check(ctx, tc.resource, rbac.WithActions(tc.action)) if tc.expectErr != nil { diff --git a/pkg/rest/api.go b/pkg/rest/api.go index e94194b..8f0928c 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -7,6 +7,7 @@ import ( "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "github.com/go-chi/chi/v5" @@ -21,12 +22,41 @@ type API interface { Subrouter() http.Handler } +type APIRoot interface { + API + ShareRouter() http.Handler +} + type api struct { - baseURL url.URL + baseURL *url.URL + shares *shareAPI + tgs *talkgroupAPI + calls *callsAPI + users *usersAPI + incidents *incidentsAPI +} + +func (a *api) ShareRouter() http.Handler { + return a.shares.RootRouter() } func New(baseURL url.URL) *api { - s := &api{baseURL} + s := &api{ + baseURL: &baseURL, + tgs: new(talkgroupAPI), + calls: new(callsAPI), + incidents: newIncidentsAPI(&baseURL), + users: new(usersAPI), + } + s.shares = newShareAPI(&baseURL, + ShareHandlers{ + ShareRequestCall: s.calls.shareCallRoute, + ShareRequestCallDL: s.calls.shareCallDLRoute, + ShareRequestIncident: s.incidents.getIncident, + ShareRequestIncidentM3U: s.incidents.getCallsM3U, + ShareRequestTalkgroups: s.tgs.getTGsShareRoute, + }, + ) return s } @@ -34,11 +64,11 @@ func New(baseURL url.URL) *api { func (a *api) Subrouter() http.Handler { r := chi.NewMux() - r.Mount("/talkgroup", new(talkgroupAPI).Subrouter()) - r.Mount("/call", new(callsAPI).Subrouter()) - r.Mount("/user", new(usersAPI).Subrouter()) - r.Mount("/incident", newIncidentsAPI(&a.baseURL).Subrouter()) - r.Mount("/share", newShareHandler(&a.baseURL).Subrouter()) + r.Mount("/talkgroup", a.tgs.Subrouter()) + r.Mount("/user", a.users.Subrouter()) + r.Mount("/call", a.calls.Subrouter()) + r.Mount("/incident", a.incidents.Subrouter()) + r.Mount("/share", a.shares.Subrouter()) return r } @@ -141,6 +171,9 @@ var statusMapping = map[error]errResponder{ ErrBadAppName: unauthErrText, common.ErrPageOutOfRange: badRequestErrText, rbac.ErrNotAuthorized: unauthErrText, + shares.ErrNoShare: notFoundErrText, + ErrBadShare: notFoundErrText, + shares.ErrBadType: badRequestErrText, } func autoError(err error) render.Renderer { diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index bc930fd..1492f6c 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -11,6 +11,7 @@ import ( "dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/pkg/calls/callstore" "dynatron.me/x/stillbox/pkg/database" + "dynatron.me/x/stillbox/pkg/shares" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -30,19 +31,20 @@ type callsAPI struct { func (ca *callsAPI) Subrouter() http.Handler { r := chi.NewMux() - r.Get(`/{call:[a-f0-9-]+}`, ca.getAudio) - r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudio) - + r.Get(`/{call:[a-f0-9-]+}`, ca.getAudioRoute) + r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudioRoute) r.Post(`/`, ca.listCalls) return r } -func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { - p := struct { - CallID *uuid.UUID `param:"call"` - Download *string `param:"download"` - }{} +type getAudioParams struct { + CallID *uuid.UUID `param:"call"` + Download *string `param:"download"` +} + +func (ca *callsAPI) getAudioRoute(w http.ResponseWriter, r *http.Request) { + p := getAudioParams{} err := decodeParams(&p, r) if err != nil { @@ -50,6 +52,10 @@ func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { return } + ca.getAudio(p, w, r) +} + +func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Request) { if p.CallID == nil { wErr(w, r, badRequest(ErrNoCall)) return @@ -96,6 +102,23 @@ func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(call.AudioBlob) } +func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { + p := getAudioParams{ + CallID: common.PtrTo(id.(uuid.UUID)), + } + + ca.getAudio(p, w, r) +} + +func (ca *callsAPI) shareCallDLRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { + p := getAudioParams{ + CallID: common.PtrTo(id.(uuid.UUID)), + Download: common.PtrTo("download"), + } + + ca.getAudio(p, w, r) +} + func (ca *callsAPI) listCalls(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cSt := callstore.FromCtx(ctx) diff --git a/pkg/rest/incidents.go b/pkg/rest/incidents.go index 7488aeb..6baf4a2 100644 --- a/pkg/rest/incidents.go +++ b/pkg/rest/incidents.go @@ -12,6 +12,7 @@ import ( "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/incidents/incstore" + "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "github.com/go-chi/chi/v5" @@ -22,16 +23,15 @@ type incidentsAPI struct { baseURL *url.URL } -func newIncidentsAPI(baseURL *url.URL) API { +func newIncidentsAPI(baseURL *url.URL) *incidentsAPI { return &incidentsAPI{baseURL} } func (ia *incidentsAPI) Subrouter() http.Handler { r := chi.NewMux() - r.Get(`/{id:[a-f0-9-]+}`, ia.getIncident) - r.Get(`/{id:[a-f0-9-]+}.m3u`, ia.getCallsM3U) - + r.Get(`/{id:[a-f0-9-]+}`, ia.getIncidentRoute) + r.Get(`/{id:[a-f0-9-]+}.m3u`, ia.getCallsM3URoute) r.Post(`/new`, ia.createIncident) r.Post(`/`, ia.listIncidents) r.Post(`/{id:[a-f0-9-]+}/calls`, ia.postCalls) @@ -88,16 +88,19 @@ func (ia *incidentsAPI) createIncident(w http.ResponseWriter, r *http.Request) { respond(w, r, inc) } -func (ia *incidentsAPI) getIncident(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - +func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request) { id, err := idOnlyParam(w, r) if err != nil { return } - inc, err := incs.Incident(ctx, id) + ia.getIncident(id, nil, w, r) +} + +func (ia *incidentsAPI) getIncident(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + inc, err := incs.Incident(ctx, id.(uuid.UUID)) if err != nil { wErr(w, r, autoError(err)) return @@ -186,17 +189,21 @@ func (ia *incidentsAPI) postCalls(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func (ia *incidentsAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - tgst := tgstore.FromCtx(ctx) - +func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request) { id, err := idOnlyParam(w, r) if err != nil { return } - inc, err := incs.Incident(ctx, id) + ia.getCallsM3U(id, nil, w, r) +} + +func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + tgst := tgstore.FromCtx(ctx) + + inc, err := incs.Incident(ctx, id.(uuid.UUID)) if err != nil { wErr(w, r, autoError(err)) return @@ -205,6 +212,10 @@ func (ia *incidentsAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) { b := new(bytes.Buffer) callUrl := common.PtrTo(*ia.baseURL) + urlRoot := "/api/call" + if share != nil { + urlRoot = fmt.Sprintf("/share/%s/call/", share.ID) + } b.WriteString("#EXTM3U\n\n") for _, c := range inc.Calls { @@ -218,7 +229,7 @@ func (ia *incidentsAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) { from = fmt.Sprintf(" from %d", c.Source) } - callUrl.Path = "/api/call/" + c.ID.String() + callUrl.Path = urlRoot + c.ID.String() fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s)\n%s\n\n", c.Duration.Seconds(), diff --git a/pkg/rest/share.go b/pkg/rest/share.go index b829f52..4cc260e 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -1,231 +1,164 @@ package rest import ( - "bytes" - "encoding/json" - "fmt" + "errors" "net/http" "net/url" + "time" - "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/forms" - "dynatron.me/x/stillbox/internal/jsontypes" - "dynatron.me/x/stillbox/pkg/incidents" - "dynatron.me/x/stillbox/pkg/incidents/incstore" - "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "dynatron.me/x/stillbox/pkg/shares" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) +var ( + ErrBadShare = errors.New("bad share request type") +) + +type ShareRequestType string + +const ( + ShareRequestCall ShareRequestType = "call" + ShareRequestCallDL ShareRequestType = "callDL" + ShareRequestIncident ShareRequestType = "incident" + ShareRequestIncidentM3U ShareRequestType = "m3u" + ShareRequestTalkgroups ShareRequestType = "talkgroups" +) + +func (rt ShareRequestType) IsValid() bool { + switch rt { + case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident, + ShareRequestIncidentM3U, ShareRequestTalkgroups: + return true + } + + return false +} + +func (rt ShareRequestType) IsValidSubtype() bool { + switch rt { + case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups: + return true + } + + return false +} + +type ID interface { +} + +type HandlerFunc func(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) +type ShareHandlers map[ShareRequestType]HandlerFunc type shareAPI struct { baseURL *url.URL + shnd ShareHandlers } -func newShareHandler(baseURL *url.URL) API { - return &shareAPI{baseURL} +func newShareAPI(baseURL *url.URL, shnd ShareHandlers) *shareAPI { + return &shareAPI{ + baseURL: baseURL, + shnd: shnd, + } } -func (ia *shareAPI) Subrouter() http.Handler { +func (sa *shareAPI) Subrouter() http.Handler { r := chi.NewMux() - //r.Get(`/{id:[A-Za-z0-9_-]{20,}}`, ia.getShare) - //r.Post('/create', ia.createShare) - //r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, ia.deleteShare) - //r.Get(`/`, ia.getShares) + r.Post(`/create`, sa.createShare) + r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, sa.deleteShare) return r } -func (ia *shareAPI) listIncidents(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) +func (sa *shareAPI) RootRouter() http.Handler { + r := chi.NewMux() + + r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}", sa.routeShare) + r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}/{subID}", sa.routeShare) + return r +} + +func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + shs := shares.FromCtx(ctx) + + p := shares.CreateShareParams{} - p := incstore.IncidentsParams{} err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) if err != nil { wErr(w, r, badRequest(err)) return } - res := struct { - Incidents []incstore.Incident `json:"incidents"` - Count int `json:"count"` + sh, err := shs.NewShare(ctx, p) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + respond(w, r, sh) +} + +func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + shs := shares.FromCtx(ctx) + + params := struct { + Type string `param:"type"` + ID string `param:"shareId"` + SubID *string `param:"subID"` }{} - res.Incidents, res.Count, err = incs.Incidents(ctx, p) + err := decodeParams(¶ms, r) if err != nil { wErr(w, r, autoError(err)) return } - respond(w, r, res) -} + rType := ShareRequestType(params.Type) + id := params.ID -func (ia *shareAPI) createIncident(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - - p := incidents.Incident{} - err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) - if err != nil { - wErr(w, r, badRequest(err)) + if !rType.IsValid() { + wErr(w, r, autoError(ErrBadShare)) return } - inc, err := incs.CreateIncident(ctx, p) + sh, err := shs.GetShare(ctx, id) if err != nil { wErr(w, r, autoError(err)) return } - respond(w, r, inc) -} - -func (ia *shareAPI) getIncident(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - - id, err := idOnlyParam(w, r) - if err != nil { + if sh.Expiration != nil && sh.Expiration.Time().Before(time.Now()) { + wErr(w, r, autoError(shares.ErrNoShare)) return } - inc, err := incs.Incident(ctx, id) - if err != nil { - wErr(w, r, autoError(err)) - return - } + ctx = entities.CtxWithSubject(ctx, sh) + r = r.WithContext(ctx) - respond(w, r, inc) -} - -func (ia *shareAPI) updateIncident(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - - id, err := idOnlyParam(w, r) - if err != nil { - return - } - - p := incstore.UpdateIncidentParams{} - err = forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) - if err != nil { - wErr(w, r, badRequest(err)) - return - } - - inc, err := incs.UpdateIncident(ctx, id, p) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - respond(w, r, inc) -} - -func (ia *shareAPI) deleteIncident(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - - urlParams := struct { - ID uuid.UUID `param:"id"` - }{} - - err := decodeParams(&urlParams, r) - if err != nil { - wErr(w, r, badRequest(err)) - return - } - - err = incs.DeleteIncident(ctx, urlParams.ID) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -type CallIncidentParams2 struct { - Add jsontypes.UUIDs `json:"add"` - Notes json.RawMessage `json:"notes"` - - Remove jsontypes.UUIDs `json:"remove"` -} - -func (ia *shareAPI) postCalls(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - - id, err := idOnlyParam(w, r) - if err != nil { - return - } - - p := CallIncidentParams2{} - err = forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) - if err != nil { - wErr(w, r, badRequest(err)) - return - } - - err = incs.AddRemoveIncidentCalls(ctx, id, p.Add.UUIDs(), p.Notes, p.Remove.UUIDs()) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -func (ia *shareAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - incs := incstore.FromCtx(ctx) - tgst := tgstore.FromCtx(ctx) - - id, err := idOnlyParam(w, r) - if err != nil { - return - } - - inc, err := incs.Incident(ctx, id) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - b := new(bytes.Buffer) - - callUrl := common.PtrTo(*ia.baseURL) - - b.WriteString("#EXTM3U\n\n") - for _, c := range inc.Calls { - tg, err := tgst.TG(ctx, c.TalkgroupTuple()) - if err != nil { - wErr(w, r, autoError(err)) + switch rType { + case ShareRequestTalkgroups: + sa.shnd[rType](nil, sh, w, r) + case ShareRequestCall, ShareRequestCallDL: + if params.SubID == nil { + wErr(w, r, autoError(ErrBadShare)) return } - var from string - if c.Source != 0 { - from = fmt.Sprintf(" from %d", c.Source) + + subIDU, err := uuid.Parse(*params.SubID) + if err != nil { + wErr(w, r, badRequest(err)) + return } - - callUrl.Path = "/api/call/" + c.ID.String() - - fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s)\n%s\n\n", - c.Duration.Seconds(), - tg.StringTag(true), - from, - c.DateTime.Format("15:04 01/02"), - callUrl, - ) + sa.shnd[rType](subIDU, sh, w, r) + case ShareRequestIncident, ShareRequestIncidentM3U: + sa.shnd[rType](sh.EntityID, sh, w, r) } - - // Not a lot of agreement on which MIME type to use for non-HLS m3u, - // let's hope this is good enough - w.Header().Set("Content-Type", "audio/x-mpegurl") - w.WriteHeader(http.StatusOK) - _, _ = b.WriteTo(w) +} + +func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) { } diff --git a/pkg/rest/talkgroups.go b/pkg/rest/talkgroups.go index 7a11365..d97e9ea 100644 --- a/pkg/rest/talkgroups.go +++ b/pkg/rest/talkgroups.go @@ -7,6 +7,8 @@ import ( "dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/pkg/database" + "dynatron.me/x/stillbox/pkg/incidents/incstore" + "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/xport" @@ -159,6 +161,30 @@ func (tga *talkgroupAPI) postPaginated(w http.ResponseWriter, r *http.Request) { respond(w, r, res) } +func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + tgs := tgstore.FromCtx(ctx) + + tgIDs, err := incstore.FromCtx(ctx).TGsIn(ctx, share.EntityID) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + idSl := make(talkgroups.IDs, 0, len(tgIDs)) + for id := range tgIDs { + idSl = append(idSl, id) + } + + tgRes, err := tgs.TGs(ctx, idSl) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + respond(w, r, tgRes) +} + func (tga *talkgroupAPI) put(w http.ResponseWriter, r *http.Request) { var id tgParams err := decodeParams(&id, r) diff --git a/pkg/server/routes.go b/pkg/server/routes.go index cb5f26e..9410baf 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -51,7 +51,7 @@ func (s *Server) setupRoutes() { s.rateLimit(r) r.Use(render.SetContentType(render.ContentTypeJSON)) s.auth.PublicRoutes(r) - // r.Mount("/share", s.share.ShareRouter(s.rest)) + r.Mount("/share", s.rest.ShareRouter()) }) r.Group(func(r chi.Router) { diff --git a/pkg/server/server.go b/pkg/server/server.go index 6c51931..a2553d7 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "fmt" "net/http" "os" "time" @@ -16,8 +17,9 @@ import ( "dynatron.me/x/stillbox/pkg/nexus" "dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/rest" - "dynatron.me/x/stillbox/pkg/share" + "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/sources" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" @@ -45,12 +47,12 @@ type Server struct { notifier notify.Notifier hup chan os.Signal tgs tgstore.Store - rest rest.API + rest rest.APIRoot partman partman.PartitionManager users users.Store calls callstore.Store incidents incstore.Store - share share.Service + share shares.Service rbac rbac.RBAC } @@ -79,7 +81,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { tgCache := tgstore.NewCache(db) api := rest.New(cfg.BaseURL.URL()) - rbacSvc, err := rbac.New() + rbacSvc, err := rbac.New(policy.Policy) if err != nil { return nil, err } @@ -96,7 +98,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { tgs: tgCache, sinks: sinks.NewSinkManager(), rest: api, - share: share.NewService(), + share: shares.NewService(), users: ust, calls: callstore.NewStore(db), incidents: incstore.NewStore(), @@ -145,6 +147,13 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { })) srv.setupRoutes() + if os.Getenv("STILLBOX_DUMP_ROUTES") == "true" { + _ = chi.Walk(r, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + fmt.Printf("[%s]: '%s' has %d middlewares\n", method, route, len(middlewares)) + return nil + }) + } + return srv, nil } @@ -154,7 +163,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context { ctx = users.CtxWithStore(ctx, s.users) ctx = callstore.CtxWithStore(ctx, s.calls) ctx = incstore.CtxWithStore(ctx, s.incidents) - ctx = share.CtxWithStore(ctx, s.share.ShareStore()) + ctx = shares.CtxWithStore(ctx, s.share) ctx = rbac.CtxWithRBAC(ctx, s.rbac) return ctx diff --git a/pkg/share/share.go b/pkg/share/share.go deleted file mode 100644 index 8865d71..0000000 --- a/pkg/share/share.go +++ /dev/null @@ -1,61 +0,0 @@ -package share - -import ( - "context" - "time" - - "dynatron.me/x/stillbox/internal/jsontypes" - - "github.com/google/uuid" - "github.com/matoous/go-nanoid" -) - -const ( - SlugLength = 20 -) - -type EntityType string - -const ( - EntityIncident EntityType = "incident" - EntityCall EntityType = "call" -) - -// If an incident is shared, all calls that are part of it must be shared too, but this can be through the incident share (/share/bLaH/callID[.mp3]) - -type Share struct { - ID string `json:"id"` - Type EntityType `json:"entityType"` - EntityID uuid.UUID `json:"entityID"` - Expiration *jsontypes.Time `json:"expiration"` -} - -// NewShare creates a new share. -func (s *service) NewShare(ctx context.Context, shType EntityType, shID uuid.UUID, exp *time.Duration) (id string, err error) { - id, err = gonanoid.ID(SlugLength) - if err != nil { - return - } - - store := FromCtx(ctx) - - var expT *jsontypes.Time - if exp != nil { - tt := time.Now().Add(*exp) - expT = (*jsontypes.Time)(&tt) - } - - share := &Share{ - ID: id, - Type: shType, - EntityID: shID, - Expiration: expT, - } - - err = store.Create(ctx, share) - if err != nil { - return - } - - return id, nil -} diff --git a/pkg/share/service.go b/pkg/shares/service.go similarity index 66% rename from pkg/share/service.go rename to pkg/shares/service.go index eea1edd..245b61d 100644 --- a/pkg/share/service.go +++ b/pkg/shares/service.go @@ -1,10 +1,10 @@ -package share +package shares import ( "context" "time" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "github.com/rs/zerolog/log" ) @@ -13,21 +13,17 @@ const ( ) type Service interface { - ShareStore() Store + Shares Go(ctx context.Context) } type service struct { - Store -} - -func (s *service) ShareStore() Store { - return s.Store + postgresStore } func (s *service) Go(ctx context.Context) { - ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "share"}) + ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "share"}) tick := time.NewTicker(PruneInterval) @@ -46,7 +42,5 @@ func (s *service) Go(ctx context.Context) { } func NewService() *service { - return &service{ - Store: NewStore(), - } + return &service{} } diff --git a/pkg/shares/share.go b/pkg/shares/share.go new file mode 100644 index 0000000..6184c02 --- /dev/null +++ b/pkg/shares/share.go @@ -0,0 +1,144 @@ +package shares + +import ( + "context" + "errors" + "time" + + "dynatron.me/x/stillbox/internal/common" + "dynatron.me/x/stillbox/internal/jsontypes" + "dynatron.me/x/stillbox/pkg/calls/callstore" + "dynatron.me/x/stillbox/pkg/incidents/incstore" + "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "dynatron.me/x/stillbox/pkg/users" + + "github.com/google/uuid" + "github.com/matoous/go-nanoid" +) + +const ( + SlugLength = 20 +) + +var ( + ErrExpirationTooSoon = errors.New("expiration too soon") + ErrBadType = errors.New("bad share type") +) + +type EntityType string + +const ( + EntityIncident EntityType = "incident" + EntityCall EntityType = "call" +) + +func (et EntityType) IsValid() bool { + switch et { + case EntityCall, EntityIncident: + return true + } + + return false +} + +// If an incident is shared, all calls that are part of it must be shared too, but this can be through the incident share (/share/bLaH/callID[.mp3]) + +type Share struct { + ID string `json:"id"` + Type EntityType `json:"entityType"` + Date *jsontypes.Time `json:"-"` // we handle this for the user + Owner users.UserID `json:"owner"` + EntityID uuid.UUID `json:"entityID"` + Expiration *jsontypes.Time `json:"expiration"` +} + +func (s *Share) GetName() string { + return "SHARE:" + s.ID +} + +func (s *Share) GetRoles() []string { + return []string{entities.RoleShareGuest} +} + +func (s *Share) GetResourceName() string { + return entities.ResourceShare +} + +type CreateShareParams struct { + Type EntityType `json:"entityType"` + EntityID uuid.UUID `json:"entityID"` + Expiration *jsontypes.Duration `json:"expiration"` +} + +func (s *service) checkEntity(ctx context.Context, sh *CreateShareParams) (*time.Time, error) { + var t *time.Time + switch sh.Type { + case EntityCall: + cs := callstore.FromCtx(ctx) + // Call does RBAC for us + call, err := cs.Call(ctx, sh.EntityID) + if err != nil { + return nil, err + } + + t = &call.DateTime + case EntityIncident: + is := incstore.FromCtx(ctx) + i, err := is.Owner(ctx, sh.EntityID) + if err != nil { + return nil, err + } + _, err = rbac.Check(ctx, &i, rbac.WithActions(entities.ActionShare)) + if err != nil { + return nil, err + } + } + + return t, nil +} + +// NewShare creates a new share. +func (s *service) NewShare(ctx context.Context, sh CreateShareParams) (*Share, error) { + if !sh.Type.IsValid() { + return nil, ErrBadType + } + + // entTime is only meaningful if we are a call + entTime, err := s.checkEntity(ctx, &sh) + if err != nil { + return nil, err + } + + u, err := users.From(ctx) + if err != nil { + return nil, err + } + + id, err := gonanoid.ID(SlugLength) + if err != nil { + return nil, err + } + + share := &Share{ + ID: id, + Type: sh.Type, + Date: (*jsontypes.Time)(entTime), + Owner: u.ID, + EntityID: sh.EntityID, + } + + if sh.Expiration != nil { + if sh.Expiration.Duration() < time.Minute { + return nil, ErrExpirationTooSoon + } + share.Expiration = common.PtrTo(jsontypes.Time(time.Now().Add(sh.Expiration.Duration()))) + } + + err = s.Create(ctx, share) + if err != nil { + return nil, err + } + + return share, nil +} diff --git a/pkg/share/store.go b/pkg/shares/store.go similarity index 53% rename from pkg/share/store.go rename to pkg/shares/store.go index 4ee6ec8..d0fbad0 100644 --- a/pkg/share/store.go +++ b/pkg/shares/store.go @@ -1,15 +1,23 @@ -package share +package shares import ( "context" + "errors" "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/database" + "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "dynatron.me/x/stillbox/pkg/users" + "github.com/jackc/pgx/v5" ) -type Store interface { - // Get retreives a share record. - Get(ctx context.Context, id string) (*Share, error) +type Shares interface { + // NewShare creates a new share. + NewShare(ctx context.Context, sh CreateShareParams) (*Share, error) + + // Share retreives a share record. + GetShare(ctx context.Context, id string) (*Share, error) // Create stores a new share record. Create(ctx context.Context, share *Share) error @@ -24,38 +32,59 @@ type Store interface { type postgresStore struct { } +var ( + ErrNoShare = errors.New("no such share") +) + func recToShare(share database.Share) *Share { return &Share{ ID: share.ID, Type: EntityType(share.EntityType), EntityID: share.EntityID, + Date: jsontypes.TimePtrFromTSTZ(share.EntityDate), Expiration: jsontypes.TimePtrFromTSTZ(share.Expiration), + Owner: users.UserID(share.Owner), } } -func (s *postgresStore) Get(ctx context.Context, id string) (*Share, error) { +func (s *postgresStore) GetShare(ctx context.Context, id string) (*Share, error) { db := database.FromCtx(ctx) rec, err := db.GetShare(ctx, id) - if err != nil { + switch err { + case nil: + return recToShare(rec), nil + case pgx.ErrNoRows: + return nil, ErrNoShare + default: return nil, err } - - return recToShare(rec), nil } func (s *postgresStore) Create(ctx context.Context, share *Share) error { + sub, err := users.UserCheck(ctx, new(Share), "create") + if err != nil { + return err + } + db := database.FromCtx(ctx) - err := db.CreateShare(ctx, database.CreateShareParams{ + err = db.CreateShare(ctx, database.CreateShareParams{ ID: share.ID, EntityType: string(share.Type), EntityID: share.EntityID, + EntityDate: share.Date.PGTypeTSTZ(), Expiration: share.Expiration.PGTypeTSTZ(), + Owner: sub.ID.Int(), }) return err } func (s *postgresStore) Delete(ctx context.Context, id string) error { + _, err := rbac.Check(ctx, new(Share), rbac.WithActions(entities.ActionDelete)) + if err != nil { + return err + } + return database.FromCtx(ctx).DeleteShare(ctx, id) } @@ -71,14 +100,14 @@ type storeCtxKey string const StoreCtxKey storeCtxKey = "store" -func CtxWithStore(ctx context.Context, s Store) context.Context { +func CtxWithStore(ctx context.Context, s Shares) context.Context { return context.WithValue(ctx, StoreCtxKey, s) } -func FromCtx(ctx context.Context) Store { - s, ok := ctx.Value(StoreCtxKey).(Store) +func FromCtx(ctx context.Context) Shares { + s, ok := ctx.Value(StoreCtxKey).(Shares) if !ok { - return NewStore() + panic("no shares store in context") } return s diff --git a/pkg/sources/http.go b/pkg/sources/http.go index dfd8df5..dbc51f7 100644 --- a/pkg/sources/http.go +++ b/pkg/sources/http.go @@ -10,6 +10,7 @@ import ( "dynatron.me/x/stillbox/pkg/auth" "dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/users" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" @@ -131,7 +132,7 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - err = h.ing.Ingest(rbac.CtxWithSubject(ctx, submitterSub), call) + err = h.ing.Ingest(entities.CtxWithSubject(ctx, submitterSub), call) if err != nil { if rbac.ErrAccessDenied(err) != nil { log.Error().Err(err).Msg("ingest failed") diff --git a/pkg/talkgroups/filter/filter.go b/pkg/talkgroups/filter/filter.go index 11c38c7..68a6b01 100644 --- a/pkg/talkgroups/filter/filter.go +++ b/pkg/talkgroups/filter/filter.go @@ -122,6 +122,7 @@ func (f *TalkgroupFilter) compile(ctx context.Context) error { if f.hasTags() { // don't bother with DB if no tags db := database.FromCtx(ctx) + // TODO: change this to use tgstore, and make sure the context is no longer a system subject (see nexus.Go) tagTGs, err := db.GetTalkgroupIDsByTags(ctx, f.TalkgroupTagsAny, f.TalkgroupTagsAll, f.TalkgroupTagsNot) if err != nil { return err diff --git a/pkg/talkgroups/talkgroup.go b/pkg/talkgroups/talkgroup.go index 7965e98..8851a53 100644 --- a/pkg/talkgroups/talkgroup.go +++ b/pkg/talkgroups/talkgroup.go @@ -9,7 +9,7 @@ import ( "strings" "dynatron.me/x/stillbox/pkg/database" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" ) type Talkgroup struct { @@ -19,7 +19,7 @@ type Talkgroup struct { } func (t *Talkgroup) GetResourceName() string { - return rbac.ResourceTalkgroup + return entities.ResourceTalkgroup } func (t Talkgroup) String() string { @@ -41,6 +41,18 @@ type ID struct { Talkgroup uint32 `json:"tg"` } +type PresenceMap map[ID]struct{} + +func (t PresenceMap) Has(id ID) bool { + _, has := t[id] + + return has +} + +func (t PresenceMap) Put(id ID) { + t[id] = struct{}{} +} + var _ encoding.TextUnmarshaler = (*ID)(nil) var ErrBadTG = errors.New("bad talkgroup format") diff --git a/pkg/talkgroups/tgstore/store.go b/pkg/talkgroups/tgstore/store.go index 64a010e..ba86f06 100644 --- a/pkg/talkgroups/tgstore/store.go +++ b/pkg/talkgroups/tgstore/store.go @@ -12,6 +12,7 @@ import ( "dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" tgsp "dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/users" @@ -327,7 +328,7 @@ func addToRowList[T rowType](t *cache, tgRecords []T) []*tgsp.Talkgroup { } func (t *cache) TGs(ctx context.Context, tgs tgsp.IDs, opts ...Option) ([]*tgsp.Talkgroup, error) { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -430,7 +431,7 @@ func (t *cache) Weight(ctx context.Context, id tgsp.ID, tm time.Time) float64 { } func (t *cache) SystemTGs(ctx context.Context, systemID int, opts ...Option) ([]*tgsp.Talkgroup, error) { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -486,7 +487,7 @@ func (t *cache) SystemTGs(ctx context.Context, systemID int, opts ...Option) ([] } func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -513,7 +514,7 @@ func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) { } func (t *cache) SystemName(ctx context.Context, id int) (name string, has bool) { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead)) if err != nil { return "", false } @@ -587,7 +588,7 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara } func (t *cache) DeleteSystem(ctx context.Context, id int) error { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionDelete)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete)) if err != nil { return err } @@ -609,7 +610,7 @@ func (t *cache) DeleteSystem(ctx context.Context, id int) error { } func (t *cache) DeleteTG(ctx context.Context, id tgsp.ID) error { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionDelete)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete)) if err != nil { return err } @@ -645,7 +646,7 @@ func (t *cache) DeleteTG(ctx context.Context, id tgsp.ID) error { } func (t *cache) LearnTG(ctx context.Context, c *calls.Call) (*tgsp.Talkgroup, error) { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionCreate, rbac.ActionUpdate)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionCreate, entities.ActionUpdate)) if err != nil { return nil, err } @@ -764,7 +765,7 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse } func (t *cache) CreateSystem(ctx context.Context, id int, name string) error { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionCreate)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionCreate)) if err != nil { return err } @@ -778,7 +779,7 @@ func (t *cache) CreateSystem(ctx context.Context, id int, name string) error { } func (t *cache) Tags(ctx context.Context) ([]string, error) { - _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } diff --git a/pkg/users/guest.go b/pkg/users/guest.go deleted file mode 100644 index c38d2dc..0000000 --- a/pkg/users/guest.go +++ /dev/null @@ -1,21 +0,0 @@ -package users - -import ( - "dynatron.me/x/stillbox/pkg/rbac" -) - -type ShareLinkGuest struct { - ShareID string -} - -func (s *ShareLinkGuest) GetRoles() []string { - return []string{rbac.RoleShareGuest} -} - -type Public struct { - RemoteAddr string -} - -func (s *Public) GetRoles() []string { - return []string{rbac.RolePublic} -} diff --git a/pkg/users/user.go b/pkg/users/user.go index d4904a7..9860be1 100644 --- a/pkg/users/user.go +++ b/pkg/users/user.go @@ -7,6 +7,7 @@ import ( "dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" ) type UserID int @@ -30,11 +31,11 @@ func (u UserID) IsValid() bool { } func From(ctx context.Context) (*User, error) { - sub := rbac.SubjectFrom(ctx) + sub := entities.SubjectFrom(ctx) return FromSubject(sub) } -func UserCheck(ctx context.Context, rsc rbac.Resource, actions string) (*User, error) { +func UserCheck(ctx context.Context, rsc entities.Resource, actions string) (*User, error) { acts := strings.Split(actions, "+") subj, err := rbac.FromCtx(ctx).Check(ctx, rsc, rbac.WithActions(acts...)) if err != nil { @@ -44,7 +45,7 @@ func UserCheck(ctx context.Context, rsc rbac.Resource, actions string) (*User, e return FromSubject(subj) } -func FromSubject(sub rbac.Subject) (*User, error) { +func FromSubject(sub entities.Subject) (*User, error) { if sub == nil { return nil, rbac.ErrBadSubject } @@ -73,10 +74,10 @@ func (u *User) GetName() string { func (u *User) GetRoles() []string { r := make([]string, 1, 2) - r[0] = rbac.RoleUser + r[0] = entities.RoleUser if u.IsAdmin { - r = append(r, rbac.RoleAdmin) + r = append(r, entities.RoleAdmin) } return r diff --git a/sql/postgres/migrations/001_initial.up.sql b/sql/postgres/migrations/001_initial.up.sql index f7889cb..2a3995d 100644 --- a/sql/postgres/migrations/001_initial.up.sql +++ b/sql/postgres/migrations/001_initial.up.sql @@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS shares( id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id UUID NOT NULL, + entity_date TIMESTAMPTZ, owner INTEGER NOT NULL REFERENCES users(id), expiration TIMESTAMPTZ NULL ); diff --git a/sql/postgres/queries/calls.sql b/sql/postgres/queries/calls.sql index cceaea1..a35ded6 100644 --- a/sql/postgres/queries/calls.sql +++ b/sql/postgres/queries/calls.sql @@ -162,3 +162,24 @@ DELETE FROM calls WHERE id = @id; -- name: GetCallSubmitter :one SELECT submitter FROM calls WHERE id = @id; + +-- name: GetCall :one +SELECT + id, + submitter, + system, + talkgroup, + call_date, + audio_name, + audio_type, + audio_url, + duration, + frequency, + frequencies, + patches, + tg_label, + tg_alpha_tag, + tg_group, + source +FROM calls +WHERE id = @id; diff --git a/sql/postgres/queries/incidents.sql b/sql/postgres/queries/incidents.sql index 98b3fc8..0bc1e89 100644 --- a/sql/postgres/queries/incidents.sql +++ b/sql/postgres/queries/incidents.sql @@ -29,6 +29,13 @@ UPDATE incidents_Calls SET notes = @notes WHERE incident_id = @incident_id AND call_id = @call_id; +-- name: CallInIncident :one +SELECT EXISTS + (SELECT 1 FROM incidents_calls ic + WHERE + ic.incident_id = @incident_id AND + ic.call_id = @call_id); + -- name: CreateIncident :one INSERT INTO incidents ( id, diff --git a/sql/postgres/queries/share.sql b/sql/postgres/queries/share.sql index 11931e3..7854c48 100644 --- a/sql/postgres/queries/share.sql +++ b/sql/postgres/queries/share.sql @@ -3,6 +3,7 @@ SELECT id, entity_type, entity_id, + entity_date, owner, expiration FROM shares @@ -13,9 +14,10 @@ INSERT INTO shares ( id, entity_type, entity_id, + entity_date, owner, expiration -) VALUES (@id, @entity_type, @entity_id, @owner, sqlc.narg('expiration')); +) VALUES (@id, @entity_type, @entity_id, sqlc.narg('entity_date'), @owner, sqlc.narg('expiration')); -- name: DeleteShare :exec DELETE FROM shares WHERE id = @id; diff --git a/sql/postgres/queries/talkgroups.sql b/sql/postgres/queries/talkgroups.sql index 2c10354..ca232d5 100644 --- a/sql/postgres/queries/talkgroups.sql +++ b/sql/postgres/queries/talkgroups.sql @@ -281,3 +281,11 @@ INSERT INTO systems(id, name) VALUES(@id, @name); -- name: DeleteSystem :exec DELETE FROM systems WHERE id = @id; + +-- name: GetIncidentTalkgroups :many +SELECT DISTINCT + c.system, + c.talkgroup +FROM incidents_calls ic +JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date) +WHERE ic.incident_id = @incident_id;