Access control
GoFastr's access control is permission-based with role-based grants.
The framework gives you the building blocks; wiring permissions to
users is your responsibility (typically in an auth middleware).
Quickstart
policy := framework.NewRolePolicy()policy.Grant("admin", "posts:read", "posts:write", "posts:delete")policy.Grant("editor", "posts:read", "posts:write")policy.Grant("reader", "posts:read")app.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := framework.WithPolicy(r.Context(), policy) ctx = framework.WithRoles(ctx, currentUserRoles(r)) next.ServeHTTP(w, r.WithContext(ctx)) })})app.Router().Post("/posts", framework.RequirePermission("posts:write")(http.HandlerFunc(postsHandler)))
The policy + roles wiring above is common enough that there's a one-liner
for it — framework.AccessMiddleware:
app.Use(framework.AccessMiddleware(policy, currentUserRoles))// currentUserRoles has signature func(ctx context.Context) []string
Gating auto-CRUD (Exposure.Access)
RequirePermission gates routes you mount yourself. To gate the
auto-generated CRUD for an entity, declare the permission for each
operation on the entity config:
app.Entity("posts", framework.EntityConfig{ Exposure: &framework.ExposureConfig{ Access: framework.AccessControl{ Read: "posts:read", // List + Get Create: "posts:write", Update: "posts:write", Delete: "posts:delete", }, },})
Each blank field leaves that operation un-gated by RBAC (owner and tenant
scoping still apply). When a field is set, auto-CRUD refuses a request
whose context lacks the permission with 403 — on List, Get, Create,
Update, Delete, the batch/stream variants, and the _events SSE feed. The
roles + policy must be in the request context first; mount
framework.AccessMiddleware (above) ahead of the CRUD routes.
The generated OpenAPI spec (/openapi.json) advertises 401 (authentication
required) and 403 (authenticated but forbidden) on every operation of an
RBAC-gated entity — including the _batch and _events endpoints. This means
generated SDKs and agents see the correct error contract instead of treating
RBAC-gated routes as public.
The spec also declares how callers authenticate. Auto-CRUD is
secure-by-default (see security → "Default CRUD
authentication"), so every entity is auth-gated in the spec — owner-scoped,
multi-tenant, RBAC-gated, or just the plain default session requirement —
UNLESS it declares Public: true. Every gated operation carries
components.securitySchemes with two schemes it accepts: bearerAuth
(HTTP bearer, JWT) and cookieAuth (the auth battery's session cookie),
listed in a per-operation security block — either scheme authorises the
call. Only Public: true entities are left unmarked, so clients and
codegen correctly treat exactly those (and nothing else) as publicly
reachable. Auth is per-operation, not global: the spec never sets a
top-level security requirement.
The cookieAuth name is the auth battery's production default
(__Host-session, set in battery/auth AuthConfig.defaults()); DevMode
flips it to session_id. If your deployment overrides
AuthConfig.SessionCookie, overwrite the scheme after building the spec —
Spec.SetSecurityScheme("cookieAuth", …) replaces it by name.
Important —
Exposure.Accessis HTTP-only. It gates the HTTP
CRUD routes, not in-process repository orCrudHandlercalls. This is
intentional: in-process Go code is trusted, while owner and tenant
isolation still apply at the data layer. SSR screens do not inherit
Exposure.Accesschecks automatically. Enforce per-row rules for SSR
in lifecycle hooks or explicit screen/handler checks before calling
CrudHandler.CreateOne/UpdateOne/DeleteOne/GetOne/ListAll/UpsertOneor a
generated typed repo. For deliberate cross-owner reads, use
owner.AllowCrossOwner(ctx)only from trusted server-side Go; HTTP CRUD
has no path to it. See entity-declarations →
"Reading across owners".Declarative cross-owner read.
Scope.CrossOwnerReadnames a
permission that, when held by the request context, lifts owner scoping
for READ operations only on that entity — letting a staff or admin role
see every owner's rows on List/Get/Count while writes stay scoped. It
is fail-closed (no policy ⇒ no widening) and requiresOwnerField. See
entity-declarations → "Letting a role read
every owner's rows".
Before this existed, exposing an entity granted every authenticated
user full CRUD unless you hand-composed route-group middleware.
Exposure.Accessmakes the requirement visible at the declaration
and enforced by default.
Concepts
- Permission — string capability. By convention
"<resource>:<verb>"
("posts:read","users:delete"). A capability registry can validate
grants, but the registry is optional and the string format remains yours. - Role — string key that holds a list of permissions.
- Policy — maps role → permissions.
RolePolicyis the shipped
implementation; thePolicyinterface lets you swap in your own.
The framework never asks who the user is — only what permissions
their context carries. Get the roles into context however you want:
JWT claims, session cookie, API key lookup. Service accounts (see
Authentication → Service accounts & API tokens)
hold roles exactly like users and flow through the same Policy.Can
path; an API token's scopes (posts:read, *:*) are an additional
token-level restriction layered on top, enforced by auth.HasScope /
auth.RequireScope — independent of the role/permission model here.
API
Building a policy
Register the capabilities the application checks, then grant them to roles:
p := framework.NewRolePolicy()p.Register("users:read", "users:write", "teams:read", "teams:write")if err := p.Grant("admin", access.Wildcard); err != nil { return err}if err := p.Grant("editor", "users:read", "users:write"); err != nil { return err}p.Revoke("editor", "users:write")caps := p.Capabilities() // sorted defensive copy
Wildcard is the one symbol here that is not re-exported on the
framework facade — import github.com/DonaldMurillo/gofastr/framework/access
for it.
Register is idempotent and thread-safe. Grant ignores duplicate entries.
With a non-empty registry, an unknown grant is accepted for backward
compatibility but emits a slog warning naming the grant, its nearest
registered capability, and that it will never match a registered gate.
Opt into rejection when configuration mistakes must stop startup:
p := framework.NewRolePolicy().StrictCapabilities()p.Register("users:read", "users:write")if err := p.Grant("editor", "usres:write"); err != nil { return err // rejected; nothing was granted}
Strict rejections are typed: errors.As(err, &e) with
*access.UnknownCapabilityError distinguishes a caller's typo (e.Grant,
e.Nearest) from a real store failure, so handlers can answer 400 instead of
500 — the admin grant screen does exactly this.
The global access.Wildcard ("*") remains the superuser grant. A
resource wildcard such as "teams:*" is different: with a non-empty
registry, Grant expands it immediately to every registered capability with
the "teams:" prefix, and Can continues to perform exact matching. The
wildcard itself is not retained. GrantStore.Grant persists those expanded
rows, and LoadInto expands old wildcard rows while loading them.
With an empty registry, ordinary grants keep the previous behavior and emit
no warning. A non-global grant containing * cannot expand, so it emits the
loud warning and remains stored for compatibility; strict mode rejects it.
Register capabilities before using resource wildcards.
Attaching to a request
ctx = framework.WithPolicy(ctx, policy)ctx = framework.WithRoles(ctx, []string{"editor", "reader"})
Both calls are required. Without them, every permission check
denies — fail-closed.
Checking from a handler
perms := framework.GetPermissions(ctx)// [posts:read posts:write …]
To branch UI (or any logic) on the caller's roles rather than their
resolved permissions, read the roles back with GetRoles:
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/framework"
import "context"
var ctx = context.Background()
import "slices"
-->
roles := framework.GetRoles(ctx)// [editor reader] — the same slice installed by WithRolesif slices.Contains(roles, "admin") { // render the admin-only nav}
GetRoles is the reader half of the role-context seam: WithRoles
puts roles in, GetRoles reads them back. It returns nil for a nil
context or one carrying no roles — never panics, so it is safe to call
on an un-wired (anonymous) request. Permission checks should still go
through GetPermissions / Can; GetRoles is for role-shaped
branching where the permission grant map isn't the right granularity.
Caching role resolution
access.NewCachedResolver wraps the role lookup function passed to
access.Middleware:
roles := access.NewCachedResolver( func(ctx context.Context) []string { user := auth.GetCurrentUser(ctx) if user == nil { return nil } return loadEffectiveRoles(ctx, user.GetID()) }, access.WithTTL(30*time.Second),)app.Use(access.Middleware(policy, roles.Resolve))
The default TTL is 30 seconds. Cache keys come from the authenticated value in
core/handler context when it implements GetID() string, which is the same
user seam populated by battery/auth. Missing or empty user IDs resolve
without caching, so anonymous requests never share role state. Concurrent
misses for one user share one lookup. Resolve returns defensive copies;
call Invalidate(userID) after changing one user's role inputs or
InvalidateAll() after a global role-policy change. A zero or negative TTL
keeps same-key single-flight behavior but does not retain results.
Or via middleware on a specific route:
app.Router().Delete("/posts/{id}", framework.RequirePermission("posts:delete")(http.HandlerFunc(postsHandler)))
RequirePermission returns 403 access denied: missing permission X
when the user does not hold the named permission. The error format
is JSON via core/handler.WriteError.
The Policy interface
type Policy interface { Can(ctx context.Context, permission Permission) bool}
The check takes only the ctx and the permission string — there is
no resource argument. Everything a policy needs (subject, roles,
tenant, request metadata) travels in the context. Implement this to
plug in:
- Database-backed permission lookups.
- External authorisation services (OPA, etc.).
RolePolicy is the shipped implementation; it resolves the roles
installed via WithRoles against its grant map.
Row-level ("can user X update post Y?") checks
The Policy interface is coarse-grained — it answers "does this
context hold permission P?", not "may this context act on record R?".
There is no resource argument, so per-record decisions are made
elsewhere:
- Owner scoping — set
Scope.OwnerFieldso auto-CRUD only
ever reads/writes rows owned by the caller. See
entity-declarations.md → "Per-user scoping". BeforeCreate/BeforeUpdate/BeforeDeletehooks — these run
with the candidate record (and, for updates, the patch) in hand, so
they can deny per-row. Return an error from the hook to reject. This is
the supported seam for "can user X update post Y?".
Keep coarse permission checks in the Policy and put record-aware logic
in a hook or owner scoping — don't try to smuggle the resource through
Can.
Where to apply checks
Two patterns, both supported:
- Per-route middleware —
RequirePermissionis one line per
route, easy to audit, but disconnects the permission name from
the entity declaration. - In a
BeforeCreate/BeforeUpdatehook — closer to the
data, can inspect the patch, can deny per-record. More code; use
when row-level checks matter.
The framework does not auto-generate permission strings from
entity declarations. Pick a convention (posts:read, posts:write,
…) and apply it consistently.
Surfaces other than the CRUD routes
The CRUD routes enforce every gate for the entity they serve. Two things do
NOT inherit that automatically, and both have to check for themselves:
- Another surface reading the same rows — a server-rendered screen, an
island fragment, a report, an export. It never enters the route middleware,
so it is a second door to the same data. - The same route reaching a DIFFERENT entity.
?include=releager-loads
the related entity's rows and?rel.field=filters across it, so the
related entity's posture governs, not the one in the path. The framework
enforces this for you; anything else that walks a relation must do the same.
The two are not enforced identically, because they read differently:
?include=relanswers 403 when you may not read the target, at every
depth. When you may read it, the rows come back scoped: owner- and
tenant-scoped targets return the rows you are entitled to, which for a
caller with no owner is none — a 200 with an empty relation rather than
a refusal. Soft-deleted rows never appear.
-?rel.field=compiles to anEXISTSclause that counts rows without
selecting them, so it cannot scope them to you. It therefore answers 403
both when you may not read the target AND whenever the target declares
Scope.OwnerFieldorScope.MultiTenant— even if you can read it —
because the resulting row count would otherwise confirm values in other
owners' or tenants' rows one guess at a time. The exception is a caller
holding a cross-owner or cross-tenant grant, who can already list the
target wholesale and learns nothing from the count. To filter by a scoped
entity's field, query that entity's own list route and filter the parent
by the ids it returns.
Use CrudHandler.CanReadScoped(ctx). It answers the whole read posture as a
boolean, with no HTTP response written:
if !crudHandler.CanReadScoped(ctx) { return ui.Callout(ui.CalloutConfig{Title: "Not available", Variant: ui.StatusWarning}, render.Text("You do not have permission to view this."))}
It covers, in order: the baseline session requirement that auto-CRUD
applies to any entity declaring no OwnerField, no Access, and no Public;
owner scoping (Scope.OwnerField); tenant scoping; and finally RBAC
(Exposure.Access).
CanRead(ctx) answers only the RBAC question. That is not the whole posture:
an entity in the default secure-by-default shape declares no Access at all, so
CanRead returns true for an anonymous caller while GET /api/<entity> answers
- A surface gated on
CanReadalone therefore rendered every row of a
default-posture entity to anonymous visitors. Prefer CanReadScoped unless you
specifically want the RBAC-only question.
For a single record — a detail page, an edit form — use
CanReadRecordScoped(ctx, id) instead. It asks the same question about a
specific row, which matters when a resource-aware Decider allows the listing
and denies one record; the collection-level predicate would render a row the
read-one route refuses.
A custom DataSource that implements neither predicate is ungated: the
interface guarantees only the three read methods, so a computed report or
search index has no posture to consult. A custom source fronting real entity
rows must implement CanReadScoped itself.
framework/ui/resource calls these for you, so generated screens
inherit the check — on List, Table, Detail, and the pre-filled edit
Form for the screen's own entity, and separately on the RELATED entity
behind relation labels, reverse-relation sections, and dashboard aggregates.
A relation to an entity the caller may not read renders muted (an em dash),
never the raw foreign key — a bare id is useless to a reader and discloses an
internal identifier. A reverse-relation section the caller may not read is
omitted entirely rather than replaced with a notice, because a notice on a
public page tells every visitor which entities exist.
battery/admin does not go through ui/resource; it enforces its own admin
gate.
Common mistakes
- Forgetting
WithPolicy. Every check fails closed. If
RequirePermissiondenies everyone, this is usually why. - Granting permissions on the wrong policy instance.
RolePolicy
is mutable; if you grant on one instance and put a different
instance into context, checks pass for the in-context one and
ignore the granted one. - Encoding business logic in permission strings. Keep them
resource:verb. Express logic inPolicyimplementations or
hooks — strings should be data, not code. - Trusting client-supplied roles. Roles come from your auth
layer; never from a request header or body the user controls. - Gating a screen on
CanReadinstead ofCanReadScoped. The
RBAC-only check passes for an anonymous caller on a default-posture
entity, so the screen serves rows the JSON route refuses.
Persistent grants (GrantStore)
RolePolicy grants are code-defined at boot: policy.Grant("admin", ...).
For apps that need runtime-editable RBAC (an admin UI that grants and
revokes without a redeploy), access.GrantStore persists grants to a
database table and keeps the live *RolePolicy in sync.
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/framework"
import "github.com/DonaldMurillo/gofastr/framework/access"
import "database/sql"
var db *sql.DB
import "context"
var ctx = context.Background()
-->
policy := framework.NewRolePolicy()policy.Grant("admin", access.Wildcard) // code-defined baselinestore := framework.NewGrantStore(db, policy)store.EnsureSchema(ctx) // CREATE TABLE IF NOT EXISTS access_grantsstore.LoadInto(ctx, policy) // hydrate from DB → live policy// Later: runtime grant (admin screen, CLI, etc.)store.Grant(ctx, "editor", "posts:write") // DB INSERT + policy.Grantstore.Revoke(ctx, "editor", "posts:write") // DB DELETE + policy.Revoke
Shape
The store holds a reference to the live *RolePolicy (store-holds-policy).
NewGrantStore(db, policy) binds the policy; LoadInto(ctx, policy) loads
persisted rows into it (call once at boot). Subsequent Grant/Revoke calls
mutate both the DB and the policy in one call — the policy's RWMutex covers
concurrent Can checks, so a grant/revoke is "atomic enough": a reader sees
the state before or after, never a torn map.
Cross-replica grant propagation
GrantStore.Grant/Revoke mutate the LOCAL *RolePolicy only. With N
replicas behind a load balancer sharing one database, the other replicas'
in-memory policies stay stale until restart — an editor granted on replica
A still fails Can("posts:write") on replica B until B reboots.
Attaching a fanout closes that window. Register the store with the app
(framework.WithGrantStore) so the framework auto-wires it to the same
fanout as WithFanout:
pg, err := fanout.NewPostgres(dsn, db)if err != nil { log.Fatal(err)}app := framework.NewApp( framework.WithDB(db), framework.WithGrantStore(store), framework.WithFanout(pg),)
On every Grant/Revoke, the store publishes a refresh-signal on the
gofastr.access lane naming the role whose grants changed. Each
subscriber re-reads that role's grants from access_grants and atomically
swaps them into its local policy via RolePolicy.ReplaceRole (the store
rebuilds the role as (code baseline ∪ DB grants) − revocation tombstones).
The message body is never trusted — a crafted payload can only trigger a
re-read, never pollute the policy directly.
Revocation tombstones
A Revoke does more than delete the grant row: it also inserts a
revocation tombstone into a parallel access_grants_revoked table
(created by EnsureSchema, same shape as the grants table). Reloads and
fresh boots subtract tombstones from the (baseline ∪ DB) union, so a
revoked grant stays revoked on every replica:
- Revokes propagate to peers. A peer's fanout-driven reload reads the
tombstone from the shared DB, so it no longer merges a revoked
code-seeded grant back in — even though the peer's own code baseline
still declares it. - Revokes survive restarts. A replica that boots after the revoke runs
LoadInto, which subtracts tombstones from both the captured baseline
and the installed DB grants (and revokes them from the live policy), so
a re-seeded code grant does not resurrect the permission. - Re-granting lifts a tombstone.
GrantStore.Grantdeletes any
matching tombstone — it is the ONE way to un-revoke. A tombstoned
permission stays revoked even if the code keeps declaring it, until
Grant()is called. DB intent outlives code declarations, the same
precedence the store already gives DB grants over the code baseline. - Tombstone wins on conflict. If a permission is somehow both granted
and tombstoned (an inconsistent write), reloads fail closed: the
tombstone wins.
Consistency window. Fanout is lossy best-effort. A publish that
doesn't reach a peer (the peer's queue overflowed, the bus was briefly
down) leaves that peer stale until the NEXT grant/revoke on the same
role, or until restart. The store itself remains correct — it always
reads from and writes to the DB; only the in-memory cache lags. A
reconnecting replica's LoadInto reloads authoritative state on boot,
and because tombstones live in the DB, a missed revoke signal does not
resurrect on restart — the boot re-applies the tombstone.
Capability validation happens before GrantStore writes. A strict rejection
therefore leaves both the database and live policy unchanged. In warning mode,
unknown concrete grants remain persisted for compatibility. The admin roles
screen uses Policy.Capabilities() as a datalist when the registry is
non-empty and marks existing non-global grants outside the registry as
unknown/dead.
Security
- Role and permission strings are bound as
$nparameters — never
interpolated into SQL. The table name is validated viaquery.SafeIdent. Grant/Revokedo not check authorization — they are trusted
server-side calls. The admin battery gates them behind its default-deny
b.gate(see Admin UI).- There is no unauthenticated or self-service grant path.
Enumeration API
RolePolicy exposes read-only getters for admin UIs:
roles := policy.Roles() // sorted []stringperms := policy.PermissionsOf("editor") // []Permission (copy)caps := policy.Capabilities() // sorted []Permission (copy)
All three return defensive copies — callers iterate without holding the lock.
Effective roles in the admin
admin.Config.EffectiveRoles can add resolved role origins to the user-role
screen without changing the direct roles stored by battery/auth:
admin.New(admin.Config{ Auth: authManager, EffectiveRoles: func(ctx context.Context, userID string) []access.RoleWithOrigin { return []access.RoleWithOrigin{ {Role: organizationRole(ctx, userID), Origin: "resolved"}, } },})
The screen unions these entries with auth_users.roles, labels stored roles
as direct, and preserves only the direct roles in its assignment form.
Duplicate role/origin pairs are shown once. When the hook is nil, the screen
keeps its direct-roles-only output.
Resource-scoped decisions
The Policy.Can check is coarse-grained by design — "does this context hold
permission P?", with no resource argument (see Row-level checks).
Owner scoping and lifecycle hooks cover most per-row needs. When you need a
per-resource authority that those don't express — "a team maintainer may
edit their team's projects, but not other teams'" — without standing up a
ReBAC/tuple store, install a Decider. The decider is consulted before the
role policy on resource-aware checks, so it can tighten or loosen the coarse
Can answer per record.
The seam
// access.Ref identifies the resource a check is about.type Ref struct { Type string // entity name: "projects" ID string // record id; "" for collection-level checks (List/Create/batch/feed)}type Decision intconst ( DecisionAbstain Decision = iota // fall through to the role policy (Can) DecisionAllow // permit; role policy not consulted DecisionDeny // refuse, even when the role policy would allow)type Decider func(ctx context.Context, roles []string, capability Permission, resource Ref) Decision
access.CanResource(ctx, capability, resource) is the resource-aware entrypoint:
- If a
Decideris in ctx → call it with the caller's roles, the capability,
and theRef.DecisionAllow→ true;DecisionDeny→ false;
DecisionAbstain→ fall through. - Otherwise (or after Abstain) → exactly
access.Can(ctx, capability).
access.Can itself is untouched — there is no wildcard or resource-segment
logic in the hot path. The resource-aware path is a separate entrypoint you opt
into; with no decider installed, CanResource answers byte-identically to Can.
Wiring it: DeciderMiddleware
access.DeciderMiddleware(d) installs a decider into request context. Mount it
alongside access.Middleware — the two compose; the policy+roles middleware
feeds the decider its roles argument:
roles := access.NewCachedResolver( func(ctx context.Context) []string { user := auth.GetCurrentUser(ctx) if user == nil { return nil } return loadEffectiveRoles(ctx, user.GetID()) }, access.WithTTL(30*time.Second),)app.Use(access.Middleware(policy, roles.Resolve))app.Use(access.DeciderMiddleware(decideProjectAccess))
Worked example: team maintainers edit their projects
A team-maintainer rule that the role policy can't express: any caller holding
projects:update may edit some projects, but a maintainer may edit every
project their team owns even without the global grant. The decider consults a
memberships table and returns Allow/Deny/Abstain:
func decideProjectAccess(ctx context.Context, roles []string, cap access.Permission, res access.Ref) access.Decision { // Only opine on project writes for a specific record. if res.Type != "projects" || res.ID == "" { return access.DecisionAbstain } user := auth.GetCurrentUser(ctx) if user == nil { return access.DecisionAbstain // let the role policy fail-closed } // Maintainer of this project's team → allow the update regardless of role. if cap == "projects:update" && isMaintainerOf(ctx, user.GetID(), res.ID) { return access.DecisionAllow } // Otherwise defer to the role policy (which may grant projects:update globally). return access.DecisionAbstain}
Auto-CRUD consults this automatically: Exposure.Access gates route through
CanResource, passing Ref{Type: <entity name>, ID: <path id>} for item-scoped
ops (read-one/update/delete) and Ref{Type: <entity name>, ID: ""} for
collection-level ops (list/create/batch/the _events feed). No handler change
is needed — declaring the Access block and mounting DeciderMiddleware is the
whole wiring.
When to Deny vs Abstain
- Deny when the decider has positive knowledge the caller must not act on
this resource — e.g. "this project belongs to a team the caller is not on".
Deny short-circuits to false; the role policy never runs, so even a wildcard
grant cannot override it. Use Deny to tighten below the role policy. - Abstain when the decider has no opinion — the resource type isn't one it
governs, the caller's relationship is unknown, or you want the role policy to
decide. Abstain is the zero value, so a decider that forgets to return is
safe (falls through toCan). Use Abstain to delegate. - Allow when the decider grants access the role policy would not — e.g. the
team-maintainer case above. Allow short-circuits to true; use it to loosen
beyond the role policy for a specific resource.
A decider that always returns Abstain is a no-op: behaviour is exactly the
role-policy-only world. That is the safe default while you roll the decider out.
Alternative: the resource:id:capability string convention
An app-side pattern (the framework does not interpret this) encodes the
resource into the permission string itself: "projects:42:update". Combined
with GrantStore, each such string becomes a row in access_grants, so the
grant matrix is visible in the admin UI and editable at runtime — every
per-resource grant is a real, enumerable row.
The tradeoff is row explosion: one row per (role, resource, capability),
which is fine for dozens of resources but does not scale to thousands. The
framework's Can performs exact-string matching, so it never parses the
resource:id:capability segments — that decomposition is a convention your
code (or a wrapper policy) owns. If you need per-resource authority at scale,
or with inheritance ("a maintainer of team T may edit all of T's projects"),
use the Decider seam above: one membership check replaces unbounded grant
rows, and the rule lives in your code where it can consult any table or
service. The two compose — a Decider can fall back to Abstain and let a
resource:id:capability grant row in the policy decide.
ScopeMatch (module/token scope algebra)
Can is the RBAC hot path and stays deliberately blunt: it matches an
exact permission string, or the global Wildcard ("*"). It does not
understand posts:* or *:read — widening it would silently change live
RBAC for every caller, so it is untouched.
access.ScopeMatch(granted []Permission, required Permission) bool is the
separate, pure matcher for the richer resource:verb wildcard grammar used
by token scopes and module capability grants:
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/framework/access"
-->
// exact | resource wildcard | verb wildcard | grant-allaccess.ScopeMatch([]access.Permission{"posts:*"}, "posts:read") // trueaccess.ScopeMatch([]access.Permission{"*:read"}, "users:read") // trueaccess.ScopeMatch([]access.Permission{"*:*"}, "anything:here") // trueaccess.ScopeMatch(nil, "posts:read") // false (deny by default)
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; it
is not fanned out to registered teams: capabilities). Matching and
grant-time expansion are deliberately not entangled.
battery/auth's token-scope matcher (HasScope / auth.ScopeMatch)
delegates to this function, so the resource:verb algebra has exactly one
home. Use access.ValidScope(s) to reject malformed scope strings at
mint/install time under the same closed vocabulary. New capability gates
that hold []Permission should call access.ScopeMatch directly rather
than reimplementing a weaker string matcher.