WIP cycle

This commit is contained in:
Daniel Ponte 2025-01-20 22:38:27 -05:00
parent b171e8431a
commit 5ff3066d6d
16 changed files with 287 additions and 172 deletions

View file

@ -8,7 +8,6 @@ import (
"dynatron.me/x/stillbox/internal/audio"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/users"
@ -16,6 +15,8 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
const Resource = "call"
type CallDuration time.Duration
func (d CallDuration) Duration() time.Duration {
@ -76,7 +77,7 @@ type Call struct {
}
func (c *Call) GetResourceName() string {
return rbac.ResourceCall
return Resource
}
func (c *Call) String() string {

View file

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

View file

@ -14,150 +14,150 @@ import (
)
type Alert struct {
ID int `json:"id,omitempty"`
Time pgtype.Timestamptz `json:"time,omitempty"`
TGID int `json:"tgid,omitempty"`
SystemID int `json:"system_id,omitempty"`
Weight *float32 `json:"weight,omitempty"`
Score *float32 `json:"score,omitempty"`
OrigScore *float32 `json:"orig_score,omitempty"`
Notified bool `json:"notified,omitempty"`
Metadata []byte `json:"metadata,omitempty"`
ID int `json:"id"`
Time pgtype.Timestamptz `json:"time"`
TGID int `json:"tgid"`
SystemID int `json:"system_id"`
Weight *float32 `json:"weight"`
Score *float32 `json:"score"`
OrigScore *float32 `json:"orig_score"`
Notified bool `json:"notified"`
Metadata []byte `json:"metadata"`
}
type ApiKey struct {
ID int `json:"id,omitempty"`
Owner int `json:"owner,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
Expires pgtype.Timestamp `json:"expires,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
ApiKey string `json:"api_key,omitempty"`
ID int `json:"id"`
Owner int `json:"owner"`
CreatedAt time.Time `json:"created_at"`
Expires pgtype.Timestamp `json:"expires"`
Disabled *bool `json:"disabled"`
ApiKey string `json:"api_key"`
}
type Call struct {
ID uuid.UUID `json:"id,omitempty"`
Submitter *int32 `json:"submitter,omitempty"`
System int `json:"system,omitempty"`
Talkgroup int `json:"talkgroup,omitempty"`
CallDate pgtype.Timestamptz `json:"call_date,omitempty"`
AudioName *string `json:"audio_name,omitempty"`
AudioBlob []byte `json:"audio_blob,omitempty"`
Duration *int32 `json:"duration,omitempty"`
AudioType *string `json:"audio_type,omitempty"`
AudioUrl *string `json:"audio_url,omitempty"`
Frequency int `json:"frequency,omitempty"`
Frequencies []int `json:"frequencies,omitempty"`
Patches []int `json:"patches,omitempty"`
TGLabel *string `json:"tg_label,omitempty"`
TGAlphaTag *string `json:"tg_alpha_tag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"`
Source int `json:"source,omitempty"`
Transcript *string `json:"transcript,omitempty"`
ID uuid.UUID `json:"id"`
Submitter *int32 `json:"submitter"`
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
CallDate pgtype.Timestamptz `json:"call_date"`
AudioName *string `json:"audio_name"`
AudioBlob []byte `json:"audio_blob"`
Duration *int32 `json:"duration"`
AudioType *string `json:"audio_type"`
AudioUrl *string `json:"audio_url"`
Frequency int `json:"frequency"`
Frequencies []int `json:"frequencies"`
Patches []int `json:"patches"`
TGLabel *string `json:"tg_label"`
TGAlphaTag *string `json:"tg_alpha_tag"`
TGGroup *string `json:"tg_group"`
Source int `json:"source"`
Transcript *string `json:"transcript"`
}
type Incident struct {
ID uuid.UUID `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Owner int `json:"owner,omitempty"`
Description *string `json:"description,omitempty"`
StartTime pgtype.Timestamptz `json:"start_time,omitempty"`
EndTime pgtype.Timestamptz `json:"end_time,omitempty"`
Location []byte `json:"location,omitempty"`
Metadata jsontypes.Metadata `json:"metadata,omitempty"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Owner int `json:"owner"`
Description *string `json:"description"`
StartTime pgtype.Timestamptz `json:"start_time"`
EndTime pgtype.Timestamptz `json:"end_time"`
Location []byte `json:"location"`
Metadata jsontypes.Metadata `json:"metadata"`
}
type IncidentsCall struct {
IncidentID uuid.UUID `json:"incident_id,omitempty"`
CallID uuid.UUID `json:"call_id,omitempty"`
CallsTblID pgtype.UUID `json:"calls_tbl_id,omitempty"`
SweptCallID pgtype.UUID `json:"swept_call_id,omitempty"`
CallDate pgtype.Timestamptz `json:"call_date,omitempty"`
Notes []byte `json:"notes,omitempty"`
IncidentID uuid.UUID `json:"incident_id"`
CallID uuid.UUID `json:"call_id"`
CallsTblID pgtype.UUID `json:"calls_tbl_id"`
SweptCallID pgtype.UUID `json:"swept_call_id"`
CallDate pgtype.Timestamptz `json:"call_date"`
Notes []byte `json:"notes"`
}
type Setting struct {
Name string `json:"name,omitempty"`
UpdatedBy *int32 `json:"updated_by,omitempty"`
Value []byte `json:"value,omitempty"`
Name string `json:"name"`
UpdatedBy *int32 `json:"updated_by"`
Value []byte `json:"value"`
}
type Share struct {
ID string `json:"id,omitempty"`
EntityType string `json:"entity_type,omitempty"`
EntityID uuid.UUID `json:"entity_id,omitempty"`
EntityDate pgtype.Timestamptz `json:"entity_date,omitempty"`
Owner int `json:"owner,omitempty"`
Expiration pgtype.Timestamptz `json:"expiration,omitempty"`
ID string `json:"id"`
EntityType string `json:"entity_type"`
EntityID uuid.UUID `json:"entity_id"`
EntityDate pgtype.Timestamptz `json:"entity_date"`
Owner int `json:"owner"`
Expiration pgtype.Timestamptz `json:"expiration"`
}
type SweptCall struct {
ID uuid.UUID `json:"id,omitempty"`
Submitter *int32 `json:"submitter,omitempty"`
System int `json:"system,omitempty"`
Talkgroup int `json:"talkgroup,omitempty"`
CallDate pgtype.Timestamptz `json:"call_date,omitempty"`
AudioName *string `json:"audio_name,omitempty"`
AudioBlob []byte `json:"audio_blob,omitempty"`
Duration *int32 `json:"duration,omitempty"`
AudioType *string `json:"audio_type,omitempty"`
AudioUrl *string `json:"audio_url,omitempty"`
Frequency int `json:"frequency,omitempty"`
Frequencies []int `json:"frequencies,omitempty"`
Patches []int `json:"patches,omitempty"`
TGLabel *string `json:"tg_label,omitempty"`
TGAlphaTag *string `json:"tg_alpha_tag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"`
Source int `json:"source,omitempty"`
Transcript *string `json:"transcript,omitempty"`
ID uuid.UUID `json:"id"`
Submitter *int32 `json:"submitter"`
System int `json:"system"`
Talkgroup int `json:"talkgroup"`
CallDate pgtype.Timestamptz `json:"call_date"`
AudioName *string `json:"audio_name"`
AudioBlob []byte `json:"audio_blob"`
Duration *int32 `json:"duration"`
AudioType *string `json:"audio_type"`
AudioUrl *string `json:"audio_url"`
Frequency int `json:"frequency"`
Frequencies []int `json:"frequencies"`
Patches []int `json:"patches"`
TGLabel *string `json:"tg_label"`
TGAlphaTag *string `json:"tg_alpha_tag"`
TGGroup *string `json:"tg_group"`
Source int `json:"source"`
Transcript *string `json:"transcript"`
}
type System struct {
ID int `json:"id,omitempty"`
Name string `json:"name,omitempty"`
ID int `json:"id"`
Name string `json:"name"`
}
type Talkgroup struct {
ID int `json:"id,omitempty"`
SystemID int32 `json:"system_id,omitempty"`
TGID int32 `json:"tgid,omitempty"`
Name *string `json:"name,omitempty"`
AlphaTag *string `json:"alpha_tag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"`
Frequency *int32 `json:"frequency,omitempty"`
Metadata jsontypes.Metadata `json:"metadata,omitempty"`
Tags []string `json:"tags,omitempty"`
Alert bool `json:"alert,omitempty"`
AlertRules rules.AlertRules `json:"alert_rules,omitempty"`
Weight float32 `json:"weight,omitempty"`
Learned bool `json:"learned,omitempty"`
Ignored bool `json:"ignored,omitempty"`
ID int `json:"id"`
SystemID int32 `json:"system_id"`
TGID int32 `json:"tgid"`
Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TGGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata jsontypes.Metadata `json:"metadata"`
Tags []string `json:"tags"`
Alert bool `json:"alert"`
AlertRules rules.AlertRules `json:"alert_rules"`
Weight float32 `json:"weight"`
Learned bool `json:"learned"`
Ignored bool `json:"ignored"`
}
type TalkgroupVersion struct {
ID int `json:"id,omitempty"`
Time pgtype.Timestamptz `json:"time,omitempty"`
CreatedBy *int32 `json:"created_by,omitempty"`
Deleted *bool `json:"deleted,omitempty"`
SystemID *int32 `json:"system_id,omitempty"`
TGID *int32 `json:"tgid,omitempty"`
Name *string `json:"name,omitempty"`
AlphaTag *string `json:"alpha_tag,omitempty"`
TGGroup *string `json:"tg_group,omitempty"`
Frequency *int32 `json:"frequency,omitempty"`
Metadata []byte `json:"metadata,omitempty"`
Tags []string `json:"tags,omitempty"`
Alert *bool `json:"alert,omitempty"`
AlertRules []byte `json:"alert_rules,omitempty"`
Weight *float32 `json:"weight,omitempty"`
Learned *bool `json:"learned,omitempty"`
Ignored *bool `json:"ignored,omitempty"`
ID int `json:"id"`
Time pgtype.Timestamptz `json:"time"`
CreatedBy *int32 `json:"created_by"`
Deleted *bool `json:"deleted"`
SystemID *int32 `json:"system_id"`
TGID *int32 `json:"tgid"`
Name *string `json:"name"`
AlphaTag *string `json:"alpha_tag"`
TGGroup *string `json:"tg_group"`
Frequency *int32 `json:"frequency"`
Metadata []byte `json:"metadata"`
Tags []string `json:"tags"`
Alert *bool `json:"alert"`
AlertRules []byte `json:"alert_rules"`
Weight *float32 `json:"weight"`
Learned *bool `json:"learned"`
Ignored *bool `json:"ignored"`
}
type User struct {
ID int `json:"id,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Email string `json:"email,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"`
Prefs []byte `json:"prefs,omitempty"`
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Prefs []byte `json:"prefs"`
}

View file

@ -16,6 +16,7 @@ type Querier interface {
AddCall(ctx context.Context, arg AddCallParams) error
AddLearnedTalkgroup(ctx context.Context, arg AddLearnedTalkgroupParams) (Talkgroup, error)
AddToIncident(ctx context.Context, incidentID uuid.UUID, callIds []uuid.UUID, notes [][]byte) error
CallInIncident(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID) (bool, error)
CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Timestamptz, rangeEnd pgtype.Timestamptz) (int64, error)
CreateAPIKey(ctx context.Context, owner int, expires pgtype.Timestamp, disabled *bool) (ApiKey, error)
CreateIncident(ctx context.Context, arg CreateIncidentParams) (Incident, error)

View file

@ -5,7 +5,6 @@ import (
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/users"
"github.com/google/uuid"
)

View file

@ -0,0 +1,65 @@
package incstore
import (
"context"
"errors"
"fmt"
"github.com/el-mike/restrict/v2"
"github.com/google/uuid"
)
const (
CallInIncidentConditionType = "CALL_IN_INCIDENT"
)
type CallInIncidentCondition struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
Call *restrict.ValueDescriptor `json:"call" yaml:"call"`
Incident *restrict.ValueDescriptor `json:"incident" yaml:"incident"`
}
func (*CallInIncidentCondition) Type() string {
return CallInIncidentConditionType
}
func (c *CallInIncidentCondition) Check(r *restrict.AccessRequest) error {
callVID, err := c.Call.GetValue(r)
if err != nil {
return err
}
incVID, err := c.Incident.GetValue(r)
if err != nil {
return err
}
ctx, hasCtx := r.Context["ctx"].(context.Context)
if !hasCtx {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("no context provided"))
}
incID, isUUID := incVID.(uuid.UUID)
if !isUUID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("incident ID is not UUID"))
}
callID, isUUID := callVID.(uuid.UUID)
if !isUUID {
return restrict.NewConditionNotSatisfiedError(c, r, errors.New("call ID is not UUID"))
}
incs := FromCtx(ctx)
inCall, err := incs.CallIn(ctx, incID, incID)
if err != nil {
return restrict.NewConditionNotSatisfiedError(c, r, err)
}
if !inCall {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf(`incident "%v" not in call "%v"`, incID, callID))
}
return nil
}

View file

@ -49,6 +49,9 @@ type Store interface {
// Owner returns an incident with only the owner filled out.
Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, error)
// CallIn returns whether an incident is in an call
CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error)
}
type store struct {
@ -373,3 +376,9 @@ func (s *store) Owner(ctx context.Context, id uuid.UUID) (incidents.Incident, er
owner, err := database.FromCtx(ctx).GetIncidentOwner(ctx, id)
return incidents.Incident{ID: id, Owner: users.UserID(owner)}, err
}
func (s *store) CallIn(ctx context.Context, inc uuid.UUID, call uuid.UUID) (bool, error) {
db := database.FromCtx(ctx)
return db.CallInIncident(ctx, inc, call)
}

View file

@ -9,7 +9,7 @@ import (
const (
SubmitterEqualConditionType = "SUBMITTER_EQUAL"
InMapConditionType = "IN_MAP"
InMapConditionType = "IN_MAP"
)
type SubmitterEqualCondition struct {
@ -53,7 +53,7 @@ func SubmitterEqualConditionFactory() restrict.Condition {
}
type InMapCondition[K comparable, V any] struct {
ID string `json:"name,omitempty" yaml:"name,omitempty"`
ID string `json:"name,omitempty" yaml:"name,omitempty"`
Key *restrict.ValueDescriptor `json:"key" yaml:"key"`
Map *restrict.ValueDescriptor `json:"map" yaml:"map"`
}
@ -77,12 +77,9 @@ func (c *InMapCondition[K, V]) Check(r *restrict.AccessRequest) error {
return err
}
keyVal := reflect.ValueOf(cKey)
mapVal := reflect.ValueOf(cMap)
key := cKey.(K)
key := keyVal.Interface().(K)
if _, in := mapVal.Interface().(map[K]V)[key]; !in {
if _, in := cMap.(map[K]V)[key]; !in {
return restrict.NewConditionNotSatisfiedError(c, r, fmt.Errorf("key '%v' not in map", key))
}

23
pkg/rbac/entities.go Normal file
View file

@ -0,0 +1,23 @@
package rbac
const (
RoleUser = "User"
RoleSubmitter = "Submitter"
RoleAdmin = "Admin"
RoleSystem = "System"
RolePublic = "Public"
RoleShareGuest = "ShareGuest"
ResourceCall = "Call"
ResourceIncident = "Incident"
ResourceTalkgroup = "Talkgroup"
ResourceAlert = "Alert"
ResourceShare = "Share"
ResourceAPIKey = "APIKey"
ActionRead = "read"
ActionCreate = "create"
ActionUpdate = "update"
ActionDelete = "delete"
ActionShare = "share"
)

View file

@ -1,10 +1,25 @@
package rbac
import (
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"github.com/el-mike/restrict/v2"
)
var policy = &restrict.PolicyDefinition{
const (
PresetUpdateOwn = "updateOwn"
PresetDeleteOwn = "deleteOwn"
PresetReadShared = "readShared"
PresetReadSharedInMap = "readSharedInMap"
PresetShareOwn = "shareOwn"
PresetUpdateSubmitter = "updateSubmitter"
PresetDeleteSubmitter = "deleteSubmitter"
PresetShareSubmitter = "shareSubmitter"
PresetReadInSharedIncident = "readInSharedIncident"
)
var Policy = &restrict.PolicyDefinition{
Roles: restrict.Roles{
RoleUser: {
Description: "An authenticated user",
@ -52,6 +67,7 @@ var policy = &restrict.PolicyDefinition{
Grants: restrict.GrantsMap{
ResourceCall: {
&restrict.Permission{Preset: PresetReadShared},
&restrict.Permission{Preset: PresetReadInSharedIncident},
},
ResourceIncident: {
&restrict.Permission{Preset: PresetReadShared},
@ -207,5 +223,21 @@ var policy = &restrict.PolicyDefinition{
},
},
},
PresetReadInSharedIncident: &restrict.Permission{
Action: ActionRead,
Conditions: restrict.Conditions{
&incstore.CallInIncidentCondition{
ID: "callInIncident",
Call: &restrict.ValueDescriptor{
Source: restrict.ResourceField,
Field: "ID",
},
Incident: &restrict.ValueDescriptor{
Source: restrict.SubjectField,
Field: "EntityID",
},
},
},
},
},
}

View file

@ -8,37 +8,7 @@ import (
"github.com/el-mike/restrict/v2/adapters"
)
const (
RoleUser = "User"
RoleSubmitter = "Submitter"
RoleAdmin = "Admin"
RoleSystem = "System"
RolePublic = "Public"
RoleShareGuest = "ShareGuest"
ResourceCall = "Call"
ResourceIncident = "Incident"
ResourceTalkgroup = "Talkgroup"
ResourceAlert = "Alert"
ResourceShare = "Share"
ResourceAPIKey = "APIKey"
ActionRead = "read"
ActionCreate = "create"
ActionUpdate = "update"
ActionDelete = "delete"
ActionShare = "share"
PresetUpdateOwn = "updateOwn"
PresetDeleteOwn = "deleteOwn"
PresetReadShared = "readShared"
PresetReadSharedInMap = "readSharedInMap"
PresetShareOwn = "shareOwn"
PresetUpdateSubmitter = "updateSubmitter"
PresetDeleteSubmitter = "deleteSubmitter"
PresetShareSubmitter = "shareSubmitter"
)
var (
ErrBadSubject = errors.New("bad subject in token")
@ -132,7 +102,7 @@ type rbac struct {
}
func New() (*rbac, error) {
adapter := adapters.NewInMemoryAdapter(policy)
adapter := adapters.NewInMemoryAdapter(Policy)
polMan, err := restrict.NewPolicyManager(adapter, true)
if err != nil {
return nil, err
@ -154,10 +124,17 @@ func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOp
sub := SubjectFrom(ctx)
o := checkOptions{}
for _, opt := range opts {
opt(&o)
}
if o.context == nil {
o.context = make(restrict.Context)
}
o.context["ctx"] = ctx
req := &restrict.AccessRequest{
Subject: sub,
Resource: res,

View file

@ -2,7 +2,6 @@ package server
import (
"errors"
"fmt"
"io/fs"
"net/http"
"path"

View file

@ -9,7 +9,10 @@ import (
"strings"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/rbac"
)
const (
Resource = "Talkgroup"
)
type Talkgroup struct {
@ -19,7 +22,7 @@ type Talkgroup struct {
}
func (t *Talkgroup) GetResourceName() string {
return rbac.ResourceTalkgroup
return Resource
}
func (t Talkgroup) String() string {

View file

@ -327,7 +327,7 @@ func addToRowList[T rowType](t *cache, tgRecords []T) []*tgsp.Talkgroup {
}
func (t *cache) TGs(ctx context.Context, tgs tgsp.IDs, opts ...Option) ([]*tgsp.Talkgroup, error) {
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
_, err := rbac.Check(ctx, rbac.UseResource(tgsp.Resource), rbac.WithActions(rbac.ActionRead))
if err != nil {
return nil, err
}

View file

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

View file

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