authz

package
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 12 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func ClaimsFromContext

func ClaimsFromContext(ctx context.Context) (*auth.Claims, bool)

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

func Deny(format string, args ...any) error

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

func SetClaimsLookup(lookup func(context.Context) (*auth.Claims, bool))

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.

func (AllowAll) Decide

func (AllowAll) Decide(ctx context.Context, method string, claims *auth.Claims) error

Decide implements Decider; always returns nil.

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

func (a *Authorizer) Can(ctx context.Context, claims *auth.Claims, action, resource string) error

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

type DeciderFunc func(ctx context.Context, method string, claims *auth.Claims) error

DeciderFunc adapts an ordinary function to the Decider interface.

func (DeciderFunc) Decide

func (f DeciderFunc) Decide(ctx context.Context, method string, claims *auth.Claims) error

Decide implements Decider.

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.

func (DenyAll) Decide

func (DenyAll) Decide(ctx context.Context, method string, claims *auth.Claims) error

Decide implements Decider; always returns CodePermissionDenied.

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

func WithFailMode(mode FailMode) Option

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

func WithOnUnknownMethod(fn func(method string)) Option

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

func WithRoleOverlay(overlay map[string][]string) Option

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

type RoleResolverFunc func(ctx context.Context, procedure string) ([]Role, error)

RoleResolverFunc adapts an ordinary function to RoleResolver.

func (RoleResolverFunc) RolesFor

func (f RoleResolverFunc) RolesFor(ctx context.Context, procedure string) ([]Role, error)

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.

func (RolesDecider) Decide

func (r RolesDecider) Decide(ctx context.Context, method string, claims *auth.Claims) error

Decide implements Decider for RolesDecider.

Jump to

Keyboard shortcuts

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