Documentation
¶
Overview ¶
Package authz is wowapi's authorization kernel: a deny-by-default evaluator that layers RBAC (role→permission assignments), ReBAC (relationship-derived grants), and ABAC (attribute policies, deny-first) exactly as specified in blueprint 01 §3. The evaluator is pure over a Store port so it is unit- testable without a database; the Postgres-backed Store lives alongside.
Two invariants are structural, not configurable:
- deny by default: no matching grant → denied;
- permission must be registered: evaluating an unregistered permission is a programming error (surfaced at boot when routes/permissions register), never a silent runtime allow.
Index ¶
- Variables
- type Actor
- type ActorKind
- type Assignment
- type AuditSink
- type CachingStore
- func (c *CachingStore) ActiveAssignments(ctx context.Context, db database.TenantDB, a Actor, at time.Time) ([]Assignment, error)
- func (c *CachingStore) Invalidate(tenantID, capacityOrUserID uuid.UUID)
- func (c *CachingStore) InvalidateAll()
- func (c *CachingStore) InvalidateTenant(tenantID uuid.UUID)
- func (c *CachingStore) OrgAncestors(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
- func (c *CachingStore) OrgSubtree(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
- func (c *CachingStore) Policies(ctx context.Context, db database.TenantDB, a Actor, perm, rt string) ([]Policy, error)
- func (c *CachingStore) ResourceOrg(ctx context.Context, db database.TenantDB, ref resource.Ref) (uuid.UUID, error)
- type Condition
- type Decision
- type Evaluator
- type ListFilter
- type Options
- type Permission
- type PgStore
- func (s *PgStore) ActiveAssignments(ctx context.Context, db database.TenantDB, a Actor, at time.Time) ([]Assignment, error)
- func (s *PgStore) OrgAncestors(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
- func (s *PgStore) OrgSubtree(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
- func (s *PgStore) Policies(ctx context.Context, db database.TenantDB, a Actor, perm, rt string) ([]Policy, error)
- func (s *PgStore) ResourceOrg(ctx context.Context, db database.TenantDB, ref resource.Ref) (uuid.UUID, error)
- type Policy
- type PolicyEffect
- type PolicyEngine
- type Registry
- type RelationshipChecker
- type ScopeKind
- type StepUpPolicy
- type Store
- type Target
Constants ¶
This section is empty.
Variables ¶
var DefaultStrongFactors = []string{"mfa", "otp", "totp", "hwk", "fpt", "face"}
DefaultStrongFactors is the out-of-the-box default strong-factor AMR set: AMR values that count as an elevated (second) authentication factor. "mfa" is the OIDC aggregate; the rest are common specific methods. "sms" is deliberately excluded — SMS-based step-up is opt-in only (Decision 5, framework-engineering-backlog B8): a deployment adds it back by listing it in Options.StrongFactors / kernel.Deps.StepUpStrongFactors.
Functions ¶
This section is empty.
Types ¶
type Actor ¶
type Actor struct {
Kind ActorKind
UserID uuid.UUID
CapacityID uuid.UUID // zero for system/webhook actors
System string // "outbox-relay", "webhook:payments"
TenantID uuid.UUID
// ImpersonatorUserID is set when a support actor impersonates a user; both
// identities are audited and impersonation is policy-restricted (01 §3).
ImpersonatorUserID uuid.UUID
// BreakGlass marks an actor operating under an activated break-glass grant;
// every decision it produces is audited and bannered.
BreakGlass bool
// Scopes is the explicit permission set of a machine principal (API key /
// service principal). It is meaningful only for ActorSystem actors: a scope
// authorizes like an RBAC grant but remains subject to ABAC deny policies.
// Human and internal-system actors leave it empty and are unaffected.
Scopes []string
// AMR is the authentication-methods-references set surfaced from the IdP token
// (e.g. ["pwd","mfa","otp"]). It drives step-up enforcement and the env.mfa
// ABAC attribute (roadmap S3).
AMR []string
}
Actor is the authenticated principal for an authorization decision. For a human it carries the user and their active capacity in the tenant; for a non-human it carries a system identifier.
type Assignment ¶
type Assignment struct {
ID uuid.UUID
RoleKey string
ScopeKind ScopeKind
ScopeID uuid.UUID // org id (org scope) or resource id (resource scope); zero for tenant
ScopeType string // resource_type key when ScopeKind == ScopeResourceType
Perms []string // permission keys the role grants
}
Assignment is one active role grant at a scope, with the role's permission keys pre-joined so the evaluator needs no second query. Loaded by Store.
type AuditSink ¶
type AuditSink interface {
AuthzDenial(ctx context.Context, a Actor, perm string, t Target, reason string)
}
AuditSink records authorization denials that must be audited (sensitive permission denials and explicit policy denies — 01 §3 step 7). The durable audit_logs writer lands in Phase 6; Phase 4 wires this port and a capturing test fake so the "denials audited" guarantee is testable now.
type CachingStore ¶
type CachingStore struct {
// contains filtered or unexported fields
}
CachingStore is an OPT-IN decorator that caches the hot authorization read — ActiveAssignments — per (tenant, actor) for a short TTL, so a burst of requests from one principal does not hit the DB on every Evaluate (roadmap R1). It is a pure add-on: unwrapped, the evaluator behaves exactly as before.
Correctness against stale-allow: a role grant/revoke MUST call Invalidate (or InvalidateTenant) so the change takes effect immediately on this pod; the TTL is only the cross-pod bound (invalidation is in-process). The other Store reads (org ancestry, policies, resource org) pass straight through — they are not on the per-actor hot path and caching them would widen the invalidation surface.
Read-replica routing (the second half of R1) is a deployment concern: point the Manager's read-only path (WithTenantRO) at a replica pool; the evaluator already runs its reads in that read-only transaction.
func NewCachingStore ¶
func NewCachingStore(inner Store, ttl time.Duration) *CachingStore
NewCachingStore wraps inner with a per-actor ActiveAssignments cache of the given TTL. A TTL <= 0 defaults to 1s (keep it short — it bounds cross-pod staleness after a revocation on another pod).
func (*CachingStore) ActiveAssignments ¶
func (c *CachingStore) ActiveAssignments(ctx context.Context, db database.TenantDB, a Actor, at time.Time) ([]Assignment, error)
ActiveAssignments returns the actor's assignments from cache when fresh, else loads and caches them. Returned slices are cloned so a caller cannot mutate the cached entry.
func (*CachingStore) Invalidate ¶
func (c *CachingStore) Invalidate(tenantID, capacityOrUserID uuid.UUID)
Invalidate drops one actor's cached assignments — call it right after changing that actor's role assignments so a revocation takes effect immediately.
func (*CachingStore) InvalidateAll ¶
func (c *CachingStore) InvalidateAll()
InvalidateAll drops the entire cache. It is the correct invalidation for a GLOBAL authorization-spine write — a seed sync of platform roles / their role_permissions — because those rows are cross-tenant: a changed role and its grants may be held by actors in ANY tenant, and the cached ActiveAssignments pre-join role_permissions, so a permission added to (or pruned from) a role is otherwise served stale until the TTL. seeds.Sync calls this after its writes commit when a live cache is wired (CA-2), so a spine change takes effect on this pod immediately rather than after the TTL.
func (*CachingStore) InvalidateTenant ¶
func (c *CachingStore) InvalidateTenant(tenantID uuid.UUID)
InvalidateTenant drops every cached actor for a tenant — for a bulk role change (role definition edit, mass revoke).
func (*CachingStore) OrgAncestors ¶
func (*CachingStore) OrgSubtree ¶
type Condition ¶
type Condition struct {
Attribute string // "resource.status", "actor.relationship", "env.time_of_day"
Op string // eq|neq|in|not_in|contains|within|gte|lte
Value json.RawMessage // comparison operand
}
Condition is one ABAC predicate over the attribute bag.
type Decision ¶
type Decision struct {
Allowed bool
Reason string
PolicyIDs []uuid.UUID
// StepUpRequired is set when the actor would be allowed but the permission
// demands an elevated auth factor the actor has not satisfied (roadmap S3).
// The HTTP gate turns this into a re-authentication challenge rather than a
// flat 403.
StepUpRequired bool
// StepUpChallenge names the factor/hint to advertise in the step-up
// challenge (WWW-Authenticate's `step_up="…"` parameter) when
// StepUpRequired is set — the permission's StepUpPolicy.Challenge, or the
// deployment's configured default challenge for the plain `step_up: true`
// shorthand. Empty when StepUpRequired is false.
StepUpChallenge string
}
Decision is the outcome of Evaluate. Reason names the matched grant/policy for audit ("role:requests.org.approver", "rel:core.owner_of", "policy:deny_locked"); it is safe to log.
type Evaluator ¶
type Evaluator interface {
// Evaluate returns whether actor a may exercise permission perm on target t.
Evaluate(ctx context.Context, db database.TenantDB, a Actor, perm string, t Target) (Decision, error)
// Filter returns the record-level constraint for listing resources of type
// rt that a may exercise perm on.
Filter(ctx context.Context, db database.TenantDB, a Actor, perm string, rt string) (ListFilter, error)
}
Evaluator is the authorization decision port modules receive. Both methods take the caller's tenant TenantDB so every authorization read runs in the SAME transaction (and MVCC snapshot) as the request's business writes — an authz check right after a mirror-row write must see that write, and the hot path must not open extra connections (review finding ARCH-36).
type ListFilter ¶
type ListFilter struct {
All bool // true → no record-level restriction (tenant RLS still applies)
// OrgIDs, when non-nil, restricts to resources in these orgs.
OrgIDs []uuid.UUID
// ResourceIDs, when non-nil, restricts to these specific resource ids
// (e.g. relationship-derived visibility).
ResourceIDs []uuid.UUID
}
ListFilter is the structured constraint Filter returns so list queries embed authorization in SQL instead of loading-then-filtering. An empty filter with All=true means unrestricted (a tenant-wide grant); All=false with no constraints means "deny all" (no rows visible).
type Options ¶
type Options struct {
Store Store
Relationships RelationshipChecker
Registry *Registry
Policies PolicyEngine
Audit AuditSink
Now func() time.Time
// StrongFactors is the deployment-configurable default strong-factor AMR
// set used by the `step_up: true` shorthand (a permission with no
// StepUpPolicy). Empty/nil uses DefaultStrongFactors (mfa, otp, totp, hwk,
// fpt, face — "sms" is EXCLUDED by default, per Decision 5: SMS-based
// step-up is opt-in only, added by naming "sms" explicitly here). This is
// the config surface a deployment overrides WITHOUT code changes (wired
// via kernel.Deps.StepUpStrongFactors — see kernel/kernel.go).
StrongFactors []string
// DefaultChallenge is the factor/hint advertised in WWW-Authenticate for a
// permission using the default strong-factor set (no per-permission
// StepUpPolicy.Challenge). Empty defaults to "mfa".
DefaultChallenge string
}
Options configures New. Store, Registry, and PolicyEngine are required; RelationshipChecker and AuditSink may be nil (ReBAC/denial-audit disabled).
type Permission ¶
type Permission struct {
Key string
Sensitive bool
GrantedVia string // relationship type key, or "" for none
// StepUp requires the actor to have satisfied an elevated authentication
// factor (MFA) for this permission: an otherwise-allowed decision becomes a
// step-up challenge when the actor's AMR carries no strong factor (roadmap
// S3). MFA itself is the IdP's job; this gates on the surfaced amr claim.
// This is the persisted shorthand (permissions.step_up) — "require ANY
// factor from the deployment's configured default strong-factor set".
StepUp bool
// StepUpPolicy, when non-nil, REPLACES the default-set behavior of StepUp
// with a permission-specific requirement (e.g. "require hwk specifically").
// It is declared by a seed's richer step_up form (kernel/seeds) and lives
// only in this in-memory, boot-populated registry — it is NOT persisted
// (permissions.step_up remains a plain bool; see kernel/seeds doc comment
// on PermissionSeed.StepUpAMR for the rationale). A permission with
// StepUpPolicy set is treated as StepUp-gated regardless of the StepUp bool.
StepUpPolicy *StepUpPolicy
}
Permission is a registered permission. GrantedVia, when set, declares the ReBAC rule "this permission is granted on a resource target to any actor that has the named relationship to it" (01 §3 step 4).
type PgStore ¶
type PgStore struct{}
PgStore is the Postgres-backed authz.Store. Every method runs on the caller's TenantDB — the request's own tenant transaction — so all authz reads share one MVCC snapshot with the request's writes and open no extra connections (review finding ARCH-36). PgStore is therefore stateless. RLS scopes reads to the tenant; global rows (platform roles/policies with tenant_id IS NULL) are admitted by their read policy.
func (*PgStore) ActiveAssignments ¶
func (s *PgStore) ActiveAssignments(ctx context.Context, db database.TenantDB, a Actor, at time.Time) ([]Assignment, error)
ActiveAssignments loads the actor's active role assignments with each role's permission keys aggregated. The actor is matched by capacity_id (human) or system_actor (non-human); temporal validity is [valid_from, valid_to) at at.
func (*PgStore) OrgAncestors ¶
func (s *PgStore) OrgAncestors(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
OrgAncestors returns orgID then each ancestor walking parent_org_id upward (self-first). The recursive CTE is cycle-guarded (SEC-30): UNION dedups and a depth cap stops a malicious/broken org cycle from running away.
func (*PgStore) OrgSubtree ¶
func (s *PgStore) OrgSubtree(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
OrgSubtree returns orgID then all descendant org ids (walking children downward), cycle-guarded like OrgAncestors.
func (*PgStore) Policies ¶
func (s *PgStore) Policies(ctx context.Context, db database.TenantDB, a Actor, perm, rt string) ([]Policy, error)
Policies returns active policies applicable to perm on resource type rt, with their conditions, ordered by priority ascending. A policy applies when its applies_to_permission matches (or is NULL = any permission) and its applies_to_resource_type matches (or is NULL = any type). When rt is empty (a check with no resource type) ONLY type-agnostic policies apply — a policy bound to a specific resource type must never leak into a typeless check (review finding SEC-27).
func (*PgStore) ResourceOrg ¶
func (s *PgStore) ResourceOrg(ctx context.Context, db database.TenantDB, ref resource.Ref) (uuid.UUID, error)
ResourceOrg returns the org id owning a resource (RLS-scoped), or the zero uuid when the resource is unknown or has no org. The resource type is matched too, so a mismatched {Type, ID} does not silently resolve a wrong org (review finding ARCH-46).
type Policy ¶
type Policy struct {
ID uuid.UUID
Key string
Effect PolicyEffect
Priority int // lower first
Conditions []Condition
}
Policy is an active ABAC policy applicable to a permission/resource type.
type PolicyEffect ¶
type PolicyEffect string
PolicyEffect is allow or deny.
const ( EffectAllow PolicyEffect = "allow" EffectDeny PolicyEffect = "deny" )
type PolicyEngine ¶
type PolicyEngine interface {
// Matches reports whether every condition holds for the given attributes.
Matches(conds []Condition, attrs map[string]any) (bool, error)
}
PolicyEngine evaluates an ABAC policy's conditions against the attribute bag. Implemented by kernel/policy; injected so the evaluator stays free of the condition-matching detail.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry is the boot-time permission catalog. Evaluating a permission absent from the registry is a programming error, so registration is validated and its Err() must gate boot — an unknown permission can never silently allow.
func (*Registry) Get ¶
func (r *Registry) Get(key string) (Permission, bool)
Get returns the permission definition.
func (*Registry) Keys ¶
Keys returns all registered permission keys, sorted (for seed sync + tests).
func (*Registry) Register ¶
func (r *Registry) Register(p Permission)
Register adds a permission. Malformed keys, unknown action verbs, and duplicates are recorded as errors surfaced by Err().
type RelationshipChecker ¶
type RelationshipChecker interface {
Has(ctx context.Context, db database.TenantDB, subject Actor, relType string, obj resource.Ref, at time.Time) (bool, error)
}
RelationshipChecker answers ReBAC questions: does subject stand in relation relType to obj at time at. Implemented by kernel/relationship; runs on the caller's tenant tx.
type StepUpPolicy ¶ added in v1.1.0
type StepUpPolicy struct {
// RequiredAMR is the set of AMR values that satisfy this permission's
// step-up gate; the actor needs ANY ONE of them. Empty means "fall back to
// the deployment's configured default strong-factor set" (the StepUp bool
// shorthand's behavior).
RequiredAMR []string
// Challenge is the factor/hint advertised in the step-up challenge's
// WWW-Authenticate header (e.g. `step_up="hwk"`). Empty falls back to the
// deployment's default challenge hint.
Challenge string
}
StepUpPolicy is a permission-specific step-up requirement: the actor must present at least one AMR value from RequiredAMR (any-of — the usual step-up semantic: any single elevated factor satisfies the gate, factors are not required in combination). Challenge names the factor/hint the HTTP gate advertises in WWW-Authenticate (e.g. "hwk", "mfa").
Scope (Decision 4, framework-engineering-backlog B8): this is AMR-only. The production IdP's ability to reliably emit `auth_time` could not be confirmed from the codebase, so no MaxAge/freshness field exists here. The struct is shaped so a MaxAge *time.Duration could be added later as an additive field without breaking existing callers — but that is explicitly out of scope now.
type Store ¶
type Store interface {
// ActiveAssignments returns the actor's active assignments (role perms
// joined) at time at.
ActiveAssignments(ctx context.Context, db database.TenantDB, a Actor, at time.Time) ([]Assignment, error)
// OrgAncestors returns orgID and all its ancestor org ids (self-first), so
// the evaluator can decide whether an org-scoped assignment (a grant at an
// ancestor org) covers a target in orgID. Empty/zero orgID → empty.
OrgAncestors(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
// OrgSubtree returns orgID and all descendant org ids, for building list
// filters from org-scoped assignments.
OrgSubtree(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)
// Policies returns active policies applicable to perm on resource type rt,
// ordered by priority ascending (evaluator applies deny-first anyway).
Policies(ctx context.Context, db database.TenantDB, a Actor, perm, rt string) ([]Policy, error)
// ResourceOrg returns the org id owning a resource (for org-scope checks on
// a resource target), or zero if none/unknown.
ResourceOrg(ctx context.Context, db database.TenantDB, ref resource.Ref) (uuid.UUID, error)
}
Store loads the authorization facts for a decision. It is the only DB seam; the evaluator is pure over it, so unit tests use an in-memory fake. Every method runs on the caller's TenantDB — the request's own tenant transaction — so all reads share one snapshot and see the request's uncommitted writes, and no extra connections are opened on the hot path (ARCH-36). RLS scopes reads; global rows (platform roles/policies, tenant_id IS NULL) are admitted by their read policy.