RBAC & the middleware chain
pkg/authz integrates Casbin behind a wrapped Enforcer. This page opens
with the wall everyone hits in their first hour — the 403 from the
default-deny gate — and then covers the policy model in full.
Your first 403
You added a session-authenticated route, signed in, and the route answers 403. Nothing is broken; two facts explain it:
- The global gate fires first. The framework mounts a global
default-deny authorizer as the last item in the core middleware chain,
before any module routes are registered — on the default stack, and on
an application built
WithoutDefaults()only when it also carriesWithAuthz(). Your module's middleware attaches later, inside the module's own sub-router — so the global gate always evaluates before anything declared inModule[C].Middleware. - The gate sees no identity. When it evaluates the request, no
auth.Claimsare in the context yet (your session bridge has not run). The enforcer looks for a subject, finds none, treats the request asanonymous, and denies it — unless a policy row explicitly permits anonymous access to that path.
The subjects the gate tries
The gate authorises a request as each of these subjects, in order, and lets it through when any of them is allowed:
- a bearer token's user id, then its role;
- an API key's owner, then
scope:<name>for each scope the key carries (withWithAPIKeys, which reads the key before the gate — API keys); - the account a session was signed in as, which a sign-in flow records
under
auth.SessionKeySubject— the account flows do; anonymous.
Identities share one namespace: the id of the person or program behind the
request. A row — or a role granted with g — for that id applies to their
token, their keys and their session alike. A subject only ever adds to what
a request may do, so anonymous keeps everything the bootstrap allow-list
and your anonymous rows grant. A deny row applies to the subject it names:
deny the owner and their scoped keys still reach a route granted to the
scope — revoke the key to stop it.
A 404 is not the gate
The gate only judges routes that exist. Both the default-deny authorizer
and the CSRF middleware are built with router.WhenMatched: they take the
routing decision on the request as they see it and let a request nothing
serves fall through to the plain 404 — so a mistyped URL answers 404, a
POST to an unknown path answers 404 rather than a CSRF 419, and a policy
row granting a path nobody serves changes nothing. A 403 always means
"this route exists and the policy does not grant it", and the authz denied line in the log names the exact row that would. (A path registered
for other methods answers 405.) The rate limiter, the bearer decode and the
request interceptors still run before routing, so an unknown path cannot
skip them; only the two gates whose answer means nothing without a route
step aside — and they get their turn back if a request interceptor
rewrites the path onto a registered route, judging the path as their own
level spells it (a rewrite inside a module's Prefix mount is judged with
the prefix restored, the namespace your policy rows and csrf_exempt_paths
are written in), so an unregistered alias answers exactly what the real
path answers (403 without a policy row, 419 for a POST without a token),
never the handler — and a handler the alias does reach reads the same
request, form fields included, as through the real path.
A session-identity bridge placed in Module.Middleware cannot influence
the global gate. There is no pre-authz identity hook today, and none is
promised for a future version. The fix is not more middleware — it is two
layers doing two different jobs:
The correct two-layer pattern
-
Operator grants reachability — add policy rows in the
rbac_policy_filethat permit theanonymoussubject (or a named bootstrap subject) to reach the module's URL prefix. The global default-deny gate will then let those requests through.# auth/policy.csv — grant anonymous access to the /auth/* pathsp, anonymous, /auth/login, create, allowp, anonymous, /auth/logout, create, allowFor entirely private surfaces where only authenticated users should ever reach (e.g.
/api/admin/*), the operator grants access to the specific roles instead:p, admin, /api/admin, *, allowp, admin, /api/admin/*, *, allow -
Module enforces identity and roles — after the global gate passes the request, the module's own middleware chain runs. Place a session-to-claims bridge first, then a role guard:
// adminModule holds framework handles captured in OnStart.type adminModule struct {rt nucleus.Runtime}func (m *adminModule) build() nucleus.ModuleSpec {return nucleus.Module[struct{}]{Name: "admin",Prefix: "/api/admin",// Module.Middleware entries are constructed before OnStart,// so they must close over the module struct, not rt directly.Middleware: []nucleus.Middleware{m.withIdentity,m.requireRole("admin"),},OnStart: func(ctx context.Context, rt nucleus.Runtime, _ struct{}) error {m.rt = rt // capture the runtime for per-request usereturn nil},Routes: func(r nucleus.Router, _ struct{}) {r.Get("/stats", adminStats)},}.Build()}// withIdentity reads the session-authenticated user ID and role,// builds auth.Claims, and injects them so that downstream// middleware and handlers can read the subject.func (m *adminModule) withIdentity(next http.Handler) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {sm := m.rt.Session() // *auth.SessionManagerif sm == nil {http.Error(w, "unauthenticated", http.StatusUnauthorized)return}userID := sm.GetString(r.Context(), "user_id")if userID == "" {http.Error(w, "unauthenticated", http.StatusUnauthorized)return}role := sm.GetString(r.Context(), "role") // stored at loginctx := auth.ContextWithClaims(r.Context(), &auth.Claims{UserID: userID,Role: role,})next.ServeHTTP(w, r.WithContext(ctx))})}// requireRole returns middleware that gates the request on the// claims injected by withIdentity. It delegates to the runtime// enforcer per request so that live policy changes are respected.func (m *adminModule) requireRole(roles ...string) nucleus.Middleware {return func(next http.Handler) http.Handler {return m.rt.Authorizer().RequireRole(roles...)(next)}}Module.Middlewareentries are evaluated in registration order within the module's sub-mux.withIdentitymust appear beforerequireRoleso thatRequireRolefinds claims in the context.
auth.ContextWithClaims also propagates the user ID for log
attribution (observe.CtxWithUserID is called internally), so
structured logs for the request automatically carry the subject
without extra instrumentation.
The login handler that writes user_id and role into the session is the
other half of this pattern — see
Your first login.
Module-declared policy rows (Module.Policies)
A module can carry the policy rows its own routes need, so mounting it
works without the operator editing rbac_policy.file by hand:
nucleus.Module[struct{}]{
Name: "articles",
Policies: []nucleus.PolicyRule{
// Same shape as a rbac_policy.csv row: subject, object, action, effect.
{Subject: "anonymous", Object: "/articles", Action: "read"},
{Subject: "anonymous", Object: "/articles", Action: "create"},
{Subject: "anonymous", Object: "/articles/*", Action: "read"},
},
// JSON API paths the module needs exempted from CSRF protection.
// Same raw-prefix matching as csrf_exempt_paths.
CSRFExempt: []string{"/articles"},
Routes: func(r nucleus.Router, _ struct{}) { /* … */ },
}
The contract, precisely:
- Objects are relative to the module's
Prefix(a module without a prefix declares full paths); keyMatch wildcards work (/articles/*). - To declare the module's own root, use
Object: "/"— it means the root and the subtree, and emits both rows. UseObject: ""for the root alone, when the landing page is public but what hangs off it is not. The distinction exists because the enforcer matches with keyMatch, where/articlesand/articles/are different paths and neither implies the other. Actionis a CRUD verb (read|create|update|delete) or*— not an HTTP method.Effectisallow(the default when empty) ordeny.- The operator always wins. Module rows join the live in-memory
ruleset only — the policy file is never written — and the Casbin
policy effect (
some(allow) && !some(deny)) means adenyrow in the host's CSV overrides any moduleallow. - Malformed declarations fail boot with
ErrInvalidModulePolicy, naming the module and the entry. A row that was silently skipped would leave its route answering a mute 403. CSRFExemptentries are declarative (not a closure) because the exemption list is frozen inside the middleware stack atapp.New, before any module closure runs — the same constraint behind the automatic webhook-prefix exemption."/"(or"") means the module's own surface and resolves to the bare prefix, which the raw-prefix matcher extends over everything below it — including a POST to the collection path itself.- A module cannot exempt the whole application. An exemption that
resolves to
"/"— which is what a module without aPrefixdeclaring"/"would mean — fails boot. Mounting a module is trusting its routes; it is not agreeing to let it unprotect its siblings. - Exemptions are logged at boot, module by module, with the resolved paths. They are the one declaration that removes a protection, so they leave the loudest trace.
Rows that depend on the module's bound config can still be added
imperatively from OnStart via rt.Authorizer().AddPolicy(...); the
declarative field is for the common case and gets boot-time validation.
RBAC
Provide a policy file, and the framework loads an enforcer accessible from the application:
rbac_policy_file: ./auth/policy.csv
allowed := a.Authorizer.Can(userID, "articles", "edit") // returns bool
The enforcer is available to all extension modules. A superuser bypass is built in for the bootstrap case.
Default-deny with deny-override
The built-in Casbin model is default-deny with deny-override semantics:
- A request with no matching policy is denied. Operators must grant access explicitly.
- A request matching an allow rule is permitted — unless a matching deny rule also exists, in which case it is denied. Deny rules always override allows.
The programmatic API mirrors that:
// Grant a role full access to an API surface.
e.AddPolicy("admin", "/api/*", "*")
// Block a specific user from one endpoint, even though their role
// would otherwise allow it.
e.Deny("alice", "/api/users/1", "delete")
// RemovePolicy lifts BOTH the allow and the deny variants matching
// (sub, obj, act) — operators say "stop applying this rule" without
// having to know which effect was originally written.
e.RemovePolicy("alice", "/api/users/1", "delete")
CSV policy files carry an eft column, so a row reads
p, <subject>, <object>, <action>, <effect> where the effect is allow or
deny. From code, AddPolicy stamps allow and Deny writes the deny
variant.
Casbin itself is an implementation detail of authz.Enforcer. Its concrete
type is not part of the public API and callers cannot reach it, which is what
keeps Casbin replaceable without breaking your code.
Reading policy state
Three read-only forwarders expose the live ruleset without requiring access to the underlying Casbin implementation:
// All permission rules as (sub, obj, act, eft) tuples.
rules, err := e.GetPolicy()
// All role-assignment rules as (user, role) tuples.
groupings, err := e.GetGroupingPolicy()
// All role names referenced by a grouping policy.
roles, err := e.GetAllRoles()
These are available to application code that needs to audit the live policy (e.g. for display in a custom UI or an audit log export).
SSR-friendly denial handling
By default, Middleware() and RequireRole(...) write a JSON error envelope
on denial — 401 or 403. That is the right answer for an API, but not for a
server-rendered site, where an anonymous visitor should be redirected to a
login page and a signed-in user lacking the role should see a styled error
page.
To replace the default behaviour, use MiddlewareWithOptions and
RequireRoleWithOptions.
import (
"net/http"
"github.com/jcsvwinston/nucleus/pkg/authz"
)
onDeny := authz.DenialHandler(func(w http.ResponseWriter, r *http.Request, d authz.Denial) {
if !d.Authenticated {
// Anonymous visitor — redirect to login.
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
// Signed-in user without the required role — show a 403 page.
http.Error(w, "Access denied", http.StatusForbidden)
})
// Global policy gate — SSR variant.
router.Use(enforcer.MiddlewareWithOptions(authz.AuthzOptions{OnDeny: onDeny}))
// Role guard on a single route — SSR variant.
router.With(enforcer.RequireRoleWithOptions(authz.AuthzOptions{OnDeny: onDeny}, "admin")).
Get("/admin/dashboard", adminDashboard)
authz.Denial carries three fields set by the middleware before
calling OnDeny:
| Field | Type | Meaning |
|---|---|---|
Status | int | HTTP status the default path would use: 401 (no identity) or 403 (insufficient role). |
Authenticated | bool | false when the visitor is anonymous; true when signed in but lacking role/permission. |
Reason | string | Human-readable explanation, e.g. "insufficient role". |
The OnDeny handler owns the response — it must write a status and body
and must not call the next handler. Passing a nil OnDeny (the zero
AuthzOptions) reproduces the default JSON envelope exactly, so existing
callers are unaffected.
Subject and action resolvers
AuthzOptions carries two additional optional fields that change what
the middleware checks, rather than just how it responds on denial.
ResolveSubject (authz.SubjectResolver) overrides the policy subject.
Its signature is func(r *http.Request, claims *auth.Claims) string, and the
default returns claims.UserID. If your policy table is keyed by role rather
than by individual user, return claims.Role instead — the enforcer then
looks the role up directly in the policy CSV.
ResolveAction (authz.ActionResolver) overrides the policy action.
Its signature is func(r *http.Request) string, and the default maps the HTTP
method: GET→read, POST→create, PUT/PATCH→update, DELETE→delete.
Pure-HTML forms can only POST, so a resolver can inspect the URL path and map
a POST ending in /delete to the delete action, making deny-override rules
fire correctly.
Both resolvers default to nil, which is the standard behaviour — callers
that set only OnDeny are unaffected.
Here is a combined server-rendered setup: role-keyed policies, path-aware action mapping, and redirect-on-denial.
import (
"net/http"
"strings"
"github.com/jcsvwinston/nucleus/pkg/auth"
"github.com/jcsvwinston/nucleus/pkg/authz"
)
opts := authz.AuthzOptions{
// Check policies keyed by role, not by individual user ID.
ResolveSubject: authz.SubjectResolver(func(r *http.Request, c *auth.Claims) string {
return c.Role
}),
// Map pure-HTML form POSTs to the correct action.
// Default: GET/HEAD→"read", POST→"create", PUT/PATCH→"update", DELETE→"delete".
ResolveAction: authz.ActionResolver(func(r *http.Request) string {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/delete") {
return "delete"
}
return "create" // treat other POSTs as create
}),
// Redirect anonymous visitors; show a styled page for signed-in denials.
OnDeny: authz.DenialHandler(func(w http.ResponseWriter, r *http.Request, d authz.Denial) {
if !d.Authenticated {
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
http.Error(w, "Access denied", http.StatusForbidden)
}),
}
router.Use(enforcer.MiddlewareWithOptions(opts))
Two limits are worth knowing. ResolveSubject is honoured by
MiddlewareWithOptions only — RequireRole and RequireRoleWithOptions
match the claim's role directly and ignore it. And a resolver that returns an
empty string produces a policy query matching nothing: the request is denied
under default-deny, and a warning is logged so the misconfiguration is
auditable.
Applications built directly on pkg/app
Applications assembled with pkg/app rather than pkg/nucleus compose the
same middleware on the Mux. The session middleware is already mounted
globally; add it to a sub-route only if you are deliberately replacing the
global mount with a scoped one. It must never be mounted twice on the same
request path.
// pkg/app-level wiring (not module code)
a.Router.Mux.Route("/api/admin", func(sub *router.Mux) {
sub.Use(sessionIdentityMiddleware)
sub.Use(a.Authorizer.RequireRole("admin"))
// ...
})
Permissions on an object, not just a path
Route permissions answer "may this subject PUT /posts/*". They cannot
answer "may she edit the posts she owns" — and without that, every
application writes the same comparison inside its handlers, where nothing
audits it, nothing lists it, and one forgotten branch is an authorization
bug you cannot see by reading the policy.
objects, err := authz.NewObjectEnforcer(logger)
objects.AddRole("ana", "editor")
objects.Allow("editor", "r.obj.AuthorID == r.sub", "edit")
objects.Deny("editor", `r.obj.TenantID == "locked"`, "edit")
objects.Can("ana", post, "edit") // decides on THIS post
A policy row carries an expression over the resource's attributes instead
of a path, and the request carries the resource itself (Casbin's ABAC
form). Policies() lists the rules, which is the point of moving ownership
out of handlers: it becomes something you can read.
The object enforcer is separate from the route one because the two answer
different questions with different inputs. They share role assignments —
AddRole on each is the same grant on a different surface.
Two properties:
- A rule the engine cannot evaluate denies. An authorization layer that answers "allow" when it could not decide is the failure mode this package exists to prevent.
Denybeats a grant, the same rule as on routes, so "deny wins" means one thing in this framework.
Asking from a handler
Mount the middleware and the request carries both policies:
r.Use(enforcer.Middleware()) // already carried the route policy
r.Use(authz.ObjectMiddleware(objects))
Then, inside a handler:
func show(c *nucleus.Context) error {
post, err := repo.Find(c.Param("id"))
if err != nil { return err }
if !c.CanObject(post, "edit") {
return c.JSON(http.StatusForbidden, forbidden)
}
...
}
c.Claims(), c.UserID() and c.HasRole() read the identity the
authentication middleware put on the request; c.Can(path, action) asks the
route policy and c.CanObject(resource, action) the object policy.
Every one of them is closed when the middleware is not mounted. A helper that answered "allowed" because nothing was configured would be worse than no helper at all.