Skip to content

feat(controller): authorization policy layer — wheels.Policy + authorize()/can()/policyScope() - #3289

Merged
bpamiri merged 1 commit into
developfrom
peter/issue-3156-policy-layer
Jul 6, 2026
Merged

feat(controller): authorization policy layer — wheels.Policy + authorize()/can()/policyScope()#3289
bpamiri merged 1 commit into
developfrom
peter/issue-3156-policy-layer

Conversation

@bpamiri

@bpamiri bpamiri commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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)

  • Pundit-shaped, method-per-action policy classes with DEFAULT-DENY baked into the base. vendor/wheels/Policy.cfc (sibling to Model.cfc/Controller.cfc/Job.cfc): init(user, record) stores variables.user / variables.record; all seven standard actions (index, show, new, create, edit, update, delete) return false; scope(collection) default-denies via the injection-safe empty whereIn from 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).
  • App policies extend "Policy"app/policies/Policy.cfc stub extends wheels.Policy, mirroring exactly how app/models/Model.cfc extends wheels.Model (same-directory resolution; no new mapping registration needed). The demo app, the wheels new app template, and the generator all ship/create the stub.
  • New controller mixin vendor/wheels/controller/authorization.cfc (sibling to filters.cfc/verifies.cfc, Invariant-7 discipline — every function public, internals $-prefixed):
    • authorize(record [, action]) — action defaults to params.action resolved in the body at call time; resolves the policy, dispatches, throws Wheels.NotAuthorized on deny, returns the record so it inlines around finders.
    • can(action [, record]) — non-throwing boolean; works in views automatically (controller variables scope).
    • policyScope(collection) — returns policy.scope(collection) for continued chaining.
    • Internals: $policyFor(), $policyClassName(), $policyModelName(), $currentUserForPolicy(), $notAuthorized().
  • $currentUserForPolicy() resolution order (approved): (1) DI service("currentUser") when registered (resolution wrapped safely), (2) the first authenticator strategy exposing currentUser() (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.
  • Missing policy CLASS: throws Wheels.Policy.NotDefined in development/testing, silently DENIES in production — mirrors the tableName() posture from docs+model: guides and CLAUDE.md use non-existent tableName("x") setter — silent no-op, models fall back to the convention table (real setter is table()) #3079 ($get("showErrorInformation") gate). Missing policy METHOD (custom action): denies.
  • Wheels.NotAuthorized → HTTP 403, wired the same way Wheels.RecordNotFound → 404: $notAuthorized() commits the 403 status header before throwing (mirroring $throwErrorOrShow404Page), and the onError status mapping in EventMethods.$runOnError re-asserts 403 (with onerrorSpec coverage that pins the contract). In production, denial renders a minimal body and aborts so no policy detail leaks.
  • No before()/admin-override hook (approved omission — Laravel's is a documented foot-gun). No verifyAuthorized filter in v1 (see follow-ups).
  • CLI generator: wheels generate policy Postapp/policies/PostPolicy.cfc (all standard actions denying + commented grant examples) + the base Policy.cfc stub when missing. Templates live at cli/src/templates/PolicyContent.txt / PolicyBaseContent.txt (bundled to cli/lucli/templates/codegen/ at release, same as every codegen template). Help text updated.
  • Docs: new guide digging-deeper/authorization-policies.mdx (example-first, honest-limitations section), sidebar entry next to Authorization & Filters, cross-link from authentication-patterns.mdx.

Cross-engine notes

  • Dynamic dispatch uses Invoke(policy, action) rather than the bracket call the research sketched: local verification on the Adobe 2023 image showed Adobe's compiler rejects local.policy[local.action]() outright (InvalidIdentifierException at compile time — it would have broken app start via $buildProtectedControllerMethods). Invoke(instance, methodName) is the cross-engine-proven form already used by QueryBuilder/ScopeChain::onMissingMethod. Extracting the function reference first was rejected because it drops receiver binding on BoxLang.
  • Method-existence guard is StructKeyExists(policy, action) && IsCustomFunction(policy[action]), mirroring the action-dispatch gate in processing.cfc::$callAction.
  • IsNull guard after Invoke() — a policy method that forgets to return yields null, which deletes the variable on Adobe CF; it now denies instead of erroring.
  • Mixin file follows Invariant 7 (all public, $-prefixed internals); production denial path uses the abort; 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 actions authorize, can, or policyScope (framework-helper collision, #2845 behavior). Intentional; pinned by a spec.

Test evidence

  • New vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc (23 specs): default-deny base (direct + through a no-override app policy); grant/deny paths via a PostPolicy fixture; authorize() throws Wheels.NotAuthorized on deny / returns the record on allow / defaults action from params.action at call time; can() both ways incl. guest and empty record; policyScope() narrows (row counts vs. direct count(where=…)) and keeps chaining; missing-class throws Wheels.Policy.NotDefined in testing and silently denies with showErrorInformation=false; guest denies; custom action with no method denies; false-record denies; Wheels.Policy.MissingAction / Wheels.Policy.InvalidCollection dev guards; protected-methods registration.
  • onerrorSpec: Wheels.NotAuthorized → 403, Wheels.Policy.NotDefined → 500 pinned; mirror helper updated.
  • CodeGenSpec: 6 new generatePolicy() specs (creation, base-stub creation, deny-all content, suffix normalization, no-overwrite without --force, base stub never overwritten).
  • Lucee 7 + SQLite (local, worktree server): full core suite 4643 pass / 0 fail / 0 error (18 pre-existing skips); CLI suite 1097 pass / 0 fail / 0 error (60 pre-existing skips).
  • Adobe CF 2023 + SQLite (local Docker, per the worktree recipe): controller area 539 pass / 0 fail / 0 error (AuthorizationSpec 23/23) and events area 60 pass / 0 fail / 0 error. This run is what caught the bracket-call compile crash before push.
  • End-to-end HTTP check on the dev server: a probe controller calling authorize() against a deny-all policy returned HTTP 403 through the full dispatch → onError path (body shows the Wheels.NotAuthorized error page in development); can() returned cannot. Probe files removed.
  • Not run locally: full engine × DB matrix (BoxLang, Adobe 2025, MySQL/Postgres/MSSQL/…) — relies on CI compat-matrix.yml.

Follow-ups (deliberately out of scope)

  • verifyAuthorized filter (Pundit's "action never called authorize" dev guard).
  • before()/admin-override hook — omitted by design; revisit only with a structurally-distinct "defer" value.
  • Customizable production 403 page (an on403/onnotauthorized event template convention); today production renders a minimal plain body.
  • wheels destroy policy support in the destroy command.

🤖 Generated with Claude Code

…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>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-crashing obj["key"]() call form — Invariant 4); the method guard IsCustomFunction(local.policy[local.action]) is bracket member access, not a call, so it's safe. All mixin functions are public with $-prefixed internals (Invariant 7). $header(statusCode=403) uses a named arg, not attributeCollection=arguments (Invariant 10). The production denial path uses the abort; script keyword (Invariant 13). The IsNull(local.allowed) guard after Invoke() correctly handles a policy method that forgets to return (Adobe null-deletes the var). authorization.cfc:52-66, authorization.cfc:264-275.
  • Resolution seams use the correct APIs. application.wheelsdi.containsInstance/getInstance matches service() in Global.cfc:1417-1429; getStrategyNames()/getStrategy()/SessionStrategy.currentUser() all exist under vendor/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.
  • policyPath follows the established modelPath convention exactly — set at application.\$wheels.policyPath in events/init/views.cfm:17 (right next to modelPath) and read via \$get("policyPath"), with the same application.wheels/application.\$wheels aliasing 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 with authorize/can/policyScope.
  • 403 wiring mirrors the 404 path in EventMethods.\$runOnError and is pinned by onerrorSpec.

Correctness

No blocking findings. One low-priority robustness note:

  • vendor/wheels/controller/authorization.cfc:132-148policyScope() is contracted to take a model class, and \$policyModelName() treats a string as a headless by-name value (returns the trimmed string, so the InvalidCollection guard doesn't fire). If someone misuses it as policyScope("Post"), the default scope() 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 pass model("Post")), so this is a nicety, not a defect — consider an IsObject(arguments.collection) guard in policyScope() 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 the EventMethods.\$runOnError regex 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.

@bpamiri
bpamiri merged commit 5509082 into develop Jul 6, 2026
20 checks passed
@bpamiri
bpamiri deleted the peter/issue-3156-policy-layer branch July 6, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: authorization/policy layer (wheels.Policy + authorize()/can()/policyScope())

1 participant