RBAC
This commit is contained in:
parent
aec89b5569
commit
c8c3023afa
31 changed files with 973 additions and 182 deletions
|
@ -1,4 +1,4 @@
|
|||
dir: '{{ replaceAll .InterfaceDirRelative "internal" "internal_" }}/mocks'
|
||||
dir: '{{.InterfaceDir}}/mocks'
|
||||
mockname: "{{.InterfaceName}}"
|
||||
outpkg: "mocks"
|
||||
filename: "{{.InterfaceName}}.go"
|
||||
|
@ -9,3 +9,7 @@ packages:
|
|||
interfaces:
|
||||
Store:
|
||||
DBTX:
|
||||
dynatron.me/x/stillbox/pkg/rbac:
|
||||
config:
|
||||
interfaces:
|
||||
RBAC:
|
||||
|
|
|
@ -15,7 +15,6 @@ import (
|
|||
|
||||
"dynatron.me/x/stillbox/internal/common"
|
||||
"dynatron.me/x/stillbox/internal/forms"
|
||||
"dynatron.me/x/stillbox/pkg/auth"
|
||||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/sources"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
"dynatron.me/x/stillbox/pkg/config"
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/notify"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
"dynatron.me/x/stillbox/pkg/sinks"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
|
||||
|
@ -123,6 +124,8 @@ func New(cfg config.Alerting, tgCache tgstore.Store, opts ...AlertOption) Alerte
|
|||
|
||||
// Go is the alerting loop. It does not start a goroutine.
|
||||
func (as *alerter) Go(ctx context.Context) {
|
||||
ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "alerter"})
|
||||
|
||||
err := as.startBackfill(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("backfill")
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"dynatron.me/x/stillbox/internal/jsontypes"
|
||||
"dynatron.me/x/stillbox/internal/trending"
|
||||
"dynatron.me/x/stillbox/pkg/config"
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
|
||||
|
||||
|
@ -59,8 +60,9 @@ func (s *Simulation) stepClock(t time.Time) {
|
|||
|
||||
// Simulate begins the simulation using the DB handle from ctx. It returns final scores.
|
||||
func (s *Simulation) Simulate(ctx context.Context) (trending.Scores[talkgroups.ID], error) {
|
||||
db := database.FromCtx(ctx)
|
||||
now := time.Now()
|
||||
tgc := tgstore.NewCache()
|
||||
tgc := tgstore.NewCache(db)
|
||||
|
||||
s.Enable = true
|
||||
s.alerter = New(s.Alerting, tgc, WithClock(&s.clock)).(*alerter)
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"time"
|
||||
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
@ -16,20 +16,19 @@ import (
|
|||
type apiKeyAuth interface {
|
||||
// CheckAPIKey validates the provided key and returns the API owner's users.UserID.
|
||||
// An error is returned if validation fails for any reason.
|
||||
CheckAPIKey(ctx context.Context, key string) (*users.UserID, error)
|
||||
CheckAPIKey(ctx context.Context, key string) (rbac.Subject, error)
|
||||
}
|
||||
|
||||
func (a *Auth) CheckAPIKey(ctx context.Context, key string) (*users.UserID, error) {
|
||||
func (a *Auth) CheckAPIKey(ctx context.Context, key string) (rbac.Subject, error) {
|
||||
keyUuid, err := uuid.Parse(key)
|
||||
if err != nil {
|
||||
log.Error().Str("apikey", key).Msg("cannot parse key")
|
||||
return nil, ErrBadRequest
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
hash := sha256.Sum256([]byte(keyUuid.String()))
|
||||
b64hash := base64.StdEncoding.EncodeToString(hash[:])
|
||||
apik, err := db.GetAPIKey(ctx, b64hash)
|
||||
apik, err := a.ust.GetAPIKey(ctx, b64hash)
|
||||
if err != nil {
|
||||
if database.IsNoRows(err) {
|
||||
log.Error().Str("apikey", keyUuid.String()).Msg("no such key")
|
||||
|
@ -45,7 +44,5 @@ func (a *Auth) CheckAPIKey(ctx context.Context, key string) (*users.UserID, erro
|
|||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
owner := users.UserID(apik.Owner)
|
||||
|
||||
return &owner, nil
|
||||
return a.ust.GetUser(ctx, apik.Username)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import (
|
|||
_ "embed"
|
||||
|
||||
"dynatron.me/x/stillbox/pkg/config"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
|
@ -22,14 +24,16 @@ type Authenticator interface {
|
|||
type Auth struct {
|
||||
rl *httprate.RateLimiter
|
||||
jwt *jwtauth.JWTAuth
|
||||
ust users.Store
|
||||
cfg config.Auth
|
||||
}
|
||||
|
||||
// NewAuthenticator creates a new Authenticator with the provided config.
|
||||
func NewAuthenticator(cfg config.Auth) *Auth {
|
||||
func NewAuthenticator(cfg config.Auth, ust users.Store) *Auth {
|
||||
a := &Auth{
|
||||
rl: httprate.NewRateLimiter(5, time.Minute),
|
||||
cfg: cfg,
|
||||
ust: ust,
|
||||
}
|
||||
a.initJWT()
|
||||
|
||||
|
@ -51,7 +55,7 @@ var (
|
|||
// ErrorResponse writes the error and appropriate HTTP response code.
|
||||
func ErrorResponse(w http.ResponseWriter, err error) {
|
||||
switch err {
|
||||
case ErrLoginFailed, ErrUnauthorized:
|
||||
case ErrLoginFailed, ErrUnauthorized, rbac.ErrBadSubject:
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
case ErrBadRequest:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -153,12 +152,12 @@ func (a *Auth) Login(ctx context.Context, username, password string) (token stri
|
|||
}
|
||||
}
|
||||
|
||||
return a.newToken(found.ID), nil
|
||||
return a.newToken(found.Username), nil
|
||||
}
|
||||
|
||||
func (a *Auth) newToken(uid int) string {
|
||||
func (a *Auth) newToken(username string) string {
|
||||
claims := claims{
|
||||
"sub": strconv.Itoa(int(uid)),
|
||||
"sub": username,
|
||||
}
|
||||
jwtauth.SetExpiryIn(claims, time.Hour*24*30) // one month
|
||||
_, tokenString, err := a.jwt.Encode(claims)
|
||||
|
@ -190,19 +189,14 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "Invalid token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
existingSubjectUID := jwToken.Subject()
|
||||
if existingSubjectUID == "" {
|
||||
|
||||
existingSubjectUsername := jwToken.Subject()
|
||||
if existingSubjectUsername == "" {
|
||||
http.Error(w, "Invalid token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
uid, err := strconv.Atoi(existingSubjectUID)
|
||||
if err != nil {
|
||||
log.Error().Str("sub", existingSubjectUID).Err(err).Msg("atoi uid for token refresh")
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tok := a.newToken(uid)
|
||||
tok := a.newToken(existingSubjectUsername)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: CookieName,
|
||||
|
|
|
@ -9,6 +9,9 @@ import (
|
|||
|
||||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
@ -16,6 +19,12 @@ import (
|
|||
)
|
||||
|
||||
type Store interface {
|
||||
// AddCall adds a call to the database.
|
||||
AddCall(ctx context.Context, call *calls.Call) error
|
||||
|
||||
// DeleteCall deletes a call.
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
// CallAudio returns a CallAudio struct
|
||||
CallAudio(ctx context.Context, id uuid.UUID) (*calls.CallAudio, error)
|
||||
|
||||
|
@ -24,10 +33,13 @@ type Store interface {
|
|||
}
|
||||
|
||||
type store struct {
|
||||
db database.Store
|
||||
}
|
||||
|
||||
func NewStore() *store {
|
||||
return new(store)
|
||||
func NewStore(db database.Store) *store {
|
||||
return &store{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
type storeCtxKey string
|
||||
|
@ -41,13 +53,77 @@ func CtxWithStore(ctx context.Context, s Store) context.Context {
|
|||
func FromCtx(ctx context.Context) Store {
|
||||
s, ok := ctx.Value(StoreCtxKey).(Store)
|
||||
if !ok {
|
||||
return NewStore()
|
||||
panic("no call store in context")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func toAddCallParams(call *calls.Call) database.AddCallParams {
|
||||
return database.AddCallParams{
|
||||
ID: call.ID,
|
||||
Submitter: call.Submitter.Int32Ptr(),
|
||||
System: call.System,
|
||||
Talkgroup: call.Talkgroup,
|
||||
CallDate: pgtype.Timestamptz{Time: call.DateTime, Valid: true},
|
||||
AudioName: common.NilIfZero(call.AudioName),
|
||||
AudioBlob: call.Audio,
|
||||
AudioType: common.NilIfZero(call.AudioType),
|
||||
Duration: call.Duration.MsInt32Ptr(),
|
||||
Frequency: call.Frequency,
|
||||
Frequencies: call.Frequencies,
|
||||
Patches: call.Patches,
|
||||
TGLabel: call.TalkgroupLabel,
|
||||
TGAlphaTag: call.TGAlphaTag,
|
||||
TGGroup: call.TalkgroupGroup,
|
||||
Source: call.Source,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *store) AddCall(ctx context.Context, call *calls.Call) error {
|
||||
_, err := rbac.Check(ctx, call, rbac.WithActions(rbac.ActionCreate))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := toAddCallParams(call)
|
||||
db := database.FromCtx(ctx)
|
||||
tgs := tgstore.FromCtx(ctx)
|
||||
|
||||
err = db.InTx(ctx, func(tx database.Store) error {
|
||||
err := tx.AddCall(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add call: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, pgx.TxOptions{})
|
||||
|
||||
if err != nil && database.IsTGConstraintViolation(err) {
|
||||
return db.InTx(ctx, func(tx database.Store) error {
|
||||
_, err := tgs.LearnTG(ctx, call)
|
||||
if err != nil {
|
||||
return fmt.Errorf("learn tg: %w", err)
|
||||
}
|
||||
|
||||
err = tx.AddCall(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("learn tg retry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, pgx.TxOptions{})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
|
||||
dbCall, err := db.GetCallAudioByID(ctx, id)
|
||||
|
@ -76,6 +152,11 @@ type CallsParams struct {
|
|||
}
|
||||
|
||||
func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
|
||||
_, err = rbac.Check(ctx, rbac.UseResource(rbac.ResourceCall), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
|
||||
offset, perPage := p.Pagination.OffsetPerPage(100)
|
||||
|
@ -127,3 +208,28 @@ func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListC
|
|||
|
||||
return rows, int(count), err
|
||||
}
|
||||
|
||||
func (s *store) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
callOwn, err := s.getCallOwner(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = rbac.Check(ctx, &callOwn, rbac.WithActions(rbac.ActionDelete))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return database.FromCtx(ctx).DeleteCall(ctx, id)
|
||||
}
|
||||
|
||||
func (s *store) getCallOwner(ctx context.Context, id uuid.UUID) (calls.Call, error) {
|
||||
subInt, err := database.FromCtx(ctx).GetCallSubmitter(ctx, id)
|
||||
|
||||
var sub *users.UserID
|
||||
|
||||
if subInt != nil {
|
||||
sub = common.PtrTo(users.UserID(*subInt))
|
||||
}
|
||||
return calls.Call{ID: id, Submitter: sub}, err
|
||||
}
|
||||
|
|
|
@ -155,6 +155,15 @@ func (q *Queries) CleanupSweptCalls(ctx context.Context, rangeStart pgtype.Times
|
|||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const deleteCall = `-- name: DeleteCall :exec
|
||||
DELETE FROM calls WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteCall(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteCall, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getCallAudioByID = `-- name: GetCallAudioByID :one
|
||||
SELECT
|
||||
c.call_date,
|
||||
|
@ -192,6 +201,17 @@ func (q *Queries) GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAu
|
|||
return i, err
|
||||
}
|
||||
|
||||
const getCallSubmitter = `-- name: GetCallSubmitter :one
|
||||
SELECT submitter FROM calls WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) {
|
||||
row := q.db.QueryRow(ctx, getCallSubmitter, id)
|
||||
var submitter *int32
|
||||
err := row.Scan(&submitter)
|
||||
return submitter, err
|
||||
}
|
||||
|
||||
const getDatabaseSize = `-- name: GetDatabaseSize :one
|
||||
SELECT pg_size_pretty(pg_database_size(current_database()))
|
||||
`
|
||||
|
|
|
@ -244,6 +244,17 @@ func (q *Queries) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetInci
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const getIncidentOwner = `-- name: GetIncidentOwner :one
|
||||
SELECT owner FROM incidents WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error) {
|
||||
row := q.db.QueryRow(ctx, getIncidentOwner, id)
|
||||
var owner int
|
||||
err := row.Scan(&owner)
|
||||
return owner, err
|
||||
}
|
||||
|
||||
const listIncidentsCount = `-- name: ListIncidentsCount :one
|
||||
SELECT COUNT(*)
|
||||
FROM incidents i
|
||||
|
|
|
@ -795,6 +795,53 @@ func (_c *Store_DeleteAPIKey_Call) RunAndReturn(run func(context.Context, string
|
|||
return _c
|
||||
}
|
||||
|
||||
// DeleteCall provides a mock function with given fields: ctx, id
|
||||
func (_m *Store) DeleteCall(ctx context.Context, id uuid.UUID) error {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteCall")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) error); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Store_DeleteCall_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteCall'
|
||||
type Store_DeleteCall_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// DeleteCall is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - id uuid.UUID
|
||||
func (_e *Store_Expecter) DeleteCall(ctx interface{}, id interface{}) *Store_DeleteCall_Call {
|
||||
return &Store_DeleteCall_Call{Call: _e.mock.On("DeleteCall", ctx, id)}
|
||||
}
|
||||
|
||||
func (_c *Store_DeleteCall_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_DeleteCall_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(uuid.UUID))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_DeleteCall_Call) Return(_a0 error) *Store_DeleteCall_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_DeleteCall_Call) RunAndReturn(run func(context.Context, uuid.UUID) error) *Store_DeleteCall_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// DeleteIncident provides a mock function with given fields: ctx, id
|
||||
func (_m *Store) DeleteIncident(ctx context.Context, id uuid.UUID) error {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
@ -1358,6 +1405,65 @@ func (_c *Store_GetCallAudioByID_Call) RunAndReturn(run func(context.Context, uu
|
|||
return _c
|
||||
}
|
||||
|
||||
// GetCallSubmitter provides a mock function with given fields: ctx, id
|
||||
func (_m *Store) GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetCallSubmitter")
|
||||
}
|
||||
|
||||
var r0 *int32
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (*int32, error)); ok {
|
||||
return rf(ctx, id)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) *int32); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*int32)
|
||||
}
|
||||
}
|
||||
|
||||
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_GetCallSubmitter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCallSubmitter'
|
||||
type Store_GetCallSubmitter_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetCallSubmitter is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - id uuid.UUID
|
||||
func (_e *Store_Expecter) GetCallSubmitter(ctx interface{}, id interface{}) *Store_GetCallSubmitter_Call {
|
||||
return &Store_GetCallSubmitter_Call{Call: _e.mock.On("GetCallSubmitter", ctx, id)}
|
||||
}
|
||||
|
||||
func (_c *Store_GetCallSubmitter_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_GetCallSubmitter_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(uuid.UUID))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_GetCallSubmitter_Call) Return(_a0 *int32, _a1 error) *Store_GetCallSubmitter_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_GetCallSubmitter_Call) RunAndReturn(run func(context.Context, uuid.UUID) (*int32, error)) *Store_GetCallSubmitter_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetDatabaseSize provides a mock function with given fields: ctx
|
||||
func (_m *Store) GetDatabaseSize(ctx context.Context) (string, error) {
|
||||
ret := _m.Called(ctx)
|
||||
|
@ -1530,6 +1636,63 @@ func (_c *Store_GetIncidentCalls_Call) RunAndReturn(run func(context.Context, uu
|
|||
return _c
|
||||
}
|
||||
|
||||
// GetIncidentOwner provides a mock function with given fields: ctx, id
|
||||
func (_m *Store) GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetIncidentOwner")
|
||||
}
|
||||
|
||||
var r0 int
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (int, error)); ok {
|
||||
return rf(ctx, id)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) int); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int)
|
||||
}
|
||||
|
||||
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_GetIncidentOwner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIncidentOwner'
|
||||
type Store_GetIncidentOwner_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetIncidentOwner is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - id uuid.UUID
|
||||
func (_e *Store_Expecter) GetIncidentOwner(ctx interface{}, id interface{}) *Store_GetIncidentOwner_Call {
|
||||
return &Store_GetIncidentOwner_Call{Call: _e.mock.On("GetIncidentOwner", ctx, id)}
|
||||
}
|
||||
|
||||
func (_c *Store_GetIncidentOwner_Call) Run(run func(ctx context.Context, id uuid.UUID)) *Store_GetIncidentOwner_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(uuid.UUID))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_GetIncidentOwner_Call) Return(_a0 int, _a1 error) *Store_GetIncidentOwner_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Store_GetIncidentOwner_Call) RunAndReturn(run func(context.Context, uuid.UUID) (int, error)) *Store_GetIncidentOwner_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetShare provides a mock function with given fields: ctx, id
|
||||
func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"dynatron.me/x/stillbox/internal/isoweek"
|
||||
"dynatron.me/x/stillbox/pkg/config"
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
@ -134,6 +135,7 @@ func New(db database.Store, cfg config.Partition) (*partman, error) {
|
|||
var _ PartitionManager = (*partman)(nil)
|
||||
|
||||
func (pm *partman) Go(ctx context.Context) {
|
||||
ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "partman"})
|
||||
tick := time.NewTicker(CheckInterval)
|
||||
|
||||
select {
|
||||
|
|
|
@ -23,6 +23,7 @@ type Querier interface {
|
|||
CreateSystem(ctx context.Context, iD int, name string) error
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||
DeleteAPIKey(ctx context.Context, apiKey string) error
|
||||
DeleteCall(ctx context.Context, id uuid.UUID) error
|
||||
DeleteIncident(ctx context.Context, id uuid.UUID) error
|
||||
DeleteShare(ctx context.Context, id string) error
|
||||
DeleteSystem(ctx context.Context, id int) error
|
||||
|
@ -32,9 +33,11 @@ type Querier interface {
|
|||
GetAllTalkgroupTags(ctx context.Context) ([]string, error)
|
||||
GetAppPrefs(ctx context.Context, appName string, uid int) ([]byte, error)
|
||||
GetCallAudioByID(ctx context.Context, id uuid.UUID) (GetCallAudioByIDRow, error)
|
||||
GetCallSubmitter(ctx context.Context, id uuid.UUID) (*int32, error)
|
||||
GetDatabaseSize(ctx context.Context) (string, error)
|
||||
GetIncident(ctx context.Context, id uuid.UUID) (Incident, error)
|
||||
GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error)
|
||||
GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error)
|
||||
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)
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/incidents"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
@ -72,7 +73,6 @@ func NewStore() Store {
|
|||
}
|
||||
|
||||
func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*incidents.Incident, error) {
|
||||
// TODO: replace this with a real RBAC check
|
||||
user, err := users.UserCheck(ctx, new(incidents.Incident), "create")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -132,6 +132,16 @@ func (s *store) CreateIncident(ctx context.Context, inc incidents.Incident) (*in
|
|||
}
|
||||
|
||||
func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID, addCallIDs []uuid.UUID, notes []byte, removeCallIDs []uuid.UUID) error {
|
||||
inc, err := s.getIncidentOwner(ctx, incidentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = rbac.Check(ctx, &inc, rbac.WithActions(rbac.ActionUpdate))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return database.FromCtx(ctx).InTx(ctx, func(db database.Store) error {
|
||||
if len(addCallIDs) > 0 {
|
||||
var noteAr [][]byte
|
||||
|
@ -160,6 +170,10 @@ func (s *store) AddRemoveIncidentCalls(ctx context.Context, incidentID uuid.UUID
|
|||
}
|
||||
|
||||
func (s *store) Incidents(ctx context.Context, p IncidentsParams) (incs []Incident, totalCount int, err error) {
|
||||
_, err = rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
db := database.FromCtx(ctx)
|
||||
|
||||
offset, perPage := p.Pagination.OffsetPerPage(100)
|
||||
|
@ -261,6 +275,11 @@ func fromDBCalls(d []database.GetIncidentCallsRow) []incidents.IncidentCall {
|
|||
}
|
||||
|
||||
func (s *store) Incident(ctx context.Context, id uuid.UUID) (*incidents.Incident, error) {
|
||||
_, err := rbac.Check(ctx, new(incidents.Incident), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var r incidents.Incident
|
||||
txErr := database.FromCtx(ctx).InTx(ctx, func(db database.Store) error {
|
||||
inc, err := db.GetIncident(ctx, id)
|
||||
|
@ -307,6 +326,16 @@ func (uip UpdateIncidentParams) toDBUIP(id uuid.UUID) database.UpdateIncidentPar
|
|||
}
|
||||
|
||||
func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncidentParams) (*incidents.Incident, error) {
|
||||
ckinc, err := s.getIncidentOwner(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = rbac.Check(ctx, &ckinc, rbac.WithActions(rbac.ActionUpdate))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
|
||||
dbInc, err := db.UpdateIncident(ctx, p.toDBUIP(id))
|
||||
|
@ -320,9 +349,24 @@ func (s *store) UpdateIncident(ctx context.Context, id uuid.UUID, p UpdateIncide
|
|||
}
|
||||
|
||||
func (s *store) DeleteIncident(ctx context.Context, id uuid.UUID) error {
|
||||
inc, err := s.getIncidentOwner(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = rbac.Check(ctx, &inc, rbac.WithActions(rbac.ActionDelete))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return database.FromCtx(ctx).DeleteIncident(ctx, id)
|
||||
}
|
||||
|
||||
func (s *store) UpdateNotes(ctx context.Context, incidentID uuid.UUID, callID uuid.UUID, notes []byte) error {
|
||||
return database.FromCtx(ctx).UpdateCallIncidentNotes(ctx, notes, incidentID, callID)
|
||||
}
|
||||
|
||||
func (s *store) getIncidentOwner(ctx context.Context, id uuid.UUID) (incidents.Incident, error) {
|
||||
owner, err := database.FromCtx(ctx).GetIncidentOwner(ctx, id)
|
||||
return incidents.Incident{ID: id, Owner: users.UserID(owner)}, err
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/pb"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
@ -38,6 +39,7 @@ func New() *Nexus {
|
|||
}
|
||||
|
||||
func (n *Nexus) Go(ctx context.Context) {
|
||||
ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "nexus"})
|
||||
for {
|
||||
select {
|
||||
case call, ok := <-n.callCh:
|
||||
|
|
113
pkg/rbac/mocks/RBAC.go
Normal file
113
pkg/rbac/mocks/RBAC.go
Normal file
|
@ -0,0 +1,113 @@
|
|||
// Code generated by mockery v2.47.0. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
rbac "dynatron.me/x/stillbox/pkg/rbac"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
restrict "github.com/el-mike/restrict/v2"
|
||||
)
|
||||
|
||||
// RBAC is an autogenerated mock type for the RBAC type
|
||||
type RBAC struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type RBAC_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *RBAC) EXPECT() *RBAC_Expecter {
|
||||
return &RBAC_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, res)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Check")
|
||||
}
|
||||
|
||||
var r0 rbac.Subject
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) (rbac.Subject, error)); ok {
|
||||
return rf(ctx, res, opts...)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, restrict.Resource, ...rbac.CheckOption) rbac.Subject); ok {
|
||||
r0 = rf(ctx, res, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(rbac.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, restrict.Resource, ...rbac.CheckOption) error); ok {
|
||||
r1 = rf(ctx, res, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// RBAC_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
|
||||
type RBAC_Check_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Check is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - res restrict.Resource
|
||||
// - opts ...rbac.CheckOption
|
||||
func (_e *RBAC_Expecter) Check(ctx interface{}, res interface{}, opts ...interface{}) *RBAC_Check_Call {
|
||||
return &RBAC_Check_Call{Call: _e.mock.On("Check",
|
||||
append([]interface{}{ctx, res}, opts...)...)}
|
||||
}
|
||||
|
||||
func (_c *RBAC_Check_Call) Run(run func(ctx context.Context, res restrict.Resource, opts ...rbac.CheckOption)) *RBAC_Check_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
variadicArgs := make([]rbac.CheckOption, len(args)-2)
|
||||
for i, a := range args[2:] {
|
||||
if a != nil {
|
||||
variadicArgs[i] = a.(rbac.CheckOption)
|
||||
}
|
||||
}
|
||||
run(args[0].(context.Context), args[1].(restrict.Resource), variadicArgs...)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *RBAC_Check_Call) Return(_a0 rbac.Subject, _a1 error) *RBAC_Check_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *RBAC_Check_Call) RunAndReturn(run func(context.Context, restrict.Resource, ...rbac.CheckOption) (rbac.Subject, error)) *RBAC_Check_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewRBAC creates a new instance of RBAC. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewRBAC(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *RBAC {
|
||||
mock := &RBAC{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
143
pkg/rbac/rbac.go
143
pkg/rbac/rbac.go
|
@ -3,6 +3,8 @@ package rbac
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/el-mike/restrict/v2"
|
||||
"github.com/el-mike/restrict/v2/adapters"
|
||||
|
@ -12,6 +14,7 @@ const (
|
|||
RoleUser = "User"
|
||||
RoleSubmitter = "Submitter"
|
||||
RoleAdmin = "Admin"
|
||||
RoleSystem = "System"
|
||||
RolePublic = "Public"
|
||||
RoleShareGuest = "ShareGuest"
|
||||
|
||||
|
@ -20,6 +23,7 @@ const (
|
|||
ResourceTalkgroup = "Talkgroup"
|
||||
ResourceAlert = "Alert"
|
||||
ResourceShare = "Share"
|
||||
ResourceAPIKey = "APIKey"
|
||||
|
||||
ActionRead = "read"
|
||||
ActionCreate = "create"
|
||||
|
@ -29,6 +33,9 @@ const (
|
|||
PresetUpdateOwn = "updateOwn"
|
||||
PresetDeleteOwn = "deleteOwn"
|
||||
PresetReadShared = "readShared"
|
||||
|
||||
PresetUpdateSubmitter = "updateSubmitter"
|
||||
PresetDeleteSubmitter = "deleteSubmitter"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -43,6 +50,14 @@ func CtxWithSubject(ctx context.Context, sub Subject) context.Context {
|
|||
return context.WithValue(ctx, SubjectCtxKey, sub)
|
||||
}
|
||||
|
||||
func ErrAccessDenied(err error) *restrict.AccessDeniedError {
|
||||
if accessErr, ok := err.(*restrict.AccessDeniedError); ok {
|
||||
return accessErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SubjectFrom(ctx context.Context) Subject {
|
||||
sub, ok := ctx.Value(SubjectCtxKey).(Subject)
|
||||
if ok {
|
||||
|
@ -87,8 +102,8 @@ var policy = &restrict.PolicyDefinition{
|
|||
ResourceCall: {
|
||||
&restrict.Permission{Action: ActionRead},
|
||||
&restrict.Permission{Action: ActionCreate},
|
||||
&restrict.Permission{Preset: PresetUpdateOwn},
|
||||
&restrict.Permission{Preset: PresetDeleteOwn},
|
||||
&restrict.Permission{Preset: PresetUpdateSubmitter},
|
||||
&restrict.Permission{Preset: PresetDeleteSubmitter},
|
||||
},
|
||||
ResourceTalkgroup: {
|
||||
&restrict.Permission{Action: ActionRead},
|
||||
|
@ -107,6 +122,11 @@ var policy = &restrict.PolicyDefinition{
|
|||
ResourceCall: {
|
||||
&restrict.Permission{Action: ActionCreate},
|
||||
},
|
||||
ResourceTalkgroup: {
|
||||
// for learning TGs
|
||||
&restrict.Permission{Action: ActionCreate},
|
||||
&restrict.Permission{Action: ActionUpdate},
|
||||
},
|
||||
},
|
||||
},
|
||||
RoleShareGuest: {
|
||||
|
@ -141,6 +161,9 @@ var policy = &restrict.PolicyDefinition{
|
|||
},
|
||||
},
|
||||
},
|
||||
RoleSystem: {
|
||||
Parents: []string{RoleSystem},
|
||||
},
|
||||
RolePublic: {
|
||||
/*
|
||||
Grants: restrict.GrantsMap{
|
||||
|
@ -184,6 +207,38 @@ var policy = &restrict.PolicyDefinition{
|
|||
},
|
||||
},
|
||||
},
|
||||
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{
|
||||
|
@ -208,15 +263,15 @@ type checkOptions struct {
|
|||
context restrict.Context
|
||||
}
|
||||
|
||||
type checkOption func(*checkOptions)
|
||||
type CheckOption func(*checkOptions)
|
||||
|
||||
func WithActions(actions ...string) checkOption {
|
||||
func WithActions(actions ...string) CheckOption {
|
||||
return func(o *checkOptions) {
|
||||
o.actions = append(o.actions, actions...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithContext(ctx restrict.Context) checkOption {
|
||||
func WithContext(ctx restrict.Context) CheckOption {
|
||||
return func(o *checkOptions) {
|
||||
o.context = ctx
|
||||
}
|
||||
|
@ -228,6 +283,7 @@ func UseResource(rsc string) restrict.Resource {
|
|||
|
||||
type Subject interface {
|
||||
restrict.Subject
|
||||
GetName() string
|
||||
}
|
||||
|
||||
type Resource interface {
|
||||
|
@ -235,7 +291,7 @@ type Resource interface {
|
|||
}
|
||||
|
||||
type RBAC interface {
|
||||
Check(ctx context.Context, res restrict.Resource, opts ...checkOption) (Subject, error)
|
||||
Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (Subject, error)
|
||||
}
|
||||
|
||||
type rbac struct {
|
||||
|
@ -257,7 +313,12 @@ func New() (*rbac, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...checkOption) (Subject, error) {
|
||||
// Check is a convenience function to pull the RBAC instance out of ctx and Check.
|
||||
func Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (Subject, error) {
|
||||
return FromCtx(ctx).Check(ctx, res, opts...)
|
||||
}
|
||||
|
||||
func (r *rbac) Check(ctx context.Context, res restrict.Resource, opts ...CheckOption) (Subject, error) {
|
||||
sub := SubjectFrom(ctx)
|
||||
o := checkOptions{}
|
||||
|
||||
|
@ -279,6 +340,10 @@ type ShareLinkGuest struct {
|
|||
ShareID string
|
||||
}
|
||||
|
||||
func (s *ShareLinkGuest) GetName() string {
|
||||
return "SHARE:" + s.ShareID
|
||||
}
|
||||
|
||||
func (s *ShareLinkGuest) GetRoles() []string {
|
||||
return []string{RoleShareGuest}
|
||||
}
|
||||
|
@ -287,6 +352,70 @@ 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)
|
||||
}
|
||||
|
|
197
pkg/rbac/rbac_test.go
Normal file
197
pkg/rbac/rbac_test.go
Normal file
|
@ -0,0 +1,197 @@
|
|||
package rbac_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"dynatron.me/x/stillbox/internal/common"
|
||||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/incidents"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
"github.com/el-mike/restrict/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRBAC(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
subject rbac.Subject
|
||||
resource rbac.Resource
|
||||
action string
|
||||
expectErr error
|
||||
}{
|
||||
{
|
||||
name: "admin update talkgroup",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
IsAdmin: true,
|
||||
},
|
||||
resource: &talkgroups.Talkgroup{},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "admin update incident",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
IsAdmin: true,
|
||||
},
|
||||
resource: &incidents.Incident{
|
||||
Name: "test incident",
|
||||
Owner: 4,
|
||||
},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "user update incident not owner",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
},
|
||||
resource: &incidents.Incident{
|
||||
Name: "test incident",
|
||||
Owner: 4,
|
||||
},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: errors.New(`access denied for Action: "update" on Resource: "Incident"`),
|
||||
},
|
||||
{
|
||||
name: "user update incident owner",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
},
|
||||
resource: &incidents.Incident{
|
||||
Name: "test incident",
|
||||
Owner: 2,
|
||||
},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "user delete incident not owner",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
},
|
||||
resource: &incidents.Incident{
|
||||
Name: "test incident",
|
||||
Owner: 6,
|
||||
},
|
||||
action: rbac.ActionDelete,
|
||||
expectErr: errors.New(`access denied for Action: "delete" on Resource: "Incident"`),
|
||||
},
|
||||
{
|
||||
name: "admin update call",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
IsAdmin: true,
|
||||
},
|
||||
resource: &calls.Call{
|
||||
Submitter: common.PtrTo(users.UserID(4)),
|
||||
},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "user update call not owner",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
},
|
||||
resource: &calls.Call{
|
||||
Submitter: common.PtrTo(users.UserID(4)),
|
||||
},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`),
|
||||
},
|
||||
{
|
||||
name: "user update call owner",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
},
|
||||
resource: &calls.Call{
|
||||
Submitter: common.PtrTo(users.UserID(2)),
|
||||
},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: nil,
|
||||
},
|
||||
{
|
||||
name: "user update call nil submitter",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
},
|
||||
resource: &calls.Call{
|
||||
Submitter: nil,
|
||||
},
|
||||
action: rbac.ActionUpdate,
|
||||
expectErr: errors.New(`access denied for Action: "update" on Resource: "Call"`),
|
||||
},
|
||||
{
|
||||
name: "user delete call not owner",
|
||||
subject: &users.User{
|
||||
ID: 2,
|
||||
},
|
||||
resource: &calls.Call{
|
||||
Submitter: common.PtrTo(users.UserID(6)),
|
||||
},
|
||||
action: rbac.ActionDelete,
|
||||
expectErr: errors.New(`access denied for Action: "delete" on Resource: "Call"`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := rbac.CtxWithSubject(context.Background(), tc.subject)
|
||||
rb, err := rbac.New()
|
||||
require.NoError(t, err)
|
||||
sub, err := rb.Check(ctx, tc.resource, rbac.WithActions(tc.action))
|
||||
if tc.expectErr != nil {
|
||||
assert.Equal(t, tc.expectErr.Error(), err.Error())
|
||||
} else {
|
||||
if !assert.NoError(t, err) {
|
||||
accErr(err)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tc.subject, sub)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func accErr(err error) {
|
||||
if accessError, ok := err.(*restrict.AccessDeniedError); ok {
|
||||
// Error() implementation. Returns a message in a form: "access denied for Action/s: ... on Resource: ..."
|
||||
fmt.Println(accessError)
|
||||
// Returns an AccessRequest that failed.
|
||||
fmt.Println(accessError.Request)
|
||||
// Returns first reason for the denied access.
|
||||
// Especially helpful in fail-early mode, where there will only be one Reason.
|
||||
fmt.Println(accessError.FirstReason())
|
||||
|
||||
// Reasons property will hold all errors that caused the access to be denied.
|
||||
for _, permissionErr := range accessError.Reasons {
|
||||
fmt.Println(permissionErr)
|
||||
fmt.Println(permissionErr.Action)
|
||||
fmt.Println(permissionErr.RoleName)
|
||||
fmt.Println(permissionErr.ResourceName)
|
||||
|
||||
// Returns first ConditionNotSatisfied error for given PermissionError, if any was returned for given PermissionError.
|
||||
// Especially helpful in fail-early mode, where there will only be one failed Condition.
|
||||
fmt.Println(permissionErr.FirstConditionError())
|
||||
|
||||
// ConditionErrors property will hold all ConditionNotSatisfied errors.
|
||||
for _, conditionErr := range permissionErr.ConditionErrors {
|
||||
fmt.Println(conditionErr)
|
||||
fmt.Println(conditionErr.Reason)
|
||||
|
||||
// Every ConditionNotSatisfied contains an instance of Condition that returned it,
|
||||
// so it can be tested using type assertion to get more details about failed Condition.
|
||||
if emptyCondition, ok := conditionErr.Condition.(*restrict.EmptyCondition); ok {
|
||||
fmt.Println(emptyCondition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -84,6 +84,14 @@ func unauthErrText(err error) render.Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
func forbiddenErrText(err error) render.Renderer {
|
||||
return &errResponse{
|
||||
Err: err,
|
||||
Code: http.StatusForbidden,
|
||||
Error: "Forbidden: " + err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func constraintErrText(err error) render.Renderer {
|
||||
return &errResponse{
|
||||
Err: err,
|
||||
|
@ -147,6 +155,10 @@ func autoError(err error) render.Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
if rbac.ErrAccessDenied(err) != nil {
|
||||
return forbiddenErrText(err)
|
||||
}
|
||||
|
||||
return internalError(err)
|
||||
}
|
||||
|
||||
|
|
|
@ -7,5 +7,6 @@ import (
|
|||
)
|
||||
|
||||
func (s *Server) Ingest(ctx context.Context, call *calls.Call) error {
|
||||
return s.sinks.EmitCall(context.Background(), call)
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
return s.sinks.EmitCall(ctx, call)
|
||||
}
|
||||
|
|
|
@ -67,14 +67,16 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
|
|||
|
||||
r := chi.NewRouter()
|
||||
|
||||
authenticator := auth.NewAuthenticator(cfg.Auth)
|
||||
ust := users.NewStore(db)
|
||||
|
||||
authenticator := auth.NewAuthenticator(cfg.Auth, ust)
|
||||
|
||||
notifier, err := notify.New(cfg.Notify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tgCache := tgstore.NewCache()
|
||||
tgCache := tgstore.NewCache(db)
|
||||
api := rest.New(cfg.BaseURL.URL())
|
||||
|
||||
rbacSvc, err := rbac.New()
|
||||
|
@ -95,8 +97,8 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
|
|||
sinks: sinks.NewSinkManager(),
|
||||
rest: api,
|
||||
share: share.NewService(),
|
||||
users: users.NewStore(),
|
||||
calls: callstore.NewStore(),
|
||||
users: ust,
|
||||
calls: callstore.NewStore(db),
|
||||
incidents: incstore.NewStore(),
|
||||
rbac: rbacSvc,
|
||||
}
|
||||
|
@ -113,7 +115,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
|
|||
}
|
||||
}
|
||||
|
||||
srv.sinks.Register("database", sinks.NewDatabaseSink(srv.db, tgCache), true)
|
||||
srv.sinks.Register("database", sinks.NewDatabaseSink(db, tgCache), true)
|
||||
srv.sinks.Register("nexus", sinks.NewNexusSink(srv.nex), false)
|
||||
|
||||
if srv.alerter.Enabled() {
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
@ -26,6 +27,8 @@ func (s *service) ShareStore() Store {
|
|||
}
|
||||
|
||||
func (s *service) Go(ctx context.Context) {
|
||||
ctx = rbac.CtxWithSubject(ctx, &rbac.SystemServiceSubject{Name: "share"})
|
||||
|
||||
tick := time.NewTicker(PruneInterval)
|
||||
|
||||
for {
|
||||
|
|
|
@ -2,15 +2,12 @@ package sinks
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"dynatron.me/x/stillbox/internal/common"
|
||||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/calls/callstore"
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
@ -29,59 +26,9 @@ func (s *DatabaseSink) Call(ctx context.Context, call *calls.Call) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
params := s.toAddCallParams(call)
|
||||
|
||||
err := s.db.InTx(ctx, func(tx database.Store) error {
|
||||
err := tx.AddCall(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add call: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().Str("id", call.ID.String()).Int("system", call.System).Int("tgid", call.Talkgroup).Msg("stored")
|
||||
|
||||
return nil
|
||||
}, pgx.TxOptions{})
|
||||
|
||||
if err != nil && database.IsTGConstraintViolation(err) {
|
||||
return s.db.InTx(ctx, func(tx database.Store) error {
|
||||
_, err := s.tgs.LearnTG(ctx, call)
|
||||
if err != nil {
|
||||
return fmt.Errorf("learn tg: %w", err)
|
||||
}
|
||||
|
||||
err = tx.AddCall(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("learn tg retry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, pgx.TxOptions{})
|
||||
}
|
||||
|
||||
return err
|
||||
return callstore.FromCtx(ctx).AddCall(ctx, call)
|
||||
}
|
||||
|
||||
func (s *DatabaseSink) SinkType() string {
|
||||
return "database"
|
||||
}
|
||||
|
||||
func (s *DatabaseSink) toAddCallParams(call *calls.Call) database.AddCallParams {
|
||||
return database.AddCallParams{
|
||||
ID: call.ID,
|
||||
Submitter: call.Submitter.Int32Ptr(),
|
||||
System: call.System,
|
||||
Talkgroup: call.Talkgroup,
|
||||
CallDate: pgtype.Timestamptz{Time: call.DateTime, Valid: true},
|
||||
AudioName: common.NilIfZero(call.AudioName),
|
||||
AudioBlob: call.Audio,
|
||||
AudioType: common.NilIfZero(call.AudioType),
|
||||
Duration: call.Duration.MsInt32Ptr(),
|
||||
Frequency: call.Frequency,
|
||||
Frequencies: call.Frequencies,
|
||||
Patches: call.Patches,
|
||||
TGLabel: call.TalkgroupLabel,
|
||||
TGAlphaTag: call.TGAlphaTag,
|
||||
TGGroup: call.TalkgroupGroup,
|
||||
Source: call.Source,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"dynatron.me/x/stillbox/internal/forms"
|
||||
"dynatron.me/x/stillbox/pkg/auth"
|
||||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
@ -99,7 +100,13 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
ctx := r.Context()
|
||||
|
||||
submitter, err := h.auth.CheckAPIKey(ctx, r.Form.Get("key"))
|
||||
submitterSub, err := h.auth.CheckAPIKey(ctx, r.Form.Get("key"))
|
||||
if err != nil {
|
||||
auth.ErrorResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
submitter, err := users.FromSubject(submitterSub)
|
||||
if err != nil {
|
||||
auth.ErrorResponse(w, err)
|
||||
return
|
||||
|
@ -118,20 +125,22 @@ func (h *RdioHTTP) routeCallUpload(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
call, err := cur.ToCall(*submitter)
|
||||
call, err := cur.ToCall(submitter.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("toCall failed")
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = h.ing.Ingest(ctx, call)
|
||||
err = h.ing.Ingest(rbac.CtxWithSubject(ctx, submitterSub), call)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("ingest failed")
|
||||
http.Error(w, "Call ingest failed.", http.StatusInternalServerError)
|
||||
if rbac.ErrAccessDenied(err) != nil {
|
||||
log.Error().Err(err).Msg("ingest failed")
|
||||
http.Error(w, "Call ingest failed.", http.StatusForbidden)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Int("system", cur.System).Int("tgid", cur.Talkgroup).Str("duration", call.Duration.Duration().String()).Msg("ingested")
|
||||
log.Info().Int("system", cur.System).Int("tgid", cur.Talkgroup).Str("duration", call.Duration.Duration().String()).Str("sub", submitter.Username).Msg("ingested")
|
||||
|
||||
written, err := w.Write([]byte("Call imported successfully."))
|
||||
if err != nil {
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
TG() tgstore.Store
|
||||
User() users.Store
|
||||
}
|
||||
|
||||
type store struct {
|
||||
tg tgstore.Store
|
||||
user users.Store
|
||||
}
|
||||
|
||||
func (s *store) TG() tgstore.Store {
|
||||
return s.tg
|
||||
}
|
||||
|
||||
func (s *store) User() users.Store {
|
||||
return s.user
|
||||
}
|
||||
|
||||
func New() Store {
|
||||
return &store{
|
||||
tg: tgstore.NewCache(),
|
||||
user: users.NewStore(),
|
||||
}
|
||||
}
|
||||
|
||||
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 New()
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
|
@ -11,6 +11,7 @@ import (
|
|||
"dynatron.me/x/stillbox/pkg/calls"
|
||||
"dynatron.me/x/stillbox/pkg/config"
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
|
||||
|
@ -176,7 +177,7 @@ func CtxWithStore(ctx context.Context, s Store) context.Context {
|
|||
func FromCtx(ctx context.Context) Store {
|
||||
s, ok := ctx.Value(StoreCtxKey).(Store)
|
||||
if !ok {
|
||||
return NewCache()
|
||||
panic("no tg store in context")
|
||||
}
|
||||
|
||||
return s
|
||||
|
@ -201,19 +202,23 @@ type cache struct {
|
|||
sync.RWMutex
|
||||
tgs tgMap
|
||||
systems map[int]string
|
||||
db database.Store
|
||||
}
|
||||
|
||||
// NewCache returns a new cache Store.
|
||||
func NewCache() *cache {
|
||||
func NewCache(db database.Store) *cache {
|
||||
tgc := &cache{
|
||||
tgs: make(tgMap),
|
||||
systems: make(map[int]string),
|
||||
db: db,
|
||||
}
|
||||
|
||||
return tgc
|
||||
}
|
||||
|
||||
func (t *cache) Hint(ctx context.Context, tgs []tgsp.ID) error {
|
||||
// since this doesn't actually return data, we can skip rbac checks.
|
||||
// This is only called by system services anyway.
|
||||
if len(tgs) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
@ -322,11 +327,15 @@ 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) {
|
||||
db := database.FromCtx(ctx)
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := t.db
|
||||
|
||||
r := make([]*tgsp.Talkgroup, 0, len(tgs))
|
||||
opt := sOpt(opts)
|
||||
var err error
|
||||
if tgs != nil {
|
||||
toGet := make(tgsp.IDs, 0, len(tgs))
|
||||
for _, id := range tgs {
|
||||
|
@ -394,7 +403,8 @@ func (t *cache) TGs(ctx context.Context, tgs tgsp.IDs, opts ...Option) ([]*tgsp.
|
|||
}
|
||||
|
||||
func (t *cache) Load(ctx context.Context, tgs database.TGTuples) error {
|
||||
tgRecords, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedBySysTGID(ctx, tgs)
|
||||
// No need for RBAC checks since this merely primes the cache and returns nothing.
|
||||
tgRecords, err := t.db.GetTalkgroupsWithLearnedBySysTGID(ctx, tgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -420,9 +430,13 @@ 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) {
|
||||
db := database.FromCtx(ctx)
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := t.db
|
||||
opt := sOpt(opts)
|
||||
var err error
|
||||
if opt.pagination != nil {
|
||||
sortDir, err := opt.pagination.SortDir()
|
||||
if err != nil {
|
||||
|
@ -472,13 +486,18 @@ func (t *cache) SystemTGs(ctx context.Context, systemID int, opts ...Option) ([]
|
|||
}
|
||||
|
||||
func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) {
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec, has := t.get(tg)
|
||||
|
||||
if has {
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
record, err := database.FromCtx(ctx).GetTalkgroupWithLearned(ctx, int32(tg.System), int32(tg.Talkgroup))
|
||||
record, err := t.db.GetTalkgroupWithLearned(ctx, int32(tg.System), int32(tg.Talkgroup))
|
||||
switch err {
|
||||
case nil:
|
||||
case pgx.ErrNoRows:
|
||||
|
@ -494,12 +513,17 @@ func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) {
|
|||
}
|
||||
|
||||
func (t *cache) SystemName(ctx context.Context, id int) (name string, has bool) {
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
t.RLock()
|
||||
n, has := t.systems[id]
|
||||
t.RUnlock()
|
||||
|
||||
if !has {
|
||||
sys, err := database.FromCtx(ctx).GetSystemName(ctx, id)
|
||||
sys, err := t.db.GetSystemName(ctx, id)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
@ -525,7 +549,7 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara
|
|||
return nil, ErrNoSuchSystem
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
db := t.db
|
||||
var tg database.Talkgroup
|
||||
err = db.InTx(ctx, func(db database.Store) error {
|
||||
var oerr error
|
||||
|
@ -563,12 +587,17 @@ func (t *cache) UpdateTG(ctx context.Context, input database.UpdateTalkgroupPara
|
|||
}
|
||||
|
||||
func (t *cache) DeleteSystem(ctx context.Context, id int) error {
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionDelete))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
t.invalidate()
|
||||
|
||||
err := database.FromCtx(ctx).DeleteSystem(ctx, id)
|
||||
err = t.db.DeleteSystem(ctx, id)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
|
@ -580,6 +609,11 @@ func (t *cache) DeleteSystem(ctx context.Context, id int) error {
|
|||
}
|
||||
|
||||
func (t *cache) DeleteTG(ctx context.Context, id tgsp.ID) error {
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionDelete))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
|
@ -588,7 +622,7 @@ func (t *cache) DeleteTG(ctx context.Context, id tgsp.ID) error {
|
|||
return err
|
||||
}
|
||||
|
||||
err = database.FromCtx(ctx).InTx(ctx, func(db database.Store) error {
|
||||
err = t.db.InTx(ctx, func(db database.Store) error {
|
||||
err := db.StoreDeletedTGVersion(ctx, common.PtrTo(int32(id.System)), common.PtrTo(int32(id.Talkgroup)), user.ID.Int32Ptr())
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -611,7 +645,12 @@ func (t *cache) DeleteTG(ctx context.Context, id tgsp.ID) error {
|
|||
}
|
||||
|
||||
func (t *cache) LearnTG(ctx context.Context, c *calls.Call) (*tgsp.Talkgroup, error) {
|
||||
db := database.FromCtx(ctx)
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionCreate, rbac.ActionUpdate))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := t.db
|
||||
|
||||
sys, has := t.SystemName(ctx, c.System)
|
||||
if !has {
|
||||
|
@ -649,7 +688,7 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
|
|||
return nil, err
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
db := t.db
|
||||
sysName, hasSys := t.SystemName(ctx, system)
|
||||
if !hasSys {
|
||||
return nil, ErrNoSuchSystem
|
||||
|
@ -725,14 +764,24 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
|
|||
}
|
||||
|
||||
func (t *cache) CreateSystem(ctx context.Context, id int, name string) error {
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionCreate))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
t.addSysNoLock(id, name)
|
||||
|
||||
return database.FromCtx(ctx).CreateSystem(ctx, id, name)
|
||||
return t.db.CreateSystem(ctx, id, name)
|
||||
}
|
||||
|
||||
func (t *cache) Tags(ctx context.Context) ([]string, error) {
|
||||
return database.FromCtx(ctx).GetAllTalkgroupTags(ctx)
|
||||
_, err := rbac.Check(ctx, rbac.UseResource(rbac.ResourceTalkgroup), rbac.WithActions(rbac.ActionRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t.db.GetAllTalkgroupTags(ctx)
|
||||
}
|
||||
|
|
|
@ -14,9 +14,12 @@ import (
|
|||
|
||||
"dynatron.me/x/stillbox/pkg/database"
|
||||
"dynatron.me/x/stillbox/pkg/database/mocks"
|
||||
"dynatron.me/x/stillbox/pkg/rbac"
|
||||
rbacmocks "dynatron.me/x/stillbox/pkg/rbac/mocks"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
|
||||
"dynatron.me/x/stillbox/pkg/talkgroups/xport"
|
||||
"dynatron.me/x/stillbox/pkg/users"
|
||||
)
|
||||
|
||||
func getFixture(fixture string) []byte {
|
||||
|
@ -51,14 +54,19 @@ func TestRadioRef(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
subject := users.User{IsAdmin: true}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dbMock := mocks.NewStore(t)
|
||||
rbacMock := rbacmocks.NewRBAC(t)
|
||||
rbacMock.EXPECT().Check(mock.AnythingOfType("*context.valueCtx"), rbac.UseResource("Talkgroup"), mock.AnythingOfType("rbac.CheckOption")).Return(&subject, nil)
|
||||
if tc.expectErr == nil {
|
||||
dbMock.EXPECT().GetSystemName(mock.AnythingOfType("*context.valueCtx"), tc.sysID).Return(tc.sysName, nil)
|
||||
}
|
||||
ctx := database.CtxWithDB(context.Background(), dbMock)
|
||||
ctx = tgstore.CtxWithStore(ctx, tgstore.NewCache())
|
||||
ctx = rbac.CtxWithRBAC(ctx, rbacMock)
|
||||
ctx = tgstore.CtxWithStore(ctx, tgstore.NewCache(dbMock))
|
||||
ij := &xport.ImportJob{
|
||||
Type: xport.Format(tc.impType),
|
||||
SystemID: tc.sysID,
|
||||
|
|
|
@ -22,15 +22,20 @@ type Store interface {
|
|||
|
||||
// UpdateUser updates a user's record
|
||||
UpdateUser(ctx context.Context, username string, user UserUpdate) error
|
||||
|
||||
// GetUserByAPIKey gets a user by API key.
|
||||
GetAPIKey(ctx context.Context, key string) (database.GetAPIKeyRow, error)
|
||||
}
|
||||
|
||||
type postgresStore struct {
|
||||
cache.Cache[string, *User]
|
||||
db database.Store
|
||||
}
|
||||
|
||||
func NewStore() *postgresStore {
|
||||
func NewStore(db database.Store) *postgresStore {
|
||||
return &postgresStore{
|
||||
Cache: cache.New[string, *User](),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,7 +50,7 @@ func CtxWithStore(ctx context.Context, s Store) context.Context {
|
|||
func FromCtx(ctx context.Context) Store {
|
||||
s, ok := ctx.Value(StoreCtxKey).(Store)
|
||||
if !ok {
|
||||
return NewStore()
|
||||
panic("no users store in context")
|
||||
}
|
||||
|
||||
return s
|
||||
|
@ -61,7 +66,7 @@ type UserUpdate struct {
|
|||
}
|
||||
|
||||
func (s *postgresStore) UpdateUser(ctx context.Context, username string, user UserUpdate) error {
|
||||
dbu, err := database.FromCtx(ctx).UpdateUser(ctx, username, user.Email, user.IsAdmin)
|
||||
dbu, err := s.db.UpdateUser(ctx, username, user.Email, user.IsAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -77,8 +82,7 @@ func (s *postgresStore) GetUser(ctx context.Context, username string) (*User, er
|
|||
return u, nil
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
dbu, err := db.GetUserByUsername(ctx, username)
|
||||
dbu, err := s.db.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -95,9 +99,7 @@ func (s *postgresStore) UserPrefs(ctx context.Context, username string, appName
|
|||
return nil, err
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
|
||||
prefs, err := db.GetAppPrefs(ctx, appName, int(u.ID))
|
||||
prefs, err := s.db.GetAppPrefs(ctx, appName, int(u.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -111,7 +113,9 @@ func (s *postgresStore) SetUserPrefs(ctx context.Context, username string, appNa
|
|||
return err
|
||||
}
|
||||
|
||||
db := database.FromCtx(ctx)
|
||||
|
||||
return db.SetAppPrefs(ctx, appName, prefs, int(u.ID))
|
||||
return s.db.SetAppPrefs(ctx, appName, prefs, int(u.ID))
|
||||
}
|
||||
|
||||
func (s *postgresStore) GetAPIKey(ctx context.Context, b64hash string) (database.GetAPIKeyRow, error) {
|
||||
return s.db.GetAPIKey(ctx, b64hash)
|
||||
}
|
||||
|
|
|
@ -66,6 +66,10 @@ type User struct {
|
|||
Prefs json.RawMessage
|
||||
}
|
||||
|
||||
func (u *User) GetName() string {
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func (u *User) GetRoles() []string {
|
||||
r := make([]string, 1, 2)
|
||||
|
||||
|
|
|
@ -156,3 +156,9 @@ CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
|
|||
c.duration > @longer_than
|
||||
) ELSE TRUE END)
|
||||
;
|
||||
|
||||
-- name: DeleteCall :exec
|
||||
DELETE FROM calls WHERE id = @id;
|
||||
|
||||
-- name: GetCallSubmitter :one
|
||||
SELECT submitter FROM calls WHERE id = @id;
|
||||
|
|
|
@ -175,3 +175,6 @@ RETURNING *;
|
|||
|
||||
-- name: DeleteIncident :exec
|
||||
DELETE FROM incidents CASCADE WHERE id = @id;
|
||||
|
||||
-- name: GetIncidentOwner :one
|
||||
SELECT owner FROM incidents WHERE id = @id;
|
||||
|
|
Loading…
Reference in a new issue