RBAC #102

Merged
amigan merged 6 commits from rbac into trunk 2025-01-18 17:22:09 -05:00
15 changed files with 569 additions and 0 deletions
Showing only changes of commit b0aab14b8c - Show all commits

1
go.mod
View file

@ -56,6 +56,7 @@ require (
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.3 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/matoous/go-nanoid v1.5.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect

2
go.sum
View file

@ -116,6 +116,8 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/matoous/go-nanoid v1.5.1 h1:aCjdvTyO9LLnTIi0fgdXhOPPvOHjpXN6Ik9DaNjIct4=
github.com/matoous/go-nanoid v1.5.1/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=

View file

@ -502,6 +502,53 @@ func (_c *Store_CreatePartition_Call) RunAndReturn(run func(context.Context, str
return _c
}
// CreateShare provides a mock function with given fields: ctx, arg
func (_m *Store) CreateShare(ctx context.Context, arg database.CreateShareParams) error {
ret := _m.Called(ctx, arg)
if len(ret) == 0 {
panic("no return value specified for CreateShare")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, database.CreateShareParams) error); ok {
r0 = rf(ctx, arg)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_CreateShare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateShare'
type Store_CreateShare_Call struct {
*mock.Call
}
// CreateShare is a helper method to define mock.On call
// - ctx context.Context
// - arg database.CreateShareParams
func (_e *Store_Expecter) CreateShare(ctx interface{}, arg interface{}) *Store_CreateShare_Call {
return &Store_CreateShare_Call{Call: _e.mock.On("CreateShare", ctx, arg)}
}
func (_c *Store_CreateShare_Call) Run(run func(ctx context.Context, arg database.CreateShareParams)) *Store_CreateShare_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(database.CreateShareParams))
})
return _c
}
func (_c *Store_CreateShare_Call) Return(_a0 error) *Store_CreateShare_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *Store_CreateShare_Call) RunAndReturn(run func(context.Context, database.CreateShareParams) error) *Store_CreateShare_Call {
_c.Call.Return(run)
return _c
}
// CreateSystem provides a mock function with given fields: ctx, iD, name
func (_m *Store) CreateSystem(ctx context.Context, iD int, name string) error {
ret := _m.Called(ctx, iD, name)
@ -795,6 +842,53 @@ func (_c *Store_DeleteIncident_Call) RunAndReturn(run func(context.Context, uuid
return _c
}
// DeleteShare provides a mock function with given fields: ctx, id
func (_m *Store) DeleteShare(ctx context.Context, id string) error {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for DeleteShare")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_DeleteShare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteShare'
type Store_DeleteShare_Call struct {
*mock.Call
}
// DeleteShare is a helper method to define mock.On call
// - ctx context.Context
// - id string
func (_e *Store_Expecter) DeleteShare(ctx interface{}, id interface{}) *Store_DeleteShare_Call {
return &Store_DeleteShare_Call{Call: _e.mock.On("DeleteShare", ctx, id)}
}
func (_c *Store_DeleteShare_Call) Run(run func(ctx context.Context, id string)) *Store_DeleteShare_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *Store_DeleteShare_Call) Return(_a0 error) *Store_DeleteShare_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *Store_DeleteShare_Call) RunAndReturn(run func(context.Context, string) error) *Store_DeleteShare_Call {
_c.Call.Return(run)
return _c
}
// DeleteSystem provides a mock function with given fields: ctx, id
func (_m *Store) DeleteSystem(ctx context.Context, id int) error {
ret := _m.Called(ctx, id)
@ -1436,6 +1530,63 @@ func (_c *Store_GetIncidentCalls_Call) RunAndReturn(run func(context.Context, uu
return _c
}
// GetShare provides a mock function with given fields: ctx, id
func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for GetShare")
}
var r0 database.Share
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (database.Share, error)); ok {
return rf(ctx, id)
}
if rf, ok := ret.Get(0).(func(context.Context, string) database.Share); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Get(0).(database.Share)
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Store_GetShare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetShare'
type Store_GetShare_Call struct {
*mock.Call
}
// GetShare is a helper method to define mock.On call
// - ctx context.Context
// - id string
func (_e *Store_Expecter) GetShare(ctx interface{}, id interface{}) *Store_GetShare_Call {
return &Store_GetShare_Call{Call: _e.mock.On("GetShare", ctx, id)}
}
func (_c *Store_GetShare_Call) Run(run func(ctx context.Context, id string)) *Store_GetShare_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *Store_GetShare_Call) Return(_a0 database.Share, _a1 error) *Store_GetShare_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Store_GetShare_Call) RunAndReturn(run func(context.Context, string) (database.Share, error)) *Store_GetShare_Call {
_c.Call.Return(run)
return _c
}
// GetSystemName provides a mock function with given fields: ctx, systemID
func (_m *Store) GetSystemName(ctx context.Context, systemID int) (string, error) {
ret := _m.Called(ctx, systemID)
@ -2887,6 +3038,52 @@ func (_c *Store_ListIncidentsP_Call) RunAndReturn(run func(context.Context, data
return _c
}
// PruneShares provides a mock function with given fields: ctx
func (_m *Store) PruneShares(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for PruneShares")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// Store_PruneShares_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PruneShares'
type Store_PruneShares_Call struct {
*mock.Call
}
// PruneShares is a helper method to define mock.On call
// - ctx context.Context
func (_e *Store_Expecter) PruneShares(ctx interface{}) *Store_PruneShares_Call {
return &Store_PruneShares_Call{Call: _e.mock.On("PruneShares", ctx)}
}
func (_c *Store_PruneShares_Call) Run(run func(ctx context.Context)) *Store_PruneShares_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *Store_PruneShares_Call) Return(_a0 error) *Store_PruneShares_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *Store_PruneShares_Call) RunAndReturn(run func(context.Context) error) *Store_PruneShares_Call {
_c.Call.Return(run)
return _c
}
// RemoveFromIncident provides a mock function with given fields: ctx, iD, callIds
func (_m *Store) RemoveFromIncident(ctx context.Context, iD uuid.UUID, callIds []uuid.UUID) error {
ret := _m.Called(ctx, iD, callIds)

View file

@ -80,6 +80,14 @@ type Setting struct {
Value []byte `json:"value,omitempty"`
}
type Share struct {
ID string `json:"id,omitempty"`
EntityType string `json:"entity_type,omitempty"`
EntityID uuid.UUID `json:"entity_id,omitempty"`
Owner int `json:"owner,omitempty"`
Expiration pgtype.Timestamptz `json:"expiration,omitempty"`
}
type SweptCall struct {
ID uuid.UUID `json:"id,omitempty"`
Submitter *int32 `json:"submitter,omitempty"`

View file

@ -19,10 +19,12 @@ type Querier interface {
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)
CreateShare(ctx context.Context, arg CreateShareParams) error
CreateSystem(ctx context.Context, iD int, name string) error
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteAPIKey(ctx context.Context, apiKey string) error
DeleteIncident(ctx context.Context, id uuid.UUID) error
DeleteShare(ctx context.Context, id string) error
DeleteSystem(ctx context.Context, id int) error
DeleteTalkgroup(ctx context.Context, systemID int32, tGID int32) error
DeleteUser(ctx context.Context, username string) error
@ -33,6 +35,7 @@ type Querier interface {
GetDatabaseSize(ctx context.Context) (string, error)
GetIncident(ctx context.Context, id uuid.UUID) (Incident, error)
GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error)
GetShare(ctx context.Context, id string) (Share, error)
GetSystemName(ctx context.Context, systemID int) (string, error)
GetTalkgroup(ctx context.Context, systemID int32, tGID int32) (GetTalkgroupRow, error)
GetTalkgroupIDsByTags(ctx context.Context, anyTags []string, allTags []string, notTags []string) ([]GetTalkgroupIDsByTagsRow, error)
@ -54,6 +57,7 @@ type Querier interface {
ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListCallsPRow, error)
ListIncidentsCount(ctx context.Context, start pgtype.Timestamptz, end pgtype.Timestamptz, filter *string) (int64, error)
ListIncidentsP(ctx context.Context, arg ListIncidentsPParams) ([]ListIncidentsPRow, error)
PruneShares(ctx context.Context) error
RemoveFromIncident(ctx context.Context, iD uuid.UUID, callIds []uuid.UUID) error
RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error)
SetAppPrefs(ctx context.Context, appName string, prefs []byte, uid int) error

84
pkg/database/share.sql.go Normal file
View file

@ -0,0 +1,84 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: share.sql
package database
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createShare = `-- name: CreateShare :exec
INSERT INTO shares (
id,
entity_type,
entity_id,
owner,
expiration
) VALUES ($1, $2, $3, $4, $5)
`
type CreateShareParams struct {
ID string `json:"id"`
EntityType string `json:"entity_type"`
EntityID uuid.UUID `json:"entity_id"`
Owner int `json:"owner"`
Expiration pgtype.Timestamptz `json:"expiration"`
}
func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) error {
_, err := q.db.Exec(ctx, createShare,
arg.ID,
arg.EntityType,
arg.EntityID,
arg.Owner,
arg.Expiration,
)
return err
}
const deleteShare = `-- name: DeleteShare :exec
DELETE FROM shares WHERE id = $1
`
func (q *Queries) DeleteShare(ctx context.Context, id string) error {
_, err := q.db.Exec(ctx, deleteShare, id)
return err
}
const getShare = `-- name: GetShare :one
SELECT
id,
entity_type,
entity_id,
owner,
expiration
FROM shares
WHERE id = $1
`
func (q *Queries) GetShare(ctx context.Context, id string) (Share, error) {
row := q.db.QueryRow(ctx, getShare, id)
var i Share
err := row.Scan(
&i.ID,
&i.EntityType,
&i.EntityID,
&i.Owner,
&i.Expiration,
)
return i, err
}
const pruneShares = `-- name: PruneShares :exec
DELETE FROM shares WHERE expiration < NOW()
`
func (q *Queries) PruneShares(ctx context.Context) error {
_, err := q.db.Exec(ctx, pruneShares)
return err
}

1
pkg/rbac/rbac.go Normal file
View file

@ -0,0 +1 @@
package rbac

View file

@ -37,6 +37,7 @@ func (a *api) Subrouter() http.Handler {
r.Mount("/call", new(callsAPI).Subrouter())
r.Mount("/user", new(usersAPI).Subrouter())
r.Mount("/incident", newIncidentsAPI(&a.baseURL).Subrouter())
r.Mount("/share", newShareHandler(&a.baseURL).Subrouter())
return r
}

39
pkg/rest/share.go Normal file
View file

@ -0,0 +1,39 @@
package rest
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/incidents"
"dynatron.me/x/stillbox/pkg/incidents/incstore"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type shareAPI struct {
baseURL *url.URL
}
func newShareHandler(baseURL *url.URL) API {
return &shareAPI{baseURL}
}
func (ia *shareAPI) Subrouter() http.Handler {
r := chi.NewMux()
//r.Get(`/{id:[A-Za-z0-9_-]{20,}}`, ia.getShare)
//r.Post('/create', ia.createShare)
//r.Delete(`/{id:[A-Za-z0-9_-]{20,}}`, ia.deleteShare)
//r.Get(`/`, ia.getShares)
return r
}

View file

@ -16,6 +16,7 @@ import (
"dynatron.me/x/stillbox/pkg/nexus"
"dynatron.me/x/stillbox/pkg/notify"
"dynatron.me/x/stillbox/pkg/rest"
"dynatron.me/x/stillbox/pkg/share"
"dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
@ -48,6 +49,7 @@ type Server struct {
users users.Store
calls callstore.Store
incidents incstore.Store
share share.Service
}
func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
@ -85,6 +87,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
tgs: tgCache,
sinks: sinks.NewSinkManager(),
rest: api,
share: share.NewService(),
users: users.NewStore(),
calls: callstore.NewStore(),
incidents: incstore.NewStore(),
@ -141,6 +144,7 @@ func (s *Server) addStoresTo(ctx context.Context) context.Context {
ctx = users.CtxWithStore(ctx, s.users)
ctx = callstore.CtxWithStore(ctx, s.calls)
ctx = incstore.CtxWithStore(ctx, s.incidents)
ctx = share.CtxWithStore(ctx, s.share.Store())
return ctx
}
@ -159,6 +163,7 @@ func (s *Server) Go(ctx context.Context) error {
go s.nex.Go(ctx)
go s.alerter.Go(ctx)
go s.share.Go(ctx)
if pm := s.partman; pm != nil {
go pm.Go(ctx)

49
pkg/share/service.go Normal file
View file

@ -0,0 +1,49 @@
package share
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
const (
PruneInterval = time.Hour * 4
)
type Service interface {
Store() Store
Go(ctx context.Context)
}
type service struct {
store Store
}
func (s *service) Store() Store {
return s.store
}
func (s *service) Go(ctx context.Context) {
tick := time.NewTicker(PruneInterval)
for {
select {
case <- tick.C:
err := s.store.Prune(ctx)
if err != nil {
log.Error().Err(err).Msg("share prune failed")
}
case <-ctx.Done():
tick.Stop()
return
}
}
}
func NewService() *service {
return &service{
store: NewStore(),
}
}

61
pkg/share/share.go Normal file
View file

@ -0,0 +1,61 @@
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 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
}

85
pkg/share/store.go Normal file
View file

@ -0,0 +1,85 @@
package share
import (
"context"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/database"
)
type Store interface {
// Get retreives a share record.
Get(ctx context.Context, id string) (*Share, error)
// Create stores a new share record.
Create(ctx context.Context, share *Share) error
// Delete deletes a share record.
Delete(ctx context.Context, id string) error
// Prune removes expired share records.
Prune(ctx context.Context) error
}
type postgresStore struct {
}
func recToShare(share database.Share) *Share {
return &Share{
ID: share.ID,
Type: EntityType(share.EntityType),
EntityID: share.EntityID,
Expiration: jsontypes.TimePtrFromTSTZ(share.Expiration),
}
}
func (s *postgresStore) Get(ctx context.Context, id string) (*Share, error) {
db := database.FromCtx(ctx)
rec, err := db.GetShare(ctx, id)
if err != nil {
return nil, err
}
return recToShare(rec), nil
}
func (s *postgresStore) Create(ctx context.Context, share *Share) error {
db := database.FromCtx(ctx)
err := db.CreateShare(ctx, database.CreateShareParams{
ID: share.ID,
EntityType: string(share.Type),
EntityID: share.EntityID,
Expiration: share.Expiration.PGTypeTSTZ(),
})
return err
}
func (s *postgresStore) Delete(ctx context.Context, id string) error {
return database.FromCtx(ctx).DeleteShare(ctx, id)
}
func (s *postgresStore) Prune(ctx context.Context) error {
return database.FromCtx(ctx).PruneShares(ctx)
}
func NewStore() *postgresStore {
return new(postgresStore)
}
type storeCtxKey string
const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, StoreCtxKey, s)
}
func FromCtx(ctx context.Context) Store {
s, ok := ctx.Value(StoreCtxKey).(Store)
if !ok {
return NewStore()
}
return s
}

View file

@ -163,3 +163,11 @@ CREATE TABLE IF NOT EXISTS incidents_calls(
FOREIGN KEY (calls_tbl_id, call_date) REFERENCES calls(id, call_date),
PRIMARY KEY (incident_id, call_id)
);
CREATE TABLE IF NOT EXISTS shares(
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
owner INTEGER NOT NULL REFERENCES users(id),
expiration TIMESTAMPTZ NULL
);

View file

@ -0,0 +1,24 @@
-- name: GetShare :one
SELECT
id,
entity_type,
entity_id,
owner,
expiration
FROM shares
WHERE id = @id;
-- name: CreateShare :exec
INSERT INTO shares (
id,
entity_type,
entity_id,
owner,
expiration
) VALUES (@id, @entity_type, @entity_id, @owner, sqlc.narg('expiration'));
-- name: DeleteShare :exec
DELETE FROM shares WHERE id = @id;
-- name: PruneShares :exec
DELETE FROM shares WHERE expiration < NOW();