feat(controller): authorization policy layer — wheels.Policy + authorize()/can()/policyScope() - #3289
Conversation
…ize()/can()/policyScope() Implements the maintainer-approved design from #3156 (child of #2962): - vendor/wheels/Policy.cfc: default-deny base class — every standard action (index/show/new/create/edit/update/delete) returns false and scope() returns an injection-safe no-rows chain (empty whereIn, #2736). - vendor/wheels/controller/authorization.cfc: new controller/view mixin with authorize() (throws Wheels.NotAuthorized on deny, returns the record on allow, action defaults to params.action at call time), can() (non-throwing boolean for views), policyScope() (delegates to the policy's scope()), and $-prefixed public internals per Invariant 7. - Wheels.NotAuthorized surfaces as HTTP 403 through the same wiring that maps Wheels.RecordNotFound to 404 (status header committed before the throw + the onError status mapping in EventMethods). - A missing policy class throws Wheels.Policy.NotDefined in development and testing and silently denies in production (mirrors the #3079 tableName() posture); a missing policy method denies. - $currentUserForPolicy() resolves the DI service currentUser first, then a configured authenticator strategy's currentUser(), then guest. - CLI: wheels generate policy <Model> writes app/policies/<Model>Policy.cfc plus the app-level Policy.cfc stub; the wheels new app template now ships the stub too (mirrors app/models/Model.cfc). - Docs: new Authorization Policies guide, sidebar entry, and cross-link from Authentication Patterns. Cross-engine: dynamic dispatch uses Invoke(policy, action) — Adobe CF's compiler rejects local.policy[local.action]() outright (verified on the Adobe 2023 image) and extracting the function reference first drops the receiver binding on BoxLang. Verified green on Lucee 7 + SQLite (full core suite 4643 pass / 0 fail / 0 error, CLI suite 1097 pass / 0 fail) and Adobe 2023 + SQLite (controller + events areas, 599 pass / 0 fail). Closes #3156 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR adds a Pundit-shaped, default-deny authorization layer (wheels.Policy + authorize()/can()/policyScope() controller mixins, Wheels.NotAuthorized→403 wiring, a wheels generate policy generator, and a guide). I traced the implementation end-to-end and it is genuinely well-built: it explicitly and correctly navigates the cross-engine invariants, the DI/auth resolution seams use the real framework APIs, and it ships comprehensive tests, a changelog fragment, and honest docs. Verdict: comment — no correctness, cross-engine, or security finding fires; only two minor robustness/test nits below. Nothing here blocks merge.
What I verified (no findings)
- Cross-engine discipline is real, not just claimed. Dynamic dispatch uses
Invoke(policy, action)(not the Adobe-crashingobj["key"]()call form — Invariant 4); the method guardIsCustomFunction(local.policy[local.action])is bracket member access, not a call, so it's safe. All mixin functions arepublicwith$-prefixed internals (Invariant 7).$header(statusCode=403)uses a named arg, notattributeCollection=arguments(Invariant 10). The production denial path uses theabort;script keyword (Invariant 13). TheIsNull(local.allowed)guard afterInvoke()correctly handles a policy method that forgets toreturn(Adobe null-deletes the var).authorization.cfc:52-66,authorization.cfc:264-275. - Resolution seams use the correct APIs.
application.wheelsdi.containsInstance/getInstancematchesservice()inGlobal.cfc:1417-1429;getStrategyNames()/getStrategy()/SessionStrategy.currentUser()all exist undervendor/wheels/auth/. Both seams are try/catch-wrapped so a broken resolver degrades to guest rather than 500-ing every request.authorization.cfc:224-253. policyPathfollows the establishedmodelPathconvention exactly — set atapplication.\$wheels.policyPathinevents/init/views.cfm:17(right next tomodelPath) and read via\$get("policyPath"), with the sameapplication.wheels/application.\$wheelsaliasing the model path relies on.- The mixin auto-integrates via
\$integrateComponents("wheels.controller")(Controller.cfc:4) — no explicit registration needed, file placement is correct, and no existing controller method collides withauthorize/can/policyScope. - 403 wiring mirrors the 404 path in
EventMethods.\$runOnErrorand is pinned byonerrorSpec.
Correctness
No blocking findings. One low-priority robustness note:
vendor/wheels/controller/authorization.cfc:132-148—policyScope()is contracted to take a model class, and\$policyModelName()treats a string as a headless by-name value (returns the trimmed string, so theInvalidCollectionguard doesn't fire). If someone misuses it aspolicyScope("Post"), the defaultscope()eventually runs"Post".whereIn("id", [])on a string and errors rather than surfacing a clear message. Not a real-world path (every example and the docs passmodel("Post")), so this is a nicety, not a defect — consider anIsObject(arguments.collection)guard inpolicyScope()if you want the same loud-in-dev message a string currently escapes.
Tests
Coverage is strong — 23 AuthorizationSpec cases (default-deny base, grant/deny, guest, missing class/method, policyScope narrowing + composition, protected-methods registration) plus 6 generatePolicy() codegen specs and the onerror 403/500 cases. One nit:
vendor/wheels/tests/specs/events/onerrorSpec.cfc:130-137—\$expectedStatusFor()is a mirror of theEventMethods.\$runOnErrorregex rather than an exercise of the real mapping, so a divergence in the source that still matches the copied regex wouldn't be caught. This is consistent with the pre-existing 404 test in the same file (the comment even flags "keep the regexes in sync"), so it's not a regression — just calling out that the 403 guarantee rests on the mirror staying honest. (Trivia: the file also ends with no trailing newline.)
Docs / Commits
Changelog fragment (changelog.d/3156-authorization-policy-layer.added.md), the new authorization-policies.mdx guide (examples match the implementation, honest-limitations section included), sidebar entry, and cross-link are all present. Commit 9c9b1919e conforms to commitlint (feat(controller):, subject under 100 chars) and carries a DCO sign-off. No issues.
Nice work — this is a clean, disciplined implementation of a security-sensitive feature.
Closes #3156 — part of the auth roadmap #2962.
Implements the authorization/policy layer per the wheels-bot cross-framework research on #3156. The maintainer signed off on the recommended bundle (2026-07-06); the settled design calls implemented here:
Design (as approved)
vendor/wheels/Policy.cfc(sibling toModel.cfc/Controller.cfc/Job.cfc):init(user, record)storesvariables.user/variables.record; all seven standard actions (index,show,new,create,edit,update,delete) returnfalse;scope(collection)default-denies via the injection-safe emptywhereInfrom feat(web/blog,model): beyond findAll post + whereIn empty-array fix #2736 (sets the builder's always-empty flag without interpolating the property, so it composes with a model class, query builder, or scope chain)."Policy"—app/policies/Policy.cfcstub extendswheels.Policy, mirroring exactly howapp/models/Model.cfcextendswheels.Model(same-directory resolution; no new mapping registration needed). The demo app, thewheels newapp template, and the generator all ship/create the stub.vendor/wheels/controller/authorization.cfc(sibling tofilters.cfc/verifies.cfc, Invariant-7 discipline — every function public, internals$-prefixed):authorize(record [, action])— action defaults toparams.actionresolved in the body at call time; resolves the policy, dispatches, throwsWheels.NotAuthorizedon deny, returns the record so it inlines around finders.can(action [, record])— non-throwing boolean; works in views automatically (controllervariablesscope).policyScope(collection)— returnspolicy.scope(collection)for continued chaining.$policyFor(),$policyClassName(),$policyModelName(),$currentUserForPolicy(),$notAuthorized().$currentUserForPolicy()resolution order (approved): (1) DIservice("currentUser")when registered (resolution wrapped safely), (2) the first authenticator strategy exposingcurrentUser()(e.g.wheels.auth.SessionStrategy) with a non-empty principal, (3)""(guest). Apps customize by overriding the method or registering the DI service — the override seam is pinned by a test fixture controller.Wheels.Policy.NotDefinedin development/testing, silently DENIES in production — mirrors thetableName()posture from docs+model: guides and CLAUDE.md use non-existenttableName("x")setter — silent no-op, models fall back to the convention table (real setter istable()) #3079 ($get("showErrorInformation")gate). Missing policy METHOD (custom action): denies.Wheels.NotAuthorized→ HTTP 403, wired the same wayWheels.RecordNotFound→ 404:$notAuthorized()commits the 403 status header before throwing (mirroring$throwErrorOrShow404Page), and the onError status mapping inEventMethods.$runOnErrorre-asserts 403 (withonerrorSpeccoverage that pins the contract). In production, denial renders a minimal body and aborts so no policy detail leaks.before()/admin-override hook (approved omission — Laravel's is a documented foot-gun). NoverifyAuthorizedfilter in v1 (see follow-ups).wheels generate policy Post→app/policies/PostPolicy.cfc(all standard actions denying + commented grant examples) + the basePolicy.cfcstub when missing. Templates live atcli/src/templates/PolicyContent.txt/PolicyBaseContent.txt(bundled tocli/lucli/templates/codegen/at release, same as every codegen template). Help text updated.digging-deeper/authorization-policies.mdx(example-first, honest-limitations section), sidebar entry next to Authorization & Filters, cross-link fromauthentication-patterns.mdx.Cross-engine notes
Invoke(policy, action)rather than the bracket call the research sketched: local verification on the Adobe 2023 image showed Adobe's compiler rejectslocal.policy[local.action]()outright (InvalidIdentifierExceptionat compile time — it would have broken app start via$buildProtectedControllerMethods).Invoke(instance, methodName)is the cross-engine-proven form already used byQueryBuilder/ScopeChain::onMissingMethod. Extracting the function reference first was rejected because it drops receiver binding on BoxLang.StructKeyExists(policy, action) && IsCustomFunction(policy[action]), mirroring the action-dispatch gate inprocessing.cfc::$callAction.IsNullguard afterInvoke()— a policy method that forgets toreturnyields null, which deletes the variable on Adobe CF; it now denies instead of erroring.public,$-prefixed internals); production denial path uses theabort;script keyword (Invariant 13); no closures around the dispatch (Invariant 4).Heads-up
New public controller mixins land in
application.wheels.protectedControllerMethods, so apps cannot name actionsauthorize,can, orpolicyScope(framework-helper collision, #2845 behavior). Intentional; pinned by a spec.Test evidence
vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc(23 specs): default-deny base (direct + through a no-override app policy); grant/deny paths via aPostPolicyfixture;authorize()throwsWheels.NotAuthorizedon deny / returns the record on allow / defaults action fromparams.actionat call time;can()both ways incl. guest and empty record;policyScope()narrows (row counts vs. directcount(where=…)) and keeps chaining; missing-class throwsWheels.Policy.NotDefinedin testing and silently denies withshowErrorInformation=false; guest denies; custom action with no method denies;false-record denies;Wheels.Policy.MissingAction/Wheels.Policy.InvalidCollectiondev guards; protected-methods registration.onerrorSpec:Wheels.NotAuthorized→ 403,Wheels.Policy.NotDefined→ 500 pinned; mirror helper updated.CodeGenSpec: 6 newgeneratePolicy()specs (creation, base-stub creation, deny-all content, suffix normalization, no-overwrite without--force, base stub never overwritten).authorize()against a deny-all policy returned HTTP 403 through the full dispatch → onError path (body shows theWheels.NotAuthorizederror page in development);can()returnedcannot. Probe files removed.compat-matrix.yml.Follow-ups (deliberately out of scope)
verifyAuthorizedfilter (Pundit's "action never called authorize" dev guard).before()/admin-override hook — omitted by design; revisit only with a structurally-distinct "defer" value.on403/onnotauthorizedevent template convention); today production renders a minimal plain body.wheels destroy policysupport in the destroy command.🤖 Generated with Claude Code