name: v0.x-to-authz-lib description: Migrate per-service authorizer_gen.go from inline matching logic to a thin shim over forge/pkg/authz. Same public API (NewGeneratedAuthorizer / Can / CanAccess); decision logic now lives in one tested library. relevance: migration
Migrating from inline authorizer_gen.go to forge/pkg/authz
Use this skill when forge upgrade reports a jump across the version
that ships forge/pkg/authz (typically 1.7.x → 1.8.x). It supersedes
the inline-matching shape the legacy authorizer template carried.
1. What changed
Forge versions before this release emitted a ~110-line
handlers/<svc>/authorizer_gen.go per service. The file inlined the
complete decision logic (empty-procedure check, unknown-procedure deny,
auth-not-required pass-through, role-match loop) on top of two
proto-derived data maps (methodRoles, methodAuthRequired). Every
service carried its own copy of identical matching code, and the only
way to refactor that logic was to re-render every project's templates.
Forge 1.8+ emits a ~35-line shim that delegates to forge/pkg/authz:
methodRolesandmethodAuthRequiredstay in the shim — they are per-service data.NewGeneratedAuthorizer()returns*authz.Authorizer(a type alias for*GeneratedAuthorizer), constructed with the newauthz.RolesDeciderconfigured by the two maps.- An
init()wiresmiddleware.ClaimsFromContextinto the library viaauthz.SetClaimsLookup, side-stepping themiddleware → authz → middlewareimport cycle.
The library provides the policy primitives:
authz.Authorizer— implementsmiddleware.Authorizer.Can/CanAccess, with panic recovery and connect.Error normalisation.authz.Decider— the user-extension point. The interfaceDecide(ctx, method, claims) errordescribes a single authorization decision;RolesDecider,DenyAll, andAllowAllare built-in implementations.authz.RolesDecider— proto-derived RBAC matcher, the one the generated shim uses.
Public API preserved exactly: NewGeneratedAuthorizer() still returns
something with Can(ctx, claims, action, resource) and
CanAccess(ctx, procedure), and the user-owned authorizer.go
delegates as before. Note on the unknown-method contract: the decider
fails closed (deny + once-per-method warn) — FailMode's zero value is
FailClosed. Newer scaffolds emit TestAuthorizerUnknownMethodFailMode
pinning the deny. Projects that intentionally serve procedures outside
the generated tables opt out explicitly with
FailMode: authz.AllowUnknownMethods on the RolesDecider.
2. Detection
How to tell which shape the project currently uses:
# Old shape: GeneratedAuthorizer is a struct, not a type alias, and
# the file is ~110 lines per service.
grep -l "type GeneratedAuthorizer struct" handlers/*/authorizer_gen.go 2>/dev/null \
|| echo "NEW SHAPE — already on forge/pkg/authz"
# Quick LOC check: old-shape authorizer_gen.go weighs in around 110
# lines; new-shape is closer to 35 plus one row per RPC.
wc -l handlers/*/authorizer_gen.go 2>/dev/null
3. Migration (deterministic part)
# Optional safety: list everything that's about to be regenerated.
git diff --name-only -- 'handlers/*/authorizer_gen.go' > /tmp/authz-files-before.txt
# Apply: regenerate every authorizer_gen.go in-place.
forge generate
# Verify: build should be clean. If it's not, see section 4 — almost
# certainly a hand-written reference reaches into a private symbol that
# only existed in the old shape.
go build ./...
forge generate rewrites every per-service authorizer_gen.go to the
new shim. The user-owned authorizer.go keeps compiling untouched
because GeneratedAuthorizer is now a type alias for
*authz.Authorizer and exposes the same Can / CanAccess methods.
4. Migration (manual part)
What user code might need to change:
Custom
authorizer.gothat swaps the inner authorizer. The scaffolded shape is:type Authorizer struct { generated *GeneratedAuthorizer } func NewAuthorizer() *Authorizer { return &Authorizer{generated: NewGeneratedAuthorizer()} }This still works because
*GeneratedAuthorizer == *authz.Authorizer. No edits needed unless you want to opt out of the proto-annotated RBAC entirely. To do that:// handlers/users/authorizer.go (user-owned) package users import ( "context" "github.com/reliant-labs/forge/pkg/auth" "github.com/reliant-labs/forge/pkg/authz" "<module>/pkg/middleware" ) type Authorizer struct{ inner *authz.Authorizer } func NewAuthorizer() *Authorizer { return &Authorizer{inner: authz.New(myDecider{})} } func (a *Authorizer) CanAccess(ctx context.Context, procedure string) error { return a.inner.CanAccess(ctx, procedure) } func (a *Authorizer) Can(ctx context.Context, claims *middleware.Claims, action, resource string) error { return a.inner.Can(ctx, claims, action, resource) } type myDecider struct{} func (myDecider) Decide(ctx context.Context, method string, claims *auth.Claims) error { // …project-specific policy (RBAC, OPA, ABAC, …) return nil }Direct references to
*GeneratedAuthorizeras a struct. The type alias means*GeneratedAuthorizerand*authz.Authorizerare interchangeable, but code that took(g GeneratedAuthorizer)by value won't compile (authz.Authorizeris constructed viaauthz.New(...); the zero value isn't useful). Pass the pointer through instead. Search:grep -rn "GeneratedAuthorizer{}\|var .* GeneratedAuthorizer$" --include="*.go" .Reaching into private symbols
methodRoles/methodAuthRequired. These are package-private in the same handler package. Existing references insideauthorizer.go(rare) keep working becauseauthorizer.goshares the package with the regeneratedauthorizer_gen.go. If you reached for them from outside the handler package — don't; expose a method on yourAuthorizerinstead.Custom Decider implementations. If you already wrote one to bypass the generated logic, switch from the legacy hand-rolled
Authorizerstruct to constructingauthz.New(yourDecider{})and returning that fromNewAuthorizer(). The contract is one method:type Decider interface { Decide(ctx context.Context, method string, claims *auth.Claims) error }The library handles panic recovery and connect.Error normalisation; a decider can return any error and the boundary will wrap appropriately.
5. Verification
go build ./... && go test ./... && forge lint
Plus a quick sanity check on the regenerated shim:
grep -l "type GeneratedAuthorizer = authz.Authorizer" handlers/*/authorizer_gen.go
# every authorizer_gen.go should match
If all three pass, forge upgrade will bump forge_version in
forge.yaml to the target version automatically.
6. Rollback
If something breaks:
git revert <forge-generate-commit> # undo the regen
forge upgrade --to 1.7.x # pin back to the prior version
--to 1.7.x requires having the older forge build on PATH first;
install with go install github.com/reliant-labs/forge/cmd/forge@vX.Y.Z.
The forge_version field in forge.yaml will be reset to 1.7.x so
subsequent forge generate runs won't warn about a mismatch with the
older binary.
See also
authskill — the authentication layer that produces the claimsauthz.Deciderconsumes.auth.Claims(=middleware.Claimsvia alias) flows through unchanged.apiskill — theauth_requiredproto annotation that drivesmethodAuthRequired(themethodRolestable exists in the generated shape; role logic is customized inauthorizer.go, not proto).migrations/v0.x-to-contractkit— canonical example of a per-version migration skill following this same six-section shape.