aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/mirumd/actor.go')
-rw-r--r--cmd/mirumd/actor.go252+252 −0
1 files changed, 252 insertions, 0 deletions
diff --git a/cmd/mirumd/actor.go b/cmd/mirumd/actor.go
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/actor.go
@@ -0,0 +1,252 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+ "context"
+ "errors"
+ "slices"
+
+ "dimidiumlabs/mirum/internal/protocol/pb"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+var (
+ ErrPermissionDenied = errors.New("database: permission denied")
+ ErrUnauthenticated = errors.New("database: authentication required")
+)
+
+var anonPermissions = []pb.Perm{
+ pb.Perm_PERM_ORG_READ,
+}
+
+var userGlobalPermissions = []pb.Perm{
+ pb.Perm_PERM_ORG_READ,
+ pb.Perm_PERM_ORG_WRITE,
+ pb.Perm_PERM_USER_READ,
+}
+
+// rolePermissions is the single source of truth for role → perm bundles.
+// RLS checks only tenancy (membership); action authz lives here.
+var rolePermissions = map[string][]pb.Perm{
+ "owner": {
+ pb.Perm_PERM_ORG_READ,
+ pb.Perm_PERM_ORG_WRITE,
+ pb.Perm_PERM_ORG_DELETE,
+ pb.Perm_PERM_ORG_MEMBER_READ,
+ pb.Perm_PERM_ORG_MEMBER_WRITE,
+ pb.Perm_PERM_WORKER_READ,
+ pb.Perm_PERM_WORKER_WRITE,
+ },
+ "admin": {
+ pb.Perm_PERM_ORG_READ,
+ pb.Perm_PERM_ORG_WRITE,
+ pb.Perm_PERM_ORG_MEMBER_READ,
+ pb.Perm_PERM_ORG_MEMBER_WRITE,
+ pb.Perm_PERM_WORKER_READ,
+ pb.Perm_PERM_WORKER_WRITE,
+ },
+ "member": {
+ pb.Perm_PERM_ORG_READ,
+ pb.Perm_PERM_ORG_MEMBER_READ,
+ pb.Perm_PERM_WORKER_READ,
+ },
+}
+
+// Actor is the principal making a database request. It carries identity,
+// display metadata, and coarse capability. Zero value is invalid: dbID
+// panics, so a missing initialisation cannot silently grant privileges.
+//
+// Synthetic actors (System/Operator/Anon) live only as Go constants —
+// they are not rows in the users table, so they cannot be logged in as
+// even if somebody writes a password into the DB.
+//
+// Authorization is divided into two planes:
+// - Tenancy: an actor can only see a subset of resources to which
+// they have access (public or through organization membership).
+// Any select statement will return only records accessible to the actor.
+// - RBAC: what the actor can do with records (create/read/write) is implemented here.
+// Any rights we grant here are a strict subset of the Tenancy rights.
+// The list of perms can be either explicit (for tokens) or implied (for user roles).
+type Actor struct {
+ kind actorKind
+ id uuid.UUID
+ email string
+ superuser bool
+}
+
+type actorKind uint8
+
+const (
+ actorInvalid actorKind = iota
+ actorUser
+ actorOperator
+ actorSystem
+ actorAnon
+)
+
+// ActorKind is the exported form of actorKind for audit sinks and logging.
+type ActorKind uint8
+
+const (
+ KindInvalid ActorKind = iota
+ KindUser
+ KindOperator
+ KindSystem
+ KindAnon
+)
+
+var (
+ anonUUID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff")
+ systemUUID = uuid.MustParse("00000000-0000-0000-0000-000000000001")
+ operatorUUID = uuid.MustParse("00000000-0000-0000-0000-000000000002")
+)
+
+// UserActor identifies an authenticated user from a session or token.
+func UserActor(id UserID, email string, superuser bool) Actor {
+ if id.IsZero() {
+ panic("database: UserActor with nil UUID")
+ }
+ if email == "" {
+ panic("database: UserActor with empty email")
+ }
+ return Actor{kind: actorUser, id: id.UUID(), email: email, superuser: superuser}
+}
+
+// OperatorActor is the principal for externally invoked privileged
+// operations (admin socket). Distinguishable from System in audit logs.
+func OperatorActor() Actor {
+ return Actor{kind: actorOperator, id: operatorUUID, email: "operator@mirum.local", superuser: true}
+}
+
+// SystemActor is the principal for internal machinery (mTLS handshake,
+// session bootstrap, background jobs). Not an operator action.
+func SystemActor() Actor {
+ return Actor{kind: actorSystem, id: systemUUID, email: "system@mirum.local", superuser: true}
+}
+
+// AnonActor is the principal for unauthenticated public requests.
+func AnonActor() Actor {
+ return Actor{kind: actorAnon, id: anonUUID, email: "anonymous@mirum.local"}
+}
+
+func (a Actor) Kind() ActorKind {
+ switch a.kind {
+ case actorUser:
+ return KindUser
+ case actorOperator:
+ return KindOperator
+ case actorSystem:
+ return KindSystem
+ case actorAnon:
+ return KindAnon
+ }
+ return KindInvalid
+}
+
+func (a Actor) UserID() UserID { return UserID(a.id) }
+func (a Actor) Email() string { return a.email }
+func (a Actor) IsSuperuser() bool { return a.superuser }
+
+// dbID returns the UUID to write into app.user_id. Panics on zero value.
+func (a Actor) dbID() uuid.UUID {
+ if a.id == uuid.Nil {
+ panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor")
+ }
+ return a.id
+}
+
+// kindString returns the string written into app.actor_kind.
+// It must match the values tested by app_issuper() in the SQL migration.
+func (a Actor) kindString() string {
+ switch a.kind {
+ case actorUser:
+ return "user"
+ case actorOperator:
+ return "operator"
+ case actorSystem:
+ return "system"
+ case actorAnon:
+ return "anon"
+ }
+ panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor")
+}
+
+// checkGlobal checks a global-scope perm (no specific org). Pure, no DB.
+func checkGlobal(actor Actor, perm pb.Perm) error {
+ switch actor.kind {
+ case actorOperator, actorSystem:
+ return nil
+ case actorAnon:
+ if slices.Contains(anonPermissions, perm) {
+ return nil
+ }
+ return ErrUnauthenticated
+ case actorUser:
+ if actor.superuser {
+ return nil
+ }
+ if slices.Contains(userGlobalPermissions, perm) {
+ return nil
+ }
+ return ErrPermissionDenied
+ default:
+ return ErrPermissionDenied
+ }
+}
+
+// checkPerm checks an org-scoped perm within an existing transaction.
+func checkPerm(ctx context.Context, tx pgx.Tx, actor Actor, orgID OrgID, perm pb.Perm) error {
+ switch actor.kind {
+ case actorOperator, actorSystem:
+ return nil
+ case actorAnon:
+ return ErrUnauthenticated
+ case actorUser:
+ if actor.superuser {
+ return nil
+ }
+ var role string
+ err := tx.QueryRow(ctx,
+ `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2`,
+ orgID, actor.id,
+ ).Scan(&role)
+ if err != nil {
+ return ErrPermissionDenied
+ }
+ if !slices.Contains(rolePermissions[role], perm) {
+ return ErrPermissionDenied
+ }
+ return nil
+ default:
+ return ErrPermissionDenied
+ }
+}
+
+// checkSystem checks that actor is the internal system principal.
+func checkSystem(actor Actor) error {
+ if actor.kind == actorSystem {
+ return nil
+ }
+ return ErrPermissionDenied
+}
+
+// checkSelf checks that actor is the target user or superuser.
+func checkSelf(actor Actor, targetID UserID) error {
+ switch actor.kind {
+ case actorOperator, actorSystem:
+ return nil
+ case actorAnon:
+ return ErrUnauthenticated
+ case actorUser:
+ if actor.superuser || targetID == actor.UserID() {
+ return nil
+ }
+ return ErrPermissionDenied
+ default:
+ return ErrPermissionDenied
+ }
+}