Documentation
¶
Index ¶
- func Can(ctx context.Context, permission Permission) bool
- func CanResource(ctx context.Context, capability Permission, resource Ref) bool
- func DeciderMiddleware(d Decider) func(http.Handler) http.Handler
- func GetRoles(ctx context.Context) []string
- func Middleware(policy *RolePolicy, roles func(ctx context.Context) []string) func(http.Handler) http.Handler
- func RequirePermission(permission Permission) func(http.Handler) http.Handler
- func ScopeMatch(granted []Permission, required Permission) bool
- func ValidScope(s string) bool
- func WithDecider(ctx context.Context, d Decider) context.Context
- func WithPolicy(ctx context.Context, policy *RolePolicy) context.Context
- func WithRoles(ctx context.Context, roles []string) context.Context
- type CachedResolver
- type CachedResolverOption
- type Decider
- type Decision
- type GrantStore
- func (s *GrantStore) EnsureSchema(ctx context.Context) error
- func (s *GrantStore) Grant(ctx context.Context, role string, perms ...Permission) error
- func (s *GrantStore) LoadInto(ctx context.Context, policy *RolePolicy) error
- func (s *GrantStore) Policy() *RolePolicy
- func (s *GrantStore) Revoke(ctx context.Context, role string, perms ...Permission) error
- func (s *GrantStore) SetFanout(f fanout.Fanout) (stop func(), err error)
- type GrantStoreOption
- type Permission
- type Policy
- type Ref
- type RolePolicy
- func (rp *RolePolicy) Can(ctx context.Context, permission Permission) bool
- func (rp *RolePolicy) Capabilities() []Permission
- func (rp *RolePolicy) Grant(role string, permissions ...Permission) error
- func (rp *RolePolicy) PermissionsOf(role string) []Permission
- func (rp *RolePolicy) Register(capabilities ...Permission)
- func (rp *RolePolicy) ReplaceRole(role string, perms ...Permission) error
- func (rp *RolePolicy) Revoke(role string, permissions ...Permission)
- func (rp *RolePolicy) Roles() []string
- func (rp *RolePolicy) Snapshot() map[string][]Permission
- func (rp *RolePolicy) StrictCapabilities() *RolePolicy
- type RoleWithOrigin
- type UnknownCapabilityError
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Can ¶
func Can(ctx context.Context, permission Permission) bool
Can reports whether the request context carries the given permission. It reads the RolePolicy and roles installed via WithPolicy / WithRoles (by access.Middleware or battery/auth). Returns false when no policy is present — the secure-by-default answer for an un-wired request. This is the seam the CRUD layer uses to enforce EntityConfig.Access.
func CanResource ¶ added in v0.30.0
func CanResource(ctx context.Context, capability Permission, resource Ref) bool
CanResource is the resource-aware capability check. It consults a Decider installed in ctx (via WithDecider / DeciderMiddleware) BEFORE the role policy:
- DecisionAllow → true (role policy not consulted)
- DecisionDeny → false (role policy not consulted)
- DecisionAbstain → falls through to Can
With no decider in ctx the answer is exactly Can(ctx, capability) — the fail-closed semantics of the coarse role policy are unchanged, so existing RBAC-only wiring is byte-identical. A nil context fails closed to false.
Can itself is untouched: there is no wildcard/segment logic in the hot path. The resource-aware path is a separate entrypoint the CRUD layer opts into.
func DeciderMiddleware ¶ added in v0.30.0
DeciderMiddleware installs a Decider into the request context so downstream CanResource calls (auto-CRUD permission gates, RequirePermission-style handlers) consult it. Mount it alongside access.Middleware:
app.Use(access.Middleware(policy, roles.Resolve)) app.Use(access.DeciderMiddleware(teamMaintainerDecider))
This shape — a sibling constructor returning the same func(http.Handler) http.Handler type — matches Middleware's. The package uses positional constructors (Middleware(policy, roles)), not an options pattern, so a DeciderMiddleware(d) wrapper is the consistent way to add the decider without changing Middleware's signature or introducing an options idiom for a single field.
func GetRoles ¶ added in v0.3.2
GetRoles reads back the roles installed via WithRoles (by access.Middleware or battery/auth). It is the reader half of the role-context seam — without it, role context is one-way (you can put roles in but not read them out), which blocks role-based UI branching (e.g. "show the admin nav only when the caller holds 'admin'").
Returns nil when ctx is nil or carries no roles — never panics. A nil context is treated as an anonymous request.
func Middleware ¶
func Middleware(policy *RolePolicy, roles func(ctx context.Context) []string) func(http.Handler) http.Handler
Middleware installs the RBAC policy and the request's roles into the context so downstream RequirePermission middleware and auto-CRUD permission gates (EntityConfig.Access) can resolve permissions. roles maps a request context to the caller's roles — typically by reading the authenticated user; pass nil to install only the policy (roles resolved elsewhere). Mount this once, app-wide or on a route group, ahead of any permission-gated routes.
func RequirePermission ¶
func RequirePermission(permission Permission) func(http.Handler) http.Handler
RequirePermission returns HTTP middleware that checks if the current user has the specified permission. Returns 403 if denied.
func ScopeMatch ¶ added in v0.31.0
func ScopeMatch(granted []Permission, required Permission) bool
ScopeMatch reports whether any granted permission satisfies the required permission using the resource:verb wildcard grammar shared by token scopes and module capability grants:
- exact match: "posts:read" grants "posts:read"
- resource wildcard: "posts:*" grants any "posts:<verb>"
- verb wildcard: "*:read" grants "<any-resource>:read"
- grant-all: "*:*" grants everything
Empty grants deny everything (secure-by-default), and a required value that does not parse as "resource:verb" is never satisfied.
ScopeMatch is PURE: it is a function of its two arguments only. It does NOT consult the capability registry and does NOT expand resource wildcards the way RolePolicy.Grant does at grant time — "teams:*" matches literally here, against whatever the caller passes as required. Grant-time expansion (teams:* → teams:read, teams:write, …) is a separate concern that lives in RolePolicy; matching and expanding are deliberately not entangled.
This is the matcher the module-grant side and battery/auth's token scopes delegate to, so the resource:verb algebra has exactly one home. access.Can (the RBAC hot path) is untouched: it performs exact-string-or-global-"*" matching and must stay that way — widening it would silently change live RBAC for every caller.
func ValidScope ¶ added in v0.31.0
ValidScope reports whether s is a well-formed "resource:verb" scope under the same closed vocabulary ScopeMatch and the token issuer use: each half is one or more of [a-z0-9_*-], separated by exactly one ':'. Both halves may be "*" so the wildcard scopes ScopeMatch documents ("posts:*", "*:read", "*:*") are mintable. Use this at install/mint time to reject malformed scope strings before they reach the matcher.
func WithDecider ¶ added in v0.30.0
WithDecider stores a Decider in the context, mirroring the WithPolicy / WithRoles pair. Downstream CanResource calls will consult it before the role policy. Installing a decider is strictly additive: with none in ctx, CanResource answers exactly what Can answers.
func WithPolicy ¶
func WithPolicy(ctx context.Context, policy *RolePolicy) context.Context
WithPolicy stores a RolePolicy in the context.
Types ¶
type CachedResolver ¶ added in v0.30.0
type CachedResolver struct {
// contains filtered or unexported fields
}
CachedResolver caches resolved roles by the authenticated user's ID. Contexts without a user exposing GetID() are resolved without caching so anonymous callers can never share an entry.
func NewCachedResolver ¶ added in v0.30.0
func NewCachedResolver(resolve func(context.Context) []string, opts ...CachedResolverOption) *CachedResolver
NewCachedResolver wraps a role resolver with per-user TTL caching. It derives the cache key from the authenticated user installed in core/handler context, the same identity seam used by battery/auth and access.Middleware resolvers.
func (*CachedResolver) Invalidate ¶ added in v0.30.0
func (r *CachedResolver) Invalidate(userID string)
Invalidate removes one user's cached roles. An in-flight result is not cached; calls waiting on it observe the invalidation and resolve again.
func (*CachedResolver) InvalidateAll ¶ added in v0.30.0
func (r *CachedResolver) InvalidateAll()
InvalidateAll removes every cached role resolution. In-flight results are not cached, and calls waiting on them resolve again.
type CachedResolverOption ¶ added in v0.30.0
type CachedResolverOption func(*CachedResolver)
CachedResolverOption configures a CachedResolver.
func WithTTL ¶ added in v0.30.0
func WithTTL(ttl time.Duration) CachedResolverOption
WithTTL sets the duration resolved roles remain cached. A zero or negative duration retains single-flight behavior but expires the result immediately.
type Decider ¶ added in v0.30.0
type Decider func(ctx context.Context, roles []string, capability Permission, resource Ref) Decision
Decider is consulted before the role policy when a resource-aware check (CanResource) runs. roles is the caller's resolved roles — the same slice access.Middleware / WithRoles install. The decider receives the capability and the Ref under check so it can express rules the coarse role policy cannot ("a team maintainer may edit their team's projects").
Return DecisionAbstain to fall through to Can. A nil/missing decider also falls through, so wiring a decider is strictly opt-in.
func GetDecider ¶ added in v0.30.0
GetDecider reads back the Decider installed via WithDecider (by DeciderMiddleware or an explicit WithDecider call). It is the reader half of the decider-context seam. Returns nil when ctx is nil or carries no decider — never panics.
type Decision ¶ added in v0.30.0
type Decision int
Decision is the verdict a Decider returns for one resource-scoped capability check. The zero value is DecisionAbstain so a decider that forgets to return falls through to the role policy rather than silently allowing or denying.
const ( // DecisionAbstain defers the check to the role policy (Can). Use it when // the decider has no opinion for this resource — e.g. the resource type is // not one it governs, or the caller holds a role it doesn't model. DecisionAbstain Decision = iota // DecisionAllow permits the check; the role policy is not consulted. DecisionAllow // DecisionDeny refuses the check even when the role policy would allow. DecisionDeny )
type GrantStore ¶ added in v0.20.0
type GrantStore struct {
// contains filtered or unexported fields
}
GrantStore persists role→permission grants to a database table so RBAC edits survive restarts. It wraps a live *RolePolicy: Grant/Revoke write the DB row AND mutate the in-memory policy in one call, keeping the two in sync. The policy's own RWMutex covers concurrent Can checks, so a Grant/Revoke call is "atomic enough" — a reader may see the state before or after the change, never a torn map.
The store holds a reference to the live *RolePolicy (store-holds-policy shape). Bind the policy at construction with NewGrantStore(db, policy), then call LoadInto once at boot to hydrate the policy from persisted rows. Subsequent Grant/Revoke calls mutate both layers.
All role and permission VALUES are passed as $n bound parameters — never interpolated into SQL. The table name is validated via query.SafeIdent at construction time and quoted via query.QuoteIdent in every statement.
Both SQLite (mattn/go-sqlite3) and PostgreSQL (lib/pq) accept $N placeholders and ON CONFLICT DO NOTHING, so the same SQL works on both.
func NewGrantStore ¶ added in v0.20.0
func NewGrantStore(db *sql.DB, policy *RolePolicy, opts ...GrantStoreOption) *GrantStore
NewGrantStore creates a GrantStore bound to the given policy. The policy reference is retained — Grant/Revoke mutate it directly so concurrent Can checks see the change without a reload. Call LoadInto once at boot to hydrate the policy from persisted rows.
A nil policy is allowed only if you intend to call LoadInto with a policy before any Grant/Revoke; Grant/Revoke on a store with a nil policy return an error.
func (*GrantStore) EnsureSchema ¶ added in v0.20.0
func (s *GrantStore) EnsureSchema(ctx context.Context) error
EnsureSchema creates the grants table if it does not already exist. Idempotent (CREATE TABLE IF NOT EXISTS). The column types (TEXT) are portable across SQLite and PostgreSQL. The (role, permission) pair has a UNIQUE constraint so INSERT ... ON CONFLICT DO NOTHING is a no-op for duplicates.
func (*GrantStore) Grant ¶ added in v0.20.0
func (s *GrantStore) Grant(ctx context.Context, role string, perms ...Permission) error
Grant validates and expands permissions, persists the resulting (role, permission) rows to the database (INSERT ... ON CONFLICT DO NOTHING), and then updates the live policy. Idempotent: granting an already-held permission is a no-op in both layers. In strict capability mode, validation happens before any database write.
Role and permission are bound as $n parameters — never interpolated.
func (*GrantStore) LoadInto ¶ added in v0.20.0
func (s *GrantStore) LoadInto(ctx context.Context, policy *RolePolicy) error
LoadInto reads all persisted grant rows and calls policy.Grant for each, hydrating the live *RolePolicy from the database. The policy is also retained as the store's active policy (overwriting any previously bound one) so subsequent Grant/Revoke calls mutate it. Call once at boot, after constructing the policy and after EnsureSchema.
If the store was constructed with a policy and policy is nil, the store's existing policy is used.
func (*GrantStore) Policy ¶ added in v0.20.0
func (s *GrantStore) Policy() *RolePolicy
Policy returns the live *RolePolicy the store mutates. May be nil if LoadInto has not yet been called and no policy was passed to NewGrantStore.
func (*GrantStore) Revoke ¶ added in v0.20.0
func (s *GrantStore) Revoke(ctx context.Context, role string, perms ...Permission) error
Revoke deletes (role, permission) rows from the database and then calls policy.Revoke on the live policy. Idempotent: revoking a permission the role doesn't hold is a no-op in both layers.
Role and permission are bound as $n parameters — never interpolated.
func (*GrantStore) SetFanout ¶ added in v0.36.0
func (s *GrantStore) SetFanout(f fanout.Fanout) (stop func(), err error)
SetFanout attaches a cross-replica fanout so grant/revoke propagate to other replicas and remote grant/revoke re-read authoritative state into the local policy. Mirrors the wiring shape of [island.Manager.SetFanout] and [ModuleManager.subscribeFanout]: store the backend, mint a node id, subscribe to accessFanoutTopic, and return the unsubscribe func as stop. Call once at boot; the returned stop is registered by the framework as an OnStop drainer. A nil fanout makes SetFanout a no-op returning a no-op stop, so callers can unconditionally wire it regardless of topology.
type GrantStoreOption ¶ added in v0.20.0
type GrantStoreOption func(*GrantStore)
GrantStoreOption configures a GrantStore.
func WithGrantTable ¶ added in v0.20.0
func WithGrantTable(name string) GrantStoreOption
WithGrantTable overrides the default table name ("access_grants"). The name is validated via query.SafeIdent — an unsafe identifier panics at construction time, not at query time.
type Permission ¶
type Permission string
Permission represents an action permission string (e.g. "posts:read", "posts:write").
const Wildcard Permission = "*"
Wildcard is the superuser permission: a role granted "*" passes every permission check. Grant it deliberately and only to fully-trusted, separately-gated surfaces (e.g. the admin back-office, which has its own Authorize gate) — never to an end-user role.
func GetPermissions ¶
func GetPermissions(ctx context.Context) []Permission
GetPermissions extracts the user's permissions from context by looking up the user's roles against the RolePolicy.
Returns nil if ctx is nil, missing a policy, or missing roles — never panics. A nil context is treated as an anonymous request rather than allowed to crash the handler.
type Policy ¶
type Policy interface {
Can(ctx context.Context, permission Permission) bool
}
Policy determines whether the subject in ctx holds a permission.
type Ref ¶ added in v0.30.0
Ref identifies the resource a capability check is about. Type is the entity name (e.g. "projects"); ID is the record id, or "" for a collection-level check (List, Create, a batch, or the SSE feed). The zero Ref carries no resource information — a decider that ignores empty Refs is effectively opting out of resource-awareness for that call.
type RolePolicy ¶
type RolePolicy struct {
// contains filtered or unexported fields
}
RolePolicy implements Policy using role-based permission grants.
Grant and Revoke may be called concurrently with Can / GetPermissions: the underlying role→permissions map is guarded by an RWMutex so reads don't block each other and writes won't trigger Go's concurrent-map fatal.
func PolicyFromContext ¶ added in v0.31.0
func PolicyFromContext(ctx context.Context) *RolePolicy
PolicyFromContext reads back the *RolePolicy installed via WithPolicy (by access.Middleware or battery/auth). It is the reader half of the policy-context seam — the symmetric pair to GetRoles. The process-module capability broker (framework/processmodule_broker.go, design #37 §5) uses it to snapshot the delegated caller's policy at delegation-mint time so the CrossOwnerRead carve-out can be checked on the reverse path without an app-wide policy reference.
Returns nil when ctx is nil or carries no policy — never panics.
func (*RolePolicy) Can ¶
func (rp *RolePolicy) Can(ctx context.Context, permission Permission) bool
Can checks if the user from ctx has the given permission via any of their roles. A role holding the Wildcard permission ("*") passes any check.
func (*RolePolicy) Capabilities ¶ added in v0.30.0
func (rp *RolePolicy) Capabilities() []Permission
Capabilities returns the policy's registered capabilities in sorted order. The returned slice is a defensive copy.
func (*RolePolicy) Grant ¶
func (rp *RolePolicy) Grant(role string, permissions ...Permission) error
Grant adds permissions to a role. Duplicate grants are ignored.
With a non-empty capability registry, resource wildcards such as "teams:*" expand to all registered capabilities with that prefix. Unknown grants warn by default and remain accepted for backward compatibility; a strict policy rejects them and returns an error.
func (*RolePolicy) PermissionsOf ¶ added in v0.20.0
func (rp *RolePolicy) PermissionsOf(role string) []Permission
PermissionsOf returns a defensive copy of the permissions granted to the given role. Returns nil when the role has no grants. Callers iterate the returned slice without holding the lock so a concurrent Grant/Revoke can't mutate it under them.
func (*RolePolicy) Register ¶ added in v0.30.0
func (rp *RolePolicy) Register(capabilities ...Permission)
Register adds capabilities to the policy's known capability registry. Registration is idempotent and safe to call concurrently with grants and permission checks.
func (*RolePolicy) ReplaceRole ¶ added in v0.36.0
func (rp *RolePolicy) ReplaceRole(role string, perms ...Permission) error
ReplaceRole atomically REPLACES the set of permissions held by role with perms (after the same prepare/validate/dedupe pass Grant uses). Unlike Grant, permissions absent from perms are dropped — the role ends up with exactly perms, nothing more. Use to re-sync a role's grants from an authoritative source (e.g. after a cross-replica invalidation re-reads the access_grants table). Honors strictCapabilities exactly like Grant: an unknown capability in perms is rejected and the role is left unchanged.
func (*RolePolicy) Revoke ¶
func (rp *RolePolicy) Revoke(role string, permissions ...Permission)
Revoke removes specific permissions from a role.
func (*RolePolicy) Roles ¶ added in v0.20.0
func (rp *RolePolicy) Roles() []string
Roles returns the sorted list of all roles that currently have at least one granted permission. The slice is a defensive copy — callers can iterate it without holding the lock. Intended for admin UIs that need to enumerate the grant matrix; not used on the hot Can path.
func (*RolePolicy) Snapshot ¶ added in v0.36.0
func (rp *RolePolicy) Snapshot() map[string][]Permission
Snapshot returns a deep copy of the current role→permissions map. Used by GrantStore to capture the code-defined baseline grants (before DB grants are overlaid) so a cross-replica refresh can merge baseline ∪ DB instead of replacing a role with only its DB rows.
func (*RolePolicy) StrictCapabilities ¶ added in v0.30.0
func (rp *RolePolicy) StrictCapabilities() *RolePolicy
StrictCapabilities makes unknown capability grants fail instead of warning. It returns the policy so callers can opt in while constructing it.
type RoleWithOrigin ¶ added in v0.30.0
RoleWithOrigin identifies an effective role and where it came from, such as a direct user assignment or a resolved organization membership.
type UnknownCapabilityError ¶ added in v0.30.0
type UnknownCapabilityError struct {
Grant Permission
Nearest Permission
}
UnknownCapabilityError reports a strict-mode grant of a capability that is not in the policy's registry. Handlers can errors.As on it to present the message as a caller mistake (e.g. HTTP 400) rather than a server fault.
func (*UnknownCapabilityError) Error ¶ added in v0.30.0
func (e *UnknownCapabilityError) Error() string