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/database"
"dynatron.me/x/stillbox/pkg/notify"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
@ -124,7 +124,7 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte
// Go is the alerting loop. It does not start a goroutine.
func (as *alerter) Go(ctx context.Context) {
ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "alerter"})
ctx = entities.CtxWithSubject(ctx, &entities.SystemServiceSubject{Name: "alerter"})
err := as.startBackfill(ctx)
if err != nil {

View file

@ -7,7 +7,7 @@ import (
"time"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
@ -16,10 +16,10 @@ import (
type apiKeyAuth interface {
// CheckAPIKey validates the provided key and returns the API owner's users.UserID.
// An error is returned if validation fails for any reason.
CheckAPIKey(ctx context.Context, key string) (rbac.Subject, error)
CheckAPIKey(ctx context.Context, key string) (entities.Subject, error)
}
func (a *Auth) CheckAPIKey(ctx context.Context, key string) (rbac.Subject, error) {
func (a *Auth) CheckAPIKey(ctx context.Context, key string) (entities.Subject, error) {
keyUuid, err := uuid.Parse(key)
if err != nil {
log.Error().Str("apikey", key).Msg("cannot parse key")

View file

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

View file

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

View file

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

View file

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

View file

@ -164,6 +164,71 @@ func (q *Queries) DeleteCall(ctx context.Context, id uuid.UUID) error {
return err
}
const getCall = `-- name: GetCall :one
SELECT
id,
submitter,
system,
talkgroup,
call_date,
audio_name,
audio_type,
audio_url,
duration,
frequency,
frequencies,
patches,
tg_label,
tg_alpha_tag,
tg_group,
source
FROM calls
WHERE id = $1
`
type GetCallRow struct {
ID uuid.UUID `json:"id"`
Submitter *int32 `json:"submitter"`
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
CallDate pgtype.Timestamptz `json:"call_date"`
AudioName *string `json:"audio_name"`
AudioType *string `json:"audio_type"`
AudioUrl *string `json:"audio_url"`
Duration *int32 `json:"duration"`
Frequency int `json:"frequency"`
Frequencies []int `json:"frequencies"`
Patches []int `json:"patches"`
TGLabel *string `json:"tg_label"`
TGAlphaTag *string `json:"tg_alpha_tag"`
TGGroup *string `json:"tg_group"`
Source int `json:"source"`
}
func (q *Queries) GetCall(ctx context.Context, id uuid.UUID) (GetCallRow, error) {
row := q.db.QueryRow(ctx, getCall, id)
var i GetCallRow
err := row.Scan(
&i.ID,
&i.Submitter,
&i.System,
&i.Talkgroup,
&i.CallDate,
&i.AudioName,
&i.AudioType,
&i.AudioUrl,
&i.Duration,
&i.Frequency,
&i.Frequencies,
&i.Patches,
&i.TGLabel,
&i.TGAlphaTag,
&i.TGGroup,
&i.Source,
)
return i, err
}
const getCallAudioByID = `-- name: GetCallAudioByID :one
SELECT
c.call_date,

View file

@ -40,6 +40,21 @@ func (q *Queries) AddToIncident(ctx context.Context, incidentID uuid.UUID, callI
return err
}
const callInIncident = `-- name: CallInIncident :one
SELECT EXISTS
(SELECT 1 FROM incidents_calls ic
WHERE
ic.incident_id = $1 AND
ic.call_id = $2)
`
func (q *Queries) CallInIncident(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID) (bool, error) {
row := q.db.QueryRow(ctx, callInIncident, incidentID, callID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const createIncident = `-- name: CreateIncident :one
INSERT INTO incidents (
id,

View file

@ -278,6 +278,64 @@ func (_c *Store_BulkSetTalkgroupTags_Call) RunAndReturn(run func(context.Context
return _c
}
// CallInIncident provides a mock function with given fields: ctx, incidentID, callID
func (_m *Store) CallInIncident(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID) (bool, error) {
ret := _m.Called(ctx, incidentID, callID)
if len(ret) == 0 {
panic("no return value specified for CallInIncident")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID) (bool, error)); ok {
return rf(ctx, incidentID, callID)
}
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID) bool); ok {
r0 = rf(ctx, incidentID, callID)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID) error); ok {
r1 = rf(ctx, incidentID, callID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_CallInIncident_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CallInIncident'
type Store_CallInIncident_Call struct {
*mock.Call
}
// CallInIncident is a helper method to define mock.On call
// - ctx context.Context
// - incidentID uuid.UUID
// - callID uuid.UUID
func (_e *Store_Expecter) CallInIncident(ctx interface{}, incidentID interface{}, callID interface{}) *Store_CallInIncident_Call {
return &Store_CallInIncident_Call{Call: _e.mock.On("CallInIncident", ctx, incidentID, callID)}
}
func (_c *Store_CallInIncident_Call) Run(run func(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID)) *Store_CallInIncident_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(uuid.UUID), args[2].(uuid.UUID))
})
return _c
}
func (_c *Store_CallInIncident_Call) Return(_a0 bool, _a1 error) *Store_CallInIncident_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_CallInIncident_Call) RunAndReturn(run func(context.Context, uuid.UUID, uuid.UUID) (bool, error)) *Store_CallInIncident_Call {
_c.Call.Return(run)
return _c
}
// CleanupSweptCalls provides a mock function with given fields: ctx, rangeStart, rangeEnd
func (_m *Store) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error) {
ret := _m.Called(ctx, rangeStart, rangeEnd)
@ -1348,6 +1406,63 @@ func (_c *Store_GetAppPrefs_Call) RunAndReturn(run func(context.Context, string,
return _c
}
// GetCall provides a mock function with given fields: ctx, id
func (_m *Store) GetCall(ctx context.Context, id uuid.UUID) (database.GetCallRow, error) {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for GetCall")
}
var r0 database.GetCallRow
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (database.GetCallRow, error)); ok {
return rf(ctx, id)
}
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) database.GetCallRow); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Get(0).(database.GetCallRow)
}
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetCall_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCall'
type Store_GetCall_Call struct {
*mock.Call
}
// GetCall is a helper method to define mock.On call
// - ctx context.Context
// - id uuid.UUID
func (_e *Store_Expecter) GetCall(ctx interface{}, id interface{}) *Store_GetCall_Call {
return &Store_GetCall_Call{Call: _e.mock.On("GetCall", ctx, id)}
}
func (_c *Store_GetCall_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_GetCall_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(uuid.UUID))
})
return _c
}
func (_c *Store_GetCall_Call) Return(_a0 database.GetCallRow, _a1 error) *Store_GetCall_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetCall_Call) RunAndReturn(run func(context.Context, uuid.UUID) (database.GetCallRow, error)) *Store_GetCall_Call {
_c.Call.Return(run)
return _c
}
// GetCallAudioByID provides a mock function with given fields: ctx, id
func (_m *Store) GetCallAudioByID(ctx context.Context, id uuid.UUID) (database.GetCallAudioByIDRow, error) {
ret := _m.Called(ctx, id)
@ -1693,6 +1808,65 @@ func (_c *Store_GetIncidentOwner_Call) RunAndReturn(run func(context.Context, uu
return _c
}
// GetIncidentTalkgroups provides a mock function with given fields: ctx, incidentID
func (_m *Store) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]database.GetIncidentTalkgroupsRow, error) {
ret := _m.Called(ctx, incidentID)
if len(ret) == 0 {
panic("no return value specified for GetIncidentTalkgroups")
}
var r0 []database.GetIncidentTalkgroupsRow
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) ([]database.GetIncidentTalkgroupsRow, error)); ok {
return rf(ctx, incidentID)
}
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) []database.GetIncidentTalkgroupsRow); ok {
r0 = rf(ctx, incidentID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]database.GetIncidentTalkgroupsRow)
}
}
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
r1 = rf(ctx, incidentID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetIncidentTalkgroups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIncidentTalkgroups'
type Store_GetIncidentTalkgroups_Call struct {
*mock.Call
}
// GetIncidentTalkgroups is a helper method to define mock.On call
// - ctx context.Context
// - incidentID uuid.UUID
func (_e *Store_Expecter) GetIncidentTalkgroups(ctx interface{}, incidentID interface{}) *Store_GetIncidentTalkgroups_Call {
return &Store_GetIncidentTalkgroups_Call{Call: _e.mock.On("GetIncidentTalkgroups", ctx, incidentID)}
}
func (_c *Store_GetIncidentTalkgroups_Call) Run(run func(ctx context.Context, incidentID uuid.UUID)) *Store_GetIncidentTalkgroups_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(uuid.UUID))
})
return _c
}
func (_c *Store_GetIncidentTalkgroups_Call) Return(_a0 []database.GetIncidentTalkgroupsRow, _a1 error) *Store_GetIncidentTalkgroups_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetIncidentTalkgroups_Call) RunAndReturn(run func(context.Context, uuid.UUID) ([]database.GetIncidentTalkgroupsRow, error)) *Store_GetIncidentTalkgroups_Call {
_c.Call.Return(run)
return _c
}
// GetShare provides a mock function with given fields: ctx, id
func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) {
ret := _m.Called(ctx, id)

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/alerting/rules"
"github.com/google/uuid"
)
const addLearnedTalkgroup = `-- name: AddLearnedTalkgroup :one
@ -117,6 +118,40 @@ func (q *Queries) GetAllTalkgroupTags(ctx context.Context) ([]string, error) {
return items, nil
}
const getIncidentTalkgroups = `-- name: GetIncidentTalkgroups :many
SELECT DISTINCT
c.system,
c.talkgroup
FROM incidents_calls ic
JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date)
WHERE ic.incident_id = $1
`
type GetIncidentTalkgroupsRow struct {
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
}
func (q *Queries) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) {
rows, err := q.db.Query(ctx, getIncidentTalkgroups, incidentID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetIncidentTalkgroupsRow
for rows.Next() {
var i GetIncidentTalkgroupsRow
if err := rows.Scan(&i.System, &i.Talkgroup); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getSystemName = `-- name: GetSystemName :one
SELECT name FROM systems WHERE id = $1
`

View file

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

View file

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

View file

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

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 (
context "context"
rbac "dynatron.me/x/stillbox/pkg/rbac"
entities "dynatron.me/x/stillbox/pkg/rbac/entities"
mock "github.com/stretchr/testify/mock"
rbac "dynatron.me/x/stillbox/pkg/rbac"
restrict "github.com/el-mike/restrict/v2"
)
@ -25,7 +27,7 @@ func (_m *RBAC) EXPECT() *RBAC_Expecter {
}
// Check provides a mock function with given fields: ctx, res, opts
func (_m *RBAC) Check(ctx context.Context, res restrict.Resource, opts ...rbac.CheckOption) (rbac.Subject, error) {
func (_m *RBAC) Check(ctx context.Context, res restrict.Resource, opts ...rbac.CheckOption) (entities.Subject, error) {
_va := make([]interface{}, len(opts))
for _i := range opts {
_va[_i] = opts[_i]
@ -39,16 +41,16 @@ func (_m *RBAC) Check(ctx context.Context, res restrict.Resource, opts ...rbac.C
panic("no return value specified for Check")
}
var r0 rbac.Subject
var r0 entities.Subject
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) (rbac.Subject, error)); ok {
if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) (entities.Subject, error)); ok {
return rf(ctx, res, opts...)
}
if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) rbac.Subject); ok {
if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) entities.Subject); ok {
r0 = rf(ctx, res, opts...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(rbac.Subject)
r0 = ret.Get(0).(entities.Subject)
}
}
@ -88,12 +90,12 @@ func (_c *RBAC_Check_Call) Run(run func(ctx context.Context, res restrict.Resour
return _c
}
func (_c *RBAC_Check_Call) Return(_a0 rbac.Subject, _a1 error) *RBAC_Check_Call {
func (_c *RBAC_Check_Call) Return(_a0 entities.Subject, _a1 error) *RBAC_Check_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *RBAC_Check_Call) RunAndReturn(run func(context.Context, restrict.Resource, ...rbac.CheckOption) (rbac.Subject, error)) *RBAC_Check_Call {
func (_c *RBAC_Check_Call) RunAndReturn(run func(context.Context, restrict.Resource, ...rbac.CheckOption) (entities.Subject, error)) *RBAC_Check_Call {
_c.Call.Return(run)
return _c
}

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,231 +1,164 @@
package rest
import (
"bytes"
"encoding/json"
"fmt"
"errors"
"net/http"
"net/url"
"time"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/shares"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
var (
ErrBadShare = errors.New("bad share request type")
)
type ShareRequestType string
const (
ShareRequestCall ShareRequestType = "call"
ShareRequestCallDL ShareRequestType = "callDL"
ShareRequestIncident ShareRequestType = "incident"
ShareRequestIncidentM3U ShareRequestType = "m3u"
ShareRequestTalkgroups ShareRequestType = "talkgroups"
)
func (rt ShareRequestType) IsValid() bool {
switch rt {
case ShareRequestCall, ShareRequestCallDL, ShareRequestIncident,
ShareRequestIncidentM3U, ShareRequestTalkgroups:
return true
}
return false
}
func (rt ShareRequestType) IsValidSubtype() bool {
switch rt {
case ShareRequestCall, ShareRequestCallDL, ShareRequestTalkgroups:
return true
}
return false
}
type ID interface {
}
type HandlerFunc func(id ID, share *shares.Share, w http.ResponseWriter, r *http.Request)
type ShareHandlers map[ShareRequestType]HandlerFunc
type shareAPI struct {
baseURL *url.URL
shnd ShareHandlers
}
func newShareHandler(baseURL *url.URL) API {
return &shareAPI{baseURL}
func newShareAPI(baseURL *url.URL, shnd ShareHandlers) *shareAPI {
return &shareAPI{
baseURL: baseURL,
shnd: shnd,
}
}
func (ia *shareAPI) Subrouter() http.Handler {
func (sa *shareAPI) Subrouter() http.Handler {
r := chi.NewMux()
//r.Get(`/{id:[A-Za-z0-9_-]{20,}}`, ia.getShare)
//r.Post('/create', ia.createShare)
//r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, ia.deleteShare)
//r.Get(`/`, ia.getShares)
r.Post(`/create`, sa.createShare)
r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, sa.deleteShare)
return r
}
func (ia *shareAPI) listIncidents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
func (sa *shareAPI) RootRouter() http.Handler {
r := chi.NewMux()
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}", sa.routeShare)
r.Get("/{shareId:[A-Za-z0-9_-]{20,}}/{type}/{subID}", sa.routeShare)
return r
}
func (sa *shareAPI) createShare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
shs := shares.FromCtx(ctx)
p := shares.CreateShareParams{}
p := incstore.IncidentsParams{}
err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil {
wErr(w, r, badRequest(err))
return
}
res := struct {
Incidents []incstore.Incident `json:"incidents"`
Count int `json:"count"`
sh, err := shs.NewShare(ctx, p)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, sh)
}
func (sa *shareAPI) routeShare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
shs := shares.FromCtx(ctx)
params := struct {
Type string `param:"type"`
ID string `param:"shareId"`
SubID *string `param:"subID"`
}{}
res.Incidents, res.Count, err = incs.Incidents(ctx, p)
err := decodeParams(&params, r)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, res)
}
rType := ShareRequestType(params.Type)
id := params.ID
func (ia *shareAPI) createIncident(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
p := incidents.Incident{}
err := forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil {
wErr(w, r, badRequest(err))
if !rType.IsValid() {
wErr(w, r, autoError(ErrBadShare))
return
}
inc, err := incs.CreateIncident(ctx, p)
sh, err := shs.GetShare(ctx, id)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, inc)
}
func (ia *shareAPI) getIncident(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
id, err := idOnlyParam(w, r)
if err != nil {
if sh.Expiration != nil && sh.Expiration.Time().Before(time.Now()) {
wErr(w, r, autoError(shares.ErrNoShare))
return
}
inc, err := incs.Incident(ctx, id)
if err != nil {
wErr(w, r, autoError(err))
return
}
ctx = entities.CtxWithSubject(ctx, sh)
r = r.WithContext(ctx)
respond(w, r, inc)
}
func (ia *shareAPI) updateIncident(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
id, err := idOnlyParam(w, r)
if err != nil {
return
}
p := incstore.UpdateIncidentParams{}
err = forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil {
wErr(w, r, badRequest(err))
return
}
inc, err := incs.UpdateIncident(ctx, id, p)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, inc)
}
func (ia *shareAPI) deleteIncident(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
urlParams := struct {
ID uuid.UUID `param:"id"`
}{}
err := decodeParams(&urlParams, r)
if err != nil {
wErr(w, r, badRequest(err))
return
}
err = incs.DeleteIncident(ctx, urlParams.ID)
if err != nil {
wErr(w, r, autoError(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
type CallIncidentParams2 struct {
Add jsontypes.UUIDs `json:"add"`
Notes json.RawMessage `json:"notes"`
Remove jsontypes.UUIDs `json:"remove"`
}
func (ia *shareAPI) postCalls(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
id, err := idOnlyParam(w, r)
if err != nil {
return
}
p := CallIncidentParams2{}
err = forms.Unmarshal(r, &p, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil {
wErr(w, r, badRequest(err))
return
}
err = incs.AddRemoveIncidentCalls(ctx, id, p.Add.UUIDs(), p.Notes, p.Remove.UUIDs())
if err != nil {
wErr(w, r, autoError(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
func (ia *shareAPI) getCallsM3U(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
incs := incstore.FromCtx(ctx)
tgst := tgstore.FromCtx(ctx)
id, err := idOnlyParam(w, r)
if err != nil {
return
}
inc, err := incs.Incident(ctx, id)
if err != nil {
wErr(w, r, autoError(err))
return
}
b := new(bytes.Buffer)
callUrl := common.PtrTo(*ia.baseURL)
b.WriteString("#EXTM3U\n\n")
for _, c := range inc.Calls {
tg, err := tgst.TG(ctx, c.TalkgroupTuple())
if err != nil {
wErr(w, r, autoError(err))
switch rType {
case ShareRequestTalkgroups:
sa.shnd[rType](nil, sh, w, r)
case ShareRequestCall, ShareRequestCallDL:
if params.SubID == nil {
wErr(w, r, autoError(ErrBadShare))
return
}
var from string
if c.Source != 0 {
from = fmt.Sprintf(" from %d", c.Source)
subIDU, err := uuid.Parse(*params.SubID)
if err != nil {
wErr(w, r, badRequest(err))
return
}
callUrl.Path = "/api/call/" + c.ID.String()
fmt.Fprintf(b, "#EXTINF:%d,%s%s (%s)\n%s\n\n",
c.Duration.Seconds(),
tg.StringTag(true),
from,
c.DateTime.Format("15:04 01/02"),
callUrl,
)
sa.shnd[rType](subIDU, sh, w, r)
case ShareRequestIncident, ShareRequestIncidentM3U:
sa.shnd[rType](sh.EntityID, sh, w, r)
}
// Not a lot of agreement on which MIME type to use for non-HLS m3u,
// let's hope this is good enough
w.Header().Set("Content-Type", "audio/x-mpegurl")
w.WriteHeader(http.StatusOK)
_, _ = b.WriteTo(w)
}
func (sa *shareAPI) deleteShare(w http.ResponseWriter, r *http.Request) {
}

View file

@ -7,6 +7,8 @@ import (
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/xport"
@ -159,6 +161,30 @@ func (tga *talkgroupAPI) postPaginated(w http.ResponseWriter, r *http.Request) {
respond(w, r, res)
}
func (tga *talkgroupAPI) getTGsShareRoute(_ ID, share *shares.Share, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tgs := tgstore.FromCtx(ctx)
tgIDs, err := incstore.FromCtx(ctx).TGsIn(ctx, share.EntityID)
if err != nil {
wErr(w, r, autoError(err))
return
}
idSl := make(talkgroups.IDs, 0, len(tgIDs))
for id := range tgIDs {
idSl = append(idSl, id)
}
tgRes, err := tgs.TGs(ctx, idSl)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, tgRes)
}
func (tga *talkgroupAPI) put(w http.ResponseWriter, r *http.Request) {
var id tgParams
err := decodeParams(&id, r)

View file

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

View file

@ -2,6 +2,7 @@ package server
import (
"context"
"fmt"
"net/http"
"os"
"time"
@ -16,8 +17,9 @@ import (
"dynatron.me/x/stillbox/pkg/nexus"
"dynatron.me/x/stillbox/pkg/notify"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/policy"
"dynatron.me/x/stillbox/pkg/rest"
"dynatron.me/x/stillbox/pkg/share"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
@ -45,12 +47,12 @@ type Server struct {
notifier notify.Notifier
hup chan os.Signal
tgs tgstore.Store
rest rest.API
rest rest.APIRoot
partman partman.PartitionManager
users users.Store
calls callstore.Store
incidents incstore.Store
share share.Service
share shares.Service
rbac rbac.RBAC
}
@ -79,7 +81,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
tgCache := tgstore.NewCache(db)
api := rest.New(cfg.BaseURL.URL())
rbacSvc, err := rbac.New()
rbacSvc, err := rbac.New(policy.Policy)
if err != nil {
return nil, err
}
@ -96,7 +98,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
tgs: tgCache,
sinks: sinks.NewSinkManager(),
rest: api,
share: share.NewService(),
share: shares.NewService(),
users: ust,
calls: callstore.NewStore(db),
incidents: incstore.NewStore(),
@ -145,6 +147,13 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
}))
srv.setupRoutes()
if os.Getenv("STILLBOX_DUMP_ROUTES") == "true" {
_ = chi.Walk(r, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
fmt.Printf("[%s]: '%s' has %d middlewares\n", method, route, len(middlewares))
return nil
})
}
return srv, nil
}
@ -154,7 +163,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context {
ctx = users.CtxWithStore(ctx, s.users)
ctx = callstore.CtxWithStore(ctx, s.calls)
ctx = incstore.CtxWithStore(ctx, s.incidents)
ctx = share.CtxWithStore(ctx, s.share.ShareStore())
ctx = shares.CtxWithStore(ctx, s.share)
ctx = rbac.CtxWithRBAC(ctx, s.rbac)
return ctx

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

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

View file

@ -10,6 +10,7 @@ import (
"dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
"dynatron.me/x/stillbox/pkg/users"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
@ -131,7 +132,7 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = h.ing.Ingest(rbac.CtxWithSubject(ctx, submitterSub), call)
err = h.ing.Ingest(entities.CtxWithSubject(ctx, submitterSub), call)
if err != nil {
if rbac.ErrAccessDenied(err) != nil {
log.Error().Err(err).Msg("ingest failed")

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
db := database.FromCtx(ctx)
// TODO: change this to use tgstore, and make sure the context is no longer a system subject (see nexus.Go)
tagTGs, err := db.GetTalkgroupIDsByTags(ctx, f.TalkgroupTagsAny, f.TalkgroupTagsAll, f.TalkgroupTagsNot)
if err != nil {
return err

View file

@ -9,7 +9,7 @@ import (
"strings"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
)
type Talkgroup struct {
@ -19,7 +19,7 @@ type Talkgroup struct {
}
func (t *Talkgroup) GetResourceName() string {
return rbac.ResourceTalkgroup
return entities.ResourceTalkgroup
}
func (t Talkgroup) String() string {
@ -41,6 +41,18 @@ type ID struct {
Talkgroup uint32 `json:"tg"`
}
type PresenceMap map[ID]struct{}
func (t PresenceMap) Has(id ID) bool {
_, has := t[id]
return has
}
func (t PresenceMap) Put(id ID) {
t[id] = struct{}{}
}
var _ encoding.TextUnmarshaler = (*ID)(nil)
var ErrBadTG = errors.New("bad talkgroup format")

View file

@ -12,6 +12,7 @@ import (
"dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/rbac/entities"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users"
@ -327,7 +328,7 @@ func addToRowList[T rowType](t *cache, tgRecords []T) []*tgsp.Talkgroup {
}
func (t *cache) TGs(ctx context.Context, tgs tgsp.IDs, opts ...Option) ([]*tgsp.Talkgroup, error) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
@ -430,7 +431,7 @@ func (t *cache) Weight(ctx context.Context, id tgsp.ID, tm time.Time) float64 {
}
func (t *cache) SystemTGs(ctx context.Context, systemID int, opts ...Option) ([]*tgsp.Talkgroup, error) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
@ -486,7 +487,7 @@ func (t *cache) SystemTGs(ctx context.Context, systemID int, opts ...Option) ([]
}
func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}
@ -513,7 +514,7 @@ func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) {
}
func (t *cache) SystemName(ctx context.Context, id int) (name string, has bool) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead))
if err != nil {
return "", false
}
@ -587,7 +588,7 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara
}
func (t *cache) DeleteSystem(ctx context.Context, id int) error {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionDelete))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete))
if err != nil {
return err
}
@ -609,7 +610,7 @@ func (t *cache) DeleteSystem(ctx context.Context, id int) error {
}
func (t *cache) DeleteTG(ctx context.Context, id tgsp.ID) error {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionDelete))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionDelete))
if err != nil {
return err
}
@ -645,7 +646,7 @@ func (t *cache) DeleteTG(ctx context.Context, id tgsp.ID) error {
}
func (t *cache) LearnTG(ctx context.Context, c *calls.Call) (*tgsp.Talkgroup, error) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionCreate, rbac.ActionUpdate))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionCreate, entities.ActionUpdate))
if err != nil {
return nil, err
}
@ -764,7 +765,7 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
}
func (t *cache) CreateSystem(ctx context.Context, id int, name string) error {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionCreate))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionCreate))
if err != nil {
return err
}
@ -778,7 +779,7 @@ func (t *cache) CreateSystem(ctx context.Context, id int, name string) error {
}
func (t *cache) Tags(ctx context.Context) ([]string, error) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
_, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceTalkgroup), rbac.WithActions(entities.ActionRead))
if err != nil {
return nil, err
}

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

View file

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

View file

@ -162,3 +162,24 @@ DELETE FROM calls WHERE id = @id;
-- name: GetCallSubmitter :one
SELECT submitter FROM calls WHERE id = @id;
-- name: GetCall :one
SELECT
id,
submitter,
system,
talkgroup,
call_date,
audio_name,
audio_type,
audio_url,
duration,
frequency,
frequencies,
patches,
tg_label,
tg_alpha_tag,
tg_group,
source
FROM calls
WHERE id = @id;

View file

@ -29,6 +29,13 @@ UPDATE incidents_Calls
SET notes = @notes
WHERE incident_id = @incident_id AND call_id = @call_id;
-- name: CallInIncident :one
SELECT EXISTS
(SELECT 1 FROM incidents_calls ic
WHERE
ic.incident_id = @incident_id AND
ic.call_id = @call_id);
-- name: CreateIncident :one
INSERT INTO incidents (
id,

View file

@ -3,6 +3,7 @@ SELECT
id,
entity_type,
entity_id,
entity_date,
owner,
expiration
FROM shares
@ -13,9 +14,10 @@ INSERT INTO shares (
id,
entity_type,
entity_id,
entity_date,
owner,
expiration
) VALUES (@id, @entity_type, @entity_id, @owner, sqlc.narg('expiration'));
) VALUES (@id, @entity_type, @entity_id, sqlc.narg('entity_date'), @owner, sqlc.narg('expiration'));
-- name: DeleteShare :exec
DELETE FROM shares WHERE id = @id;

View file

@ -281,3 +281,11 @@ INSERT INTO systems(id, name) VALUES(@id, @name);
-- name: DeleteSystem :exec
DELETE FROM systems WHERE id = @id;
-- name: GetIncidentTalkgroups :many
SELECT DISTINCT
c.system,
c.talkgroup
FROM incidents_calls ic
JOIN calls c ON (c.id = ic.call_id AND c.call_date = ic.call_date)
WHERE ic.incident_id = @incident_id;