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;