Merge pull request 'Share links and RBAC improvements' (#104) from shareLinks into trunk

Reviewed-on: #104
This commit is contained in:
amigan 2025-01-22 22:18:59 -05:00
commit 0e5f62ce51
45 changed files with 1569 additions and 725 deletions

View file

@ -14,7 +14,7 @@ import (
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/notify" "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/sinks"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "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. // Go is the alerting loop. It does not start a goroutine.
func (as *alerter) Go(ctx context.Context) { 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) err := as.startBackfill(ctx)
if err != nil { if err != nil {

View file

@ -7,7 +7,7 @@ import (
"time" "time"
"dynatron.me/x/stillbox/pkg/database" "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/google/uuid"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -16,10 +16,10 @@ import (
type apiKeyAuth interface { type apiKeyAuth interface {
// CheckAPIKey validates the provided key and returns the API owner's users.UserID. // CheckAPIKey validates the provided key and returns the API owner's users.UserID.
// An error is returned if validation fails for any reason. // 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) keyUuid, err := uuid.Parse(key)
if err != nil { if err != nil {
log.Error().Str("apikey", key).Msg("cannot parse key") log.Error().Str("apikey", key).Msg("cannot parse key")

View file

@ -10,7 +10,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"dynatron.me/x/stillbox/pkg/database" "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" "dynatron.me/x/stillbox/pkg/users"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -104,7 +104,7 @@ func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler {
return return
} }
ctx = rbac.CtxWithSubject(ctx, sub) ctx = entities.CtxWithSubject(ctx, sub)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))

View file

@ -8,7 +8,7 @@ import (
"dynatron.me/x/stillbox/internal/audio" "dynatron.me/x/stillbox/internal/audio"
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/pb" "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/talkgroups"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
@ -57,6 +57,7 @@ type Call struct {
Audio []byte `json:"audio,omitempty" relayOut:"audio,omitempty" filenameField:"AudioName"` Audio []byte `json:"audio,omitempty" relayOut:"audio,omitempty" filenameField:"AudioName"`
AudioName string `json:"audioName,omitempty" relayOut:"audioName,omitempty"` AudioName string `json:"audioName,omitempty" relayOut:"audioName,omitempty"`
AudioType string `json:"audioType,omitempty" relayOut:"audioType,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"` Duration CallDuration `json:"duration,omitempty" relayOut:"duration,omitempty"`
DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"` DateTime time.Time `json:"call_date,omitempty" relayOut:"dateTime,omitempty"`
Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"` Frequencies []int `json:"frequencies,omitempty" relayOut:"frequencies,omitempty"`
@ -75,7 +76,7 @@ type Call struct {
} }
func (c *Call) GetResourceName() string { func (c *Call) GetResourceName() string {
return rbac.ResourceCall return entities.ResourceCall
} }
func (c *Call) String() string { func (c *Call) String() string {

View file

@ -3,6 +3,7 @@ package callstore
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
@ -10,6 +11,7 @@ import (
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "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/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
@ -28,6 +30,9 @@ type Store interface {
// CallAudio returns a CallAudio struct // CallAudio returns a CallAudio struct
CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) 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 gets paginated Calls.
Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error) Calls(ctx context.Context, p CallsParams) (calls []database.ListCallsPRow, totalCount int, err error)
} }
@ -81,7 +86,7 @@ func toAddCallParams(call *calls.Call) database.AddCallParams {
} }
func (s *store) AddCall(ctx context.Context, call *calls.Call) error { 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 { if err != nil {
return err return err
} }
@ -119,7 +124,7 @@ func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
} }
func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) { func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceCall), rbac.WithActions(rbac.ActionRead)) _, err := rbac.Check(ctx, &calls.Call{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -139,6 +144,43 @@ func (s *store) CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio,
}, nil }, nil
} }
func (s *store) Call(ctx context.Context, id uuid.UUID) (*calls.Call, error) {
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceCall), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
db := database.FromCtx(ctx)
c, err := db.GetCall(ctx, id)
if err != nil {
return nil, err
}
var sub *users.UserID
if c.Submitter != nil {
sub = common.PtrTo(users.UserID(*c.Submitter))
}
return &calls.Call{
ID: c.ID,
Submitter: sub,
System: c.System,
Talkgroup: c.Talkgroup,
DateTime: c.CallDate.Time,
AudioName: common.ZeroIfNil(c.AudioName),
AudioType: common.ZeroIfNil(c.AudioType),
AudioURL: c.AudioUrl,
Duration: calls.CallDuration(time.Duration(common.ZeroIfNil(c.Duration)) * time.Millisecond),
Frequency: c.Frequency,
Frequencies: c.Frequencies,
Patches: c.Patches,
TalkgroupLabel: c.TGLabel,
TalkgroupGroup: c.TGGroup,
TGAlphaTag: c.TGAlphaTag,
}, nil
}
type CallsParams struct { type CallsParams struct {
common.Pagination common.Pagination
Direction *common.SortDirection `json:"dir"` Direction *common.SortDirection `json:"dir"`
@ -152,7 +194,7 @@ type CallsParams struct {
} }
func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) { 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -215,7 +257,7 @@ func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
return err return err
} }
_, err = rbac.Check(ctx, &callOwn, rbac.WithActions(rbac.ActionDelete)) _, err = rbac.Check(ctx, &callOwn, rbac.WithActions(entities.ActionDelete))
if err != nil { if err != nil {
return err return err
} }

View file

@ -16,17 +16,18 @@ type Configuration struct {
} }
type Config struct { type Config struct {
BaseURL jsontypes.URL `yaml:"baseURL"` BaseURL jsontypes.URL `yaml:"baseURL"`
DB DB `yaml:"db"` DumpRoutes bool `yaml:"dumpRoutes"`
CORS CORS `yaml:"cors"` DB DB `yaml:"db"`
Auth Auth `yaml:"auth"` CORS CORS `yaml:"cors"`
Alerting Alerting `yaml:"alerting"` Auth Auth `yaml:"auth"`
Log []Logger `yaml:"log"` Alerting Alerting `yaml:"alerting"`
Listen string `yaml:"listen"` Log []Logger `yaml:"log"`
Public bool `yaml:"public"` Listen string `yaml:"listen"`
RateLimit RateLimit `yaml:"rateLimit"` Public bool `yaml:"public"`
Notify Notify `yaml:"notify"` RateLimit RateLimit `yaml:"rateLimit"`
Relay []Relay `yaml:"relay"` Notify Notify `yaml:"notify"`
Relay []Relay `yaml:"relay"`
} }
type Auth struct { type Auth struct {

View file

@ -164,6 +164,71 @@ func (q *Queries) DeleteCall(ctx context.Context, id uuid.UUID) error {
return err 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 const getCallAudioByID = `-- name: GetCallAudioByID :one
SELECT SELECT
c.call_date, c.call_date,

View file

@ -40,6 +40,21 @@ func (q *Queries) AddToIncident(ctx context.Context, incidentID uuid.UUID, callI
return err 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 const createIncident = `-- name: CreateIncident :one
INSERT INTO incidents ( INSERT INTO incidents (
id, id,

View file

@ -278,6 +278,64 @@ func (_c *Store_BulkSetTalkgroupTags_Call) RunAndReturn(run func(context.Context
return _c 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 // 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) { func (_m *Store) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
ret := _m.Called(ctx, rangeStart, rangeEnd) ret := _m.Called(ctx, rangeStart, rangeEnd)
@ -1348,6 +1406,63 @@ func (_c *Store_GetAppPrefs_Call) RunAndReturn(run func(context.Context, string,
return _c 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 // GetCallAudioByID provides a mock function with given fields: ctx, id
func (_m *Store) GetCallAudioByID(ctx context.Context, id uuid.UUID) (database.GetCallAudioByIDRow, error) { func (_m *Store) GetCallAudioByID(ctx context.Context, id uuid.UUID) (database.GetCallAudioByIDRow, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)
@ -1693,6 +1808,65 @@ func (_c *Store_GetIncidentOwner_Call) RunAndReturn(run func(context.Context, uu
return _c 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 // GetShare provides a mock function with given fields: ctx, id
func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) { func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)

View file

@ -85,6 +85,7 @@ type Share struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
EntityType string `json:"entity_type,omitempty"` EntityType string `json:"entity_type,omitempty"`
EntityID uuid.UUID `json:"entity_id,omitempty"` EntityID uuid.UUID `json:"entity_id,omitempty"`
EntityDate pgtype.Timestamptz `json:"entity_date,omitempty"`
Owner int `json:"owner,omitempty"` Owner int `json:"owner,omitempty"`
Expiration pgtype.Timestamptz `json:"expiration,omitempty"` Expiration pgtype.Timestamptz `json:"expiration,omitempty"`
} }

View file

@ -13,7 +13,7 @@ import (
"dynatron.me/x/stillbox/internal/isoweek" "dynatron.me/x/stillbox/internal/isoweek"
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database" "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"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@ -135,7 +135,7 @@ func New(db database.Store, cfg config.Partition) (*partman, error) {
var _ PartitionManager = (*partman)(nil) var _ PartitionManager = (*partman)(nil)
func (pm *partman) Go(ctx context.Context) { 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) tick := time.NewTicker(CheckInterval)
select { select {

View file

@ -16,6 +16,7 @@ type Querier interface {
AddCall(ctx context.Context, arg AddCallParams) error AddCall(ctx context.Context, arg AddCallParams) error
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error) AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error)
AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) 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) 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) CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error)
CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error) CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error)
@ -32,12 +33,14 @@ type Querier interface {
GetAPIKey(ctx context.Context, apiKey string) (GetAPIKeyRow, error) GetAPIKey(ctx context.Context, apiKey string) (GetAPIKeyRow, error)
GetAllTalkgroupTags(ctx context.Context) ([]string, error) GetAllTalkgroupTags(ctx context.Context) ([]string, error)
GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, 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) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error)
GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error)
GetDatabaseSize(ctx context.Context) (string, error) GetDatabaseSize(ctx context.Context) (string, error)
GetIncident(ctx context.Context, id uuid.UUID) (Incident, error) GetIncident(ctx context.Context, id uuid.UUID) (Incident, error)
GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error)
GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, 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) GetShare(ctx context.Context, id string) (Share, error)
GetSystemName(ctx context.Context, systemID int) (string, error) GetSystemName(ctx context.Context, systemID int) (string, error)
GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error) GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error)

View file

@ -17,15 +17,17 @@ INSERT INTO shares (
id, id,
entity_type, entity_type,
entity_id, entity_id,
entity_date,
owner, owner,
expiration expiration
) VALUES ($1, $2, $3, $4, $5) ) VALUES ($1, $2, $3, $4, $5, $6)
` `
type CreateShareParams struct { type CreateShareParams struct {
ID string `json:"id"` ID string `json:"id"`
EntityType string `json:"entity_type"` EntityType string `json:"entity_type"`
EntityID uuid.UUID `json:"entity_id"` EntityID uuid.UUID `json:"entity_id"`
EntityDate pgtype.Timestamptz `json:"entity_date"`
Owner int `json:"owner"` Owner int `json:"owner"`
Expiration pgtype.Timestamptz `json:"expiration"` Expiration pgtype.Timestamptz `json:"expiration"`
} }
@ -35,6 +37,7 @@ func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) error
arg.ID, arg.ID,
arg.EntityType, arg.EntityType,
arg.EntityID, arg.EntityID,
arg.EntityDate,
arg.Owner, arg.Owner,
arg.Expiration, arg.Expiration,
) )
@ -55,6 +58,7 @@ SELECT
id, id,
entity_type, entity_type,
entity_id, entity_id,
entity_date,
owner, owner,
expiration expiration
FROM shares FROM shares
@ -68,6 +72,7 @@ func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) {
&i.ID, &i.ID,
&i.EntityType, &i.EntityType,
&i.EntityID, &i.EntityID,
&i.EntityDate,
&i.Owner, &i.Owner,
&i.Expiration, &i.Expiration,
) )

View file

@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/alerting/rules" "dynatron.me/x/stillbox/pkg/alerting/rules"
"github.com/google/uuid"
) )
const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one
@ -117,6 +118,40 @@ func (q *Queries) GetAllTalkgroupTags(ctx context.Context) ([]string, error) {
return items, nil 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 const getSystemName = `-- name: GetSystemName :one
SELECT name FROM systems WHERE id = $1 SELECT name FROM systems WHERE id = $1
` `

View file

@ -5,7 +5,7 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls" "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" "dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -23,7 +23,7 @@ type Incident struct {
} }
func (inc *Incident) GetResourceName() string { func (inc *Incident) GetResourceName() string {
return rbac.ResourceIncident return entities.ResourceIncident
} }
type IncidentCall struct { type IncidentCall struct {

View file

@ -10,6 +10,8 @@ import (
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/rbac" "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" "dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@ -46,9 +48,18 @@ type Store interface {
// DeleteIncident deletes an incident. // DeleteIncident deletes an incident.
DeleteIncident(ctx context.Context, id uuid.UUID) error DeleteIncident(ctx context.Context, id uuid.UUID) error
// Owner returns an incident with only the owner filled out.
Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, error)
// CallIn returns whether an incident is in an call
CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error)
// TGsIn returns the talkgroups referenced by an incident as a map, primary for rbac use.
TGsIn(ctx context.Context, inc uuid.UUID) (talkgroups.PresenceMap, error)
} }
type store struct { type postgresStore struct {
} }
type storeCtxKey string type storeCtxKey string
@ -69,10 +80,10 @@ func FromCtx(ctx context.Context) Store {
} }
func NewStore() 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") user, err := users.UserCheck(ctx, new(incidents.Incident), "create")
if err != nil { if err != nil {
return nil, err return nil, err
@ -131,13 +142,13 @@ func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*in
return &inc, nil 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.getIncidentOwner(ctx, incidentID) inc, err := s.Owner(ctx, incidentID)
if err != nil { if err != nil {
return err return err
} }
_, err = rbac.Check(ctx, &inc, rbac.WithActions(rbac.ActionUpdate)) _, err = rbac.Check(ctx, &inc, rbac.WithActions(entities.ActionUpdate))
if err != nil { if err != nil {
return err return err
} }
@ -169,8 +180,8 @@ func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID
}, pgx.TxOptions{}) }, 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(rbac.ActionRead)) _, err = rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -274,8 +285,8 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
return r 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, new(incidents.Incident), rbac.WithActions(rbac.ActionRead)) _, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -325,13 +336,13 @@ func (uip UpdateIncidentParams) toDBUIP(id uuid.UUID) database.UpdateIncidentPar
} }
} }
func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) { func (s *postgresStore) 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 { if err != nil {
return nil, err return nil, err
} }
_, err = rbac.Check(ctx, &ckinc, rbac.WithActions(rbac.ActionUpdate)) _, err = rbac.Check(ctx, &ckinc, rbac.WithActions(entities.ActionUpdate))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -348,13 +359,13 @@ func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncide
return &inc, nil 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.getIncidentOwner(ctx, id) inc, err := s.Owner(ctx, id)
if err != nil { if err != nil {
return err return err
} }
_, err = rbac.Check(ctx, &inc, rbac.WithActions(rbac.ActionDelete)) _, err = rbac.Check(ctx, &inc, rbac.WithActions(entities.ActionDelete))
if err != nil { if err != nil {
return err return err
} }
@ -362,11 +373,39 @@ func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error {
return database.FromCtx(ctx).DeleteIncident(ctx, id) 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) return database.FromCtx(ctx).UpdateCallIncidentNotes(ctx, notes, incidentID, callID)
} }
func (s *store) getIncidentOwner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) { func (s *postgresStore) Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) {
owner, err := database.FromCtx(ctx).GetIncidentOwner(ctx, id) owner, err := database.FromCtx(ctx).GetIncidentOwner(ctx, id)
return incidents.Incident{ID: id, Owner: users.UserID(owner)}, err return incidents.Incident{ID: id, Owner: users.UserID(owner)}, err
} }
func (s *postgresStore) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) {
db := database.FromCtx(ctx)
return db.CallInIncident(ctx, inc, call)
}
func (s *postgresStore) TGsIn(ctx context.Context, id uuid.UUID) (talkgroups.PresenceMap, error) {
_, err := rbac.Check(ctx, &incidents.Incident{ID: id}, rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
db := database.FromCtx(ctx)
tgs, err := db.GetIncidentTalkgroups(ctx, id)
if err != nil {
return nil, err
}
m := make(talkgroups.PresenceMap, len(tgs))
for _, t := range tgs {
m.Put(talkgroups.ID{
System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup),
})
}
return m, nil
}

View file

@ -6,7 +6,7 @@ import (
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/pb" "dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac/entities"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -39,7 +39,7 @@ func New() *Nexus {
} }
func (n *Nexus) Go(ctx context.Context) { func (n *Nexus) Go(ctx context.Context) {
ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "nexus"}) ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "nexus"})
for { for {
select { select {
case call, ok := <-n.callCh: case call, ok := <-n.callCh:

View file

@ -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}
}

View file

@ -5,9 +5,11 @@ package mocks
import ( import (
context "context" context "context"
rbac "dynatron.me/x/stillbox/pkg/rbac" entities "dynatron.me/x/stillbox/pkg/rbac/entities"
mock "github.com/stretchr/testify/mock" mock "github.com/stretchr/testify/mock"
rbac "dynatron.me/x/stillbox/pkg/rbac"
restrict "github.com/el-mike/restrict/v2" 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 // 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)) _va := make([]interface{}, len(opts))
for _i := range opts { for _i := range opts {
_va[_i] = opts[_i] _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") panic("no return value specified for Check")
} }
var r0 rbac.Subject var r0 entities.Subject
var r1 error 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...) 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...) r0 = rf(ctx, res, opts...)
} else { } else {
if ret.Get(0) != nil { 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 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) _c.Call.Return(_a0, _a1)
return _c 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) _c.Call.Return(run)
return _c return _c
} }

View file

@ -0,0 +1,196 @@
package policy
import (
"context"
"errors"
"fmt"
"reflect"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/el-mike/restrict/v2"
"github.com/google/uuid"
)
const (
SubmitterEqualConditionType = "SUBMITTER_EQUAL"
InMapConditionType = "IN_MAP"
CallInIncidentConditionType = "CALL_IN_INCIDENT"
TGInIncidentConditionType = "TG_IN_INCIDENT"
)
type TGInIncidentCondition struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
TG *restrict.ValueDescriptor `json:"tg" yaml:"tg"`
Incident *restrict.ValueDescriptor `json:"incident" yaml:"incident"`
}
func (*TGInIncidentCondition) Type() string {
return TGInIncidentConditionType
}
func (c *TGInIncidentCondition) Check(r *restrict.AccessRequest) error {
tgVID, err := c.TG.GetValue(r)
if err != nil {
return err
}
incVID, err := c.Incident.GetValue(r)
if err != nil {
return err
}
ctx, hasCtx := r.Context["ctx"].(context.Context)
if !hasCtx {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("no context provided"))
}
incID, isUUID := incVID.(uuid.UUID)
if !isUUID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("incident ID is not UUID"))
}
tgID, isTGID := tgVID.(talkgroups.ID)
if !isTGID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("tg ID is not TGID"))
}
// XXX: this should instead come from the access request context, for better reuse upstream
tgm, err := incstore.FromCtx(ctx).TGsIn(ctx, incID)
if err != nil {
return restrict.NewConditionNotSatisfiedError(c, r, err)
}
if !tgm.Has(tgID) {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`tg "%v" not in incident "%v"`, tgID, incID))
}
return nil
}
type CallInIncidentCondition struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
Call *restrict.ValueDescriptor `json:"call" yaml:"call"`
Incident *restrict.ValueDescriptor `json:"incident" yaml:"incident"`
}
func (*CallInIncidentCondition) Type() string {
return CallInIncidentConditionType
}
func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error {
callVID, err := c.Call.GetValue(r)
if err != nil {
return err
}
incVID, err := c.Incident.GetValue(r)
if err != nil {
return err
}
ctx, hasCtx := r.Context["ctx"].(context.Context)
if !hasCtx {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("no context provided"))
}
incID, isUUID := incVID.(uuid.UUID)
if !isUUID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("incident ID is not UUID"))
}
callID, isUUID := callVID.(uuid.UUID)
if !isUUID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("call ID is not UUID"))
}
inCall, err := incstore.FromCtx(ctx).CallIn(ctx, incID, callID)
if err != nil {
return restrict.NewConditionNotSatisfiedError(c, r, err)
}
if !inCall {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`call "%v" not in incident "%v"`, callID, incID))
}
return nil
}
type SubmitterEqualCondition struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
Left *restrict.ValueDescriptor `json:"left" yaml:"left"`
Right *restrict.ValueDescriptor `json:"right" yaml:"right"`
}
func (s *SubmitterEqualCondition) Type() string {
return SubmitterEqualConditionType
}
func (c *SubmitterEqualCondition) Check(r *restrict.AccessRequest) error {
left, err := c.Left.GetValue(r)
if err != nil {
return err
}
right, err := c.Right.GetValue(r)
if err != nil {
return err
}
lVal := reflect.ValueOf(left)
rVal := reflect.ValueOf(right)
// deref Left. this is the difference between us and EqualCondition
for lVal.Kind() == reflect.Pointer {
lVal = lVal.Elem()
}
if !lVal.IsValid() || !reflect.DeepEqual(rVal.Interface(), lVal.Interface()) {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("values \"%v\" and \"%v\" are not equal", left, right))
}
return nil
}
func SubmitterEqualConditionFactory() restrict.Condition {
return new(SubmitterEqualCondition)
}
type InMapCondition[K comparable, V any] struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
Key *restrict.ValueDescriptor `json:"key" yaml:"key"`
Map *restrict.ValueDescriptor `json:"map" yaml:"map"`
}
func (s *InMapCondition[K, V]) Type() string {
return SubmitterEqualConditionType
}
func InMapConditionFactory[K comparable, V any]() restrict.Condition {
return new(InMapCondition[K, V])
}
func (c *InMapCondition[K, V]) Check(r *restrict.AccessRequest) error {
cKey, err := c.Key.GetValue(r)
if err != nil {
return err
}
cMap, err := c.Map.GetValue(r)
if err != nil {
return err
}
key := cKey.(K)
if _, in := cMap.(map[K]V)[key]; !in {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("key '%v' not in map", key))
}
return nil
}
func init() {
_ = restrict.RegisterConditionFactory(SubmitterEqualConditionType, SubmitterEqualConditionFactory)
}

243
pkg/rbac/policy/policy.go Normal file
View file

@ -0,0 +1,243 @@
package policy
import (
"dynatron.me/x/stillbox/pkg/rbac/entities"
"github.com/el-mike/restrict/v2"
)
const (
PresetUpdateOwn = "updateOwn"
PresetDeleteOwn = "deleteOwn"
PresetReadShared = "readShared"
PresetReadSharedInMap = "readSharedInMap"
PresetShareOwn = "shareOwn"
PresetUpdateSubmitter = "updateSubmitter"
PresetDeleteSubmitter = "deleteSubmitter"
PresetShareSubmitter = "shareSubmitter"
PresetReadInSharedIncident = "readInSharedIncident"
)
var Policy = &restrict.PolicyDefinition{
Roles: restrict.Roles{
entities.RoleUser: {
Description: "An authenticated user",
Grants: restrict.GrantsMap{
entities.ResourceIncident: {
&restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Preset: PresetUpdateOwn},
&restrict.Permission{Preset: PresetDeleteOwn},
&restrict.Permission{Preset: PresetShareOwn},
},
entities.ResourceCall: {
&restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Preset: PresetUpdateSubmitter},
&restrict.Permission{Preset: PresetDeleteSubmitter},
&restrict.Permission{Action: entities.ActionShare},
},
entities.ResourceTalkgroup: {
&restrict.Permission{Action: entities.ActionRead},
},
entities.ResourceShare: {
&restrict.Permission{Action: entities.ActionRead},
&restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Preset: PresetUpdateOwn},
&restrict.Permission{Preset: PresetDeleteOwn},
},
},
},
entities.RoleSubmitter: {
Description: "A role that can submit calls",
Grants: restrict.GrantsMap{
entities.ResourceCall: {
&restrict.Permission{Action: entities.ActionCreate},
},
entities.ResourceTalkgroup: {
// for learning TGs
&restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Action: entities.ActionUpdate},
},
},
},
entities.RoleShareGuest: {
Description: "Someone who has a valid share link",
Grants: restrict.GrantsMap{
entities.ResourceCall: {
&restrict.Permission{Preset: PresetReadShared},
&restrict.Permission{Preset: PresetReadInSharedIncident},
},
entities.ResourceIncident: {
&restrict.Permission{Preset: PresetReadShared},
},
entities.ResourceTalkgroup: {
&restrict.Permission{Action: entities.ActionRead},
},
},
},
entities.RoleAdmin: {
Parents: []string{entities.RoleUser},
Grants: restrict.GrantsMap{
entities.ResourceIncident: {
&restrict.Permission{Action: entities.ActionUpdate},
&restrict.Permission{Action: entities.ActionDelete},
&restrict.Permission{Action: entities.ActionShare},
},
entities.ResourceCall: {
&restrict.Permission{Action: entities.ActionUpdate},
&restrict.Permission{Action: entities.ActionDelete},
&restrict.Permission{Action: entities.ActionShare},
},
entities.ResourceTalkgroup: {
&restrict.Permission{Action: entities.ActionUpdate},
&restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Action: entities.ActionDelete},
},
},
},
entities.RoleSystem: {
Parents: []string{entities.RoleSystem},
},
entities.RolePublic: {
/*
Grants: restrict.GrantsMap{
entities.ResourceShare: {
&restrict.Permission{Action: entities.ActionRead},
},
},
*/
},
},
PermissionPresets: restrict.PermissionPresets{
PresetUpdateOwn: &restrict.Permission{
Action: entities.ActionUpdate,
Conditions: restrict.Conditions{
&restrict.EqualCondition{
ID: "isOwner",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Owner",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetDeleteOwn: &restrict.Permission{
Action: entities.ActionDelete,
Conditions: restrict.Conditions{
&restrict.EqualCondition{
ID: "isOwner",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Owner",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetShareOwn: &restrict.Permission{
Action: entities.ActionShare,
Conditions: restrict.Conditions{
&restrict.EqualCondition{
ID: "isOwner",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Owner",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetUpdateSubmitter: &restrict.Permission{
Action: entities.ActionUpdate,
Conditions: restrict.Conditions{
&SubmitterEqualCondition{
ID: "isSubmitter",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Submitter",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetDeleteSubmitter: &restrict.Permission{
Action: entities.ActionDelete,
Conditions: restrict.Conditions{
&SubmitterEqualCondition{
ID: "isSubmitter",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Submitter",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetShareSubmitter: &restrict.Permission{
Action: entities.ActionShare,
Conditions: restrict.Conditions{
&SubmitterEqualCondition{
ID: "isSubmitter",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Submitter",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetReadShared: &restrict.Permission{
Action: entities.ActionRead,
Conditions: restrict.Conditions{
&restrict.EqualCondition{
ID: "isOwner",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "ID",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "EntityID",
},
},
},
},
PresetReadInSharedIncident: &restrict.Permission{
Action: entities.ActionRead,
Conditions: restrict.Conditions{
&CallInIncidentCondition{
ID: "callInIncident",
Call: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "ID",
},
Incident: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "EntityID",
},
},
},
},
},
}

View file

@ -3,53 +3,17 @@ package rbac
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"reflect" "dynatron.me/x/stillbox/pkg/rbac/entities"
"github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2"
"github.com/el-mike/restrict/v2/adapters" "github.com/el-mike/restrict/v2/adapters"
) )
const (
RoleUser = "User"
RoleSubmitter = "Submitter"
RoleAdmin = "Admin"
RoleSystem = "System"
RolePublic = "Public"
RoleShareGuest = "ShareGuest"
ResourceCall = "Call"
ResourceIncident = "Incident"
ResourceTalkgroup = "Talkgroup"
ResourceAlert = "Alert"
ResourceShare = "Share"
ResourceAPIKey = "APIKey"
ActionRead = "read"
ActionCreate = "create"
ActionUpdate = "update"
ActionDelete = "delete"
PresetUpdateOwn = "updateOwn"
PresetDeleteOwn = "deleteOwn"
PresetReadShared = "readShared"
PresetUpdateSubmitter = "updateSubmitter"
PresetDeleteSubmitter = "deleteSubmitter"
)
var ( var (
ErrBadSubject = errors.New("bad subject in token") 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 { func ErrAccessDenied(err error) *restrict.AccessDeniedError {
if accessErr, ok := err.(*restrict.AccessDeniedError); ok { if accessErr, ok := err.(*restrict.AccessDeniedError); ok {
return accessErr return accessErr
@ -58,15 +22,6 @@ func ErrAccessDenied(err error) *restrict.AccessDeniedError {
return nil return nil
} }
func SubjectFrom(ctx context.Context) Subject {
sub, ok := ctx.Value(SubjectCtxKey).(Subject)
if ok {
return sub
}
return new(PublicSubject)
}
type rbacCtxKey string type rbacCtxKey string
const RBACCtxKey rbacCtxKey = "rbac" const RBACCtxKey rbacCtxKey = "rbac"
@ -88,176 +43,6 @@ var (
ErrNotAuthorized = errors.New("not authorized") ErrNotAuthorized = errors.New("not authorized")
) )
var policy = &restrict.PolicyDefinition{
Roles: restrict.Roles{
RoleUser: {
Description: "An authenticated user",
Grants: restrict.GrantsMap{
ResourceIncident: {
&restrict.Permission{Action: ActionRead},
&restrict.Permission{Action: ActionCreate},
&restrict.Permission{Preset: PresetUpdateOwn},
&restrict.Permission{Preset: PresetDeleteOwn},
},
ResourceCall: {
&restrict.Permission{Action: ActionRead},
&restrict.Permission{Action: ActionCreate},
&restrict.Permission{Preset: PresetUpdateSubmitter},
&restrict.Permission{Preset: PresetDeleteSubmitter},
},
ResourceTalkgroup: {
&restrict.Permission{Action: ActionRead},
},
ResourceShare: {
&restrict.Permission{Action: ActionRead},
&restrict.Permission{Action: ActionCreate},
&restrict.Permission{Preset: PresetUpdateOwn},
&restrict.Permission{Preset: PresetDeleteOwn},
},
},
},
RoleSubmitter: {
Description: "A role that can submit calls",
Grants: restrict.GrantsMap{
ResourceCall: {
&restrict.Permission{Action: ActionCreate},
},
ResourceTalkgroup: {
// for learning TGs
&restrict.Permission{Action: ActionCreate},
&restrict.Permission{Action: ActionUpdate},
},
},
},
RoleShareGuest: {
Description: "Someone who has a valid share link",
Grants: restrict.GrantsMap{
ResourceCall: {
&restrict.Permission{Preset: PresetReadShared},
},
ResourceIncident: {
&restrict.Permission{Preset: PresetReadShared},
},
ResourceTalkgroup: {
&restrict.Permission{Action: ActionRead},
},
},
},
RoleAdmin: {
Parents: []string{RoleUser},
Grants: restrict.GrantsMap{
ResourceIncident: {
&restrict.Permission{Action: ActionUpdate},
&restrict.Permission{Action: ActionDelete},
},
ResourceCall: {
&restrict.Permission{Action: ActionUpdate},
&restrict.Permission{Action: ActionDelete},
},
ResourceTalkgroup: {
&restrict.Permission{Action: ActionUpdate},
&restrict.Permission{Action: ActionCreate},
&restrict.Permission{Action: ActionDelete},
},
},
},
RoleSystem: {
Parents: []string{RoleSystem},
},
RolePublic: {
/*
Grants: restrict.GrantsMap{
ResourceShare: {
&restrict.Permission{Action: ActionRead},
},
},
*/
},
},
PermissionPresets: restrict.PermissionPresets{
PresetUpdateOwn: &restrict.Permission{
Action: ActionUpdate,
Conditions: restrict.Conditions{
&restrict.EqualCondition{
ID: "isOwner",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Owner",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetDeleteOwn: &restrict.Permission{
Action: ActionDelete,
Conditions: restrict.Conditions{
&restrict.EqualCondition{
ID: "isOwner",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Owner",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetUpdateSubmitter: &restrict.Permission{
Action: ActionUpdate,
Conditions: restrict.Conditions{
&SubmitterEqualCondition{
ID: "isSubmitter",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Submitter",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetDeleteSubmitter: &restrict.Permission{
Action: ActionDelete,
Conditions: restrict.Conditions{
&SubmitterEqualCondition{
ID: "isSubmitter",
Left: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "Submitter",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
PresetReadShared: &restrict.Permission{
Action: ActionRead,
Conditions: restrict.Conditions{
&restrict.EqualCondition{
ID: "isOwner",
Left: &restrict.ValueDescriptor{
Source: restrict.ContextField,
Field: "Owner",
},
Right: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "ID",
},
},
},
},
},
}
type checkOptions struct { type checkOptions struct {
actions []string actions []string
context restrict.Context context restrict.Context
@ -281,17 +66,8 @@ func UseResource(rsc string) restrict.Resource {
return restrict.UseResource(rsc) return restrict.UseResource(rsc)
} }
type Subject interface {
restrict.Subject
GetName() string
}
type Resource interface {
restrict.Resource
}
type RBAC interface { 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 { type rbac struct {
@ -299,8 +75,8 @@ type rbac struct {
access *restrict.AccessManager access *restrict.AccessManager
} }
func New() (*rbac, error) { func New(pol *restrict.PolicyDefinition) (*rbac, error) {
adapter := adapters.NewInMemoryAdapter(policy) adapter := adapters.NewInMemoryAdapter(pol)
polMan, err := restrict.NewPolicyManager(adapter, true) polMan, err := restrict.NewPolicyManager(adapter, true)
if err != nil { if err != nil {
return nil, err return nil, err
@ -314,18 +90,24 @@ func New() (*rbac, error) {
} }
// Check is a convenience function to pull the RBAC instance out of ctx and Check. // 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...) return FromCtx(ctx).Check(ctx, res, opts...)
} }
func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (Subject, error) { func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (entities.Subject, error) {
sub := SubjectFrom(ctx) sub := entities.SubjectFrom(ctx)
o := checkOptions{} o := checkOptions{}
for _, opt := range opts { for _, opt := range opts {
opt(&o) opt(&o)
} }
if o.context == nil {
o.context = make(restrict.Context)
}
o.context["ctx"] = ctx
req := &restrict.AccessRequest{ req := &restrict.AccessRequest{
Subject: sub, Subject: sub,
Resource: res, Resource: res,
@ -335,87 +117,3 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp
return sub, r.access.Authorize(req) return sub, r.access.Authorize(req)
} }
type ShareLinkGuest struct {
ShareID string
}
func (s *ShareLinkGuest) GetName() string {
return "SHARE:" + s.ShareID
}
func (s *ShareLinkGuest) GetRoles() []string {
return []string{RoleShareGuest}
}
type PublicSubject struct {
RemoteAddr string
}
func (s *PublicSubject) GetName() string {
return "PUBLIC:" + s.RemoteAddr
}
func (s *PublicSubject) GetRoles() []string {
return []string{RolePublic}
}
type SystemServiceSubject struct {
Name string
}
func (s *SystemServiceSubject) GetName() string {
return "SYSTEM:" + s.Name
}
func (s *SystemServiceSubject) GetRoles() []string {
return []string{RoleSystem}
}
const (
SubmitterEqualConditionType = "SUBMITTER_EQUAL"
)
type SubmitterEqualCondition struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
Left *restrict.ValueDescriptor `json:"left" yaml:"left"`
Right *restrict.ValueDescriptor `json:"right" yaml:"right"`
}
func (s *SubmitterEqualCondition) Type() string {
return SubmitterEqualConditionType
}
func (c *SubmitterEqualCondition) Check(r *restrict.AccessRequest) error {
left, err := c.Left.GetValue(r)
if err != nil {
return err
}
right, err := c.Right.GetValue(r)
if err != nil {
return err
}
lVal := reflect.ValueOf(left)
rVal := reflect.ValueOf(right)
// deref Left. this is the difference between us and EqualCondition
for lVal.Kind() == reflect.Pointer {
lVal = lVal.Elem()
}
if !lVal.IsValid() || !reflect.DeepEqual(rVal.Interface(), lVal.Interface()) {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("values \"%v\" and \"%v\" are not equal", left, right))
}
return nil
}
func SubmitterEqualConditionFactory() restrict.Condition {
return new(SubmitterEqualCondition)
}
func init() {
restrict.RegisterConditionFactory(SubmitterEqualConditionType, SubmitterEqualConditionFactory)
}

View file

@ -10,6 +10,8 @@ import (
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/rbac" "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/talkgroups"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
"github.com/el-mike/restrict/v2" "github.com/el-mike/restrict/v2"
@ -20,8 +22,8 @@ import (
func TestRBAC(t *testing.T) { func TestRBAC(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
subject rbac.Subject subject entities.Subject
resource rbac.Resource resource entities.Resource
action string action string
expectErr error expectErr error
}{ }{
@ -32,7 +34,7 @@ func TestRBAC(t *testing.T) {
IsAdmin: true, IsAdmin: true,
}, },
resource: &talkgroups.Talkgroup{}, resource: &talkgroups.Talkgroup{},
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: nil, expectErr: nil,
}, },
{ {
@ -45,7 +47,7 @@ func TestRBAC(t *testing.T) {
Name: "test incident", Name: "test incident",
Owner: 4, Owner: 4,
}, },
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: nil, expectErr: nil,
}, },
{ {
@ -57,7 +59,7 @@ func TestRBAC(t *testing.T) {
Name: "test incident", Name: "test incident",
Owner: 4, Owner: 4,
}, },
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: errors.New(`access denied for Action: "update" on Resource: "Incident"`), expectErr: errors.New(`access denied for Action: "update" on Resource: "Incident"`),
}, },
{ {
@ -69,7 +71,7 @@ func TestRBAC(t *testing.T) {
Name: "test incident", Name: "test incident",
Owner: 2, Owner: 2,
}, },
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: nil, expectErr: nil,
}, },
{ {
@ -81,7 +83,7 @@ func TestRBAC(t *testing.T) {
Name: "test incident", Name: "test incident",
Owner: 6, Owner: 6,
}, },
action: rbac.ActionDelete, action: entities.ActionDelete,
expectErr: errors.New(`access denied for Action: "delete" on Resource: "Incident"`), expectErr: errors.New(`access denied for Action: "delete" on Resource: "Incident"`),
}, },
{ {
@ -93,7 +95,7 @@ func TestRBAC(t *testing.T) {
resource: &calls.Call{ resource: &calls.Call{
Submitter: common.PtrTo(users.UserID(4)), Submitter: common.PtrTo(users.UserID(4)),
}, },
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: nil, expectErr: nil,
}, },
{ {
@ -104,7 +106,7 @@ func TestRBAC(t *testing.T) {
resource: &calls.Call{ resource: &calls.Call{
Submitter: common.PtrTo(users.UserID(4)), Submitter: common.PtrTo(users.UserID(4)),
}, },
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`), expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`),
}, },
{ {
@ -115,7 +117,7 @@ func TestRBAC(t *testing.T) {
resource: &calls.Call{ resource: &calls.Call{
Submitter: common.PtrTo(users.UserID(2)), Submitter: common.PtrTo(users.UserID(2)),
}, },
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: nil, expectErr: nil,
}, },
{ {
@ -126,7 +128,7 @@ func TestRBAC(t *testing.T) {
resource: &calls.Call{ resource: &calls.Call{
Submitter: nil, Submitter: nil,
}, },
action: rbac.ActionUpdate, action: entities.ActionUpdate,
expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`), expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`),
}, },
{ {
@ -137,15 +139,83 @@ func TestRBAC(t *testing.T) {
resource: &calls.Call{ resource: &calls.Call{
Submitter: common.PtrTo(users.UserID(6)), Submitter: common.PtrTo(users.UserID(6)),
}, },
action: rbac.ActionDelete, action: entities.ActionDelete,
expectErr: errors.New(`access denied for Action: "delete" on Resource: "Call"`), expectErr: errors.New(`access denied for Action: "delete" on Resource: "Call"`),
}, },
{
name: "user share call not submitter",
subject: &users.User{
ID: 2,
},
resource: &calls.Call{
Submitter: common.PtrTo(users.UserID(6)),
},
action: entities.ActionShare,
expectErr: nil,
},
{
name: "user share call admin",
subject: &users.User{
ID: 2,
IsAdmin: true,
},
resource: &calls.Call{
Submitter: common.PtrTo(users.UserID(6)),
},
action: entities.ActionShare,
expectErr: nil,
},
{
name: "user share call submitter",
subject: &users.User{
ID: 6,
},
resource: &calls.Call{
Submitter: common.PtrTo(users.UserID(6)),
},
action: entities.ActionShare,
expectErr: nil,
},
{
name: "user share incident not owner",
subject: &users.User{
ID: 2,
},
resource: &incidents.Incident{
Owner: users.UserID(6),
},
action: entities.ActionShare,
expectErr: errors.New(`access denied for Action: "share" on Resource: "Incident"`),
},
{
name: "user share incident admin",
subject: &users.User{
ID: 2,
IsAdmin: true,
},
resource: &incidents.Incident{
Owner: users.UserID(6),
},
action: entities.ActionShare,
expectErr: nil,
},
{
name: "user share incident owner",
subject: &users.User{
ID: 6,
},
resource: &incidents.Incident{
Owner: users.UserID(6),
},
action: entities.ActionShare,
expectErr: nil,
},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
ctx := rbac.CtxWithSubject(context.Background(), tc.subject) ctx := entities.CtxWithSubject(context.Background(), tc.subject)
rb, err := rbac.New() rb, err := rbac.New(policy.Policy)
require.NoError(t, err) require.NoError(t, err)
sub, err := rb.Check(ctx, tc.resource, rbac.WithActions(tc.action)) sub, err := rb.Check(ctx, tc.resource, rbac.WithActions(tc.action))
if tc.expectErr != nil { if tc.expectErr != nil {

View file

@ -7,6 +7,7 @@ import (
"dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -21,12 +22,41 @@ type API interface {
Subrouter() http.Handler Subrouter() http.Handler
} }
type APIRoot interface {
API
ShareRouter() http.Handler
}
type api struct { type api struct {
baseURL url.URL baseURL *url.URL
shares *shareAPI
tgs *talkgroupAPI
calls *callsAPI
users *usersAPI
incidents *incidentsAPI
}
func (a *api) ShareRouter() http.Handler {
return a.shares.RootRouter()
} }
func New(baseURL url.URL) *api { func New(baseURL url.URL) *api {
s := &api{baseURL} s := &api{
baseURL: &baseURL,
tgs: new(talkgroupAPI),
calls: new(callsAPI),
incidents: newIncidentsAPI(&baseURL),
users: new(usersAPI),
}
s.shares = newShareAPI(&baseURL,
ShareHandlers{
ShareRequestCall: s.calls.shareCallRoute,
ShareRequestCallDL: s.calls.shareCallDLRoute,
ShareRequestIncident: s.incidents.getIncident,
ShareRequestIncidentM3U: s.incidents.getCallsM3U,
ShareRequestTalkgroups: s.tgs.getTGsShareRoute,
},
)
return s return s
} }
@ -34,11 +64,11 @@ func New(baseURL url.URL) *api {
func (a *api) Subrouter() http.Handler { func (a *api) Subrouter() http.Handler {
r := chi.NewMux() r := chi.NewMux()
r.Mount("/talkgroup", new(talkgroupAPI).Subrouter()) r.Mount("/talkgroup", a.tgs.Subrouter())
r.Mount("/call", new(callsAPI).Subrouter()) r.Mount("/user", a.users.Subrouter())
r.Mount("/user", new(usersAPI).Subrouter()) r.Mount("/call", a.calls.Subrouter())
r.Mount("/incident", newIncidentsAPI(&a.baseURL).Subrouter()) r.Mount("/incident", a.incidents.Subrouter())
r.Mount("/share", newShareHandler(&a.baseURL).Subrouter()) r.Mount("/share", a.shares.Subrouter())
return r return r
} }
@ -141,6 +171,9 @@ var statusMapping = map[error]errResponder{
ErrBadAppName: unauthErrText, ErrBadAppName: unauthErrText,
common.ErrPageOutOfRange: badRequestErrText, common.ErrPageOutOfRange: badRequestErrText,
rbac.ErrNotAuthorized: unauthErrText, rbac.ErrNotAuthorized: unauthErrText,
shares.ErrNoShare: notFoundErrText,
ErrBadShare: notFoundErrText,
shares.ErrBadType: badRequestErrText,
} }
func autoError(err error) render.Renderer { func autoError(err error) render.Renderer {

View file

@ -11,6 +11,7 @@ import (
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls/callstore" "dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/shares"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
@ -30,19 +31,20 @@ type callsAPI struct {
func (ca *callsAPI) Subrouter() http.Handler { func (ca *callsAPI) Subrouter() http.Handler {
r := chi.NewMux() r := chi.NewMux()
r.Get(`/{call:[a-f0-9-]+}`, ca.getAudio) r.Get(`/{call:[a-f0-9-]+}`, ca.getAudioRoute)
r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudio) r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudioRoute)
r.Post(`/`, ca.listCalls) r.Post(`/`, ca.listCalls)
return r return r
} }
func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) { type getAudioParams struct {
p := struct { CallID *uuid.UUID `param:"call"`
CallID *uuid.UUID `param:"call"` Download *string `param:"download"`
Download *string `param:"download"` }
}{}
func (ca *callsAPI) getAudioRoute(w http.ResponseWriter, r *http.Request) {
p := getAudioParams{}
err := decodeParams(&p, r) err := decodeParams(&p, r)
if err != nil { if err != nil {
@ -50,6 +52,10 @@ func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) {
return return
} }
ca.getAudio(p, w, r)
}
func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Request) {
if p.CallID == nil { if p.CallID == nil {
wErr(w, r, badRequest(ErrNoCall)) wErr(w, r, badRequest(ErrNoCall))
return return
@ -96,6 +102,23 @@ func (ca *callsAPI) getAudio(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(call.AudioBlob) _, _ = w.Write(call.AudioBlob)
} }
func (ca *callsAPI) shareCallRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) {
p := getAudioParams{
CallID: common.PtrTo(id.(uuid.UUID)),
}
ca.getAudio(p, w, r)
}
func (ca *callsAPI) shareCallDLRoute(id ID, _ *shares.Share, w http.ResponseWriter, r *http.Request) {
p := getAudioParams{
CallID: common.PtrTo(id.(uuid.UUID)),
Download: common.PtrTo("download"),
}
ca.getAudio(p, w, r)
}
func (ca *callsAPI) listCalls(w http.ResponseWriter, r *http.Request) { func (ca *callsAPI) listCalls(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
cSt := callstore.FromCtx(ctx) cSt := callstore.FromCtx(ctx)

View file

@ -12,6 +12,7 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/incidents/incstore" "dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -22,16 +23,15 @@ type incidentsAPI struct {
baseURL *url.URL baseURL *url.URL
} }
func newIncidentsAPI(baseURL *url.URL) API { func newIncidentsAPI(baseURL *url.URL) *incidentsAPI {
return &incidentsAPI{baseURL} return &incidentsAPI{baseURL}
} }
func (ia *incidentsAPI) Subrouter() http.Handler { func (ia *incidentsAPI) Subrouter() http.Handler {
r := chi.NewMux() r := chi.NewMux()
r.Get(`/{id:[a-f0-9-]+}`, ia.getIncident) r.Get(`/{id:[a-f0-9-]+}`, ia.getIncidentRoute)
r.Get(`/{id:[a-f0-9-]+}.m3u`, ia.getCallsM3U) r.Get(`/{id:[a-f0-9-]+}.m3u`, ia.getCallsM3URoute)
r.Post(`/new`, ia.createIncident) r.Post(`/new`, ia.createIncident)
r.Post(`/`, ia.listIncidents) r.Post(`/`, ia.listIncidents)
r.Post(`/{id:[a-f0-9-]+}/calls`, ia.postCalls) r.Post(`/{id:[a-f0-9-]+}/calls`, ia.postCalls)
@ -88,16 +88,19 @@ func (ia *incidentsAPI) createIncident(w http.ResponseWriter, r *http.Request) {
respond(w, r, inc) respond(w, r, inc)
} }
func (ia *incidentsAPI) getIncident(w http.ResponseWriter, r *http.Request) { func (ia *incidentsAPI) getIncidentRoute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
id, err := idOnlyParam(w, r) id, err := idOnlyParam(w, r)
if err != nil { if err != nil {
return return
} }
inc, err := incs.Incident(ctx, id) ia.getIncident(id, nil, w, r)
}
func (ia *incidentsAPI) getIncident(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
inc, err := incs.Incident(ctx, id.(uuid.UUID))
if err != nil { if err != nil {
wErr(w, r, autoError(err)) wErr(w, r, autoError(err))
return return
@ -186,17 +189,21 @@ func (ia *incidentsAPI) postCalls(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (ia *incidentsAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) { func (ia *incidentsAPI) getCallsM3URoute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
tgst := tgstore.FromCtx(ctx)
id, err := idOnlyParam(w, r) id, err := idOnlyParam(w, r)
if err != nil { if err != nil {
return return
} }
inc, err := incs.Incident(ctx, id) ia.getCallsM3U(id, nil, w, r)
}
func (ia *incidentsAPI) getCallsM3U(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
tgst := tgstore.FromCtx(ctx)
inc, err := incs.Incident(ctx, id.(uuid.UUID))
if err != nil { if err != nil {
wErr(w, r, autoError(err)) wErr(w, r, autoError(err))
return return
@ -205,6 +212,10 @@ func (ia *incidentsAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) {
b := new(bytes.Buffer) b := new(bytes.Buffer)
callUrl := common.PtrTo(*ia.baseURL) callUrl := common.PtrTo(*ia.baseURL)
urlRoot := "/api/call"
if share != nil {
urlRoot = fmt.Sprintf("/share/%s/call/", share.ID)
}
b.WriteString("#EXTM3U\n\n") b.WriteString("#EXTM3U\n\n")
for _, c := range inc.Calls { for _, c := range inc.Calls {
@ -218,7 +229,7 @@ func (ia *incidentsAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) {
from = fmt.Sprintf(" from %d", c.Source) 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", fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s)\n%s\n\n",
c.Duration.Seconds(), c.Duration.Seconds(),

View file

@ -1,231 +1,164 @@
package rest package rest
import ( import (
"bytes" "errors"
"encoding/json"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"time"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/incidents" "dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
) )
var (
ErrBadShare = errors.New("bad share request type")
)
type ShareRequestType string
const (
ShareRequestCall ShareRequestType = "call"
ShareRequestCallDL ShareRequestType = "callDL"
ShareRequestIncident ShareRequestType = "incident"
ShareRequestIncidentM3U ShareRequestType = "m3u"
ShareRequestTalkgroups ShareRequestType = "talkgroups"
)
func (rt ShareRequestType) IsValid() bool {
switch rt {
case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident,
ShareRequestIncidentM3U, ShareRequestTalkgroups:
return true
}
return false
}
func (rt ShareRequestType) IsValidSubtype() bool {
switch rt {
case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups:
return true
}
return false
}
type ID interface {
}
type HandlerFunc func(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request)
type ShareHandlers map[ShareRequestType]HandlerFunc
type shareAPI struct { type shareAPI struct {
baseURL *url.URL baseURL *url.URL
shnd ShareHandlers
} }
func newShareHandler(baseURL *url.URL) API { func newShareAPI(baseURL *url.URL, shnd ShareHandlers) *shareAPI {
return &shareAPI{baseURL} return &shareAPI{
baseURL: baseURL,
shnd: shnd,
}
} }
func (ia *shareAPI) Subrouter() http.Handler { func (sa *shareAPI) Subrouter() http.Handler {
r := chi.NewMux() r := chi.NewMux()
//r.Get(`/{id:[A-Za-z0-9_-]{20,}}`, ia.getShare) r.Post(`/create`, sa.createShare)
//r.Post('/create', ia.createShare) r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, sa.deleteShare)
//r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, ia.deleteShare)
//r.Get(`/`, ia.getShares)
return r return r
} }
func (ia *shareAPI) listIncidents(w http.ResponseWriter, r *http.Request) { func (sa *shareAPI) RootRouter() http.Handler {
ctx := r.Context() r := chi.NewMux()
incs := incstore.FromCtx(ctx)
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}", sa.routeShare)
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}/{subID}", sa.routeShare)
return r
}
func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
shs := shares.FromCtx(ctx)
p := shares.CreateShareParams{}
p := incstore.IncidentsParams{}
err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil { if err != nil {
wErr(w, r, badRequest(err)) wErr(w, r, badRequest(err))
return return
} }
res := struct { sh, err := shs.NewShare(ctx, p)
Incidents []incstore.Incident `json:"incidents"` if err != nil {
Count int `json:"count"` wErr(w, r, autoError(err))
return
}
respond(w, r, sh)
}
func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
shs := shares.FromCtx(ctx)
params := struct {
Type string `param:"type"`
ID string `param:"shareId"`
SubID *string `param:"subID"`
}{} }{}
res.Incidents, res.Count, err = incs.Incidents(ctx, p) err := decodeParams(&params, r)
if err != nil { if err != nil {
wErr(w, r, autoError(err)) wErr(w, r, autoError(err))
return return
} }
respond(w, r, res) rType := ShareRequestType(params.Type)
} id := params.ID
func (ia *shareAPI) createIncident(w http.ResponseWriter, r *http.Request) { if !rType.IsValid() {
ctx := r.Context() wErr(w, r, autoError(ErrBadShare))
incs := incstore.FromCtx(ctx)
p := incidents.Incident{}
err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil {
wErr(w, r, badRequest(err))
return return
} }
inc, err := incs.CreateIncident(ctx, p) sh, err := shs.GetShare(ctx, id)
if err != nil { if err != nil {
wErr(w, r, autoError(err)) wErr(w, r, autoError(err))
return return
} }
respond(w, r, inc) if sh.Expiration != nil && sh.Expiration.Time().Before(time.Now()) {
} wErr(w, r, autoError(shares.ErrNoShare))
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 return
} }
inc, err := incs.Incident(ctx, id) ctx = entities.CtxWithSubject(ctx, sh)
if err != nil { r = r.WithContext(ctx)
wErr(w, r, autoError(err))
return
}
respond(w, r, inc) switch rType {
} case ShareRequestTalkgroups:
sa.shnd[rType](nil, sh, w, r)
func (ia *shareAPI) updateIncident(w http.ResponseWriter, r *http.Request) { case ShareRequestCall, ShareRequestCallDL:
ctx := r.Context() if params.SubID == nil {
incs := incstore.FromCtx(ctx) wErr(w, r, autoError(ErrBadShare))
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 return
} }
var from string
if c.Source != 0 { subIDU, err := uuid.Parse(*params.SubID)
from = fmt.Sprintf(" from %d", c.Source) if err != nil {
wErr(w, r, badRequest(err))
return
} }
sa.shnd[rType](subIDU, sh, w, r)
callUrl.Path = "/api/call/" + c.ID.String() case ShareRequestIncident, ShareRequestIncidentM3U:
sa.shnd[rType](sh.EntityID, sh, w, r)
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 func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/x-mpegurl")
w.WriteHeader(http.StatusOK)
_, _ = b.WriteTo(w)
} }

View file

@ -7,6 +7,8 @@ import (
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/database" "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"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/xport" "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) 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) { func (tga *talkgroupAPI) put(w http.ResponseWriter, r *http.Request) {
var id tgParams var id tgParams
err := decodeParams(&id, r) err := decodeParams(&id, r)

View file

@ -51,7 +51,7 @@ func (s *Server) setupRoutes() {
s.rateLimit(r) s.rateLimit(r)
r.Use(render.SetContentType(render.ContentTypeJSON)) r.Use(render.SetContentType(render.ContentTypeJSON))
s.auth.PublicRoutes(r) s.auth.PublicRoutes(r)
// r.Mount("/share", s.share.ShareRouter(s.rest)) r.Mount("/share", s.rest.ShareRouter())
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {

View file

@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"os" "os"
"time" "time"
@ -16,8 +17,9 @@ import (
"dynatron.me/x/stillbox/pkg/nexus" "dynatron.me/x/stillbox/pkg/nexus"
"dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/notify"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/policy"
"dynatron.me/x/stillbox/pkg/rest" "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/sinks"
"dynatron.me/x/stillbox/pkg/sources" "dynatron.me/x/stillbox/pkg/sources"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
@ -45,12 +47,12 @@ type Server struct {
notifier notify.Notifier notifier notify.Notifier
hup chan os.Signal hup chan os.Signal
tgs tgstore.Store tgs tgstore.Store
rest rest.API rest rest.APIRoot
partman partman.PartitionManager partman partman.PartitionManager
users users.Store users users.Store
calls callstore.Store calls callstore.Store
incidents incstore.Store incidents incstore.Store
share share.Service share shares.Service
rbac rbac.RBAC rbac rbac.RBAC
} }
@ -79,7 +81,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
tgCache := tgstore.NewCache(db) tgCache := tgstore.NewCache(db)
api := rest.New(cfg.BaseURL.URL()) api := rest.New(cfg.BaseURL.URL())
rbacSvc, err := rbac.New() rbacSvc, err := rbac.New(policy.Policy)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -96,7 +98,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
tgs: tgCache, tgs: tgCache,
sinks: sinks.NewSinkManager(), sinks: sinks.NewSinkManager(),
rest: api, rest: api,
share: share.NewService(), share: shares.NewService(),
users: ust, users: ust,
calls: callstore.NewStore(db), calls: callstore.NewStore(db),
incidents: incstore.NewStore(), incidents: incstore.NewStore(),
@ -145,6 +147,13 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
})) }))
srv.setupRoutes() 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 return srv, nil
} }
@ -154,7 +163,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context {
ctx = users.CtxWithStore(ctx, s.users) ctx = users.CtxWithStore(ctx, s.users)
ctx = callstore.CtxWithStore(ctx, s.calls) ctx = callstore.CtxWithStore(ctx, s.calls)
ctx = incstore.CtxWithStore(ctx, s.incidents) 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) ctx = rbac.CtxWithRBAC(ctx, s.rbac)
return ctx return ctx

View file

@ -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
}

View file

@ -1,10 +1,10 @@
package share package shares
import ( import (
"context" "context"
"time" "time"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac/entities"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -13,21 +13,17 @@ const (
) )
type Service interface { type Service interface {
ShareStore() Store Shares
Go(ctx context.Context) Go(ctx context.Context)
} }
type service struct { type service struct {
Store postgresStore
}
func (s *service) ShareStore() Store {
return s.Store
} }
func (s *service) Go(ctx context.Context) { 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) tick := time.NewTicker(PruneInterval)
@ -46,7 +42,5 @@ func (s *service) Go(ctx context.Context) {
} }
func NewService() *service { func NewService() *service {
return &service{ return &service{}
Store: NewStore(),
}
} }

144
pkg/shares/share.go Normal file
View file

@ -0,0 +1,144 @@
package shares
import (
"context"
"errors"
"time"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid"
"github.com/matoous/go-nanoid"
)
const (
SlugLength = 20
)
var (
ErrExpirationTooSoon = errors.New("expiration too soon")
ErrBadType = errors.New("bad share type")
)
type EntityType string
const (
EntityIncident EntityType = "incident"
EntityCall EntityType = "call"
)
func (et EntityType) IsValid() bool {
switch et {
case EntityCall, EntityIncident:
return true
}
return false
}
// If an incident is shared, all calls that are part of it must be shared too, but this can be through the incident share (/share/bLaH/callID[.mp3])
type Share struct {
ID string `json:"id"`
Type EntityType `json:"entityType"`
Date *jsontypes.Time `json:"-"` // we handle this for the user
Owner users.UserID `json:"owner"`
EntityID uuid.UUID `json:"entityID"`
Expiration *jsontypes.Time `json:"expiration"`
}
func (s *Share) GetName() string {
return "SHARE:" + s.ID
}
func (s *Share) GetRoles() []string {
return []string{entities.RoleShareGuest}
}
func (s *Share) GetResourceName() string {
return entities.ResourceShare
}
type CreateShareParams struct {
Type EntityType `json:"entityType"`
EntityID uuid.UUID `json:"entityID"`
Expiration *jsontypes.Duration `json:"expiration"`
}
func (s *service) checkEntity(ctx context.Context, sh *CreateShareParams) (*time.Time, error) {
var t *time.Time
switch sh.Type {
case EntityCall:
cs := callstore.FromCtx(ctx)
// Call does RBAC for us
call, err := cs.Call(ctx, sh.EntityID)
if err != nil {
return nil, err
}
t = &call.DateTime
case EntityIncident:
is := incstore.FromCtx(ctx)
i, err := is.Owner(ctx, sh.EntityID)
if err != nil {
return nil, err
}
_, err = rbac.Check(ctx, &i, rbac.WithActions(entities.ActionShare))
if err != nil {
return nil, err
}
}
return t, nil
}
// NewShare creates a new share.
func (s *service) NewShare(ctx context.Context, sh CreateShareParams) (*Share, error) {
if !sh.Type.IsValid() {
return nil, ErrBadType
}
// entTime is only meaningful if we are a call
entTime, err := s.checkEntity(ctx, &sh)
if err != nil {
return nil, err
}
u, err := users.From(ctx)
if err != nil {
return nil, err
}
id, err := gonanoid.ID(SlugLength)
if err != nil {
return nil, err
}
share := &Share{
ID: id,
Type: sh.Type,
Date: (*jsontypes.Time)(entTime),
Owner: u.ID,
EntityID: sh.EntityID,
}
if sh.Expiration != nil {
if sh.Expiration.Duration() < time.Minute {
return nil, ErrExpirationTooSoon
}
share.Expiration = common.PtrTo(jsontypes.Time(time.Now().Add(sh.Expiration.Duration())))
}
err = s.Create(ctx, share)
if err != nil {
return nil, err
}
return share, nil
}

View file

@ -1,15 +1,23 @@
package share package shares
import ( import (
"context" "context"
"errors"
"dynatron.me/x/stillbox/internal/jsontypes" "dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/users"
"github.com/jackc/pgx/v5"
) )
type Store interface { type Shares interface {
// Get retreives a share record. // NewShare creates a new share.
Get(ctx context.Context, id string) (*Share, error) 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 stores a new share record.
Create(ctx context.Context, share *Share) error Create(ctx context.Context, share *Share) error
@ -24,38 +32,59 @@ type Store interface {
type postgresStore struct { type postgresStore struct {
} }
var (
ErrNoShare = errors.New("no such share")
)
func recToShare(share database.Share) *Share { func recToShare(share database.Share) *Share {
return &Share{ return &Share{
ID: share.ID, ID: share.ID,
Type: EntityType(share.EntityType), Type: EntityType(share.EntityType),
EntityID: share.EntityID, EntityID: share.EntityID,
Date: jsontypes.TimePtrFromTSTZ(share.EntityDate),
Expiration: jsontypes.TimePtrFromTSTZ(share.Expiration), 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) db := database.FromCtx(ctx)
rec, err := db.GetShare(ctx, id) 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 nil, err
} }
return recToShare(rec), nil
} }
func (s *postgresStore) Create(ctx context.Context, share *Share) error { 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) db := database.FromCtx(ctx)
err := db.CreateShare(ctx, database.CreateShareParams{ err = db.CreateShare(ctx, database.CreateShareParams{
ID: share.ID, ID: share.ID,
EntityType: string(share.Type), EntityType: string(share.Type),
EntityID: share.EntityID, EntityID: share.EntityID,
EntityDate: share.Date.PGTypeTSTZ(),
Expiration: share.Expiration.PGTypeTSTZ(), Expiration: share.Expiration.PGTypeTSTZ(),
Owner: sub.ID.Int(),
}) })
return err return err
} }
func (s *postgresStore) Delete(ctx context.Context, id string) error { func (s *postgresStore) Delete(ctx context.Context, id string) error {
_, err := rbac.Check(ctx, new(Share), rbac.WithActions(entities.ActionDelete))
if err != nil {
return err
}
return database.FromCtx(ctx).DeleteShare(ctx, id) return database.FromCtx(ctx).DeleteShare(ctx, id)
} }
@ -71,14 +100,14 @@ type storeCtxKey string
const StoreCtxKey storeCtxKey = "store" 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) return context.WithValue(ctx, StoreCtxKey, s)
} }
func FromCtx(ctx context.Context) Store { func FromCtx(ctx context.Context) Shares {
s, ok := ctx.Value(StoreCtxKey).(Store) s, ok := ctx.Value(StoreCtxKey).(Shares)
if !ok { if !ok {
return NewStore() panic("no shares store in context")
} }
return s return s

View file

@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/pkg/auth" "dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/calls" "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/users" "dynatron.me/x/stillbox/pkg/users"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log" "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) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
err = h.ing.Ingest(rbac.CtxWithSubject(ctx, submitterSub), call) err = h.ing.Ingest(entities.CtxWithSubject(ctx, submitterSub), call)
if err != nil { if err != nil {
if rbac.ErrAccessDenied(err) != nil { if rbac.ErrAccessDenied(err) != nil {
log.Error().Err(err).Msg("ingest failed") log.Error().Err(err).Msg("ingest failed")

View file

@ -122,6 +122,7 @@ func (f *TalkgroupFilter) compile(ctx context.Context) error {
if f.hasTags() { // don't bother with DB if no tags if f.hasTags() { // don't bother with DB if no tags
db := database.FromCtx(ctx) 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) tagTGs, err := db.GetTalkgroupIDsByTags(ctx, f.TalkgroupTagsAny, f.TalkgroupTagsAll, f.TalkgroupTagsNot)
if err != nil { if err != nil {
return err return err

View file

@ -9,7 +9,7 @@ import (
"strings" "strings"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac/entities"
) )
type Talkgroup struct { type Talkgroup struct {
@ -19,7 +19,7 @@ type Talkgroup struct {
} }
func (t *Talkgroup) GetResourceName() string { func (t *Talkgroup) GetResourceName() string {
return rbac.ResourceTalkgroup return entities.ResourceTalkgroup
} }
func (t Talkgroup) String() string { func (t Talkgroup) String() string {
@ -41,6 +41,18 @@ type ID struct {
Talkgroup uint32 `json:"tg"` 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 _ encoding.TextUnmarshaler = (*ID)(nil)
var ErrBadTG = errors.New("bad talkgroup format") var ErrBadTG = errors.New("bad talkgroup format")

View file

@ -12,6 +12,7 @@ import (
"dynatron.me/x/stillbox/pkg/config" "dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups" tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users" "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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return "", false 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 { 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 { if err != nil {
return err 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 { 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 { if err != nil {
return err 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) { 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 { if err != nil {
return nil, err 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 { 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 { if err != nil {
return err 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) { 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -1,21 +0,0 @@
package users
import (
"dynatron.me/x/stillbox/pkg/rbac"
)
type ShareLinkGuest struct {
ShareID string
}
func (s *ShareLinkGuest) GetRoles() []string {
return []string{rbac.RoleShareGuest}
}
type Public struct {
RemoteAddr string
}
func (s *Public) GetRoles() []string {
return []string{rbac.RolePublic}
}

View file

@ -7,6 +7,7 @@ import (
"dynatron.me/x/stillbox/pkg/database" "dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
) )
type UserID int type UserID int
@ -30,11 +31,11 @@ func (u UserID) IsValid() bool {
} }
func From(ctx context.Context) (*User, error) { func From(ctx context.Context) (*User, error) {
sub := rbac.SubjectFrom(ctx) sub := entities.SubjectFrom(ctx)
return FromSubject(sub) 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, "+") acts := strings.Split(actions, "+")
subj, err := rbac.FromCtx(ctx).Check(ctx, rsc, rbac.WithActions(acts...)) subj, err := rbac.FromCtx(ctx).Check(ctx, rsc, rbac.WithActions(acts...))
if err != nil { if err != nil {
@ -44,7 +45,7 @@ func UserCheck(ctx context.Context, rsc rbac.Resource, actions string) (*User, e
return FromSubject(subj) return FromSubject(subj)
} }
func FromSubject(sub rbac.Subject) (*User, error) { func FromSubject(sub entities.Subject) (*User, error) {
if sub == nil { if sub == nil {
return nil, rbac.ErrBadSubject return nil, rbac.ErrBadSubject
} }
@ -73,10 +74,10 @@ func (u *User) GetName() string {
func (u *User) GetRoles() []string { func (u *User) GetRoles() []string {
r := make([]string, 1, 2) r := make([]string, 1, 2)
r[0] = rbac.RoleUser r[0] = entities.RoleUser
if u.IsAdmin { if u.IsAdmin {
r = append(r, rbac.RoleAdmin) r = append(r, entities.RoleAdmin)
} }
return r return r

View file

@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS shares(
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL, entity_type TEXT NOT NULL,
entity_id UUID NOT NULL, entity_id UUID NOT NULL,
entity_date TIMESTAMPTZ,
owner INTEGER NOT NULL REFERENCES users(id), owner INTEGER NOT NULL REFERENCES users(id),
expiration TIMESTAMPTZ NULL expiration TIMESTAMPTZ NULL
); );

View file

@ -162,3 +162,24 @@ DELETE FROM calls WHERE id = @id;
-- name: GetCallSubmitter :one -- name: GetCallSubmitter :one
SELECT submitter FROM calls WHERE id = @id; 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;

View file

@ -29,6 +29,13 @@ UPDATE incidents_Calls
SET notes = @notes SET notes = @notes
WHERE incident_id = @incident_id AND call_id = @call_id; 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 -- name: CreateIncident :one
INSERT INTO incidents ( INSERT INTO incidents (
id, id,

View file

@ -3,6 +3,7 @@ SELECT
id, id,
entity_type, entity_type,
entity_id, entity_id,
entity_date,
owner, owner,
expiration expiration
FROM shares FROM shares
@ -13,9 +14,10 @@ INSERT INTO shares (
id, id,
entity_type, entity_type,
entity_id, entity_id,
entity_date,
owner, owner,
expiration 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 -- name: DeleteShare :exec
DELETE FROM shares WHERE id = @id; DELETE FROM shares WHERE id = @id;

View file

@ -281,3 +281,11 @@ INSERT INTO systems(id, name) VALUES(@id, @name);
-- name: DeleteSystem :exec -- name: DeleteSystem :exec
DELETE FROM systems WHERE id = @id; 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;