authz

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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 ActorKind

type ActorKind string

ActorKind enumerates who is acting.

const (
	ActorUser    ActorKind = "user"
	ActorSystem  ActorKind = "system"
	ActorWebhook ActorKind = "webhook"
)

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 (c *CachingStore) OrgAncestors(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)

func (*CachingStore) OrgSubtree

func (c *CachingStore) OrgSubtree(ctx context.Context, db database.TenantDB, orgID uuid.UUID) ([]uuid.UUID, error)

func (*CachingStore) Policies

func (c *CachingStore) Policies(ctx context.Context, db database.TenantDB, a Actor, perm, rt string) ([]Policy, error)

func (*CachingStore) ResourceOrg

func (c *CachingStore) ResourceOrg(ctx context.Context, db database.TenantDB, ref resource.Ref) (uuid.UUID, error)

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).

func New

func New(o Options) Evaluator

New builds an Evaluator. It panics on missing required collaborators — that is a wiring error at composition, not a runtime condition.

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 NewStore

func NewStore() *PgStore

NewStore builds the authz store.

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 NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty permission registry.

func (*Registry) Err

func (r *Registry) Err() error

Err returns accumulated registration errors joined, or nil.

func (*Registry) Get

func (r *Registry) Get(key string) (Permission, bool)

Get returns the permission definition.

func (*Registry) Has

func (r *Registry) Has(key string) bool

Has reports whether key is registered.

func (*Registry) Keys

func (r *Registry) Keys() []string

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 ScopeKind

type ScopeKind string

ScopeKind is the granularity of an authorization target.

const (
	ScopeTenant       ScopeKind = "tenant"
	ScopeOrg          ScopeKind = "org"
	ScopeResourceType ScopeKind = "resource_type"
	ScopeResource     ScopeKind = "resource"
)

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.

type Target

type Target struct {
	Scope    ScopeKind
	OrgID    uuid.UUID
	Resource resource.Ref
}

Target is what an actor wants to act upon.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL