diff --git a/app/policies/Policy.cfc b/app/policies/Policy.cfc new file mode 100644 index 0000000000..3c3d246f0b --- /dev/null +++ b/app/policies/Policy.cfc @@ -0,0 +1,13 @@ +/** + * This is the parent policy file that all your policies should extend. + * You can add functions to this file to make them available in all your policies. + * Do not delete this file. + * + * Policies are DEFAULT-DENY: every standard action on the wheels.Policy base + * returns false, so each policy must explicitly override a method to grant it. + * Scaffold a policy with `wheels generate policy Post`. + */ +component extends="wheels.Policy" { + + +} diff --git a/changelog.d/3156-authorization-policy-layer.added.md b/changelog.d/3156-authorization-policy-layer.added.md new file mode 100644 index 0000000000..9bc13197b9 --- /dev/null +++ b/changelog.d/3156-authorization-policy-layer.added.md @@ -0,0 +1 @@ +- Authorization policy layer: new `wheels.Policy` base class (default-deny — every standard action denies and `scope()` returns a no-rows chain) with `app/policies/Policy.cfc` resolution, plus `authorize()` / `can()` / `policyScope()` controller-and-view helpers. `authorize()` throws `Wheels.NotAuthorized` (HTTP 403, mapped like `Wheels.RecordNotFound` → 404) and returns the record on allow; a missing policy class throws `Wheels.Policy.NotDefined` in development/testing and silently denies in production. The current user resolves through the `currentUser` DI service, then a configured authenticator strategy's `currentUser()`, then guest — customizable by overriding `$currentUserForPolicy()`. Includes a `wheels generate policy ` CLI generator and a new Authorization Policies guide (#3156, part of #2962) diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 25704db14b..5e8e03fd15 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -507,7 +507,7 @@ component extends="modules.BaseModule" { // ───────────────────────────────────────────────── /** - * hint: Generate Wheels components (model, controller, view, migration, scaffold, route, test, property, api-resource, helper, snippets) + * hint: Generate Wheels components (model, controller, view, migration, scaffold, route, test, property, api-resource, helper, policy, snippets) */ public string function generate() { var args = new services.ArgSpec().toArgv(structuredArgs(arguments)); @@ -527,6 +527,7 @@ component extends="modules.BaseModule" { out(" test Generate a test spec file"); out(" property Generate an add-column migration for a model property"); out(" helper Generate a helper file in app/helpers/"); + out(" policy Generate an authorization policy in app/policies/ (default-deny)"); out(" snippets Generate common code pattern snippets (auth, soft-delete, api, etc.)"); out(" admin Generate admin CRUD interface for an existing model"); out(""); @@ -541,6 +542,7 @@ component extends="modules.BaseModule" { out(" wheels generate test model User"); out(" wheels generate property User email:string"); out(" wheels generate helper formatting"); + out(" wheels generate policy Post"); out(" wheels generate snippets auth"); out(" wheels generate admin User"); return ""; @@ -584,6 +586,8 @@ component extends="modules.BaseModule" { case "helper": case "h": return generateHelper(remaining); + case "policy": + return generatePolicy(remaining); case "snippets": return generateSnippets(remaining); case "admin": @@ -3930,6 +3934,56 @@ component extends="modules.BaseModule" { return ""; } + private string function generatePolicy(required array args) { + // Parse --force flag from the args list + var force = false; + var positional = []; + for (var a in args) { + if (a == "--force") { + force = true; + } else { + arrayAppend(positional, a); + } + } + + if (!arrayLen(positional)) { + out("Usage: wheels generate policy [--force]", "yellow"); + out(" Example: wheels generate policy Post"); + out(""); + out("Writes app/policies/Policy.cfc — default-deny, one method per action."); + out("Enforce with authorize()/can()/policyScope() in your controllers and views."); + return ""; + } + + var codegen = getService("codegen"); + var validation = codegen.validateName(positional[1], "policy"); + if (!validation.valid) { + out("Invalid policy name: #arrayToList(validation.errors, '; ')#", "red"); + return ""; + } + + var result = codegen.generatePolicy(name = positional[1], force = force); + + if (result.success) { + if (structKeyExists(result, "baseCreated") && result.baseCreated) { + printCreated("app/policies/Policy.cfc"); + } + // Derive the actual file name (CodeGen appends the "Policy" suffix) + var fileName = listLast(result.path, "/\"); + printCreated("app/policies/#fileName#"); + + out(""); + out("Policy created! Next steps:", "green"); + out(" 1. Edit app/policies/#fileName# — every action denies until you grant it"); + out(" 2. Enforce in a controller action: authorize(post)"); + out(" 3. Check in views without throwing: can('update', post)"); + out(" 4. Narrow index collections: policyScope(model('#reReplace(fileName, 'Policy\.cfc$', '')#')).findAll()"); + } else { + out(result.error, "red"); + } + return ""; + } + private string function generateSnippets(required array args) { var force = false; var positional = []; diff --git a/cli/lucli/services/CodeGen.cfc b/cli/lucli/services/CodeGen.cfc index b97afaf1f0..624fa201f8 100644 --- a/cli/lucli/services/CodeGen.cfc +++ b/cli/lucli/services/CodeGen.cfc @@ -385,6 +385,59 @@ component { return result; } + /** + * Generate an authorization policy CFC file (issue #3156). + * + * Writes app/policies/Policy.cfc with every standard action + * denying (policies are default-deny) plus commented grant examples. Also + * scaffolds the app-level app/policies/Policy.cfc base stub when missing so + * `extends="Policy"` resolves (mirrors app/models/Model.cfc). + */ + public struct function generatePolicy( + required string name, + string description = "", + boolean force = false + ) { + var modelName = variables.helpers.capitalize(arguments.name); + // Accept both "Post" and "PostPolicy" — normalize to the model name. + if (reFindNoCase("Policy$", modelName) && len(modelName) > 6) { + modelName = left(modelName, len(modelName) - 6); + } + var policyName = modelName & "Policy"; + var filePath = variables.projectRoot & "/app/policies/#policyName#.cfc"; + + if (fileExists(filePath) && !arguments.force) { + return {success: false, error: "Policy already exists: app/policies/#policyName#.cfc", path: filePath, baseCreated: false}; + } + + // Ensure the parent Policy.cfc stub exists. Never overwritten. + var baseCreated = false; + if (!fileExists(variables.projectRoot & "/app/policies/Policy.cfc")) { + var baseResult = variables.templateService.generateFromTemplate( + template = "PolicyBaseContent.txt", + destination = "app/policies/Policy.cfc", + context = {timestamp: dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss")} + ); + baseCreated = baseResult.success; + } + + var context = { + policyName: policyName, + modelName: modelName, + description: arguments.description, + timestamp: dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss") + }; + + var result = variables.templateService.generateFromTemplate( + template = "PolicyContent.txt", + destination = "app/policies/#policyName#.cfc", + context = context + ); + result.baseCreated = baseCreated; + + return result; + } + /** * Validate name for code generation */ diff --git a/cli/lucli/templates/app/app/policies/Policy.cfc b/cli/lucli/templates/app/app/policies/Policy.cfc new file mode 100644 index 0000000000..3c3d246f0b --- /dev/null +++ b/cli/lucli/templates/app/app/policies/Policy.cfc @@ -0,0 +1,13 @@ +/** + * This is the parent policy file that all your policies should extend. + * You can add functions to this file to make them available in all your policies. + * Do not delete this file. + * + * Policies are DEFAULT-DENY: every standard action on the wheels.Policy base + * returns false, so each policy must explicitly override a method to grant it. + * Scaffold a policy with `wheels generate policy Post`. + */ +component extends="wheels.Policy" { + + +} diff --git a/cli/lucli/tests/specs/services/CodeGenSpec.cfc b/cli/lucli/tests/specs/services/CodeGenSpec.cfc index 6b9370eb6f..504da638a7 100644 --- a/cli/lucli/tests/specs/services/CodeGenSpec.cfc +++ b/cli/lucli/tests/specs/services/CodeGenSpec.cfc @@ -267,6 +267,60 @@ component extends="wheels.wheelstest.system.BaseSpec" { }); + describe("generatePolicy()", () => { + + it("creates the policy and the base Policy.cfc stub on first run", () => { + var result = codegen.generatePolicy(name = "Gadget"); + expect(result.success).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/GadgetPolicy.cfc")).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/Policy.cfc")).toBeTrue(); + expect(result.baseCreated).toBeTrue(); + }); + + it("policy extends Policy and declares every standard action denying", () => { + codegen.generatePolicy(name = "Widget", force = true); + var content = fileRead(tempRoot & "/app/policies/WidgetPolicy.cfc"); + expect(content).toInclude('extends="Policy"'); + for (var actionName in ["index", "show", "new", "create", "edit", "update", "delete"]) { + expect(content).toInclude("function #actionName#("); + } + expect(content).toInclude("return false;"); + }); + + it("base stub extends wheels.Policy", () => { + codegen.generatePolicy(name = "Sprocket", force = true); + var content = fileRead(tempRoot & "/app/policies/Policy.cfc"); + expect(content).toInclude('extends="wheels.Policy"'); + }); + + it("normalizes a name already carrying the Policy suffix", () => { + var result = codegen.generatePolicy(name = "ArticlePolicy", force = true); + expect(result.success).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/ArticlePolicy.cfc")).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/ArticlePolicyPolicy.cfc")).toBeFalse(); + }); + + it("refuses to overwrite an existing policy without force", () => { + codegen.generatePolicy(name = "Doohickey", force = true); + var path = tempRoot & "/app/policies/DoohickeyPolicy.cfc"; + fileWrite(path, "// SENTINEL"); + var result = codegen.generatePolicy(name = "Doohickey"); + expect(result.success).toBeFalse(); + expect(fileRead(path)).toInclude("SENTINEL"); + }); + + it("never overwrites an existing base Policy.cfc stub", () => { + codegen.generatePolicy(name = "Flange", force = true); + var basePath = tempRoot & "/app/policies/Policy.cfc"; + fileWrite(basePath, "// BASE SENTINEL"); + var result = codegen.generatePolicy(name = "Grommet", force = true); + expect(result.success).toBeTrue(); + expect(result.baseCreated).toBeFalse(); + expect(fileRead(basePath)).toInclude("BASE SENTINEL"); + }); + + }); + describe("validateName()", () => { it("rejects empty name", () => { diff --git a/cli/src/templates/PolicyBaseContent.txt b/cli/src/templates/PolicyBaseContent.txt new file mode 100644 index 0000000000..10f3acbb5b --- /dev/null +++ b/cli/src/templates/PolicyBaseContent.txt @@ -0,0 +1,12 @@ +/** + * This is the parent policy file that all your policies should extend. + * You can add functions to this file to make them available in all your policies. + * Do not delete this file. + * + * Policies are DEFAULT-DENY: every standard action on the wheels.Policy base + * returns false, so each policy must explicitly override a method to grant it. + */ +component extends="wheels.Policy" { + + +} diff --git a/cli/src/templates/PolicyContent.txt b/cli/src/templates/PolicyContent.txt new file mode 100644 index 0000000000..ac8fb7df59 --- /dev/null +++ b/cli/src/templates/PolicyContent.txt @@ -0,0 +1,67 @@ +|DescriptionComment|/** + * Authorization policy for the {{modelName}} model — answers "may this user + * perform this action on this {{modelName}}?". + * + * Policies are DEFAULT-DENY: every method below denies until you change it. + * `variables.user` holds the current identity (empty string for guests) and + * `variables.record` holds the {{modelName}} being authorized. + * + * Enforce in a controller action: authorize(post); + * Check without throwing (views): can("update", post) + * Narrow an index collection: policyScope(model("{{modelName}}")).findAll() + */ +component extends="Policy" { + + public boolean function index() { + return false; + } + + public boolean function show() { + return false; + } + + public boolean function new() { + return false; + } + + public boolean function create() { + return false; + } + + public boolean function edit() { + return false; + } + + public boolean function update() { + return false; + } + + public boolean function delete() { + return false; + } + + // Examples — adapt and replace the denials above: + // + // Grant to any signed-in user: + // public boolean function index() { + // return IsStruct(variables.user) && !StructIsEmpty(variables.user); + // } + // + // Grant to the record's owner: + // public boolean function update() { + // return IsStruct(variables.user) + // && StructKeyExists(variables.user, "id") + // && variables.user.id == variables.record.userId; + // } + + /** + * Narrows a collection to the records the user may see (used by + * policyScope() for index actions). Inherits "no rows" from the base — + * override to widen, e.g.: + * + * public any function scope(required any collection) { + * return arguments.collection.where("userId", variables.user.id); + * } + */ + +} diff --git a/vendor/wheels/Policy.cfc b/vendor/wheels/Policy.cfc new file mode 100644 index 0000000000..a0cd0ec421 --- /dev/null +++ b/vendor/wheels/Policy.cfc @@ -0,0 +1,113 @@ +/** + * Base Policy class for the Wheels authorization layer (issue #3156). + * + * A policy answers "may this user perform this action on this record?" with one + * method per action (Pundit-style). This base class is DEFAULT-DENY: every + * standard action returns `false` and `scope()` returns a no-rows chain, so an + * app policy must explicitly override a method to grant access. + * + * App policies live in `app/policies/Policy.cfc` and extend the + * app-level `Policy.cfc` stub in the same folder (which extends `wheels.Policy`, + * mirroring how `app/models/Model.cfc` extends `wheels.Model`). Scaffold one + * with `wheels generate policy Post`. + * + * Usage: + * // app/policies/PostPolicy.cfc + * component extends="Policy" { + * public boolean function update() { + * return IsStruct(variables.user) + * && StructKeyExists(variables.user, "id") + * && variables.user.id == variables.record.authorId; + * } + * public any function scope(required any collection) { + * if (IsStruct(variables.user) && StructKeyExists(variables.user, "id")) { + * return arguments.collection.where("authorId", variables.user.id); + * } + * return super.scope(arguments.collection); + * } + * } + * + * Controllers and views consume policies through the `authorize()`, `can()`, + * and `policyScope()` helpers mixed in from `wheels.controller.authorization`. + * + * [section: Authorization] + * [category: Core] + */ +component { + + /** + * Stores the authenticated identity and the record under evaluation. + * + * @user The authenticated identity (typically a struct or model instance), or an empty string for a guest. + * @record The model instance or model class being authorized, or an empty string for headless policies. + */ + public any function init(any user = "", any record = "") { + variables.user = arguments.user; + variables.record = arguments.record; + return this; + } + + /** + * May the user list records? Default-deny — override in your policy to grant. + */ + public boolean function index() { + return false; + } + + /** + * May the user view this record? Default-deny — override in your policy to grant. + */ + public boolean function show() { + return false; + } + + /** + * May the user see the new-record form? Default-deny — override in your policy to grant. + */ + public boolean function new() { + return false; + } + + /** + * May the user create a record? Default-deny — override in your policy to grant. + */ + public boolean function create() { + return false; + } + + /** + * May the user see the edit form for this record? Default-deny — override in your policy to grant. + */ + public boolean function edit() { + return false; + } + + /** + * May the user update this record? Default-deny — override in your policy to grant. + */ + public boolean function update() { + return false; + } + + /** + * May the user delete this record? Default-deny — override in your policy to grant. + */ + public boolean function delete() { + return false; + } + + /** + * Narrows a collection to the records the user may see (used by `policyScope()` + * for `index` actions). Default-deny: returns a no-rows chain. The empty + * `whereIn` sets the query builder's injection-safe always-empty flag (see + * ##2736) without interpolating the property name into SQL, so it composes + * with any model, query-builder chain, or scope chain. Override in your + * policy to widen. + * + * @collection The model class (or chainable query builder / scope chain) to narrow. + */ + public any function scope(required any collection) { + return arguments.collection.whereIn("id", []); + } + +} diff --git a/vendor/wheels/controller/authorization.cfc b/vendor/wheels/controller/authorization.cfc new file mode 100644 index 0000000000..0daf3cf209 --- /dev/null +++ b/vendor/wheels/controller/authorization.cfc @@ -0,0 +1,277 @@ +component { + /** + * Authorizes the current user for an action on a record by dispatching to the + * record's policy (`app/policies/Policy.cfc`). Throws + * `Wheels.NotAuthorized` (HTTP 403) when the policy denies, and returns the + * record unchanged when it allows so the call can be inlined: + * + * ``` + * function update() { + * post = authorize(model("Post").findByKey(params.key)); + * post.update(params.post); + * } + * ``` + * + * A missing policy class throws `Wheels.Policy.NotDefined` in development and + * testing (loud, Pundit-style, to catch typos) and silently denies in + * production — the same environment posture as `tableName()` (##3079). A + * policy class that lacks a method for the action denies. + * + * [section: Controller] + * [category: Authorization Functions] + * + * @record The model instance (or model class / model name string) to authorize against. + * @action The policy method to dispatch. Defaults to the current `params.action`, resolved at call time. + */ + public any function authorize(required any record, string action = "") { + local.action = arguments.action; + if (!Len(local.action)) { + if ( + StructKeyExists(variables, "params") + && IsStruct(variables.params) + && StructKeyExists(variables.params, "action") + ) { + local.action = variables.params.action; + } else if ($get("showErrorInformation")) { + Throw( + type = "Wheels.Policy.MissingAction", + message = "authorize() could not resolve an action to authorize.", + extendedInfo = "No `action` argument was passed and `params.action` is not available on this controller. Pass the action explicitly, e.g. `authorize(record=post, action=""update"")`." + ); + } + } + local.modelName = $policyModelName(arguments.record); + local.policy = $policyFor(arguments.record); + local.allowed = false; + if ( + IsObject(local.policy) + && Len(local.action) + && StructKeyExists(local.policy, local.action) + && IsCustomFunction(local.policy[local.action]) + ) { + // Dynamic dispatch via the built-in Invoke() — Adobe CF's compiler + // rejects a direct `local.policy[local.action]()` call outright + // (InvalidIdentifierException at compile time, verified on Adobe + // 2023), and extracting the function reference first drops the + // receiver binding on BoxLang. Invoke(instance, methodName) is the + // cross-engine-proven form (see QueryBuilder/ScopeChain + // onMissingMethod). The StructKeyExists + IsCustomFunction guard + // mirrors the action-dispatch gate in processing.cfc ($callAction). + local.allowed = Invoke(local.policy, local.action); + // A policy method that forgets to return yields null — on Adobe CF a + // null assignment deletes the variable, so re-materialize the deny. + if (IsNull(local.allowed)) { + local.allowed = false; + } + } + if (!IsBoolean(local.allowed) || !local.allowed) { + $notAuthorized(action = local.action, modelName = local.modelName); + } + return arguments.record; + } + + /** + * Non-throwing boolean policy check for conditionals and views (views run in + * the controller's `variables` scope, so `can()` is available in templates + * automatically): + * + * ``` + * ##linkTo(text="Edit", route="editPost", key=post.id)## + * ``` + * + * Returns `false` (deny) for a guest, for an empty record, and for actions the + * policy has no method for. A missing policy class still throws + * `Wheels.Policy.NotDefined` in development/testing so typos fail loud; in + * production it returns `false`. + * + * [section: Controller] + * [category: Authorization Functions] + * + * @action The policy method to check. + * @record The model instance (or model class / model name string) to check against. Empty string denies. + */ + public boolean function can(required string action, any record = "") { + local.policy = $policyFor(arguments.record); + if ( + !IsObject(local.policy) + || !StructKeyExists(local.policy, arguments.action) + || !IsCustomFunction(local.policy[arguments.action]) + ) { + return false; + } + // Dynamic dispatch via Invoke() (see authorize() for the cross-engine + // reasoning). The IsNull guard covers a policy method that forgets to + // return — null deletes the variable on Adobe CF. + local.allowed = Invoke(local.policy, arguments.action); + return !IsNull(local.allowed) && IsBoolean(local.allowed) && local.allowed; + } + + /** + * Narrows a collection to the records the current user may see by delegating + * to the policy's `scope()` method. Returns whatever the policy returns — + * conventionally a chainable finder you keep composing: + * + * ``` + * function index() { + * posts = policyScope(model("Post")).findAll(page = params.page, perPage = 25); + * } + * ``` + * + * Pass the model class first and chain scopes after the call + * (`policyScope(model("Post")).active()`) — a query-builder or scope chain + * that is already in flight cannot be introspected for its model. When the + * policy class is missing, this throws `Wheels.Policy.NotDefined` in + * development/testing and returns a default-deny (no rows) chain in + * production. + * + * [section: Controller] + * [category: Authorization Functions] + * + * @collection The model class to narrow. + */ + public any function policyScope(required any collection) { + local.modelName = $policyModelName(arguments.collection); + if (!Len(local.modelName) && $get("showErrorInformation")) { + Throw( + type = "Wheels.Policy.InvalidCollection", + message = "policyScope() could not derive a model from the passed collection.", + extendedInfo = "Pass the model class first and chain from the result, e.g. `policyScope(model(""Post"")).active().findAll()`. Query-builder and scope chains that are already in flight cannot be passed to policyScope()." + ); + } + local.policy = $policyFor(arguments.collection); + if (!IsObject(local.policy)) { + // Production missing-policy posture: default-deny (no rows). The empty + // whereIn sets the injection-safe always-empty flag (##2736) without + // interpolating the property name into SQL. + return arguments.collection.whereIn("id", []); + } + return local.policy.scope(arguments.collection); + } + + /** + * Internal function. Resolves and instantiates the policy for a record. + * Returns the initialized policy object, or an empty string when no policy + * applies (which callers treat as deny). A resolvable model name whose policy + * file is missing throws `Wheels.Policy.NotDefined` in development/testing + * and returns an empty string (silent deny) in production. + */ + public any function $policyFor(required any record) { + local.modelName = $policyModelName(arguments.record); + if (!Len(local.modelName)) { + return ""; + } + local.className = $policyClassName(local.modelName); + local.policyPath = $get("policyPath"); + local.file = false; + if (DirectoryExists(ExpandPath(local.policyPath))) { + local.file = $fileExistsNoCase(ExpandPath(local.policyPath & "/" & local.className & ".cfc")); + } + if (IsBoolean(local.file) && !local.file) { + if ($get("showErrorInformation")) { + Throw( + type = "Wheels.Policy.NotDefined", + message = "No policy found for the `#local.modelName#` model.", + extendedInfo = "Create `#local.policyPath#/#local.className#.cfc` (e.g. by running `wheels generate policy #local.modelName#`) with one method per action to grant. In production a missing policy silently denies instead of throwing." + ); + } + return ""; + } + local.componentPath = ListChangeDelims(local.policyPath, ".", "/") & "." & SpanExcluding(local.file, "."); + local.policy = CreateObject("component", local.componentPath); + local.policy.init(user = $currentUserForPolicy(), record = arguments.record); + return local.policy; + } + + /** + * Internal function. Derives the model name a policy should be resolved for: + * model instances and model classes report their class model name, strings + * pass through (headless / by-name checks), and everything else — including + * the boolean `false` a missed finder returns — yields an empty string. + */ + public string function $policyModelName(required any record) { + if (IsBoolean(arguments.record)) { + return ""; + } + if (IsSimpleValue(arguments.record)) { + return Trim(arguments.record); + } + if (IsObject(arguments.record) && StructKeyExists(arguments.record, "$classData")) { + local.classData = arguments.record.$classData(); + if (StructKeyExists(local.classData, "modelName")) { + return local.classData.modelName; + } + } + return ""; + } + + /** + * Internal function. Maps a model name to its conventional policy class name + * (`Post` -> `PostPolicy`). + */ + public string function $policyClassName(required string modelName) { + return capitalize(arguments.modelName) & "Policy"; + } + + /** + * Internal function. Resolves the identity policies are evaluated against, in + * order: (1) the DI service registered as `currentUser` when present, (2) the + * first registered authenticator strategy that exposes a `currentUser()` + * method (e.g. `wheels.auth.SessionStrategy`) and reports a non-empty + * principal, (3) an empty string (guest). Apps customize by registering the + * `currentUser` DI service or by overriding this method on their base + * controller. + */ + public any function $currentUserForPolicy() { + // 1. Explicit DI registration wins. + try { + if (IsDefined("application.wheelsdi") && application.wheelsdi.containsInstance("currentUser")) { + return application.wheelsdi.getInstance("currentUser"); + } + } catch (any e) { + // A broken resolver must not turn every request into a 500 — fall through to the next seam. + } + // 2. A configured authenticator whose strategy can report the current user. + try { + if (IsDefined("application.wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + local.authenticator = application.wheelsdi.getInstance("authenticator"); + local.strategyNames = local.authenticator.getStrategyNames(); + for (local.strategyName in local.strategyNames) { + local.strategy = local.authenticator.getStrategy(local.strategyName); + if (StructKeyExists(local.strategy, "currentUser")) { + local.candidate = local.strategy.currentUser(); + if (IsStruct(local.candidate) && !StructIsEmpty(local.candidate)) { + return local.candidate; + } + } + } + } + } catch (any e) { + // Session scope unavailable or authenticator misconfigured — treat as guest. + } + // 3. Guest. + return ""; + } + + /** + * Internal function. Surfaces a policy denial as HTTP 403, mirroring how + * `$throwErrorOrShow404Page()` wires `Wheels.RecordNotFound` to 404: the + * status header is committed first, then development/testing throw + * `Wheels.NotAuthorized` (re-asserted to 403 by the onError status mapping in + * `wheels.events.EventMethods`) while production renders a minimal body and + * aborts so no policy detail leaks. + */ + public void function $notAuthorized(required string action, string modelName = "") { + $header(statusCode = 403); + if ($get("showErrorInformation")) { + local.target = Len(arguments.modelName) ? " on `#arguments.modelName#`" : ""; + Throw( + type = "Wheels.NotAuthorized", + message = "Not authorized to perform the `#arguments.action#` action#local.target#.", + extendedInfo = "The resolved policy denied this action (policies are default-deny). Override the `#arguments.action#` method in the policy to grant access. This error maps to HTTP 403." + ); + } else { + WriteOutput("Forbidden"); + abort; + } + } +} diff --git a/vendor/wheels/events/EventMethods.cfc b/vendor/wheels/events/EventMethods.cfc index ec055aaaca..a6a2424c4b 100644 --- a/vendor/wheels/events/EventMethods.cfc +++ b/vendor/wheels/events/EventMethods.cfc @@ -81,21 +81,30 @@ component extends="wheels.Global" implements="wheels.interfaces.events.EventHand // ViewNotFound, etc) is a 404, as is `Wheels.ActionNotAllowed` // — the action-dispatch gate blocks framework helpers and // $-prefixed internals by treating them as missing actions - // (#2845, #3075); everything else is a 500. + // (#2845, #3075); `Wheels.NotAuthorized` — a policy denial + // from the authorization layer (#3156) — is a 403; everything + // else is a 500. // Set the status BEFORE writing the body so the response // header is committed at the right code regardless of // when the servlet engine flushes (HTML-format Wheels // errors used to render with HTTP 200 because no // $header(statusCode=...) fired before the body was // written — see GH #2319). Note: $throwErrorOrShow404Page - // already calls $header(statusCode=404) before throwing, - // but onError reaches us via Application.cfc which can - // reset the response, so we re-assert the status here. + // already calls $header(statusCode=404) before throwing + // (and the authorization mixin's $notAuthorized() calls + // $header(statusCode=403)), but onError reaches us via + // Application.cfc which can reset the response, so we + // re-assert the status here. if ( StructKeyExists(local.wheelsError, "type") && ReFindNoCase("^Wheels\.([A-Za-z]*NotFound|ActionNotAllowed)$", local.wheelsError.type) ) { $header(statusCode = 404); + } else if ( + StructKeyExists(local.wheelsError, "type") + && ReFindNoCase("^Wheels\.NotAuthorized$", local.wheelsError.type) + ) { + $header(statusCode = 403); } else { $header(statusCode = 500); } diff --git a/vendor/wheels/events/init/views.cfm b/vendor/wheels/events/init/views.cfm index 8a63c18b32..ca21f9bb07 100644 --- a/vendor/wheels/events/init/views.cfm +++ b/vendor/wheels/events/init/views.cfm @@ -14,6 +14,7 @@ application.$wheels.filePath = "files"; application.$wheels.imagePath = "images"; application.$wheels.javascriptPath = "javascripts"; application.$wheels.modelPath = "/app/models"; +application.$wheels.policyPath = "/app/policies"; application.$wheels.pluginPath = "/plugins"; application.$wheels.pluginComponentPath = "/plugins"; application.$wheels.packagePath = "/vendor"; diff --git a/vendor/wheels/tests/_assets/controllers/Authorization.cfc b/vendor/wheels/tests/_assets/controllers/Authorization.cfc new file mode 100644 index 0000000000..e0692b9ffd --- /dev/null +++ b/vendor/wheels/tests/_assets/controllers/Authorization.cfc @@ -0,0 +1,16 @@ +component extends="Controller" { + + /** + * Overrides the authorization mixin's identity resolver — methods declared on + * the controller win over mixins in $integrateComponents(), which is also the + * documented app-side customization seam. Lets specs control the current user + * without touching the DI container or the session scope. + */ + public any function $currentUserForPolicy() { + if (StructKeyExists(request, "$policyTestUser")) { + return request.$policyTestUser; + } + return ""; + } + +} diff --git a/vendor/wheels/tests/_assets/policies/AuthorPolicy.cfc b/vendor/wheels/tests/_assets/policies/AuthorPolicy.cfc new file mode 100644 index 0000000000..bdfc4a8fd1 --- /dev/null +++ b/vendor/wheels/tests/_assets/policies/AuthorPolicy.cfc @@ -0,0 +1,9 @@ +/** + * Test fixture policy for the Author model with NO overrides — every action and + * the scope inherit the DEFAULT-DENY behavior from the wheels.Policy base, so + * specs can pin the base-class contract through a real app-style policy. + */ +component extends="Policy" { + + +} diff --git a/vendor/wheels/tests/_assets/policies/Policy.cfc b/vendor/wheels/tests/_assets/policies/Policy.cfc new file mode 100644 index 0000000000..3220bcb330 --- /dev/null +++ b/vendor/wheels/tests/_assets/policies/Policy.cfc @@ -0,0 +1,8 @@ +/** + * Test fixture: app-level base policy stub, mirroring `app/policies/Policy.cfc` + * (which mirrors how `app/models/Model.cfc` extends `wheels.Model`). + */ +component extends="wheels.Policy" { + + +} diff --git a/vendor/wheels/tests/_assets/policies/PostPolicy.cfc b/vendor/wheels/tests/_assets/policies/PostPolicy.cfc new file mode 100644 index 0000000000..49ca09db9c --- /dev/null +++ b/vendor/wheels/tests/_assets/policies/PostPolicy.cfc @@ -0,0 +1,36 @@ +/** + * Test fixture policy for the Post model. Exercises the grant/deny surface of + * the authorization layer: + * - index: any authenticated user (guest denies) + * - show: everyone (including guests) + * - update: only the post's author + * - scope: authors see their own posts; guests see nothing (inherited default-deny) + * - publish (custom action): intentionally NOT defined — must deny + * - create/edit/delete/new: inherited default-deny from the base + */ +component extends="Policy" { + + public boolean function index() { + return IsStruct(variables.user) && !StructIsEmpty(variables.user); + } + + public boolean function show() { + return true; + } + + public boolean function update() { + return IsStruct(variables.user) + && StructKeyExists(variables.user, "id") + && IsObject(variables.record) + && StructKeyExists(variables.record, "authorId") + && variables.user.id == variables.record.authorId; + } + + public any function scope(required any collection) { + if (IsStruct(variables.user) && StructKeyExists(variables.user, "id")) { + return arguments.collection.where("authorid", variables.user.id); + } + return super.scope(arguments.collection); + } + +} diff --git a/vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc b/vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc new file mode 100644 index 0000000000..b3ed18c6de --- /dev/null +++ b/vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc @@ -0,0 +1,242 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("Authorization policy layer (wheels.Policy + authorize()/can()/policyScope())", () => { + + beforeEach(() => { + $savedPolicyPath = application.wheels.policyPath + $savedShowError = application.wheels.showErrorInformation + application.wheels.policyPath = "/wheels/tests/_assets/policies" + + author = g.model("author").findOne(where = "firstName = 'Per'", order = "id") + otherAuthor = g.model("author").findOne(where = "firstName = 'Tony'", order = "id") + post = g.model("post").findOne(where = "authorid = #author.id#", order = "id") + + // Fixture controller whose $currentUserForPolicy() override reads + // request.$policyTestUser (the documented app customization seam). + _controller = g.controller("authorization", {controller = "authorization", action = "update"}) + }) + + afterEach(() => { + application.wheels.policyPath = $savedPolicyPath + application.wheels.showErrorInformation = $savedShowError + StructDelete(request, "$policyTestUser") + }) + + describe("wheels.Policy base class", () => { + + it("default-denies every standard action", () => { + basePolicy = CreateObject("component", "wheels.Policy").init(user = {id = 1}, record = post) + + expect(basePolicy.index()).toBeFalse() + expect(basePolicy.show()).toBeFalse() + expect(basePolicy.new()).toBeFalse() + expect(basePolicy.create()).toBeFalse() + expect(basePolicy.edit()).toBeFalse() + expect(basePolicy.update()).toBeFalse() + expect(basePolicy.delete()).toBeFalse() + }) + + it("default-denies scope() with an injection-safe no-rows chain", () => { + basePolicy = CreateObject("component", "wheels.Policy").init(user = {id = 1}, record = "") + scoped = basePolicy.scope(g.model("post")) + + expect(g.model("post").count()).toBeGT(0) + expect(scoped.count()).toBe(0) + expect(scoped.findAll().recordCount).toBe(0) + }) + + it("default-denies through an app policy that overrides nothing", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("index", author)).toBeFalse() + expect(_controller.can("show", author)).toBeFalse() + expect(_controller.can("update", author)).toBeFalse() + expect(_controller.policyScope(g.model("author")).count()).toBe(0) + expect(() => _controller.authorize(record = author, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + }) + + describe("authorize()", () => { + + it("returns the record when the policy allows", () => { + request.$policyTestUser = {id = author.id} + result = _controller.authorize(record = post, action = "update") + + expect(result.id).toBe(post.id) + expect(result.title).toBe(post.title) + }) + + it("throws Wheels.NotAuthorized when the policy denies", () => { + request.$policyTestUser = {id = otherAuthor.id} + + expect(() => _controller.authorize(record = post, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + + it("defaults the action from params.action at call time", () => { + // _controller was created with params.action = "update". + request.$policyTestUser = {id = author.id} + result = _controller.authorize(post) + expect(result.id).toBe(post.id) + + request.$policyTestUser = {id = otherAuthor.id} + expect(() => _controller.authorize(post)).toThrow(type = "Wheels.NotAuthorized") + }) + + it("throws Wheels.Policy.MissingAction when no action can be resolved in development/testing", () => { + actionless = g.controller("authorization", {controller = "authorization"}) + request.$policyTestUser = {id = author.id} + + expect(() => actionless.authorize(post)).toThrow(type = "Wheels.Policy.MissingAction") + }) + + it("denies a guest (no user)", () => { + // No request.$policyTestUser -> the resolver returns "" (guest). + expect(() => _controller.authorize(record = post, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + + it("denies a custom action the policy has no method for", () => { + request.$policyTestUser = {id = author.id} + + expect(() => _controller.authorize(record = post, action = "publish")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + + it("denies the boolean false a missed finder returns", () => { + request.$policyTestUser = {id = author.id} + + expect(() => _controller.authorize(record = false, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + }) + + describe("can()", () => { + + it("returns true when the policy grants the action", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("update", post)).toBeTrue() + expect(_controller.can("index", post)).toBeTrue() + expect(_controller.can("show", post)).toBeTrue() + }) + + it("returns false when the policy denies the action", () => { + request.$policyTestUser = {id = otherAuthor.id} + + expect(_controller.can("update", post)).toBeFalse() + // Inherited default-deny from the base class. + expect(_controller.can("delete", post)).toBeFalse() + }) + + it("returns false for a guest on user-gated actions but true on public ones", () => { + expect(_controller.can("index", post)).toBeFalse() + expect(_controller.can("update", post)).toBeFalse() + expect(_controller.can("show", post)).toBeTrue() + }) + + it("returns false for a custom action the policy has no method for", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("publish", post)).toBeFalse() + }) + + it("returns false for an empty record", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("update")).toBeFalse() + }) + }) + + describe("policyScope()", () => { + + it("narrows the collection to the policy's scope", () => { + request.$policyTestUser = {id = author.id} + expected = g.model("post").count(where = "authorid = #author.id#") + scoped = _controller.policyScope(g.model("post")) + + expect(expected).toBeGT(0) + expect(g.model("post").count()).toBeGT(expected) + expect(scoped.count()).toBe(expected) + }) + + it("returns a chain that keeps composing", () => { + request.$policyTestUser = {id = author.id} + expected = g.model("post").count(where = "authorid = #author.id# AND status = 'published'") + scoped = _controller.policyScope(g.model("post")).where("status", "published") + + expect(expected).toBeGT(0) + expect(scoped.count()).toBe(expected) + }) + + it("default-denies (no rows) for a guest via the inherited base scope", () => { + expect(g.model("post").count()).toBeGT(0) + expect(_controller.policyScope(g.model("post")).count()).toBe(0) + }) + + it("throws Wheels.Policy.InvalidCollection for an in-flight chain in development/testing", () => { + request.$policyTestUser = {id = author.id} + builder = g.model("post").where("views", ">", 0) + + expect(() => _controller.policyScope(builder)).toThrow(type = "Wheels.Policy.InvalidCollection") + }) + }) + + describe("missing policy class", () => { + + it("throws Wheels.Policy.NotDefined in development/testing", () => { + request.$policyTestUser = {id = author.id} + comment = g.model("comment").findOne(order = "id") + + expect(() => _controller.can("update", comment)).toThrow(type = "Wheels.Policy.NotDefined") + expect(() => _controller.authorize(record = comment, action = "update")).toThrow( + type = "Wheels.Policy.NotDefined" + ) + expect(() => _controller.policyScope(g.model("comment"))).toThrow( + type = "Wheels.Policy.NotDefined" + ) + }) + + it("silently denies in production (showErrorInformation off)", () => { + request.$policyTestUser = {id = author.id} + comment = g.model("comment").findOne(order = "id") + application.wheels.showErrorInformation = false + + expect(_controller.can("update", comment)).toBeFalse() + expect(g.model("comment").count()).toBeGT(0) + expect(_controller.policyScope(g.model("comment")).count()).toBe(0) + }) + }) + + describe("identity resolution", () => { + + it("resolves a guest (empty string) through the default seam when nothing is registered", () => { + plain = g.controller("test", {controller = "test", action = "show"}) + + expect(plain.$currentUserForPolicy()).toBe("") + expect(plain.can("update", post)).toBeFalse() + expect(plain.can("show", post)).toBeTrue() + }) + }) + + describe("routable surface", () => { + + it("registers authorize/can/policyScope as protected controller methods", () => { + expect(ListFindNoCase(application.wheels.protectedControllerMethods, "authorize")).toBeGT(0) + expect(ListFindNoCase(application.wheels.protectedControllerMethods, "can")).toBeGT(0) + expect(ListFindNoCase(application.wheels.protectedControllerMethods, "policyScope")).toBeGT(0) + }) + }) + }) + } +} diff --git a/vendor/wheels/tests/specs/events/onerrorSpec.cfc b/vendor/wheels/tests/specs/events/onerrorSpec.cfc index ec61cf2c92..ab2faf507b 100644 --- a/vendor/wheels/tests/specs/events/onerrorSpec.cfc +++ b/vendor/wheels/tests/specs/events/onerrorSpec.cfc @@ -63,6 +63,17 @@ component extends="wheels.WheelsTest" { expect($expectedStatusFor("Wheels.ActionNotAllowed")).toBe(404) }) + // GH ##3156: policy denials from the authorization layer throw + // Wheels.NotAuthorized, which must surface as 403 — the same wiring + // pattern that maps the *NotFound family to 404. + it("maps Wheels.NotAuthorized to HTTP 403 (##3156)", () => { + expect($expectedStatusFor("Wheels.NotAuthorized")).toBe(403) + }) + + it("maps Wheels.Policy.NotDefined to HTTP 500 (a programmer error, not a denial, ##3156)", () => { + expect($expectedStatusFor("Wheels.Policy.NotDefined")).toBe(500) + }) + it("maps a generic Wheels error type to HTTP 500 (##2319)", () => { expect($expectedStatusFor("Wheels.UnknownThingHappened")).toBe(500) }) @@ -116,11 +127,14 @@ component extends="wheels.WheelsTest" { } private numeric function $expectedStatusFor(required string wheelsType) { - // Mirrors the status map in EventMethods.$runOnError. Keep the regex in + // Mirrors the status map in EventMethods.$runOnError. Keep the regexes in // sync with that source — a rename or narrowing there must break here. if (ReFindNoCase("^Wheels\.([A-Za-z]*NotFound|ActionNotAllowed)$", arguments.wheelsType)) { return 404 } + if (ReFindNoCase("^Wheels\.NotAuthorized$", arguments.wheelsType)) { + return 403 + } return 500 } } \ No newline at end of file diff --git a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx index 98156fd2f2..3a0e64cf64 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authentication-patterns.mdx @@ -374,6 +374,7 @@ You don't have to. `Authenticator`, `SessionStrategy`, `TokenStrategy`, and `Jwt + diff --git a/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authorization-policies.mdx b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authorization-policies.mdx new file mode 100644 index 0000000000..bafac4e834 --- /dev/null +++ b/web/sites/guides/src/content/docs/v4-0-0/digging-deeper/authorization-policies.mdx @@ -0,0 +1,180 @@ +--- +title: Authorization Policies +description: The built-in policy layer — wheels.Policy classes with authorize(), can(), and policyScope(), default-deny by design. +type: howto +sidebar: + order: 3 +--- + +import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components'; + +This page shows you how to use the built-in authorization layer. Where [authentication](/v4-0-0/digging-deeper/authentication-patterns/) answers *"who are you?"*, policies answer *"may this user perform this action on this record?"*. You'll generate a `PostPolicy`, grant actions to owners, enforce it with `authorize()` in controllers, check it with `can()` in views, and narrow `index` collections with `policyScope()`. + +**You'll learn:** + +- How the default-deny `wheels.Policy` base class works and how to grant actions +- How `authorize()` throws `Wheels.NotAuthorized` (HTTP 403) and inlines around finders +- How `can()` powers view conditionals without throwing +- How `policyScope()` narrows a collection and keeps chaining +- Where the current user comes from and how to customize the seam + + + +## The pieces + +| Piece | Role | +|-------|------| +| `wheels.Policy` | Base class. Every standard action (`index`, `show`, `new`, `create`, `edit`, `update`, `delete`) returns `false`, and `scope()` returns a no-rows chain. **Default-deny**: nothing is allowed until a policy grants it. | +| `app/policies/Policy.cfc` | One policy per model, resolved by convention. Extends the app-level `Policy.cfc` stub (which extends `wheels.Policy` — same pattern as `app/models/Model.cfc`). | +| `authorize(record [, action])` | Controller helper. Dispatches to the policy; throws `Wheels.NotAuthorized` (HTTP 403) on deny; returns the record on allow. `action` defaults to `params.action`. | +| `can(action [, record])` | Non-throwing boolean, available in controllers *and* views (views run in the controller's `variables` scope). | +| `policyScope(collection)` | Calls the policy's `scope()` and returns the narrowed chain for `index`-style listings. | +| `wheels generate policy Post` | CLI generator — scaffolds `app/policies/PostPolicy.cfc` (plus the base `Policy.cfc` stub on first run). | + +The shape is deliberately Pundit-like (Rails), and the default-deny call matches every major framework surveyed for [the design issue](https://github.com/wheels-dev/wheels/issues/3156): Laravel, Django, Symfony, Spring, and Phoenix all deny when no rule matches. + +## Generate a policy + +```bash +wheels generate policy Post +``` + +This writes `app/policies/PostPolicy.cfc` with every standard action explicitly denying, and — on first run — `app/policies/Policy.cfc`, the parent stub all your policies extend. The generated file is safe to deploy as-is: it grants nothing. + +## Grant actions + +Override a method to grant it. `variables.user` is the current identity (an empty string for guests) and `variables.record` is the record being authorized. + +```cfm {test:compile} title="app/policies/PostPolicy.cfc" +component extends="Policy" { + + // Any signed-in user may list posts. + public boolean function index() { + return IsStruct(variables.user) && !StructIsEmpty(variables.user); + } + + // Everyone may read a post, including guests. + public boolean function show() { + return true; + } + + // Only the author may update. + public boolean function update() { + return IsStruct(variables.user) + && StructKeyExists(variables.user, "id") + && variables.user.id == variables.record.authorId; + } + + // Authors see their own posts on index pages; guests see nothing. + public any function scope(required any collection) { + if (IsStruct(variables.user) && StructKeyExists(variables.user, "id")) { + return arguments.collection.where("authorId", variables.user.id); + } + return super.scope(arguments.collection); + } + +} +``` + +Anything you don't override stays denied — including `delete`, `create`, and any custom action name you dispatch. A policy method for a custom action works the same way: define `public boolean function publish()` and call `authorize(post, "publish")`. + +## Enforce in controllers + +`authorize()` returns the record when the policy allows, so it inlines around a finder: + +```cfm {test:compile} title="app/controllers/Posts.cfc" +component extends="Controller" { + + function index() { + posts = policyScope(model("Post")).findAll(page = params.page, perPage = 25); + } + + function update() { + post = authorize(model("Post").findByKey(params.key)); + post.update(params.post); + redirectTo(route = "post", key = post.id); + } + +} +``` + +When the policy denies, `authorize()` throws `Wheels.NotAuthorized`, which the framework maps to **HTTP 403** — the same wiring that maps `Wheels.RecordNotFound` to 404. In development and testing you get the full Wheels error page (at status 403); in production the response is a plain 403 with no policy detail leaked. + +The `action` argument defaults to `params.action` at call time, so inside an `update` action `authorize(post)` checks the policy's `update()` method. Pass it explicitly to check a different rule: `authorize(post, "publish")`. + +## Check in views + +`can()` never throws — it returns `false` for denials, guests, empty records, and actions the policy has no method for: + +```cfm title="app/views/posts/show.cfm" + + #linkTo(text = "Edit", route = "editPost", key = post.id)# + +``` + +Because the same policy object backs `can()` and `authorize()`, the link's visibility and the controller's enforcement can't drift apart. + +## Narrow index collections + +`policyScope()` resolves the policy, calls its `scope()`, and hands back the chain for further composition: + +```cfm +posts = policyScope(model("Post")).where("status", "published").findAll(page = params.page); +``` + +Pass the **model class first** and chain afterwards — an in-flight query-builder or scope chain can't be introspected for its model, so `policyScope(model("Post").where(...))` throws `Wheels.Policy.InvalidCollection` in development. The base `scope()` returns a no-rows chain (built on the injection-safe empty `whereIn` from [#2736](https://github.com/wheels-dev/wheels/pull/2736)), so an ungranted scope lists nothing rather than everything. + +## Where the user comes from + +Policies receive the identity resolved by `$currentUserForPolicy()`, which tries, in order: + +1. **The DI service `currentUser`** — if you registered one in `config/services.cfm`, it wins: + + ```cfm + injector().map("currentUser").to("app.lib.CurrentUserResolver").asRequestScoped(); + ``` + +2. **A configured authenticator** — the first registered strategy exposing a `currentUser()` method (e.g. `wheels.auth.SessionStrategy`) that reports a non-empty principal. + +3. **Guest** — an empty string. Policies should treat it as "not signed in". + +To customize beyond those seams, override `$currentUserForPolicy()` in your base `app/controllers/Controller.cfc` — declared methods win over the framework mixin. + +## Missing policies fail loud (in development) + +| Situation | Development / testing | Production | +|-----------|----------------------|------------| +| No policy class for the model | Throws `Wheels.Policy.NotDefined` | Silently **denies** | +| Policy exists, no method for the action | Denies | Denies | +| Guest (no resolvable user) | Policy decides (`variables.user` is `""`) | Same | + +The loud `NotDefined` in development is deliberate (borrowed from Pundit): a typo'd or forgotten policy should read as a bug while you're building, not a mysterious denial. Production flips to silent deny — the same environment posture as `tableName()`'s argument guard ([#3079](https://github.com/wheels-dev/wheels/issues/3079)) — so an upgrade or a missed file never turns into an error page for end users. + +## Honest limitations + +- **`authorize`, `can`, and `policyScope` are framework helpers now.** Like every controller mixin, they land in the protected-methods set, so you cannot name your own *actions* `authorize`, `can`, or `policyScope` ([#2845](https://github.com/wheels-dev/wheels/pull/2845) behavior). Standard REST action names are unaffected. +- **There is no `verifyAuthorized` filter yet.** Pundit's "flag actions that never called authorize" guard is a tracked follow-up; today, forgetting to call `authorize()` means the action runs unprotected. +- **There is no `before()` / admin-override hook, on purpose.** Laravel's equivalent is a documented foot-gun (a bare boolean silently allows everything). Grant admins inside each policy method instead — it's one condition, and it's greppable. +- **Production denials render a plain 403.** There's no `on403` event template convention yet; if you need a branded page, catch `Wheels.NotAuthorized` in your own error handling. + +## Related guides + + + + + + diff --git a/web/sites/guides/src/sidebars/v4-0-0.json b/web/sites/guides/src/sidebars/v4-0-0.json index 2dc74c3d69..3d317466c3 100644 --- a/web/sites/guides/src/sidebars/v4-0-0.json +++ b/web/sites/guides/src/sidebars/v4-0-0.json @@ -85,6 +85,7 @@ "items": [ { "label": "Authentication Patterns", "link": "/v4-0-0/digging-deeper/authentication-patterns/" }, { "label": "Authorization & Filters", "link": "/v4-0-0/digging-deeper/authorization-and-filters/" }, + { "label": "Authorization Policies", "link": "/v4-0-0/digging-deeper/authorization-policies/" }, { "label": "Background Jobs", "link": "/v4-0-0/digging-deeper/background-jobs/" }, { "label": "Caching", "link": "/v4-0-0/digging-deeper/caching/" }, { "label": "Sending Email", "link": "/v4-0-0/digging-deeper/sending-email/" },