From 3116874247d84f1e9fdf60232fdf05b293aba3cb Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Sun, 19 Jan 2025 21:51:39 -0500 Subject: [PATCH 01/10] Doesn't work --- pkg/calls/call.go | 1 + pkg/calls/callstore/store.go | 41 +++ pkg/database/calls.sql.go | 65 +++++ pkg/database/mocks/Store.go | 57 ++++ pkg/database/models.go | 1 + pkg/database/querier.go | 1 + pkg/database/share.sql.go | 7 +- pkg/incidents/incstore/store.go | 11 +- pkg/rbac/rbac.go | 51 +++- pkg/rbac/rbac_test.go | 68 +++++ pkg/rest/api.go | 30 ++- pkg/rest/share.go | 300 ++++++++------------- pkg/server/routes.go | 2 +- pkg/server/server.go | 10 +- pkg/share/share.go | 61 ----- pkg/{share => shares}/service.go | 14 +- pkg/shares/share.go | 143 ++++++++++ pkg/{share => shares}/store.go | 54 +++- pkg/users/guest.go | 8 - sql/postgres/migrations/001_initial.up.sql | 1 + sql/postgres/queries/calls.sql | 21 ++ sql/postgres/queries/share.sql | 4 +- 22 files changed, 639 insertions(+), 312 deletions(-) delete mode 100644 pkg/share/share.go rename pkg/{share => shares}/service.go (80%) create mode 100644 pkg/shares/share.go rename pkg/{share => shares}/store.go (54%) diff --git a/pkg/calls/call.go b/pkg/calls/call.go index 090fa4f..1f0e338 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -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"` diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go index 218113d..3de46d4 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" @@ -28,6 +29,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) } @@ -139,6 +143,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(rbac.ResourceCall), rbac.WithActions(rbac.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"` 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/mocks/Store.go b/pkg/database/mocks/Store.go index 11f1ae2..3f6c509 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -1348,6 +1348,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) 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/querier.go b/pkg/database/querier.go index d0a8553..4f8d8db 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -32,6 +32,7 @@ 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) 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/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index 1ba52fa..195837a 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -46,6 +46,9 @@ 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) } type store struct { @@ -132,7 +135,7 @@ func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*in } 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) + inc, err := s.Owner(ctx, incidentID) if err != nil { return err } @@ -326,7 +329,7 @@ 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) + ckinc, err := s.Owner(ctx, id) if err != nil { return nil, err } @@ -349,7 +352,7 @@ func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncide } func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error { - inc, err := s.getIncidentOwner(ctx, id) + inc, err := s.Owner(ctx, id) if err != nil { return err } @@ -366,7 +369,7 @@ func (s *store) UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uu return database.FromCtx(ctx).UpdateCallIncidentNotes(ctx, notes, incidentID, callID) } -func (s *store) getIncidentOwner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) { +func (s *store) 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 } diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index b626f34..7ad53ab 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -29,13 +29,16 @@ const ( ActionCreate = "create" ActionUpdate = "update" ActionDelete = "delete" + ActionShare = "share" PresetUpdateOwn = "updateOwn" PresetDeleteOwn = "deleteOwn" PresetReadShared = "readShared" + PresetShareOwn = "shareOwn" PresetUpdateSubmitter = "updateSubmitter" PresetDeleteSubmitter = "deleteSubmitter" + PresetShareSubmitter = "shareSubmitter" ) var ( @@ -98,12 +101,14 @@ var policy = &restrict.PolicyDefinition{ &restrict.Permission{Action: ActionCreate}, &restrict.Permission{Preset: PresetUpdateOwn}, &restrict.Permission{Preset: PresetDeleteOwn}, + &restrict.Permission{Preset: PresetShareOwn}, }, ResourceCall: { &restrict.Permission{Action: ActionRead}, &restrict.Permission{Action: ActionCreate}, &restrict.Permission{Preset: PresetUpdateSubmitter}, &restrict.Permission{Preset: PresetDeleteSubmitter}, + &restrict.Permission{Action: ActionShare}, }, ResourceTalkgroup: { &restrict.Permission{Action: ActionRead}, @@ -149,10 +154,12 @@ var policy = &restrict.PolicyDefinition{ ResourceIncident: { &restrict.Permission{Action: ActionUpdate}, &restrict.Permission{Action: ActionDelete}, + &restrict.Permission{Action: ActionShare}, }, ResourceCall: { &restrict.Permission{Action: ActionUpdate}, &restrict.Permission{Action: ActionDelete}, + &restrict.Permission{Action: ActionShare}, }, ResourceTalkgroup: { &restrict.Permission{Action: ActionUpdate}, @@ -207,6 +214,22 @@ var policy = &restrict.PolicyDefinition{ }, }, }, + PresetShareOwn: &restrict.Permission{ + Action: 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: ActionUpdate, Conditions: restrict.Conditions{ @@ -239,6 +262,22 @@ var policy = &restrict.PolicyDefinition{ }, }, }, + PresetShareSubmitter: &restrict.Permission{ + Action: 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: ActionRead, Conditions: restrict.Conditions{ @@ -336,18 +375,6 @@ 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 } diff --git a/pkg/rbac/rbac_test.go b/pkg/rbac/rbac_test.go index 582732d..55fd81b 100644 --- a/pkg/rbac/rbac_test.go +++ b/pkg/rbac/rbac_test.go @@ -140,6 +140,74 @@ func TestRBAC(t *testing.T) { action: rbac.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: rbac.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: rbac.ActionShare, + expectErr: nil, + }, + { + name: "user share call submitter", + subject: &users.User{ + ID: 6, + }, + resource: &calls.Call{ + Submitter: common.PtrTo(users.UserID(6)), + }, + action: rbac.ActionShare, + expectErr: nil, + }, + { + name: "user share incident not owner", + subject: &users.User{ + ID: 2, + }, + resource: &incidents.Incident{ + Owner: users.UserID(6), + }, + action: rbac.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: rbac.ActionShare, + expectErr: nil, + }, + { + name: "user share incident owner", + subject: &users.User{ + ID: 6, + }, + resource: &incidents.Incident{ + Owner: users.UserID(6), + }, + action: rbac.ActionShare, + expectErr: nil, + }, } for _, tc := range tests { diff --git a/pkg/rest/api.go b/pkg/rest/api.go index e94194b..500ab65 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,24 +22,44 @@ type API interface { Subrouter() http.Handler } +type PublicAPI interface { + API + PublicRoutes(r chi.Router) +} + type api struct { baseURL url.URL + share publicAPI +} + +type publicAPI interface { + API + PublicRouter() http.Handler } func New(baseURL url.URL) *api { - s := &api{baseURL} + s := &api{ + baseURL: baseURL, + } return s } +func (a *api) PublicRoutes(r chi.Router) { + r.Mount("/share", a.share.PublicRouter()) +} + 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("/call", new(callsAPI).Subrouter()) r.Mount("/incident", newIncidentsAPI(&a.baseURL).Subrouter()) - r.Mount("/share", newShareHandler(&a.baseURL).Subrouter()) + + a.share = newShareAPI(&a.baseURL, r) + + r.Mount("/share", a.share.Subrouter()) return r } @@ -141,6 +162,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/share.go b/pkg/rest/share.go index b829f52..475e85b 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -1,231 +1,145 @@ package rest import ( - "bytes" - "encoding/json" + "errors" "fmt" "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" + "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" + ShareRequestIncident ShareRequestType = "incident" + ShareRequestIncidentM3U ShareRequestType = "m3u" +) + +func (rt ShareRequestType) IsValid() bool { + switch rt { + case ShareRequestCall, ShareRequestIncident, ShareRequestIncidentM3U: + return true + } + + return false +} + +type ShareHandlers map[shares.EntityType]http.Handler type shareAPI struct { baseURL *url.URL + + router http.Handler } -func newShareHandler(baseURL *url.URL) API { - return &shareAPI{baseURL} +func newShareAPI(baseURL *url.URL, hand http.Handler) publicAPI { + return &shareAPI{ + baseURL: baseURL, + router: hand, + } } -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) PublicRouter() http.Handler { + r := chi.NewMux() + + r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}`, sa.getShare) + r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}*`, sa.getShare) + + 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) deleteShare(w http.ResponseWriter, r *http.Request) { +} + +func (sa *shareAPI) getShare(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + shs := shares.FromCtx(ctx) + + rType, id, err := shareParams(w, r) + if err != nil { + return + } + + if !rType.IsValid() { + wErr(w, r, autoError(ErrBadShare)) + } + + sh, err := shs.GetShare(ctx, id) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + if sh.Expiration != nil && sh.Expiration.Time().Before(time.Now()) { + wErr(w, r, autoError(shares.ErrNoShare)) + return + } + + ctx = rbac.CtxWithSubject(ctx, sh) + r = r.WithContext(ctx) + + switch rType { + case ShareRequestCall, ShareRequestIncident: + r.URL.Path = fmt.Sprintf("/%s/%s", rType, sh.EntityID.String()) + case ShareRequestIncidentM3U: + r.URL.Path = fmt.Sprintf("/incident/%s.m3u", sh.EntityID.String()) + } + + sa.router.ServeHTTP(w, r) +} + +// idOnlyParam checks for a sole URL parameter, id, and writes an errorif this fails. +func shareParams(w http.ResponseWriter, r *http.Request) (rType ShareRequestType, rID string, err error) { + params := struct { + Type string `param:"type"` + ID string `param:"id"` }{} - res.Incidents, res.Count, err = incs.Incidents(ctx, p) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - respond(w, r, res) -} - -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()) + err = decodeParams(¶ms, r) if err != nil { wErr(w, r, badRequest(err)) - return + return "", "", err } - inc, err := incs.CreateIncident(ctx, p) - 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 { - return - } - - inc, err := incs.Incident(ctx, id) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - 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)) - return - } - var from string - if c.Source != 0 { - from = fmt.Sprintf(" from %d", c.Source) - } - - 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, - ) - } - - // 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) + return ShareRequestType(params.Type), params.ID, nil } diff --git a/pkg/server/routes.go b/pkg/server/routes.go index cb5f26e..094de1c 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)) + s.rest.PublicRoutes(r) }) r.Group(func(r chi.Router) { diff --git a/pkg/server/server.go b/pkg/server/server.go index 6c51931..aa05bc5 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -17,7 +17,7 @@ import ( "dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/rbac" "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 +45,12 @@ type Server struct { notifier notify.Notifier hup chan os.Signal tgs tgstore.Store - rest rest.API + rest rest.PublicAPI partman partman.PartitionManager users users.Store calls callstore.Store incidents incstore.Store - share share.Service + share shares.Service rbac rbac.RBAC } @@ -96,7 +96,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(), @@ -154,7 +154,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 80% rename from pkg/share/service.go rename to pkg/shares/service.go index eea1edd..376a17e 100644 --- a/pkg/share/service.go +++ b/pkg/shares/service.go @@ -1,4 +1,4 @@ -package share +package shares import ( "context" @@ -13,17 +13,13 @@ 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) { @@ -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..17350c1 --- /dev/null +++ b/pkg/shares/share.go @@ -0,0 +1,143 @@ +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/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{rbac.RoleShareGuest} +} + +func (s *Share) GetResourceName() string { + return rbac.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(rbac.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 54% rename from pkg/share/store.go rename to pkg/shares/store.go index 4ee6ec8..35faa04 100644 --- a/pkg/share/store.go +++ b/pkg/shares/store.go @@ -1,15 +1,22 @@ -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/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 +31,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(rbac.ActionDelete)) + if err != nil { + return err + } + return database.FromCtx(ctx).DeleteShare(ctx, id) } @@ -71,14 +99,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/users/guest.go b/pkg/users/guest.go index c38d2dc..52657e7 100644 --- a/pkg/users/guest.go +++ b/pkg/users/guest.go @@ -4,14 +4,6 @@ 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 } 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/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; From a9f64f74fb444b460cc592d8873edcdcda4dec93 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Mon, 20 Jan 2025 16:31:43 -0500 Subject: [PATCH 02/10] wip --- pkg/rest/api.go | 52 +++++++++++++++++++------------- pkg/rest/calls.go | 9 ++++-- pkg/rest/incidents.go | 11 ++++--- pkg/rest/share.go | 69 +++++++++++++++++++++++++++++-------------- pkg/server/routes.go | 12 +++++++- pkg/server/server.go | 2 +- 6 files changed, 104 insertions(+), 51 deletions(-) diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 500ab65..6b0c7ee 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -18,52 +18,64 @@ import ( "github.com/rs/zerolog/log" ) +type APIRoot interface { + API + Shares() ShareAPI + ShareSubroutes(chi.Router) +} + type API interface { Subrouter() http.Handler } -type PublicAPI interface { +type ShareableAPI interface { API - PublicRoutes(r chi.Router) + GETSubroutes(chi.Router) } type api struct { - baseURL url.URL - share publicAPI + baseURL *url.URL + shares ShareAPI + tgs API + calls ShareableAPI + users API + incidents ShareableAPI } -type publicAPI interface { - API - PublicRouter() http.Handler +func (a *api) Shares() ShareAPI { + return a.shares } func New(baseURL url.URL) *api { s := &api{ - baseURL: baseURL, + baseURL: &baseURL, + shares: newShareAPI(&baseURL), + tgs: new(talkgroupAPI), + calls: new(callsAPI), + incidents: newIncidentsAPI(&baseURL), + users: new(usersAPI), } return s } -func (a *api) PublicRoutes(r chi.Router) { - r.Mount("/share", a.share.PublicRouter()) -} - func (a *api) Subrouter() http.Handler { r := chi.NewMux() - r.Mount("/talkgroup", new(talkgroupAPI).Subrouter()) - r.Mount("/user", new(usersAPI).Subrouter()) - r.Mount("/call", new(callsAPI).Subrouter()) - r.Mount("/incident", newIncidentsAPI(&a.baseURL).Subrouter()) - - a.share = newShareAPI(&a.baseURL, r) - - r.Mount("/share", a.share.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 } +func (a *api) ShareSubroutes(r chi.Router) { + r.Route("/calls", a.calls.GETSubroutes) + r.Route("/incidents", a.incidents.GETSubroutes) +} + type errResponse struct { Err error `json:"-"` Code int `json:"-"` diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index bc930fd..e6b411b 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -30,14 +30,17 @@ 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) - + ca.GETSubroutes(r) r.Post(`/`, ca.listCalls) return r } +func (ca *callsAPI) GETSubroutes(r chi.Router) { + r.Get(`/{call:[a-f0-9-]+}`, ca.getAudio) + r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudio) +} + func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { p := struct { CallID *uuid.UUID `param:"call"` diff --git a/pkg/rest/incidents.go b/pkg/rest/incidents.go index 7488aeb..802d256 100644 --- a/pkg/rest/incidents.go +++ b/pkg/rest/incidents.go @@ -22,16 +22,14 @@ type incidentsAPI struct { baseURL *url.URL } -func newIncidentsAPI(baseURL *url.URL) API { +func newIncidentsAPI(baseURL *url.URL) ShareableAPI { 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) - + ia.GETSubroutes(r) r.Post(`/new`, ia.createIncident) r.Post(`/`, ia.listIncidents) r.Post(`/{id:[a-f0-9-]+}/calls`, ia.postCalls) @@ -43,6 +41,11 @@ func (ia *incidentsAPI) Subrouter() http.Handler { return r } +func (ia *incidentsAPI) GETSubroutes(r chi.Router) { + r.Get(`/{id:[a-f0-9-]+}`, ia.getIncident) + r.Get(`/{id:[a-f0-9-]+}.m3u`, ia.getCallsM3U) +} + func (ia *incidentsAPI) listIncidents(w http.ResponseWriter, r *http.Request) { ctx := r.Context() incs := incstore.FromCtx(ctx) diff --git a/pkg/rest/share.go b/pkg/rest/share.go index 475e85b..08f0ef1 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" "dynatron.me/x/stillbox/internal/forms" @@ -38,14 +39,36 @@ func (rt ShareRequestType) IsValid() bool { type ShareHandlers map[shares.EntityType]http.Handler type shareAPI struct { baseURL *url.URL - - router http.Handler } -func newShareAPI(baseURL *url.URL, hand http.Handler) publicAPI { +type ShareAPI interface { + API + ShareMiddleware() func(http.Handler) http.Handler +} + +func newShareAPI(baseURL *url.URL) ShareAPI { return &shareAPI{ baseURL: baseURL, - router: hand, + } +} + +func (a *shareAPI) ShareMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/share/") { + next.ServeHTTP(w, r) + return + } + nr, err := a.getShare(r) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + next.ServeHTTP(w, nr) + } + + return http.HandlerFunc(fn) } } @@ -58,14 +81,10 @@ func (sa *shareAPI) Subrouter() http.Handler { return r } -func (sa *shareAPI) PublicRouter() http.Handler { - r := chi.NewMux() - - r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}`, sa.getShare) - r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}*`, sa.getShare) - - return r -} +//func (sa *shareAPI) PublicRoutes(r chi.Router) http.Handler { +// r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}`, sa.getShare) +// r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}*`, sa.getShare) +//} func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -91,28 +110,34 @@ func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) { func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) { } -func (sa *shareAPI) getShare(w http.ResponseWriter, r *http.Request) { +func (sa *shareAPI) getShare(r *http.Request) (*http.Request, error) { ctx := r.Context() shs := shares.FromCtx(ctx) - rType, id, err := shareParams(w, r) + params := struct { + Type string `param:"type"` + ID string `param:"shareId"` + }{} + + err := decodeParams(¶ms, r) if err != nil { - return + return nil, err } + rType := ShareRequestType(params.Type) + id := params.ID + if !rType.IsValid() { - wErr(w, r, autoError(ErrBadShare)) + return nil, ErrBadShare } sh, err := shs.GetShare(ctx, id) if err != nil { - wErr(w, r, autoError(err)) - return + return nil, err } if sh.Expiration != nil && sh.Expiration.Time().Before(time.Now()) { - wErr(w, r, autoError(shares.ErrNoShare)) - return + return nil, shares.ErrNoShare } ctx = rbac.CtxWithSubject(ctx, sh) @@ -120,12 +145,12 @@ func (sa *shareAPI) getShare(w http.ResponseWriter, r *http.Request) { switch rType { case ShareRequestCall, ShareRequestIncident: - r.URL.Path = fmt.Sprintf("/%s/%s", rType, sh.EntityID.String()) + r.URL.Path += fmt.Sprintf("/%s/%s", rType, sh.EntityID.String()) case ShareRequestIncidentM3U: r.URL.Path = fmt.Sprintf("/incident/%s.m3u", sh.EntityID.String()) } - sa.router.ServeHTTP(w, r) + return r, nil } // idOnlyParam checks for a sole URL parameter, id, and writes an errorif this fails. diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 094de1c..9d4ae96 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -2,6 +2,7 @@ package server import ( "errors" + "fmt" "io/fs" "net/http" "path" @@ -30,6 +31,7 @@ func (s *Server) setupRoutes() { s.installPprof() + r.Group(func(r chi.Router) { // authenticated routes r.Use(s.auth.VerifyMiddleware(), s.auth.AuthMiddleware()) @@ -39,6 +41,11 @@ func (s *Server) setupRoutes() { r.Mount("/api", s.rest.Subrouter()) }) + r.Route("/share/{type}/{shareId:[A-Za-z0-9_-]{20,}}", func(r chi.Router) { + r.Use(s.rest.Shares().ShareMiddleware()) + s.rest.ShareSubroutes(r) + }) + r.Group(func(r chi.Router) { s.rateLimit(r) r.Use(render.SetContentType(render.ContentTypeJSON)) @@ -51,7 +58,6 @@ func (s *Server) setupRoutes() { s.rateLimit(r) r.Use(render.SetContentType(render.ContentTypeJSON)) s.auth.PublicRoutes(r) - s.rest.PublicRoutes(r) }) r.Group(func(r chi.Router) { @@ -62,6 +68,10 @@ func (s *Server) setupRoutes() { s.clientRoute(r, clientRoot) }) + 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 +}) } // WithCtxStores is a middleware that installs all stores in the request context. diff --git a/pkg/server/server.go b/pkg/server/server.go index aa05bc5..a18aec4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -45,7 +45,7 @@ type Server struct { notifier notify.Notifier hup chan os.Signal tgs tgstore.Store - rest rest.PublicAPI + rest rest.APIRoot partman partman.PartitionManager users users.Store calls callstore.Store From b171e8431afe128bb33127f35a861f20b56af276 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Mon, 20 Jan 2025 20:28:25 -0500 Subject: [PATCH 03/10] WIP --- pkg/calls/callstore/store.go | 2 +- pkg/config/config.go | 23 +-- pkg/incidents/incstore/store.go | 2 +- pkg/rbac/conditions.go | 94 +++++++++++ pkg/rbac/policy.go | 211 +++++++++++++++++++++++++ pkg/rbac/rbac.go | 265 +------------------------------- pkg/rest/api.go | 48 +++--- pkg/rest/calls.go | 37 +++-- pkg/rest/incidents.go | 35 +++-- pkg/rest/share.go | 87 +++-------- pkg/server/routes.go | 11 +- pkg/server/server.go | 8 + 12 files changed, 426 insertions(+), 397 deletions(-) create mode 100644 pkg/rbac/conditions.go create mode 100644 pkg/rbac/policy.go diff --git a/pkg/calls/callstore/store.go b/pkg/calls/callstore/store.go index 3de46d4..72dc684 100644 --- a/pkg/calls/callstore/store.go +++ b/pkg/calls/callstore/store.go @@ -123,7 +123,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(rbac.ActionRead)) if err != nil { return nil, 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/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index 195837a..e2f396f 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -278,7 +278,7 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall { } func (s *store) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) { - _, err := rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(rbac.ActionRead)) if err != nil { return nil, err } diff --git a/pkg/rbac/conditions.go b/pkg/rbac/conditions.go new file mode 100644 index 0000000..721e83d --- /dev/null +++ b/pkg/rbac/conditions.go @@ -0,0 +1,94 @@ +package rbac + +import ( + "fmt" + "reflect" + + "github.com/el-mike/restrict/v2" +) + +const ( + SubmitterEqualConditionType = "SUBMITTER_EQUAL" + InMapConditionType = "IN_MAP" +) + +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 + } + + keyVal := reflect.ValueOf(cKey) + mapVal := reflect.ValueOf(cMap) + + key := keyVal.Interface().(K) + + if _, in := mapVal.Interface().(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.go b/pkg/rbac/policy.go new file mode 100644 index 0000000..32e20da --- /dev/null +++ b/pkg/rbac/policy.go @@ -0,0 +1,211 @@ +package rbac + +import ( + "github.com/el-mike/restrict/v2" +) + +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}, + &restrict.Permission{Preset: PresetShareOwn}, + }, + ResourceCall: { + &restrict.Permission{Action: ActionRead}, + &restrict.Permission{Action: ActionCreate}, + &restrict.Permission{Preset: PresetUpdateSubmitter}, + &restrict.Permission{Preset: PresetDeleteSubmitter}, + &restrict.Permission{Action: ActionShare}, + }, + 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}, + &restrict.Permission{Action: ActionShare}, + }, + ResourceCall: { + &restrict.Permission{Action: ActionUpdate}, + &restrict.Permission{Action: ActionDelete}, + &restrict.Permission{Action: ActionShare}, + }, + 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", + }, + }, + }, + }, + PresetShareOwn: &restrict.Permission{ + Action: 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: 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", + }, + }, + }, + }, + PresetShareSubmitter: &restrict.Permission{ + Action: 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: ActionRead, + Conditions: restrict.Conditions{ + &restrict.EqualCondition{ + ID: "isOwner", + Left: &restrict.ValueDescriptor{ + Source: restrict.ResourceField, + Field: "ID", + }, + Right: &restrict.ValueDescriptor{ + Source: restrict.SubjectField, + Field: "EntityID", + }, + }, + }, + }, + }, +} diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index 7ad53ab..9a9525c 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -3,8 +3,6 @@ package rbac import ( "context" "errors" - "fmt" - "reflect" "github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2/adapters" @@ -31,10 +29,11 @@ const ( ActionDelete = "delete" ActionShare = "share" - PresetUpdateOwn = "updateOwn" - PresetDeleteOwn = "deleteOwn" - PresetReadShared = "readShared" - PresetShareOwn = "shareOwn" + PresetUpdateOwn = "updateOwn" + PresetDeleteOwn = "deleteOwn" + PresetReadShared = "readShared" + PresetReadSharedInMap = "readSharedInMap" + PresetShareOwn = "shareOwn" PresetUpdateSubmitter = "updateSubmitter" PresetDeleteSubmitter = "deleteSubmitter" @@ -91,212 +90,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}, - &restrict.Permission{Preset: PresetShareOwn}, - }, - ResourceCall: { - &restrict.Permission{Action: ActionRead}, - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Preset: PresetUpdateSubmitter}, - &restrict.Permission{Preset: PresetDeleteSubmitter}, - &restrict.Permission{Action: ActionShare}, - }, - 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}, - &restrict.Permission{Action: ActionShare}, - }, - ResourceCall: { - &restrict.Permission{Action: ActionUpdate}, - &restrict.Permission{Action: ActionDelete}, - &restrict.Permission{Action: ActionShare}, - }, - 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", - }, - }, - }, - }, - PresetShareOwn: &restrict.Permission{ - Action: 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: 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", - }, - }, - }, - }, - PresetShareSubmitter: &restrict.Permission{ - Action: 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: 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 @@ -398,51 +191,3 @@ func (s *SystemServiceSubject) GetName() string { 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/rest/api.go b/pkg/rest/api.go index 6b0c7ee..9e7f066 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -18,43 +18,44 @@ import ( "github.com/rs/zerolog/log" ) -type APIRoot interface { - API - Shares() ShareAPI - ShareSubroutes(chi.Router) -} - type API interface { Subrouter() http.Handler } -type ShareableAPI interface { +type APIRoot interface { API - GETSubroutes(chi.Router) + ShareRouter() http.Handler } type api struct { - baseURL *url.URL - shares ShareAPI - tgs API - calls ShareableAPI - users API - incidents ShareableAPI + baseURL *url.URL + shares *shareAPI + tgs *talkgroupAPI + calls *callsAPI + users *usersAPI + incidents *incidentsAPI } -func (a *api) Shares() ShareAPI { - return a.shares +func (a *api) ShareRouter() http.Handler { + return a.shares.RootRouter() } func New(baseURL url.URL) *api { s := &api{ - baseURL: &baseURL, - shares: newShareAPI(&baseURL), - tgs: new(talkgroupAPI), - calls: new(callsAPI), + baseURL: &baseURL, + tgs: new(talkgroupAPI), + calls: new(callsAPI), incidents: newIncidentsAPI(&baseURL), - users: new(usersAPI), + users: new(usersAPI), } + s.shares = newShareAPI(&baseURL, + ShareHandlers{ + ShareRequestCall: s.calls.shareCallRoute, + ShareRequestCallDL: s.calls.shareCallDLRoute, + ShareRequestIncident: s.incidents.getIncident, + ShareRequestIncidentM3U: s.incidents.getCallsM3U, + }, + ) return s } @@ -71,11 +72,6 @@ func (a *api) Subrouter() http.Handler { return r } -func (a *api) ShareSubroutes(r chi.Router) { - r.Route("/calls", a.calls.GETSubroutes) - r.Route("/incidents", a.incidents.GETSubroutes) -} - type errResponse struct { Err error `json:"-"` Code int `json:"-"` diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index e6b411b..285a761 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -30,22 +30,20 @@ type callsAPI struct { func (ca *callsAPI) Subrouter() http.Handler { r := chi.NewMux() - ca.GETSubroutes(r) + 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) GETSubroutes(r chi.Router) { - r.Get(`/{call:[a-f0-9-]+}`, ca.getAudio) - r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudio) +type getAudioParams struct { + CallID *uuid.UUID `param:"call"` + Download *string `param:"download"` } -func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { - p := 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 { @@ -53,6 +51,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 @@ -99,6 +101,23 @@ func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(call.AudioBlob) } +func (ca *callsAPI) shareCallRoute(id uuid.UUID, w http.ResponseWriter, r *http.Request) { + p := getAudioParams{ + CallID: &id, + } + + ca.getAudio(p, w, r) +} + +func (ca *callsAPI) shareCallDLRoute(id uuid.UUID, w http.ResponseWriter, r *http.Request) { + p := getAudioParams{ + CallID: &id, + 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 802d256..c7effb5 100644 --- a/pkg/rest/incidents.go +++ b/pkg/rest/incidents.go @@ -22,14 +22,15 @@ type incidentsAPI struct { baseURL *url.URL } -func newIncidentsAPI(baseURL *url.URL) ShareableAPI { +func newIncidentsAPI(baseURL *url.URL) *incidentsAPI { return &incidentsAPI{baseURL} } func (ia *incidentsAPI) Subrouter() http.Handler { r := chi.NewMux() - ia.GETSubroutes(r) + 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) @@ -41,11 +42,6 @@ func (ia *incidentsAPI) Subrouter() http.Handler { return r } -func (ia *incidentsAPI) GETSubroutes(r chi.Router) { - r.Get(`/{id:[a-f0-9-]+}`, ia.getIncident) - r.Get(`/{id:[a-f0-9-]+}.m3u`, ia.getCallsM3U) -} - func (ia *incidentsAPI) listIncidents(w http.ResponseWriter, r *http.Request) { ctx := r.Context() incs := incstore.FromCtx(ctx) @@ -91,15 +87,18 @@ 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 } + ia.getIncident(id, w, r) +} + +func (ia *incidentsAPI) getIncident(id uuid.UUID, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) inc, err := incs.Incident(ctx, id) if err != nil { wErr(w, r, autoError(err)) @@ -189,16 +188,20 @@ 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 } + ia.getCallsM3U(id, w, r) +} + +func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + incs := incstore.FromCtx(ctx) + tgst := tgstore.FromCtx(ctx) + inc, err := incs.Incident(ctx, id) if err != nil { wErr(w, r, autoError(err)) diff --git a/pkg/rest/share.go b/pkg/rest/share.go index 08f0ef1..aee47aa 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -2,10 +2,8 @@ package rest import ( "errors" - "fmt" "net/http" "net/url" - "strings" "time" "dynatron.me/x/stillbox/internal/forms" @@ -13,6 +11,7 @@ import ( "dynatron.me/x/stillbox/pkg/shares" "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) var ( @@ -23,6 +22,7 @@ type ShareRequestType string const ( ShareRequestCall ShareRequestType = "call" + ShareRequestCallDL ShareRequestType = "callDL" ShareRequestIncident ShareRequestType = "incident" ShareRequestIncidentM3U ShareRequestType = "m3u" ) @@ -36,39 +36,17 @@ func (rt ShareRequestType) IsValid() bool { return false } -type ShareHandlers map[shares.EntityType]http.Handler +type HandlerFunc func(uuid.UUID, http.ResponseWriter, *http.Request) +type ShareHandlers map[ShareRequestType]HandlerFunc type shareAPI struct { baseURL *url.URL + shnd ShareHandlers } -type ShareAPI interface { - API - ShareMiddleware() func(http.Handler) http.Handler -} - -func newShareAPI(baseURL *url.URL) ShareAPI { +func newShareAPI(baseURL *url.URL, shnd ShareHandlers) *shareAPI { return &shareAPI{ baseURL: baseURL, - } -} - -func (a *shareAPI) ShareMiddleware() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.URL.Path, "/share/") { - next.ServeHTTP(w, r) - return - } - nr, err := a.getShare(r) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - next.ServeHTTP(w, nr) - } - - return http.HandlerFunc(fn) + shnd: shnd, } } @@ -81,10 +59,12 @@ func (sa *shareAPI) Subrouter() http.Handler { return r } -//func (sa *shareAPI) PublicRoutes(r chi.Router) http.Handler { -// r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}`, sa.getShare) -// r.Get(`/{type}/{id:[A-Za-z0-9_-]{20,}}*`, sa.getShare) -//} +func (sa *shareAPI) RootRouter() http.Handler { + r := chi.NewMux() + + r.Get("/{type}/{shareId:[A-Za-z0-9_-]{20,}}", sa.routeShare) + return r +} func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -107,10 +87,7 @@ func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) { respond(w, r, sh) } -func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) { -} - -func (sa *shareAPI) getShare(r *http.Request) (*http.Request, error) { +func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { ctx := r.Context() shs := shares.FromCtx(ctx) @@ -121,50 +98,34 @@ func (sa *shareAPI) getShare(r *http.Request) (*http.Request, error) { err := decodeParams(¶ms, r) if err != nil { - return nil, err + wErr(w, r, autoError(err)) + return } rType := ShareRequestType(params.Type) id := params.ID if !rType.IsValid() { - return nil, ErrBadShare + wErr(w, r, autoError(ErrBadShare)) + return } sh, err := shs.GetShare(ctx, id) if err != nil { - return nil, err + wErr(w, r, autoError(err)) + return } if sh.Expiration != nil && sh.Expiration.Time().Before(time.Now()) { - return nil, shares.ErrNoShare + wErr(w, r, autoError(shares.ErrNoShare)) + return } ctx = rbac.CtxWithSubject(ctx, sh) r = r.WithContext(ctx) - switch rType { - case ShareRequestCall, ShareRequestIncident: - r.URL.Path += fmt.Sprintf("/%s/%s", rType, sh.EntityID.String()) - case ShareRequestIncidentM3U: - r.URL.Path = fmt.Sprintf("/incident/%s.m3u", sh.EntityID.String()) - } - - return r, nil + sa.shnd[rType](sh.EntityID, w, r) } -// idOnlyParam checks for a sole URL parameter, id, and writes an errorif this fails. -func shareParams(w http.ResponseWriter, r *http.Request) (rType ShareRequestType, rID string, err error) { - params := struct { - Type string `param:"type"` - ID string `param:"id"` - }{} - - err = decodeParams(¶ms, r) - if err != nil { - wErr(w, r, badRequest(err)) - return "", "", err - } - - return ShareRequestType(params.Type), params.ID, nil +func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) { } diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 9d4ae96..43fa02f 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -31,7 +31,6 @@ func (s *Server) setupRoutes() { s.installPprof() - r.Group(func(r chi.Router) { // authenticated routes r.Use(s.auth.VerifyMiddleware(), s.auth.AuthMiddleware()) @@ -41,11 +40,6 @@ func (s *Server) setupRoutes() { r.Mount("/api", s.rest.Subrouter()) }) - r.Route("/share/{type}/{shareId:[A-Za-z0-9_-]{20,}}", func(r chi.Router) { - r.Use(s.rest.Shares().ShareMiddleware()) - s.rest.ShareSubroutes(r) - }) - r.Group(func(r chi.Router) { s.rateLimit(r) r.Use(render.SetContentType(render.ContentTypeJSON)) @@ -58,6 +52,7 @@ func (s *Server) setupRoutes() { s.rateLimit(r) r.Use(render.SetContentType(render.ContentTypeJSON)) s.auth.PublicRoutes(r) + r.Mount("/share", s.rest.ShareRouter()) }) r.Group(func(r chi.Router) { @@ -68,10 +63,6 @@ func (s *Server) setupRoutes() { s.clientRoute(r, clientRoot) }) - 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 -}) } // WithCtxStores is a middleware that installs all stores in the request context. diff --git a/pkg/server/server.go b/pkg/server/server.go index a18aec4..5d144a8 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "fmt" "net/http" "os" "time" @@ -145,6 +146,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 } From 5ff3066d6d1831c6b3ed0a944221bcbe8cad2d10 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Mon, 20 Jan 2025 22:38:27 -0500 Subject: [PATCH 04/10] WIP cycle --- pkg/calls/call.go | 5 +- pkg/database/incidents.sql.go | 15 ++ pkg/database/models.go | 226 ++++++++++++++--------------- pkg/database/querier.go | 1 + pkg/incidents/incident.go | 1 - pkg/incidents/incstore/rbac.go | 65 +++++++++ pkg/incidents/incstore/store.go | 9 ++ pkg/rbac/conditions.go | 11 +- pkg/rbac/entities.go | 23 +++ pkg/rbac/policy.go | 34 ++++- pkg/rbac/rbac.go | 39 +---- pkg/server/routes.go | 1 - pkg/talkgroups/talkgroup.go | 7 +- pkg/talkgroups/tgstore/store.go | 2 +- pkg/users/guest.go | 13 -- sql/postgres/queries/incidents.sql | 7 + 16 files changed, 287 insertions(+), 172 deletions(-) create mode 100644 pkg/incidents/incstore/rbac.go create mode 100644 pkg/rbac/entities.go delete mode 100644 pkg/users/guest.go diff --git a/pkg/calls/call.go b/pkg/calls/call.go index 1f0e338..8511294 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -8,7 +8,6 @@ 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/talkgroups" "dynatron.me/x/stillbox/pkg/users" @@ -16,6 +15,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +const Resource = "call" + type CallDuration time.Duration func (d CallDuration) Duration() time.Duration { @@ -76,7 +77,7 @@ type Call struct { } func (c *Call) GetResourceName() string { - return rbac.ResourceCall + return Resource } func (c *Call) String() string { 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/models.go b/pkg/database/models.go index 585f860..257acee 100644 --- a/pkg/database/models.go +++ b/pkg/database/models.go @@ -14,150 +14,150 @@ import ( ) type Alert struct { - ID int `json:"id,omitempty"` - Time pgtype.Timestamptz `json:"time,omitempty"` - TGID int `json:"tgid,omitempty"` - SystemID int `json:"system_id,omitempty"` - Weight *float32 `json:"weight,omitempty"` - Score *float32 `json:"score,omitempty"` - OrigScore *float32 `json:"orig_score,omitempty"` - Notified bool `json:"notified,omitempty"` - Metadata []byte `json:"metadata,omitempty"` + ID int `json:"id"` + Time pgtype.Timestamptz `json:"time"` + TGID int `json:"tgid"` + SystemID int `json:"system_id"` + Weight *float32 `json:"weight"` + Score *float32 `json:"score"` + OrigScore *float32 `json:"orig_score"` + Notified bool `json:"notified"` + Metadata []byte `json:"metadata"` } type ApiKey struct { - ID int `json:"id,omitempty"` - Owner int `json:"owner,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - Expires pgtype.Timestamp `json:"expires,omitempty"` - Disabled *bool `json:"disabled,omitempty"` - ApiKey string `json:"api_key,omitempty"` + ID int `json:"id"` + Owner int `json:"owner"` + CreatedAt time.Time `json:"created_at"` + Expires pgtype.Timestamp `json:"expires"` + Disabled *bool `json:"disabled"` + ApiKey string `json:"api_key"` } type Call struct { - ID uuid.UUID `json:"id,omitempty"` - Submitter *int32 `json:"submitter,omitempty"` - System int `json:"system,omitempty"` - Talkgroup int `json:"talkgroup,omitempty"` - CallDate pgtype.Timestamptz `json:"call_date,omitempty"` - AudioName *string `json:"audio_name,omitempty"` - AudioBlob []byte `json:"audio_blob,omitempty"` - Duration *int32 `json:"duration,omitempty"` - AudioType *string `json:"audio_type,omitempty"` - AudioUrl *string `json:"audio_url,omitempty"` - Frequency int `json:"frequency,omitempty"` - Frequencies []int `json:"frequencies,omitempty"` - Patches []int `json:"patches,omitempty"` - TGLabel *string `json:"tg_label,omitempty"` - TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` - Source int `json:"source,omitempty"` - Transcript *string `json:"transcript,omitempty"` + 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"` + AudioBlob []byte `json:"audio_blob"` + Duration *int32 `json:"duration"` + AudioType *string `json:"audio_type"` + AudioUrl *string `json:"audio_url"` + 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"` + Transcript *string `json:"transcript"` } type Incident struct { - ID uuid.UUID `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Owner int `json:"owner,omitempty"` - Description *string `json:"description,omitempty"` - StartTime pgtype.Timestamptz `json:"start_time,omitempty"` - EndTime pgtype.Timestamptz `json:"end_time,omitempty"` - Location []byte `json:"location,omitempty"` - Metadata jsontypes.Metadata `json:"metadata,omitempty"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Owner int `json:"owner"` + Description *string `json:"description"` + StartTime pgtype.Timestamptz `json:"start_time"` + EndTime pgtype.Timestamptz `json:"end_time"` + Location []byte `json:"location"` + Metadata jsontypes.Metadata `json:"metadata"` } type IncidentsCall struct { - IncidentID uuid.UUID `json:"incident_id,omitempty"` - CallID uuid.UUID `json:"call_id,omitempty"` - CallsTblID pgtype.UUID `json:"calls_tbl_id,omitempty"` - SweptCallID pgtype.UUID `json:"swept_call_id,omitempty"` - CallDate pgtype.Timestamptz `json:"call_date,omitempty"` - Notes []byte `json:"notes,omitempty"` + IncidentID uuid.UUID `json:"incident_id"` + CallID uuid.UUID `json:"call_id"` + CallsTblID pgtype.UUID `json:"calls_tbl_id"` + SweptCallID pgtype.UUID `json:"swept_call_id"` + CallDate pgtype.Timestamptz `json:"call_date"` + Notes []byte `json:"notes"` } type Setting struct { - Name string `json:"name,omitempty"` - UpdatedBy *int32 `json:"updated_by,omitempty"` - Value []byte `json:"value,omitempty"` + Name string `json:"name"` + UpdatedBy *int32 `json:"updated_by"` + Value []byte `json:"value"` } 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"` + 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"` } type SweptCall struct { - ID uuid.UUID `json:"id,omitempty"` - Submitter *int32 `json:"submitter,omitempty"` - System int `json:"system,omitempty"` - Talkgroup int `json:"talkgroup,omitempty"` - CallDate pgtype.Timestamptz `json:"call_date,omitempty"` - AudioName *string `json:"audio_name,omitempty"` - AudioBlob []byte `json:"audio_blob,omitempty"` - Duration *int32 `json:"duration,omitempty"` - AudioType *string `json:"audio_type,omitempty"` - AudioUrl *string `json:"audio_url,omitempty"` - Frequency int `json:"frequency,omitempty"` - Frequencies []int `json:"frequencies,omitempty"` - Patches []int `json:"patches,omitempty"` - TGLabel *string `json:"tg_label,omitempty"` - TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` - Source int `json:"source,omitempty"` - Transcript *string `json:"transcript,omitempty"` + 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"` + AudioBlob []byte `json:"audio_blob"` + Duration *int32 `json:"duration"` + AudioType *string `json:"audio_type"` + AudioUrl *string `json:"audio_url"` + 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"` + Transcript *string `json:"transcript"` } type System struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` + ID int `json:"id"` + Name string `json:"name"` } type Talkgroup struct { - ID int `json:"id,omitempty"` - SystemID int32 `json:"system_id,omitempty"` - TGID int32 `json:"tgid,omitempty"` - Name *string `json:"name,omitempty"` - AlphaTag *string `json:"alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` - Frequency *int32 `json:"frequency,omitempty"` - Metadata jsontypes.Metadata `json:"metadata,omitempty"` - Tags []string `json:"tags,omitempty"` - Alert bool `json:"alert,omitempty"` - AlertRules rules.AlertRules `json:"alert_rules,omitempty"` - Weight float32 `json:"weight,omitempty"` - Learned bool `json:"learned,omitempty"` - Ignored bool `json:"ignored,omitempty"` + ID int `json:"id"` + SystemID int32 `json:"system_id"` + TGID int32 `json:"tgid"` + Name *string `json:"name"` + AlphaTag *string `json:"alpha_tag"` + TGGroup *string `json:"tg_group"` + Frequency *int32 `json:"frequency"` + Metadata jsontypes.Metadata `json:"metadata"` + Tags []string `json:"tags"` + Alert bool `json:"alert"` + AlertRules rules.AlertRules `json:"alert_rules"` + Weight float32 `json:"weight"` + Learned bool `json:"learned"` + Ignored bool `json:"ignored"` } type TalkgroupVersion struct { - ID int `json:"id,omitempty"` - Time pgtype.Timestamptz `json:"time,omitempty"` - CreatedBy *int32 `json:"created_by,omitempty"` - Deleted *bool `json:"deleted,omitempty"` - SystemID *int32 `json:"system_id,omitempty"` - TGID *int32 `json:"tgid,omitempty"` - Name *string `json:"name,omitempty"` - AlphaTag *string `json:"alpha_tag,omitempty"` - TGGroup *string `json:"tg_group,omitempty"` - Frequency *int32 `json:"frequency,omitempty"` - Metadata []byte `json:"metadata,omitempty"` - Tags []string `json:"tags,omitempty"` - Alert *bool `json:"alert,omitempty"` - AlertRules []byte `json:"alert_rules,omitempty"` - Weight *float32 `json:"weight,omitempty"` - Learned *bool `json:"learned,omitempty"` - Ignored *bool `json:"ignored,omitempty"` + ID int `json:"id"` + Time pgtype.Timestamptz `json:"time"` + CreatedBy *int32 `json:"created_by"` + Deleted *bool `json:"deleted"` + SystemID *int32 `json:"system_id"` + TGID *int32 `json:"tgid"` + Name *string `json:"name"` + AlphaTag *string `json:"alpha_tag"` + TGGroup *string `json:"tg_group"` + Frequency *int32 `json:"frequency"` + Metadata []byte `json:"metadata"` + Tags []string `json:"tags"` + Alert *bool `json:"alert"` + AlertRules []byte `json:"alert_rules"` + Weight *float32 `json:"weight"` + Learned *bool `json:"learned"` + Ignored *bool `json:"ignored"` } type User struct { - ID int `json:"id,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - Email string `json:"email,omitempty"` - IsAdmin bool `json:"is_admin,omitempty"` - Prefs []byte `json:"prefs,omitempty"` + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + Prefs []byte `json:"prefs"` } diff --git a/pkg/database/querier.go b/pkg/database/querier.go index 4f8d8db..dfd32b1 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) diff --git a/pkg/incidents/incident.go b/pkg/incidents/incident.go index b48f152..37aa68c 100644 --- a/pkg/incidents/incident.go +++ b/pkg/incidents/incident.go @@ -5,7 +5,6 @@ import ( "dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/calls" - "dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/users" "github.com/google/uuid" ) diff --git a/pkg/incidents/incstore/rbac.go b/pkg/incidents/incstore/rbac.go new file mode 100644 index 0000000..7f5af77 --- /dev/null +++ b/pkg/incidents/incstore/rbac.go @@ -0,0 +1,65 @@ +package incstore + +import ( + "context" + "errors" + "fmt" + + "github.com/el-mike/restrict/v2" + "github.com/google/uuid" +) + +const ( + CallInIncidentConditionType = "CALL_IN_INCIDENT" +) + +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")) + } + + incs := FromCtx(ctx) + inCall, err := incs.CallIn(ctx, incID, incID) + if err != nil { + return restrict.NewConditionNotSatisfiedError(c, r, err) + } + + if !inCall { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`incident "%v" not in call "%v"`, incID, callID)) + } + + return nil +} + + diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index e2f396f..44d64b1 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -49,6 +49,9 @@ type Store interface { // 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) } type store struct { @@ -373,3 +376,9 @@ func (s *store) Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, er owner, err := database.FromCtx(ctx).GetIncidentOwner(ctx, id) return incidents.Incident{ID: id, Owner: users.UserID(owner)}, err } + +func (s *store) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) { + db := database.FromCtx(ctx) + return db.CallInIncident(ctx, inc, call) +} + diff --git a/pkg/rbac/conditions.go b/pkg/rbac/conditions.go index 721e83d..4d89604 100644 --- a/pkg/rbac/conditions.go +++ b/pkg/rbac/conditions.go @@ -9,7 +9,7 @@ import ( const ( SubmitterEqualConditionType = "SUBMITTER_EQUAL" - InMapConditionType = "IN_MAP" + InMapConditionType = "IN_MAP" ) type SubmitterEqualCondition struct { @@ -53,7 +53,7 @@ func SubmitterEqualConditionFactory() restrict.Condition { } type InMapCondition[K comparable, V any] struct { - ID string `json:"name,omitempty" yaml:"name,omitempty"` + ID string `json:"name,omitempty" yaml:"name,omitempty"` Key *restrict.ValueDescriptor `json:"key" yaml:"key"` Map *restrict.ValueDescriptor `json:"map" yaml:"map"` } @@ -77,12 +77,9 @@ func (c *InMapCondition[K, V]) Check(r *restrict.AccessRequest) error { return err } - keyVal := reflect.ValueOf(cKey) - mapVal := reflect.ValueOf(cMap) + key := cKey.(K) - key := keyVal.Interface().(K) - - if _, in := mapVal.Interface().(map[K]V)[key]; !in { + if _, in := cMap.(map[K]V)[key]; !in { return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("key '%v' not in map", key)) } diff --git a/pkg/rbac/entities.go b/pkg/rbac/entities.go new file mode 100644 index 0000000..9c710eb --- /dev/null +++ b/pkg/rbac/entities.go @@ -0,0 +1,23 @@ +package rbac + +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" +) diff --git a/pkg/rbac/policy.go b/pkg/rbac/policy.go index 32e20da..894903d 100644 --- a/pkg/rbac/policy.go +++ b/pkg/rbac/policy.go @@ -1,10 +1,25 @@ package rbac import ( + "dynatron.me/x/stillbox/pkg/incidents/incstore" + "github.com/el-mike/restrict/v2" ) -var policy = &restrict.PolicyDefinition{ +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{ RoleUser: { Description: "An authenticated user", @@ -52,6 +67,7 @@ var policy = &restrict.PolicyDefinition{ Grants: restrict.GrantsMap{ ResourceCall: { &restrict.Permission{Preset: PresetReadShared}, + &restrict.Permission{Preset: PresetReadInSharedIncident}, }, ResourceIncident: { &restrict.Permission{Preset: PresetReadShared}, @@ -207,5 +223,21 @@ var policy = &restrict.PolicyDefinition{ }, }, }, + PresetReadInSharedIncident: &restrict.Permission{ + Action: ActionRead, + Conditions: restrict.Conditions{ + &incstore.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 9a9525c..778a5c2 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -8,37 +8,7 @@ import ( "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" - ActionShare = "share" - - PresetUpdateOwn = "updateOwn" - PresetDeleteOwn = "deleteOwn" - PresetReadShared = "readShared" - PresetReadSharedInMap = "readSharedInMap" - PresetShareOwn = "shareOwn" - - PresetUpdateSubmitter = "updateSubmitter" - PresetDeleteSubmitter = "deleteSubmitter" - PresetShareSubmitter = "shareSubmitter" -) var ( ErrBadSubject = errors.New("bad subject in token") @@ -132,7 +102,7 @@ type rbac struct { } func New() (*rbac, error) { - adapter := adapters.NewInMemoryAdapter(policy) + adapter := adapters.NewInMemoryAdapter(Policy) polMan, err := restrict.NewPolicyManager(adapter, true) if err != nil { return nil, err @@ -154,10 +124,17 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp sub := 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, diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 43fa02f..9410baf 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -2,7 +2,6 @@ package server import ( "errors" - "fmt" "io/fs" "net/http" "path" diff --git a/pkg/talkgroups/talkgroup.go b/pkg/talkgroups/talkgroup.go index 7965e98..c2b306f 100644 --- a/pkg/talkgroups/talkgroup.go +++ b/pkg/talkgroups/talkgroup.go @@ -9,7 +9,10 @@ import ( "strings" "dynatron.me/x/stillbox/pkg/database" - "dynatron.me/x/stillbox/pkg/rbac" +) + +const ( + Resource = "Talkgroup" ) type Talkgroup struct { @@ -19,7 +22,7 @@ type Talkgroup struct { } func (t *Talkgroup) GetResourceName() string { - return rbac.ResourceTalkgroup + return Resource } func (t Talkgroup) String() string { diff --git a/pkg/talkgroups/tgstore/store.go b/pkg/talkgroups/tgstore/store.go index 64a010e..42a347c 100644 --- a/pkg/talkgroups/tgstore/store.go +++ b/pkg/talkgroups/tgstore/store.go @@ -327,7 +327,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(tgsp.Resource), rbac.WithActions(rbac.ActionRead)) if err != nil { return nil, err } diff --git a/pkg/users/guest.go b/pkg/users/guest.go deleted file mode 100644 index 52657e7..0000000 --- a/pkg/users/guest.go +++ /dev/null @@ -1,13 +0,0 @@ -package users - -import ( - "dynatron.me/x/stillbox/pkg/rbac" -) - -type Public struct { - RemoteAddr string -} - -func (s *Public) GetRoles() []string { - return []string{rbac.RolePublic} -} 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, From e9415a471fe2a295ab6deb3e0a8c6483f20fa03f Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Tue, 21 Jan 2025 08:20:13 -0500 Subject: [PATCH 05/10] still cycle --- pkg/incidents/incstore/rbac.go | 65 ---------------------------------- pkg/rbac/conditions.go | 53 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 65 deletions(-) delete mode 100644 pkg/incidents/incstore/rbac.go diff --git a/pkg/incidents/incstore/rbac.go b/pkg/incidents/incstore/rbac.go deleted file mode 100644 index 7f5af77..0000000 --- a/pkg/incidents/incstore/rbac.go +++ /dev/null @@ -1,65 +0,0 @@ -package incstore - -import ( - "context" - "errors" - "fmt" - - "github.com/el-mike/restrict/v2" - "github.com/google/uuid" -) - -const ( - CallInIncidentConditionType = "CALL_IN_INCIDENT" -) - -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")) - } - - incs := FromCtx(ctx) - inCall, err := incs.CallIn(ctx, incID, incID) - if err != nil { - return restrict.NewConditionNotSatisfiedError(c, r, err) - } - - if !inCall { - return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`incident "%v" not in call "%v"`, incID, callID)) - } - - return nil -} - - diff --git a/pkg/rbac/conditions.go b/pkg/rbac/conditions.go index 4d89604..dd0c723 100644 --- a/pkg/rbac/conditions.go +++ b/pkg/rbac/conditions.go @@ -1,17 +1,70 @@ package rbac import ( + "context" + "errors" "fmt" "reflect" "github.com/el-mike/restrict/v2" + "github.com/google/uuid" ) const ( SubmitterEqualConditionType = "SUBMITTER_EQUAL" InMapConditionType = "IN_MAP" + CallInIncidentConditionType = "CALL_IN_INCIDENT" ) +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")) + } + + incs := FromCtx(ctx) + inCall, err := incs.CallIn(ctx, incID, incID) + if err != nil { + return restrict.NewConditionNotSatisfiedError(c, r, err) + } + + if !inCall { + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`incident "%v" not in call "%v"`, incID, callID)) + } + + return nil +} + type SubmitterEqualCondition struct { ID string `json:"name,omitempty" yaml:"name,omitempty"` Left *restrict.ValueDescriptor `json:"left" yaml:"left"` From 769dd9eb7c36fb2cdfd8dcbc3adb698793b599ee Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Tue, 21 Jan 2025 08:43:03 -0500 Subject: [PATCH 06/10] working for now --- pkg/calls/call.go | 5 ++--- pkg/incidents/incident.go | 1 + pkg/incidents/incstore/store.go | 1 - pkg/rbac/conditions.go | 13 +++++++------ pkg/rbac/policy.go | 10 ++++------ pkg/rbac/rbac.go | 3 --- pkg/talkgroups/talkgroup.go | 7 ++----- pkg/talkgroups/tgstore/store.go | 2 +- 8 files changed, 17 insertions(+), 25 deletions(-) diff --git a/pkg/calls/call.go b/pkg/calls/call.go index 8511294..1f0e338 100644 --- a/pkg/calls/call.go +++ b/pkg/calls/call.go @@ -8,6 +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/talkgroups" "dynatron.me/x/stillbox/pkg/users" @@ -15,8 +16,6 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -const Resource = "call" - type CallDuration time.Duration func (d CallDuration) Duration() time.Duration { @@ -77,7 +76,7 @@ type Call struct { } func (c *Call) GetResourceName() string { - return Resource + return rbac.ResourceCall } func (c *Call) String() string { diff --git a/pkg/incidents/incident.go b/pkg/incidents/incident.go index 37aa68c..b48f152 100644 --- a/pkg/incidents/incident.go +++ b/pkg/incidents/incident.go @@ -5,6 +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/users" "github.com/google/uuid" ) diff --git a/pkg/incidents/incstore/store.go b/pkg/incidents/incstore/store.go index 44d64b1..c2fa0a8 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -381,4 +381,3 @@ func (s *store) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool db := database.FromCtx(ctx) return db.CallInIncident(ctx, inc, call) } - diff --git a/pkg/rbac/conditions.go b/pkg/rbac/conditions.go index dd0c723..91a0c3c 100644 --- a/pkg/rbac/conditions.go +++ b/pkg/rbac/conditions.go @@ -6,19 +6,21 @@ import ( "fmt" "reflect" + "dynatron.me/x/stillbox/pkg/database" + "github.com/el-mike/restrict/v2" "github.com/google/uuid" ) const ( SubmitterEqualConditionType = "SUBMITTER_EQUAL" - InMapConditionType = "IN_MAP" + InMapConditionType = "IN_MAP" CallInIncidentConditionType = "CALL_IN_INCIDENT" ) type CallInIncidentCondition struct { - ID string `json:"name,omitempty" yaml:"name,omitempty"` - Call *restrict.ValueDescriptor `json:"call" yaml:"call"` + ID string `json:"name,omitempty" yaml:"name,omitempty"` + Call *restrict.ValueDescriptor `json:"call" yaml:"call"` Incident *restrict.ValueDescriptor `json:"incident" yaml:"incident"` } @@ -52,8 +54,7 @@ func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error { return restrict.NewConditionNotSatisfiedError(c, r, errors.New("call ID is not UUID")) } - incs := FromCtx(ctx) - inCall, err := incs.CallIn(ctx, incID, incID) + inCall, err := database.FromCtx(ctx).CallInIncident(ctx, incID, incID) if err != nil { return restrict.NewConditionNotSatisfiedError(c, r, err) } @@ -106,7 +107,7 @@ func SubmitterEqualConditionFactory() restrict.Condition { } type InMapCondition[K comparable, V any] struct { - ID string `json:"name,omitempty" yaml:"name,omitempty"` + ID string `json:"name,omitempty" yaml:"name,omitempty"` Key *restrict.ValueDescriptor `json:"key" yaml:"key"` Map *restrict.ValueDescriptor `json:"map" yaml:"map"` } diff --git a/pkg/rbac/policy.go b/pkg/rbac/policy.go index 894903d..0e76ca2 100644 --- a/pkg/rbac/policy.go +++ b/pkg/rbac/policy.go @@ -1,8 +1,6 @@ package rbac import ( - "dynatron.me/x/stillbox/pkg/incidents/incstore" - "github.com/el-mike/restrict/v2" ) @@ -13,9 +11,9 @@ const ( PresetReadSharedInMap = "readSharedInMap" PresetShareOwn = "shareOwn" - PresetUpdateSubmitter = "updateSubmitter" - PresetDeleteSubmitter = "deleteSubmitter" - PresetShareSubmitter = "shareSubmitter" + PresetUpdateSubmitter = "updateSubmitter" + PresetDeleteSubmitter = "deleteSubmitter" + PresetShareSubmitter = "shareSubmitter" PresetReadInSharedIncident = "readInSharedIncident" ) @@ -226,7 +224,7 @@ var Policy = &restrict.PolicyDefinition{ PresetReadInSharedIncident: &restrict.Permission{ Action: ActionRead, Conditions: restrict.Conditions{ - &incstore.CallInIncidentCondition{ + &CallInIncidentCondition{ ID: "callInIncident", Call: &restrict.ValueDescriptor{ Source: restrict.ResourceField, diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index 778a5c2..d1f9ec4 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -8,8 +8,6 @@ import ( "github.com/el-mike/restrict/v2/adapters" ) - - var ( ErrBadSubject = errors.New("bad subject in token") ) @@ -124,7 +122,6 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp sub := SubjectFrom(ctx) o := checkOptions{} - for _, opt := range opts { opt(&o) } diff --git a/pkg/talkgroups/talkgroup.go b/pkg/talkgroups/talkgroup.go index c2b306f..7965e98 100644 --- a/pkg/talkgroups/talkgroup.go +++ b/pkg/talkgroups/talkgroup.go @@ -9,10 +9,7 @@ import ( "strings" "dynatron.me/x/stillbox/pkg/database" -) - -const ( - Resource = "Talkgroup" + "dynatron.me/x/stillbox/pkg/rbac" ) type Talkgroup struct { @@ -22,7 +19,7 @@ type Talkgroup struct { } func (t *Talkgroup) GetResourceName() string { - return Resource + return rbac.ResourceTalkgroup } func (t Talkgroup) String() string { diff --git a/pkg/talkgroups/tgstore/store.go b/pkg/talkgroups/tgstore/store.go index 42a347c..64a010e 100644 --- a/pkg/talkgroups/tgstore/store.go +++ b/pkg/talkgroups/tgstore/store.go @@ -327,7 +327,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(tgsp.Resource), rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead)) if err != nil { return nil, err } From 957aebe695de9c04d862bb43d9a938ebee41f6bb Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Tue, 21 Jan 2025 09:21:46 -0500 Subject: [PATCH 07/10] Working! --- pkg/rbac/conditions.go | 2 +- pkg/rbac/rbac.go | 7 ++++++- pkg/rest/calls.go | 5 +++-- pkg/rest/incidents.go | 15 +++++++++----- pkg/rest/share.go | 45 +++++++++++++++++++++++++++++++++++++----- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/pkg/rbac/conditions.go b/pkg/rbac/conditions.go index 91a0c3c..ddebd69 100644 --- a/pkg/rbac/conditions.go +++ b/pkg/rbac/conditions.go @@ -54,7 +54,7 @@ func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error { return restrict.NewConditionNotSatisfiedError(c, r, errors.New("call ID is not UUID")) } - inCall, err := database.FromCtx(ctx).CallInIncident(ctx, incID, incID) + inCall, err := database.FromCtx(ctx).CallInIncident(ctx, incID, callID) if err != nil { return restrict.NewConditionNotSatisfiedError(c, r, err) } diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index d1f9ec4..172b634 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -139,7 +139,12 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp Context: o.context, } - return sub, r.access.Authorize(req) + err := r.access.Authorize(req) + if err != nil { + return nil, err + } + + return sub, nil } type PublicSubject struct { diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index 285a761..ea5b6c0 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" @@ -101,7 +102,7 @@ func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Re _, _ = w.Write(call.AudioBlob) } -func (ca *callsAPI) shareCallRoute(id uuid.UUID, w http.ResponseWriter, r *http.Request) { +func (ca *callsAPI) shareCallRoute(id uuid.UUID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { p := getAudioParams{ CallID: &id, } @@ -109,7 +110,7 @@ func (ca *callsAPI) shareCallRoute(id uuid.UUID, w http.ResponseWriter, r *http. ca.getAudio(p, w, r) } -func (ca *callsAPI) shareCallDLRoute(id uuid.UUID, w http.ResponseWriter, r *http.Request) { +func (ca *callsAPI) shareCallDLRoute(id uuid.UUID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { p := getAudioParams{ CallID: &id, Download: common.PtrTo("download"), diff --git a/pkg/rest/incidents.go b/pkg/rest/incidents.go index c7effb5..0da9412 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" @@ -93,10 +94,10 @@ func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request) return } - ia.getIncident(id, w, r) + ia.getIncident(id, nil, w, r) } -func (ia *incidentsAPI) getIncident(id uuid.UUID, w http.ResponseWriter, r *http.Request) { +func (ia *incidentsAPI) getIncident(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) { ctx := r.Context() incs := incstore.FromCtx(ctx) inc, err := incs.Incident(ctx, id) @@ -194,10 +195,10 @@ func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request) return } - ia.getCallsM3U(id, w, r) + ia.getCallsM3U(id, nil, w, r) } -func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, w http.ResponseWriter, r *http.Request) { +func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) { ctx := r.Context() incs := incstore.FromCtx(ctx) tgst := tgstore.FromCtx(ctx) @@ -211,6 +212,10 @@ func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, w http.ResponseWriter, r *http b := new(bytes.Buffer) callUrl := common.PtrTo(*ia.baseURL) + urlRoot := "/api/call" + if share != nil { + urlRoot = fmt.Sprintf("/share/%s/%s/call/", share.Type, share.ID) + } b.WriteString("#EXTM3U\n\n") for _, c := range inc.Calls { @@ -224,7 +229,7 @@ func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, w http.ResponseWriter, r *http 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 aee47aa..8406533 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -29,14 +29,23 @@ const ( func (rt ShareRequestType) IsValid() bool { switch rt { - case ShareRequestCall, ShareRequestIncident, ShareRequestIncidentM3U: + case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident, ShareRequestIncidentM3U: return true } return false } -type HandlerFunc func(uuid.UUID, http.ResponseWriter, *http.Request) +func (rt ShareRequestType) IsValidSubtype() bool { + switch rt { + case ShareRequestCall, ShareRequestCallDL: + return true + } + + return false +} + +type HandlerFunc func(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) type ShareHandlers map[ShareRequestType]HandlerFunc type shareAPI struct { baseURL *url.URL @@ -63,6 +72,7 @@ func (sa *shareAPI) RootRouter() http.Handler { r := chi.NewMux() r.Get("/{type}/{shareId:[A-Za-z0-9_-]{20,}}", sa.routeShare) + r.Get("/{type}/{shareId:[A-Za-z0-9_-]{20,}}/{subType}/{subID}", sa.routeShare) return r } @@ -92,8 +102,10 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { shs := shares.FromCtx(ctx) params := struct { - Type string `param:"type"` - ID string `param:"shareId"` + Type string `param:"type"` + ID string `param:"shareId"` + SubType *string `param:"subType"` + SubID *string `param:"subID"` }{} err := decodeParams(¶ms, r) @@ -124,7 +136,30 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { ctx = rbac.CtxWithSubject(ctx, sh) r = r.WithContext(ctx) - sa.shnd[rType](sh.EntityID, w, r) + if params.SubType != nil { + if params.SubID == nil { + // probably can't happen + wErr(w, r, autoError(ErrBadShare)) + return + } + + subT := ShareRequestType(*params.SubType) + if !subT.IsValidSubtype() { + wErr(w, r, autoError(ErrBadShare)) + return + } + + subIDU, err := uuid.Parse(*params.SubID) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + sa.shnd[subT](subIDU, sh, w, r) + return + } + + sa.shnd[rType](sh.EntityID, sh, w, r) } func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) { From 76a2214377dcd36d6018925fe59c0f6793c46ebd Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Wed, 22 Jan 2025 10:39:23 -0500 Subject: [PATCH 08/10] Able to use store in rbac policy finally --- pkg/alerting/alerting.go | 4 +- pkg/auth/apikey.go | 6 +- pkg/auth/jwt.go | 4 +- pkg/calls/call.go | 4 +- pkg/calls/callstore/store.go | 11 +- pkg/database/mocks/Store.go | 117 ++++++++++++++ pkg/database/models.go | 226 ++++++++++++++-------------- pkg/database/partman/partman.go | 4 +- pkg/database/querier.go | 1 + pkg/database/share.sql.go | 34 +++++ pkg/incidents/incident.go | 4 +- pkg/incidents/incstore/store.go | 11 +- pkg/nexus/nexus.go | 4 +- pkg/rbac/entities.go | 23 --- pkg/rbac/entities/entities.go | 79 ++++++++++ pkg/rbac/mocks/RBAC.go | 18 ++- pkg/rbac/{ => policy}/conditions.go | 6 +- pkg/rbac/{ => policy}/policy.go | 106 ++++++------- pkg/rbac/rbac.go | 71 ++------- pkg/rbac/rbac_test.go | 42 +++--- pkg/rest/share.go | 4 +- pkg/server/server.go | 3 +- pkg/shares/service.go | 4 +- pkg/shares/share.go | 7 +- pkg/shares/store.go | 3 +- pkg/sources/http.go | 3 +- pkg/talkgroups/filter/filter.go | 1 + pkg/talkgroups/talkgroup.go | 4 +- pkg/talkgroups/tgstore/store.go | 19 +-- pkg/users/user.go | 11 +- sql/postgres/queries/share.sql | 8 + 31 files changed, 510 insertions(+), 332 deletions(-) delete mode 100644 pkg/rbac/entities.go create mode 100644 pkg/rbac/entities/entities.go rename pkg/rbac/{ => policy}/conditions.go (96%) rename pkg/rbac/{ => policy}/policy.go (66%) 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 1f0e338..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" @@ -76,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 72dc684..85aed7a 100644 --- a/pkg/calls/callstore/store.go +++ b/pkg/calls/callstore/store.go @@ -11,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" @@ -85,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 } @@ -123,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, &calls.Call{ID: id}, rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -144,7 +145,7 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, } func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, 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, err } @@ -193,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 } @@ -256,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/database/mocks/Store.go b/pkg/database/mocks/Store.go index 3f6c509..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) @@ -1750,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 257acee..585f860 100644 --- a/pkg/database/models.go +++ b/pkg/database/models.go @@ -14,150 +14,150 @@ import ( ) type Alert struct { - ID int `json:"id"` - Time pgtype.Timestamptz `json:"time"` - TGID int `json:"tgid"` - SystemID int `json:"system_id"` - Weight *float32 `json:"weight"` - Score *float32 `json:"score"` - OrigScore *float32 `json:"orig_score"` - Notified bool `json:"notified"` - Metadata []byte `json:"metadata"` + ID int `json:"id,omitempty"` + Time pgtype.Timestamptz `json:"time,omitempty"` + TGID int `json:"tgid,omitempty"` + SystemID int `json:"system_id,omitempty"` + Weight *float32 `json:"weight,omitempty"` + Score *float32 `json:"score,omitempty"` + OrigScore *float32 `json:"orig_score,omitempty"` + Notified bool `json:"notified,omitempty"` + Metadata []byte `json:"metadata,omitempty"` } type ApiKey struct { - ID int `json:"id"` - Owner int `json:"owner"` - CreatedAt time.Time `json:"created_at"` - Expires pgtype.Timestamp `json:"expires"` - Disabled *bool `json:"disabled"` - ApiKey string `json:"api_key"` + ID int `json:"id,omitempty"` + Owner int `json:"owner,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Expires pgtype.Timestamp `json:"expires,omitempty"` + Disabled *bool `json:"disabled,omitempty"` + ApiKey string `json:"api_key,omitempty"` } type Call 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"` - AudioBlob []byte `json:"audio_blob"` - Duration *int32 `json:"duration"` - AudioType *string `json:"audio_type"` - AudioUrl *string `json:"audio_url"` - 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"` - Transcript *string `json:"transcript"` + ID uuid.UUID `json:"id,omitempty"` + Submitter *int32 `json:"submitter,omitempty"` + System int `json:"system,omitempty"` + Talkgroup int `json:"talkgroup,omitempty"` + CallDate pgtype.Timestamptz `json:"call_date,omitempty"` + AudioName *string `json:"audio_name,omitempty"` + AudioBlob []byte `json:"audio_blob,omitempty"` + Duration *int32 `json:"duration,omitempty"` + AudioType *string `json:"audio_type,omitempty"` + AudioUrl *string `json:"audio_url,omitempty"` + Frequency int `json:"frequency,omitempty"` + Frequencies []int `json:"frequencies,omitempty"` + Patches []int `json:"patches,omitempty"` + TGLabel *string `json:"tg_label,omitempty"` + TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` + TGGroup *string `json:"tg_group,omitempty"` + Source int `json:"source,omitempty"` + Transcript *string `json:"transcript,omitempty"` } type Incident struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Owner int `json:"owner"` - Description *string `json:"description"` - StartTime pgtype.Timestamptz `json:"start_time"` - EndTime pgtype.Timestamptz `json:"end_time"` - Location []byte `json:"location"` - Metadata jsontypes.Metadata `json:"metadata"` + ID uuid.UUID `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Owner int `json:"owner,omitempty"` + Description *string `json:"description,omitempty"` + StartTime pgtype.Timestamptz `json:"start_time,omitempty"` + EndTime pgtype.Timestamptz `json:"end_time,omitempty"` + Location []byte `json:"location,omitempty"` + Metadata jsontypes.Metadata `json:"metadata,omitempty"` } type IncidentsCall struct { - IncidentID uuid.UUID `json:"incident_id"` - CallID uuid.UUID `json:"call_id"` - CallsTblID pgtype.UUID `json:"calls_tbl_id"` - SweptCallID pgtype.UUID `json:"swept_call_id"` - CallDate pgtype.Timestamptz `json:"call_date"` - Notes []byte `json:"notes"` + IncidentID uuid.UUID `json:"incident_id,omitempty"` + CallID uuid.UUID `json:"call_id,omitempty"` + CallsTblID pgtype.UUID `json:"calls_tbl_id,omitempty"` + SweptCallID pgtype.UUID `json:"swept_call_id,omitempty"` + CallDate pgtype.Timestamptz `json:"call_date,omitempty"` + Notes []byte `json:"notes,omitempty"` } type Setting struct { - Name string `json:"name"` - UpdatedBy *int32 `json:"updated_by"` - Value []byte `json:"value"` + Name string `json:"name,omitempty"` + UpdatedBy *int32 `json:"updated_by,omitempty"` + Value []byte `json:"value,omitempty"` } type Share 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"` + 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"` } type SweptCall 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"` - AudioBlob []byte `json:"audio_blob"` - Duration *int32 `json:"duration"` - AudioType *string `json:"audio_type"` - AudioUrl *string `json:"audio_url"` - 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"` - Transcript *string `json:"transcript"` + ID uuid.UUID `json:"id,omitempty"` + Submitter *int32 `json:"submitter,omitempty"` + System int `json:"system,omitempty"` + Talkgroup int `json:"talkgroup,omitempty"` + CallDate pgtype.Timestamptz `json:"call_date,omitempty"` + AudioName *string `json:"audio_name,omitempty"` + AudioBlob []byte `json:"audio_blob,omitempty"` + Duration *int32 `json:"duration,omitempty"` + AudioType *string `json:"audio_type,omitempty"` + AudioUrl *string `json:"audio_url,omitempty"` + Frequency int `json:"frequency,omitempty"` + Frequencies []int `json:"frequencies,omitempty"` + Patches []int `json:"patches,omitempty"` + TGLabel *string `json:"tg_label,omitempty"` + TGAlphaTag *string `json:"tg_alpha_tag,omitempty"` + TGGroup *string `json:"tg_group,omitempty"` + Source int `json:"source,omitempty"` + Transcript *string `json:"transcript,omitempty"` } type System struct { - ID int `json:"id"` - Name string `json:"name"` + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` } type Talkgroup struct { - ID int `json:"id"` - SystemID int32 `json:"system_id"` - TGID int32 `json:"tgid"` - Name *string `json:"name"` - AlphaTag *string `json:"alpha_tag"` - TGGroup *string `json:"tg_group"` - Frequency *int32 `json:"frequency"` - Metadata jsontypes.Metadata `json:"metadata"` - Tags []string `json:"tags"` - Alert bool `json:"alert"` - AlertRules rules.AlertRules `json:"alert_rules"` - Weight float32 `json:"weight"` - Learned bool `json:"learned"` - Ignored bool `json:"ignored"` + ID int `json:"id,omitempty"` + SystemID int32 `json:"system_id,omitempty"` + TGID int32 `json:"tgid,omitempty"` + Name *string `json:"name,omitempty"` + AlphaTag *string `json:"alpha_tag,omitempty"` + TGGroup *string `json:"tg_group,omitempty"` + Frequency *int32 `json:"frequency,omitempty"` + Metadata jsontypes.Metadata `json:"metadata,omitempty"` + Tags []string `json:"tags,omitempty"` + Alert bool `json:"alert,omitempty"` + AlertRules rules.AlertRules `json:"alert_rules,omitempty"` + Weight float32 `json:"weight,omitempty"` + Learned bool `json:"learned,omitempty"` + Ignored bool `json:"ignored,omitempty"` } type TalkgroupVersion struct { - ID int `json:"id"` - Time pgtype.Timestamptz `json:"time"` - CreatedBy *int32 `json:"created_by"` - Deleted *bool `json:"deleted"` - SystemID *int32 `json:"system_id"` - TGID *int32 `json:"tgid"` - Name *string `json:"name"` - AlphaTag *string `json:"alpha_tag"` - TGGroup *string `json:"tg_group"` - Frequency *int32 `json:"frequency"` - Metadata []byte `json:"metadata"` - Tags []string `json:"tags"` - Alert *bool `json:"alert"` - AlertRules []byte `json:"alert_rules"` - Weight *float32 `json:"weight"` - Learned *bool `json:"learned"` - Ignored *bool `json:"ignored"` + ID int `json:"id,omitempty"` + Time pgtype.Timestamptz `json:"time,omitempty"` + CreatedBy *int32 `json:"created_by,omitempty"` + Deleted *bool `json:"deleted,omitempty"` + SystemID *int32 `json:"system_id,omitempty"` + TGID *int32 `json:"tgid,omitempty"` + Name *string `json:"name,omitempty"` + AlphaTag *string `json:"alpha_tag,omitempty"` + TGGroup *string `json:"tg_group,omitempty"` + Frequency *int32 `json:"frequency,omitempty"` + Metadata []byte `json:"metadata,omitempty"` + Tags []string `json:"tags,omitempty"` + Alert *bool `json:"alert,omitempty"` + AlertRules []byte `json:"alert_rules,omitempty"` + Weight *float32 `json:"weight,omitempty"` + Learned *bool `json:"learned,omitempty"` + Ignored *bool `json:"ignored,omitempty"` } type User struct { - ID int `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Email string `json:"email"` - IsAdmin bool `json:"is_admin"` - Prefs []byte `json:"prefs"` + ID int `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Email string `json:"email,omitempty"` + IsAdmin bool `json:"is_admin,omitempty"` + Prefs []byte `json:"prefs,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 dfd32b1..d2cd4a9 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -40,6 +40,7 @@ type Querier interface { 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 fbd2829..53ab241 100644 --- a/pkg/database/share.sql.go +++ b/pkg/database/share.sql.go @@ -53,6 +53,40 @@ func (q *Queries) DeleteShare(ctx context.Context, id string) error { return err } +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 getShare = `-- name: GetShare :one SELECT id, 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 c2fa0a8..45880c8 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -10,6 +10,7 @@ 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/users" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -143,7 +144,7 @@ func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID 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 } @@ -176,7 +177,7 @@ func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID } 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)) + _, err = rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(entities.ActionRead)) if err != nil { return nil, 0, err } @@ -281,7 +282,7 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall { } func (s *store) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) { - _, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(rbac.ActionRead)) + _, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(entities.ActionRead)) if err != nil { return nil, err } @@ -337,7 +338,7 @@ func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncide 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 } @@ -360,7 +361,7 @@ func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error { 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 } 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.go b/pkg/rbac/entities.go deleted file mode 100644 index 9c710eb..0000000 --- a/pkg/rbac/entities.go +++ /dev/null @@ -1,23 +0,0 @@ -package rbac - -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" -) 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/conditions.go b/pkg/rbac/policy/conditions.go similarity index 96% rename from pkg/rbac/conditions.go rename to pkg/rbac/policy/conditions.go index ddebd69..77b8428 100644 --- a/pkg/rbac/conditions.go +++ b/pkg/rbac/policy/conditions.go @@ -1,4 +1,4 @@ -package rbac +package policy import ( "context" @@ -6,7 +6,7 @@ import ( "fmt" "reflect" - "dynatron.me/x/stillbox/pkg/database" + "dynatron.me/x/stillbox/pkg/incidents/incstore" "github.com/el-mike/restrict/v2" "github.com/google/uuid" @@ -54,7 +54,7 @@ func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error { return restrict.NewConditionNotSatisfiedError(c, r, errors.New("call ID is not UUID")) } - inCall, err := database.FromCtx(ctx).CallInIncident(ctx, incID, callID) + inCall, err := incstore.FromCtx(ctx).CallIn(ctx, incID, callID) if err != nil { return restrict.NewConditionNotSatisfiedError(c, r, err) } diff --git a/pkg/rbac/policy.go b/pkg/rbac/policy/policy.go similarity index 66% rename from pkg/rbac/policy.go rename to pkg/rbac/policy/policy.go index 0e76ca2..16798a6 100644 --- a/pkg/rbac/policy.go +++ b/pkg/rbac/policy/policy.go @@ -1,6 +1,8 @@ -package rbac +package policy import ( + "dynatron.me/x/stillbox/pkg/rbac/entities" + "github.com/el-mike/restrict/v2" ) @@ -19,90 +21,90 @@ const ( var Policy = &restrict.PolicyDefinition{ Roles: restrict.Roles{ - RoleUser: { + entities.RoleUser: { Description: "An authenticated user", Grants: restrict.GrantsMap{ - ResourceIncident: { - &restrict.Permission{Action: ActionRead}, - &restrict.Permission{Action: ActionCreate}, + entities.ResourceIncident: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Preset: PresetUpdateOwn}, &restrict.Permission{Preset: PresetDeleteOwn}, &restrict.Permission{Preset: PresetShareOwn}, }, - ResourceCall: { - &restrict.Permission{Action: ActionRead}, - &restrict.Permission{Action: ActionCreate}, + entities.ResourceCall: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Preset: PresetUpdateSubmitter}, &restrict.Permission{Preset: PresetDeleteSubmitter}, - &restrict.Permission{Action: ActionShare}, + &restrict.Permission{Action: entities.ActionShare}, }, - ResourceTalkgroup: { - &restrict.Permission{Action: ActionRead}, + entities.ResourceTalkgroup: { + &restrict.Permission{Action: entities.ActionRead}, }, - ResourceShare: { - &restrict.Permission{Action: ActionRead}, - &restrict.Permission{Action: ActionCreate}, + entities.ResourceShare: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Preset: PresetUpdateOwn}, &restrict.Permission{Preset: PresetDeleteOwn}, }, }, }, - RoleSubmitter: { + entities.RoleSubmitter: { Description: "A role that can submit calls", Grants: restrict.GrantsMap{ - ResourceCall: { - &restrict.Permission{Action: ActionCreate}, + entities.ResourceCall: { + &restrict.Permission{Action: entities.ActionCreate}, }, - ResourceTalkgroup: { + entities.ResourceTalkgroup: { // for learning TGs - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Action: ActionUpdate}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Action: entities.ActionUpdate}, }, }, }, - RoleShareGuest: { + entities.RoleShareGuest: { Description: "Someone who has a valid share link", Grants: restrict.GrantsMap{ - ResourceCall: { + entities.ResourceCall: { &restrict.Permission{Preset: PresetReadShared}, &restrict.Permission{Preset: PresetReadInSharedIncident}, }, - ResourceIncident: { + entities.ResourceIncident: { &restrict.Permission{Preset: PresetReadShared}, }, - ResourceTalkgroup: { - &restrict.Permission{Action: ActionRead}, + entities.ResourceTalkgroup: { + &restrict.Permission{Action: entities.ActionRead}, }, }, }, - RoleAdmin: { - Parents: []string{RoleUser}, + entities.RoleAdmin: { + Parents: []string{entities.RoleUser}, Grants: restrict.GrantsMap{ - ResourceIncident: { - &restrict.Permission{Action: ActionUpdate}, - &restrict.Permission{Action: ActionDelete}, - &restrict.Permission{Action: ActionShare}, + entities.ResourceIncident: { + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionDelete}, + &restrict.Permission{Action: entities.ActionShare}, }, - ResourceCall: { - &restrict.Permission{Action: ActionUpdate}, - &restrict.Permission{Action: ActionDelete}, - &restrict.Permission{Action: ActionShare}, + entities.ResourceCall: { + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionDelete}, + &restrict.Permission{Action: entities.ActionShare}, }, - ResourceTalkgroup: { - &restrict.Permission{Action: ActionUpdate}, - &restrict.Permission{Action: ActionCreate}, - &restrict.Permission{Action: ActionDelete}, + entities.ResourceTalkgroup: { + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Action: entities.ActionDelete}, }, }, }, - RoleSystem: { - Parents: []string{RoleSystem}, + entities.RoleSystem: { + Parents: []string{entities.RoleSystem}, }, - RolePublic: { + entities.RolePublic: { /* Grants: restrict.GrantsMap{ - ResourceShare: { - &restrict.Permission{Action: ActionRead}, + entities.ResourceShare: { + &restrict.Permission{Action: entities.ActionRead}, }, }, */ @@ -110,7 +112,7 @@ var Policy = &restrict.PolicyDefinition{ }, PermissionPresets: restrict.PermissionPresets{ PresetUpdateOwn: &restrict.Permission{ - Action: ActionUpdate, + Action: entities.ActionUpdate, Conditions: restrict.Conditions{ &restrict.EqualCondition{ ID: "isOwner", @@ -126,7 +128,7 @@ var Policy = &restrict.PolicyDefinition{ }, }, PresetDeleteOwn: &restrict.Permission{ - Action: ActionDelete, + Action: entities.ActionDelete, Conditions: restrict.Conditions{ &restrict.EqualCondition{ ID: "isOwner", @@ -142,7 +144,7 @@ var Policy = &restrict.PolicyDefinition{ }, }, PresetShareOwn: &restrict.Permission{ - Action: ActionShare, + Action: entities.ActionShare, Conditions: restrict.Conditions{ &restrict.EqualCondition{ ID: "isOwner", @@ -158,7 +160,7 @@ var Policy = &restrict.PolicyDefinition{ }, }, PresetUpdateSubmitter: &restrict.Permission{ - Action: ActionUpdate, + Action: entities.ActionUpdate, Conditions: restrict.Conditions{ &SubmitterEqualCondition{ ID: "isSubmitter", @@ -174,7 +176,7 @@ var Policy = &restrict.PolicyDefinition{ }, }, PresetDeleteSubmitter: &restrict.Permission{ - Action: ActionDelete, + Action: entities.ActionDelete, Conditions: restrict.Conditions{ &SubmitterEqualCondition{ ID: "isSubmitter", @@ -190,7 +192,7 @@ var Policy = &restrict.PolicyDefinition{ }, }, PresetShareSubmitter: &restrict.Permission{ - Action: ActionShare, + Action: entities.ActionShare, Conditions: restrict.Conditions{ &SubmitterEqualCondition{ ID: "isSubmitter", @@ -206,7 +208,7 @@ var Policy = &restrict.PolicyDefinition{ }, }, PresetReadShared: &restrict.Permission{ - Action: ActionRead, + Action: entities.ActionRead, Conditions: restrict.Conditions{ &restrict.EqualCondition{ ID: "isOwner", @@ -222,7 +224,7 @@ var Policy = &restrict.PolicyDefinition{ }, }, PresetReadInSharedIncident: &restrict.Permission{ - Action: ActionRead, + Action: entities.ActionRead, Conditions: restrict.Conditions{ &CallInIncidentCondition{ ID: "callInIncident", diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go index 172b634..58da816 100644 --- a/pkg/rbac/rbac.go +++ b/pkg/rbac/rbac.go @@ -4,6 +4,8 @@ import ( "context" "errors" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2/adapters" ) @@ -12,14 +14,6 @@ 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 @@ -28,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" @@ -81,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 { @@ -99,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 @@ -114,12 +90,12 @@ 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 { @@ -139,34 +115,5 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp Context: o.context, } - err := r.access.Authorize(req) - if err != nil { - return nil, err - } - - return sub, nil -} - -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} + return sub, r.access.Authorize(req) } diff --git a/pkg/rbac/rbac_test.go b/pkg/rbac/rbac_test.go index 55fd81b..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,7 +139,7 @@ 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"`), }, { @@ -148,7 +150,7 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: common.PtrTo(users.UserID(6)), }, - action: rbac.ActionShare, + action: entities.ActionShare, expectErr: nil, }, { @@ -160,7 +162,7 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: common.PtrTo(users.UserID(6)), }, - action: rbac.ActionShare, + action: entities.ActionShare, expectErr: nil, }, { @@ -171,7 +173,7 @@ func TestRBAC(t *testing.T) { resource: &calls.Call{ Submitter: common.PtrTo(users.UserID(6)), }, - action: rbac.ActionShare, + action: entities.ActionShare, expectErr: nil, }, { @@ -182,7 +184,7 @@ func TestRBAC(t *testing.T) { resource: &incidents.Incident{ Owner: users.UserID(6), }, - action: rbac.ActionShare, + action: entities.ActionShare, expectErr: errors.New(`access denied for Action: "share" on Resource: "Incident"`), }, { @@ -194,7 +196,7 @@ func TestRBAC(t *testing.T) { resource: &incidents.Incident{ Owner: users.UserID(6), }, - action: rbac.ActionShare, + action: entities.ActionShare, expectErr: nil, }, { @@ -205,15 +207,15 @@ func TestRBAC(t *testing.T) { resource: &incidents.Incident{ Owner: users.UserID(6), }, - action: rbac.ActionShare, + 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/share.go b/pkg/rest/share.go index 8406533..86e33b8 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -7,7 +7,7 @@ import ( "time" "dynatron.me/x/stillbox/internal/forms" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/shares" "github.com/go-chi/chi/v5" @@ -133,7 +133,7 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { return } - ctx = rbac.CtxWithSubject(ctx, sh) + ctx = entities.CtxWithSubject(ctx, sh) r = r.WithContext(ctx) if params.SubType != nil { diff --git a/pkg/server/server.go b/pkg/server/server.go index 5d144a8..9aa0efb 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -17,6 +17,7 @@ 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/shares" "dynatron.me/x/stillbox/pkg/sinks" @@ -80,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 } diff --git a/pkg/shares/service.go b/pkg/shares/service.go index 376a17e..245b61d 100644 --- a/pkg/shares/service.go +++ b/pkg/shares/service.go @@ -4,7 +4,7 @@ import ( "context" "time" - "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "github.com/rs/zerolog/log" ) @@ -23,7 +23,7 @@ type service struct { } 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) diff --git a/pkg/shares/share.go b/pkg/shares/share.go index 17350c1..6184c02 100644 --- a/pkg/shares/share.go +++ b/pkg/shares/share.go @@ -10,6 +10,7 @@ import ( "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" @@ -57,11 +58,11 @@ func (s *Share) GetName() string { } func (s *Share) GetRoles() []string { - return []string{rbac.RoleShareGuest} + return []string{entities.RoleShareGuest} } func (s *Share) GetResourceName() string { - return rbac.ResourceShare + return entities.ResourceShare } type CreateShareParams struct { @@ -88,7 +89,7 @@ func (s *service) checkEntity(ctx context.Context, sh *CreateShareParams) (*time if err != nil { return nil, err } - _, err = rbac.Check(ctx, &i, rbac.WithActions(rbac.ActionShare)) + _, err = rbac.Check(ctx, &i, rbac.WithActions(entities.ActionShare)) if err != nil { return nil, err } diff --git a/pkg/shares/store.go b/pkg/shares/store.go index 35faa04..d0fbad0 100644 --- a/pkg/shares/store.go +++ b/pkg/shares/store.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -79,7 +80,7 @@ func (s *postgresStore) Create(ctx context.Context, share *Share) error { } func (s *postgresStore) Delete(ctx context.Context, id string) error { - _, err := rbac.Check(ctx, new(Share), rbac.WithActions(rbac.ActionDelete)) + _, err := rbac.Check(ctx, new(Share), rbac.WithActions(entities.ActionDelete)) if err != nil { return err } 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..8082ffd 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 { 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/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/queries/share.sql b/sql/postgres/queries/share.sql index 7854c48..afbf1b6 100644 --- a/sql/postgres/queries/share.sql +++ b/sql/postgres/queries/share.sql @@ -24,3 +24,11 @@ DELETE FROM shares WHERE id = @id; -- name: PruneShares :exec DELETE FROM shares WHERE expiration < NOW(); + +-- 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; From f1a5f70a79adff98bc4d57add398841958aa407b Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Wed, 22 Jan 2025 14:15:53 -0500 Subject: [PATCH 09/10] TG share --- pkg/database/share.sql.go | 34 ------------------ pkg/database/talkgroups.sql.go | 35 +++++++++++++++++++ pkg/incidents/incstore/store.go | 49 ++++++++++++++++++++------ pkg/rbac/policy/conditions.go | 53 ++++++++++++++++++++++++++++- pkg/rest/api.go | 1 + pkg/rest/calls.go | 8 ++--- pkg/rest/incidents.go | 10 +++--- pkg/rest/share.go | 42 +++++++++++------------ pkg/rest/talkgroups.go | 26 ++++++++++++++ pkg/talkgroups/talkgroup.go | 12 +++++++ sql/postgres/queries/share.sql | 8 ----- sql/postgres/queries/talkgroups.sql | 8 +++++ 12 files changed, 201 insertions(+), 85 deletions(-) diff --git a/pkg/database/share.sql.go b/pkg/database/share.sql.go index 53ab241..fbd2829 100644 --- a/pkg/database/share.sql.go +++ b/pkg/database/share.sql.go @@ -53,40 +53,6 @@ func (q *Queries) DeleteShare(ctx context.Context, id string) error { return err } -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 getShare = `-- name: GetShare :one SELECT id, 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/incstore/store.go b/pkg/incidents/incstore/store.go index 45880c8..8aa284b 100644 --- a/pkg/incidents/incstore/store.go +++ b/pkg/incidents/incstore/store.go @@ -11,6 +11,7 @@ import ( "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" @@ -53,9 +54,12 @@ type Store interface { // 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 @@ -76,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 @@ -138,7 +142,7 @@ 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 { +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 @@ -176,7 +180,7 @@ 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) { +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 @@ -281,7 +285,7 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall { return r } -func (s *store) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) { +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 @@ -332,7 +336,7 @@ func (uip UpdateIncidentParams) toDBUIP(id uuid.UUID) database.UpdateIncidentPar } } -func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) { +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 @@ -355,7 +359,7 @@ 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 { +func (s *postgresStore) DeleteIncident(ctx context.Context, id uuid.UUID) error { inc, err := s.Owner(ctx, id) if err != nil { return err @@ -369,16 +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) Owner(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 *store) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) { +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/rbac/policy/conditions.go b/pkg/rbac/policy/conditions.go index 77b8428..8a9eef6 100644 --- a/pkg/rbac/policy/conditions.go +++ b/pkg/rbac/policy/conditions.go @@ -7,6 +7,7 @@ import ( "reflect" "dynatron.me/x/stillbox/pkg/incidents/incstore" + "dynatron.me/x/stillbox/pkg/talkgroups" "github.com/el-mike/restrict/v2" "github.com/google/uuid" @@ -16,8 +17,58 @@ 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"` @@ -60,7 +111,7 @@ func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error { } if !inCall { - return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`incident "%v" not in call "%v"`, incID, callID)) + return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`call "%v" not in incident "%v"`, callID, incID)) } return nil diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 9e7f066..8f0928c 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -54,6 +54,7 @@ func New(baseURL url.URL) *api { ShareRequestCallDL: s.calls.shareCallDLRoute, ShareRequestIncident: s.incidents.getIncident, ShareRequestIncidentM3U: s.incidents.getCallsM3U, + ShareRequestTalkgroups: s.tgs.getTGsShareRoute, }, ) diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index ea5b6c0..1492f6c 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -102,17 +102,17 @@ func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Re _, _ = w.Write(call.AudioBlob) } -func (ca *callsAPI) shareCallRoute(id uuid.UUID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { +func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { p := getAudioParams{ - CallID: &id, + CallID: common.PtrTo(id.(uuid.UUID)), } ca.getAudio(p, w, r) } -func (ca *callsAPI) shareCallDLRoute(id uuid.UUID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { +func (ca *callsAPI) shareCallDLRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) { p := getAudioParams{ - CallID: &id, + CallID: common.PtrTo(id.(uuid.UUID)), Download: common.PtrTo("download"), } diff --git a/pkg/rest/incidents.go b/pkg/rest/incidents.go index 0da9412..6baf4a2 100644 --- a/pkg/rest/incidents.go +++ b/pkg/rest/incidents.go @@ -97,10 +97,10 @@ func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request) ia.getIncident(id, nil, w, r) } -func (ia *incidentsAPI) getIncident(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) { +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) + inc, err := incs.Incident(ctx, id.(uuid.UUID)) if err != nil { wErr(w, r, autoError(err)) return @@ -198,12 +198,12 @@ func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request) ia.getCallsM3U(id, nil, w, r) } -func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) { +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) + inc, err := incs.Incident(ctx, id.(uuid.UUID)) if err != nil { wErr(w, r, autoError(err)) return @@ -214,7 +214,7 @@ func (ia *incidentsAPI) getCallsM3U(id uuid.UUID, share *shares.Share, w http.Re callUrl := common.PtrTo(*ia.baseURL) urlRoot := "/api/call" if share != nil { - urlRoot = fmt.Sprintf("/share/%s/%s/call/", share.Type, share.ID) + urlRoot = fmt.Sprintf("/share/%s/call/", share.ID) } b.WriteString("#EXTM3U\n\n") diff --git a/pkg/rest/share.go b/pkg/rest/share.go index 86e33b8..4cc260e 100644 --- a/pkg/rest/share.go +++ b/pkg/rest/share.go @@ -25,11 +25,13 @@ const ( ShareRequestCallDL ShareRequestType = "callDL" ShareRequestIncident ShareRequestType = "incident" ShareRequestIncidentM3U ShareRequestType = "m3u" + ShareRequestTalkgroups ShareRequestType = "talkgroups" ) func (rt ShareRequestType) IsValid() bool { switch rt { - case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident, ShareRequestIncidentM3U: + case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident, + ShareRequestIncidentM3U, ShareRequestTalkgroups: return true } @@ -38,14 +40,17 @@ func (rt ShareRequestType) IsValid() bool { func (rt ShareRequestType) IsValidSubtype() bool { switch rt { - case ShareRequestCall, ShareRequestCallDL: + case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups: return true } return false } -type HandlerFunc func(id uuid.UUID, share *shares.Share, w http.ResponseWriter, r *http.Request) +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 @@ -71,8 +76,8 @@ func (sa *shareAPI) Subrouter() http.Handler { func (sa *shareAPI) RootRouter() http.Handler { r := chi.NewMux() - r.Get("/{type}/{shareId:[A-Za-z0-9_-]{20,}}", sa.routeShare) - r.Get("/{type}/{shareId:[A-Za-z0-9_-]{20,}}/{subType}/{subID}", sa.routeShare) + 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 } @@ -102,10 +107,9 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { shs := shares.FromCtx(ctx) params := struct { - Type string `param:"type"` - ID string `param:"shareId"` - SubType *string `param:"subType"` - SubID *string `param:"subID"` + Type string `param:"type"` + ID string `param:"shareId"` + SubID *string `param:"subID"` }{} err := decodeParams(¶ms, r) @@ -136,15 +140,11 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { ctx = entities.CtxWithSubject(ctx, sh) r = r.WithContext(ctx) - if params.SubType != nil { + switch rType { + case ShareRequestTalkgroups: + sa.shnd[rType](nil, sh, w, r) + case ShareRequestCall, ShareRequestCallDL: if params.SubID == nil { - // probably can't happen - wErr(w, r, autoError(ErrBadShare)) - return - } - - subT := ShareRequestType(*params.SubType) - if !subT.IsValidSubtype() { wErr(w, r, autoError(ErrBadShare)) return } @@ -154,12 +154,10 @@ func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) { wErr(w, r, badRequest(err)) return } - - sa.shnd[subT](subIDU, sh, w, r) - return + sa.shnd[rType](subIDU, sh, w, r) + case ShareRequestIncident, ShareRequestIncidentM3U: + sa.shnd[rType](sh.EntityID, sh, w, r) } - - sa.shnd[rType](sh.EntityID, sh, w, r) } 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/talkgroups/talkgroup.go b/pkg/talkgroups/talkgroup.go index 8082ffd..8851a53 100644 --- a/pkg/talkgroups/talkgroup.go +++ b/pkg/talkgroups/talkgroup.go @@ -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/sql/postgres/queries/share.sql b/sql/postgres/queries/share.sql index afbf1b6..7854c48 100644 --- a/sql/postgres/queries/share.sql +++ b/sql/postgres/queries/share.sql @@ -24,11 +24,3 @@ DELETE FROM shares WHERE id = @id; -- name: PruneShares :exec DELETE FROM shares WHERE expiration < NOW(); - --- 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; 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; From e38e17abeeab912686e760abbd2ac3ca2dc3aa20 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Wed, 22 Jan 2025 22:16:12 -0500 Subject: [PATCH 10/10] lint --- pkg/rbac/policy/conditions.go | 2 +- pkg/server/server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/rbac/policy/conditions.go b/pkg/rbac/policy/conditions.go index 8a9eef6..91334c0 100644 --- a/pkg/rbac/policy/conditions.go +++ b/pkg/rbac/policy/conditions.go @@ -192,5 +192,5 @@ func (c *InMapCondition[K, V]) Check(r *restrict.AccessRequest) error { } func init() { - restrict.RegisterConditionFactory(SubmitterEqualConditionType, SubmitterEqualConditionFactory) + _ = restrict.RegisterConditionFactory(SubmitterEqualConditionType, SubmitterEqualConditionFactory) } diff --git a/pkg/server/server.go b/pkg/server/server.go index 9aa0efb..a2553d7 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -148,7 +148,7 @@ 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 { + _ = 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 })