Merge pull request 'Share links and RBAC improvements' (#104) from shareLinks into trunk
Reviewed-on: #104
This commit is contained in:
commit
0e5f62ce51
45 changed files with 1569 additions and 725 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
`
|
`
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
79
pkg/rbac/entities/entities.go
Normal file
79
pkg/rbac/entities/entities.go
Normal 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}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
196
pkg/rbac/policy/conditions.go
Normal file
196
pkg/rbac/policy/conditions.go
Normal 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
243
pkg/rbac/policy/policy.go
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
330
pkg/rbac/rbac.go
330
pkg/rbac/rbac.go
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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(¶ms, 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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
144
pkg/shares/share.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue