Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/policies/Policy.cfc
Original file line number Diff line number Diff line change
@@ -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" {


}
1 change: 1 addition & 0 deletions changelog.d/3156-authorization-policy-layer.added.md
Original file line number Diff line number Diff line change
@@ -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/<ModelName>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 <Model>` CLI generator and a new Authorization Policies guide (#3156, part of #2962)
56 changes: 55 additions & 1 deletion cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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("");
Expand All @@ -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 "";
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 <ModelName> [--force]", "yellow");
out(" Example: wheels generate policy Post");
out("");
out("Writes app/policies/<ModelName>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 = [];
Expand Down
53 changes: 53 additions & 0 deletions cli/lucli/services/CodeGen.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,59 @@ component {
return result;
}

/**
* Generate an authorization policy CFC file (issue #3156).
*
* Writes app/policies/<ModelName>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
*/
Expand Down
13 changes: 13 additions & 0 deletions cli/lucli/templates/app/app/policies/Policy.cfc
Original file line number Diff line number Diff line change
@@ -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" {


}
54 changes: 54 additions & 0 deletions cli/lucli/tests/specs/services/CodeGenSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
12 changes: 12 additions & 0 deletions cli/src/templates/PolicyBaseContent.txt
Original file line number Diff line number Diff line change
@@ -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" {


}
67 changes: 67 additions & 0 deletions cli/src/templates/PolicyContent.txt
Original file line number Diff line number Diff line change
@@ -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);
* }
*/

}
Loading
Loading