Documentation
¶
Overview ¶
Package authz provides the runtime helper used by the per-service handlers/<svc>/authorizer_gen.go file forge generates.
Shape ¶
The library exposes an interface-driven authorizer. Generated forge projects emit a thin shim that constructs an Authorizer wrapping a project-supplied Decider. The shim conforms to the project's pkg/middleware.Authorizer interface (Can / CanAccess) so the rest of the handler layer needs no changes when policy logic moves around.
The boundary of responsibility:
- The library owns the wiring (CanAccess procedure → method lookup, Can action+resource → method lookup, panic recovery, deny-by-default fall-through, error envelope).
- The user owns policy via a Decider implementation. The default stub deciders (DenyAll, AllowAll) cover dev/test paths; a real project ships its decider in handlers/<svc>/authz.go.
Usage from a handler package ¶
// handlers/users/authz.go (user-owned)
package users
import (
"context"
"github.com/reliant-labs/forge/pkg/auth"
"github.com/reliant-labs/forge/pkg/authz"
)
type decider struct{}
func (decider) Decide(ctx context.Context, method string, claims *auth.Claims) error {
if claims == nil {
return authz.Deny("authentication required")
}
if claims.Role == "admin" {
return nil
}
return authz.Deny("admin role required for %s", method)
}
func newDecider() authz.Decider { return decider{} }
The companion handlers/<svc>/authorizer_gen.go (regenerated by forge) is a thin shim that calls authz.New(newDecider()) and is wired into pkg/app/bootstrap.go automatically.
Why interface-driven over data-driven ¶
An earlier sketch in CODEGEN_AUDIT.md proposed a data-shim where the generated file populated a methodRoles map and the library carried the matching logic. That approach forced the library to know about role semantics and to bake in a single matching strategy. The interface approach lets each service ship arbitrary policy (RBAC, ABAC, OPA callouts, …) without changing the library or the generated shim. A roles-table decider is one helper among many — see RolesDecider.
Index ¶
- func ClaimsFromContext(ctx context.Context) (*auth.Claims, bool)
- func Deny(format string, args ...any) error
- func Interceptor(checker AccessChecker) connect.Interceptor
- func RoleInterceptor(policy *RolePolicy, resolver RoleResolver) connect.Interceptor
- func SetClaimsLookup(lookup func(context.Context) (*auth.Claims, bool))
- type AccessChecker
- type AllowAll
- type Authorizer
- type Decider
- type DeciderFunc
- type DenyAll
- type FailMode
- type Option
- type PolicyOption
- type Role
- type RolePolicy
- type RoleResolver
- type RoleResolverFunc
- type RolesDecider
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ClaimsFromContext ¶
ClaimsFromContext returns the authenticated claims (or nil + false) by dispatching to the lookup wired via SetClaimsLookup. When no lookup is wired (library used standalone in tests, say), it returns nil + false.
func Deny ¶
Deny is a small helper for Decider implementations that want a plain "denied" error without importing connect. The returned error is plain; [Authorizer.invoke] wraps it as CodePermissionDenied at the boundary.
func Interceptor ¶
func Interceptor(checker AccessChecker) connect.Interceptor
Interceptor returns a Connect interceptor that checks authorization on every unary and streaming-handler RPC via checker.CanAccess. The project's scaffolded middleware.AuthzInterceptor is a one-line shim over this function.
checker must be non-nil; passing nil would nil-panic on every request so the constructor panics at boot instead.
func RoleInterceptor ¶
func RoleInterceptor(policy *RolePolicy, resolver RoleResolver) connect.Interceptor
RoleInterceptor returns a Connect interceptor that enforces the descriptor-built RolePolicy on every unary and streaming-handler RPC. On each call it resolves the caller's roles via resolver, then asks the policy whether those roles satisfy the procedure's declared requirement, denying with PermissionDenied (loudly) on failure.
policy and resolver must be non-nil; passing nil is a construction bug that would nil-panic per request, so the constructor panics at boot instead.
func SetClaimsLookup ¶
SetClaimsLookup wires the project's claims-from-context helper. It is called by the generated handlers/<svc>/authorizer_gen.go shim's init() before any handler runs.
Idempotent on identical lookups; setting it twice with different functions panics so a stray re-init in tests surfaces immediately.
Types ¶
type AccessChecker ¶
type AccessChecker interface {
// CanAccess checks whether the request in ctx may call the given
// procedure (the full RPC method name, e.g.
// "/proto.services.users.v1.UsersService/Create").
CanAccess(ctx context.Context, procedure string) error
}
AccessChecker is the per-procedure gate Interceptor consults on every RPC. The project's pkg/middleware.Authorizer interface (Can / CanAccess) satisfies it, as does *Authorizer.
Return nil to allow the call, or a *connect.Error (typically CodePermissionDenied / CodeUnauthenticated) to deny it.
type AllowAll ¶
type AllowAll struct{}
AllowAll is the dev/test counterpart to DenyAll: every call is allowed.
Suitable for local development (where the [DevAuthorizer] in pkg/middleware uses it under the hood) and for tests whose subject of interest is not the authorization layer. NOT for production use.
type Authorizer ¶
type Authorizer struct {
// contains filtered or unexported fields
}
Authorizer is the boundary type used by generated handlers.
It implements both halves of the project's pkg/middleware.Authorizer interface (Can / CanAccess) by delegating to a user-supplied Decider. Construct with New; the zero value is not usable.
func FromDescriptors ¶
func FromDescriptors(opts ...Option) (*Authorizer, error)
FromDescriptors builds an *Authorizer at runtime by walking registered proto FileDescriptors and reading the per-method (forge.v1.method) option, instead of consuming a per-service generated authorizer table.
For every method of every service it finds, it constructs the Connect procedure path ("/<pkg>.<Service>/<Method>") and records whether the method requires authentication. The rule mirrors the proto-level contract:
- method annotated with auth_required = true → MethodAuthRequired = true
- method annotated with auth_required = false → MethodAuthRequired = false (the method is reachable without authentication)
- method with NO explicit auth_required (no (forge.v1.method) annotation, or one that sets only other fields) → left OUT of the table, so it hits the unknown-method path: FailClosed denies it (the default), AllowUnknownMethods serves it. This keeps a forgotten annotation a loud, fail-closed event rather than a silent require-auth default.
The proto carries only auth_required; ROLES are user-owned policy and never live in the proto. Supply them out-of-band with WithRoleOverlay. The default empty overlay means "any authenticated user", matching today's daemon behaviour where MethodRoles is empty.
The returned *Authorizer wraps a RolesDecider; compose it with the existing Interceptor to enforce it on a Connect handler. Construction does not read claims — wire SetClaimsLookup (the generated shim does this) so CanAccess can resolve claims at request time.
By default it scans protoregistry.GlobalFiles; pass WithFiles to scope the scan to an explicit descriptor set (used by tests and by callers that want to bound which services participate).
func New ¶
func New(d Decider) *Authorizer
New returns an Authorizer backed by d.
d must be non-nil; passing nil is an obvious construction bug and would produce nil-panics on every request. New panics in that case so the failure surfaces at boot rather than under load. Tests that want a no-policy authorizer should pass DenyAll (production-safe default) or AllowAll (only for local-dev / unit-test fixtures).
func (*Authorizer) Can ¶
Can implements pkg/middleware.Authorizer.Can.
It synthesises a method identifier of the form "<action>:<resource>" (e.g. "create:patient", "list:invoice") and forwards to the decider alongside the supplied claims. Generated CRUD handlers call Can after they've already extracted claims from the context, so callers pass them through directly.
When claims is nil and the decider returns a plain error (or a PermissionDenied connect.Error from the default wrapping), the result is re-cast as CodeUnauthenticated so callers can distinguish "you must sign in" from "you can't do that". A decider that explicitly chooses a different connect code (e.g. CodeFailedPrecondition) keeps that code verbatim.
func (*Authorizer) CanAccess ¶
func (a *Authorizer) CanAccess(ctx context.Context, procedure string) error
CanAccess implements pkg/middleware.Authorizer.CanAccess.
Given a Connect procedure path (e.g. "/users.v1.UsersService/Get"), it pulls authenticated claims out of ctx (using auth.Claims via ClaimsFromContext, which is a project-supplied callback wired by the generated shim) and asks the decider whether the call is allowed.
An empty procedure is denied unconditionally — Connect always passes a real procedure name, so an empty value is a defensive bug check.
type Decider ¶
type Decider interface {
// Decide returns nil to allow the call, or an error to deny it.
Decide(ctx context.Context, method string, claims *auth.Claims) error
}
Decider makes a single authorization decision for one method invocation.
Implementations receive the canonical method identifier (the Connect procedure for Authorizer.CanAccess, or the synthesised "<action>:<resource>" string for Authorizer.Can) and the authenticated claims (nil when no authentication ran). They return nil to allow, or a non-nil error to deny.
Returned errors flow back to the handler verbatim; if the implementation returns a *connect.Error the code is preserved, otherwise the Authorizer wraps it as connect.CodePermissionDenied. Implementations MAY panic; the Authorizer recovers and converts a panic into a PermissionDenied error so a misbehaving policy never tears down a service.
type DeciderFunc ¶
DeciderFunc adapts an ordinary function to the Decider interface.
type DenyAll ¶
type DenyAll struct{}
DenyAll is the production-safe default decider: every call is denied.
New services scaffold with a stub decider; until a real policy lands, fail-closed semantics mean an unsecured handler returns 403 rather than silently allowing every caller through. Use for tests that want to assert deny behaviour without rolling a one-off Decider type.
type FailMode ¶
type FailMode int
FailMode controls what RolesDecider does when an RPC reaches the authorizer with no entry in either MethodRoles or MethodAuthRequired (the "unknown method" case). The zero value is FailClosed: no policy means no access. An unknown method is always one of (a) proto drift — the running binary predates the RPC, regenerate; (b) a hand-mounted endpoint outside the proto; or (c) an attacker probing procedure names. None of those should be silently allowed in production.
Local-dev permissiveness is the DevAuthorizer's job (the bootstrap dev-mode swap), NOT the policy table's — a freshly-scaffolded service runs allow-all in dev mode and fail-closed everywhere else.
The escape hatch is AllowUnknownMethods, named for exactly what it does. It still emits the unknown-method warning (once per method) so the missing annotation surfaces in logs.
const ( // FailClosed denies calls to unknown methods. This is the zero value: // every fork between fail-loud and degrade-silent in the authz path // resolves to fail-loud unless a project explicitly opts out. FailClosed FailMode = 0 // AllowUnknownMethods allows calls to methods missing from the policy // tables, emitting the once-per-method unknown-method warning. The // name is deliberately self-indicting: setting it means RPCs your // policy tables have never heard of are served. Use only when a // service intentionally mounts procedures outside the generated // tables AND cannot enumerate them in MethodRoles/Default. AllowUnknownMethods FailMode = 1 )
type Option ¶
type Option func(*config)
Option configures FromDescriptors.
func WithFailMode ¶
WithFailMode sets the FailMode applied to procedures the scan never saw (proto drift, hand-mounted endpoints, probes). Default is FailClosed.
func WithFiles ¶
func WithFiles(files fileSource) Option
WithFiles scopes the descriptor scan to an explicit file source instead of protoregistry.GlobalFiles. The common production caller omits this and scans the global registry (every imported .pb.go self-registers there). Tests pass a bounded *protoregistry.Files so one test's descriptors do not bleed into another's.
func WithOnUnknownMethod ¶
WithOnUnknownMethod wires the once-per-method unknown-procedure callback passed through to the underlying RolesDecider. nil (the default) emits the standard slog warning.
func WithRoleOverlay ¶
WithRoleOverlay supplies the user-owned MethodRoles map. Keys are Connect procedure paths ("/<pkg>.<Service>/<Method>"); values are the allowed roles for that method (empty slice == any authenticated user). Procedures absent from the overlay fall through to "any authenticated user" for known methods. Roles are deliberately NOT carried in the proto — they are policy the caller owns and may source from config, a DB, or a policy engine.
type PolicyOption ¶
type PolicyOption func(*roleConfig)
PolicyOption configures PolicyFromDescriptors.
func WithPolicyFailMode ¶
func WithPolicyFailMode(mode FailMode) PolicyOption
WithPolicyFailMode sets the FailMode applied to procedures absent from the built policy (proto drift, hand-mounted endpoints, probes). Default is FailClosed — defense in depth behind the generate-time completeness lint.
func WithPolicyFiles ¶
func WithPolicyFiles(files fileSource) PolicyOption
WithPolicyFiles scopes the descriptor scan to an explicit file source instead of protoregistry.GlobalFiles. Production callers omit this (every imported .pb.go self-registers globally); tests pass a bounded *protoregistry.Files so one test's descriptors do not bleed into another's.
func WithPolicyOnUnknown ¶
func WithPolicyOnUnknown(fn func(procedure string)) PolicyOption
WithPolicyOnUnknown wires the once-per-procedure unknown-method callback. nil (the default) emits the standard slog warning.
func WithRoleImplication ¶
func WithRoleImplication(implies map[Role][]Role) PolicyOption
WithRoleImplication declares that holding the key role implies holding each listed role (e.g. {"admin": {"user"}} — an admin satisfies any method that requires "user"). Implication is applied transitively at build time, so {"owner": {"admin"}, "admin": {"user"}} grants an owner the user role too. Cycles are tolerated (the expansion fixpoints). When unset, roles match only themselves.
type Role ¶
type Role string
Role is a string-backed authorization role. Projects typically derive the concrete role values from their own roles proto enum (the scaffold ships a starter `Role` enum), but any string the RoleResolver returns and the proto annotations name will match — the library never inspects role semantics beyond set membership and the optional implication map.
type RolePolicy ¶
type RolePolicy struct {
// contains filtered or unexported fields
}
RolePolicy is the immutable, descriptor-built authorization policy: a map from Connect procedure path to its required roles, plus the configured role implication. Build it ONCE at startup with PolicyFromDescriptors and hand it to RoleInterceptor; it is safe for concurrent use.
func PolicyFromDescriptors ¶
func PolicyFromDescriptors(opts ...PolicyOption) (*RolePolicy, error)
PolicyFromDescriptors builds a *RolePolicy by walking registered proto FileDescriptors and reading the per-method/per-service authz annotations. It runs ONCE at startup; the resulting policy is immutable and the shared RoleInterceptor consults it on every RPC.
For each method it resolves the effective policy:
- (forge.v1.method).authz_public = true → public (any admitted caller).
- (forge.v1.method).required_roles = [...] → those roles (any-of).
- neither, but (forge.v1.service).default_roles = [...] → the service default applies to the method.
- none of the above → the method is left OUT of the policy, so it routes through the unknown-method FailMode path (FailClosed denies). This is the runtime backstop for the generate-time completeness lint: a forgotten annotation is a loud, fail-closed event, never a silent open.
A method that sets BOTH required_roles and authz_public is a contradiction; the builder fails so the ambiguity can never ship (the lint also catches it at generate time, but the builder is the last line of defense).
func (*RolePolicy) Check ¶
func (p *RolePolicy) Check(procedure string, callerRoles []Role) error
Check reports whether a caller holding callerRoles may invoke procedure under this policy. It returns nil to allow, or a *connect.Error to deny.
Decision order:
- procedure not in the policy (proto drift / hand-mounted endpoint / probe): FailClosed (the default) denies with a once-per-procedure warning; AllowUnknownMethods serves it.
- public method: allow unconditionally.
- role-restricted method: allow iff callerRoles (expanded by the implication map) intersects the method's required roles; else deny.
Check does NOT resolve identity — the interceptor does that via the RoleResolver and passes the resolved roles in. Exposed directly so it is unit-testable and reusable from a non-Connect call site.
func (*RolePolicy) Methods ¶
func (p *RolePolicy) Methods() []string
Methods returns the procedures the policy covers, sorted. Useful for boot logging ("authz: enforcing N procedures") and for the completeness check in tests. The returned slice is a copy.
func (*RolePolicy) RequiredRoles ¶
func (p *RolePolicy) RequiredRoles(procedure string) ([]Role, bool)
RequiredRoles returns the declared required roles for a procedure and whether the procedure is known to the policy. A public method returns (nil, true). The returned slice is a copy.
type RoleResolver ¶
type RoleResolver interface {
// RolesFor returns the roles the caller in ctx holds for the given
// procedure (the full Connect method path,
// e.g. "/shop.v1.OrderService/Create"). procedure is passed so a
// resolver MAY scope roles per-procedure (e.g. resource-scoped roles),
// though most resolvers ignore it and return the caller's global roles.
RolesFor(ctx context.Context, procedure string) ([]Role, error)
}
RoleResolver maps an authenticated request to the roles its caller holds.
This is the ONE seam the app implements: identity→roles. The library calls RolesFor at request time with the request context (carrying whatever the auth layer stashed — claims, headers, an mTLS identity) and the procedure being invoked, and decides allow/deny by comparing the returned roles against the method's proto-declared required roles.
Returning an error denies the call (surfaced as PermissionDenied unless the error is already a *connect.Error). Returning an empty slice with no error means "an admitted caller with no roles" — allowed only for methods marked authz_public; every role-restricted method denies.
type RoleResolverFunc ¶
RoleResolverFunc adapts an ordinary function to RoleResolver.
func (RoleResolverFunc) RolesFor ¶
RolesFor implements RoleResolver.
type RolesDecider ¶
type RolesDecider struct {
// MethodRoles is the per-method allow-list.
MethodRoles map[string][]string
// MethodAuthRequired flips per-method auth on or off. Optional; when
// nil, every method is treated as authenticated (matches Default).
MethodAuthRequired map[string]bool
// Default is consulted when MethodRoles has no entry for the method.
// nil → fall through to FailMode (open allows, closed denies);
// empty slice → allow any authenticated user (overrides FailMode).
// A non-empty slice forces role membership even under FailOpen, which
// lets a project bias toward open-for-known but admin-only for
// unknown-but-callable procedures.
Default []string
// FailMode governs the unknown-method branch. The zero value is
// FailClosed (deny) — see the [FailMode] doc for the rationale and
// the AllowUnknownMethods opt-out.
FailMode FailMode
// OnUnknownMethod is called ONCE PER DISTINCT METHOD per process
// (allow or deny, depending on FailMode) so the foot-gun shows up in
// server logs without flooding them — steady-state traffic against
// one missing annotation produces one line, not one per request.
// nil (the default) emits a slog.Default().Warn line; pass a no-op
// to silence or wire a project-specific log shape.
// Unknown here covers BOTH the MethodAuthRequired-missing and the
// MethodRoles-missing-with-nil-Default branches — they share the
// same operator-visible symptom ("regenerate or check the proto")
// so they share the same signal.
OnUnknownMethod func(method string)
}
RolesDecider is a convenience Decider implementation that allows a call when the authenticated user has at least one of the configured roles for the requested method.
MethodRoles maps the canonical method identifier (Connect procedure or "<action>:<resource>") to the slice of allowed roles. An entry whose value is an empty slice grants access to any authenticated user.
MethodAuthRequired (optional) records which methods require authentication. When set, a method with MethodAuthRequired[method] == false is allowed unconditionally — no claims required. This is the data shape the forge authorizer_gen.go shim feeds in from proto annotations (auth_required + required_roles per RPC).
When MethodRoles has no entry for the method, behaviour is governed by the combination of FailMode + Default. The zero value fails closed — see FailMode. Set Default to e.g. []string{"admin"} to require admin for unknown procedures instead of denying them outright; an empty (non-nil) Default allows any authenticated user.
This is offered as a building block, not a mandate: the design stays interface-driven so projects can swap in OPA, Casbin, or hand-rolled matchers without touching the generated shim.