Skip to content

fix(policy): fail-close empty scope and deny reserved actions - #3414

Merged
bpamiri merged 3 commits into
developfrom
cursor/policy-hardener-s2-s9-5355
Aug 25, 2026
Merged

fix(policy): fail-close empty scope and deny reserved actions#3414
bpamiri merged 3 commits into
developfrom
cursor/policy-hardener-s2-s9-5355

Conversation

@bpamiri

@bpamiri bpamiri commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Why

Policy default-deny still called whereIn("id", []). An empty id list can become IN () or match every row if the collection is not a QueryBuilder. Production InvalidCollection stayed silent and then made that same whereIn call. authorize() Invoked scope and init when those names arrived as params.action, which 500s on scope() instead of denying.

This PR lands S2, S5, S6, S7, S8, and S9 only. S1, S3, and S4 stay HELD.

Scope

  • vendor/wheels/Policy.cfc
    • scope() returns a no-rows wheels.Policy and does not call whereIn
    • currentUser() returns the init identity (guest is "")
    • count() / findAll() / where() / whereIn() keep that empty chain
  • vendor/wheels/controller/authorization.cfc
    • policyScope() returns the empty Policy after production InvalidCollection
    • authorize() and can() skip Invoke for reserved init and scope
  • vendor/wheels/tests/specs/Authorization/AuthorizationSpec.cfc (moved from controller/ so --filter=Authorization discovers a directory)
    • S8 asserts production 403, then restores HTTP 200 with g.$header(statusCode = 200), the same helper $notAuthorized() uses. afterEach does the same restore so the runner request cannot leave 403.
  • Test fixtures WhereInSpy.cfc and CurrentUserStub.cfc
  • changelog.d/policy-hardener-s2-s9.fixed.md

CLI stays out. Cache leftovers stay closed. No onMissingMethod. Unknown actions still throw. Loose CF boolean grant is unchanged. Status code stays 403.

Missing-policy production still calls whereIn("id", []) on a resolved model class. That path is leftover-closed. It is not S10.

Tradeoffs

The empty scope is a wheels.Policy instance, not a QueryBuilder $alwaysEmpty flag. That keeps fail-closed out of whereIn and still answers count() / findAll().

Blast Radius

App policies that inherit scope() get the same no-rows result they already had. policyScope() on an in-flight chain still throws Wheels.Policy.InvalidCollection in development and testing. Production now returns zero rows instead of calling whereIn on a bad collection. authorize(action="scope") and authorize(action="init") become Wheels.NotAuthorized (403 in production) instead of a missing-argument 500.

S8 still proves production deny is 403. It no longer leaves that status on the TestBox HTTP request (LuCLI at 03cc18fa reported Test HTTP status: 403, no Results line, CLI 1179/0).

Desk S1–S9

ID Status Note
S1 HELD Unknown action still throws. No default-deny flip. No onMissingMethod.
S2 PROVEN scope() does not call whereIn with an empty id list. Spy whereInCalls is 0 and count() is 0.
S3 HELD First catch(any) on DI currentUser is wrong-user failover. A broken resolver can still hit the authenticator. Not catch-to-guest. Unflipped.
S4 HELD authorize() / can() still grant on CF loose boolean "yes" / "true".
S5 PROVEN Production InvalidCollection returns the empty Policy. Spy whereInCalls is 0. A struct without whereIn does not throw.
S6 PROVEN $currentUserForPolicy() reads the DI currentUser service (id=9001) and the authenticator SessionStrategy.currentUser() (id=4242).
S7 PROVEN Policy.init() with no user. currentUser() is "", not an object.
S8 PROVEN Production authorize() deny sets status 403 and does not return the record. Spec and afterEach restore 200 via $header.
S9 PROVEN authorize(action="scope") and authorize(action="init") throw Wheels.NotAuthorized. can() also skips those names. Specs are authorize()-only.

Verification

wheels test --core --ci --filter=Authorization

LuCLI at 03cc18fa76754575099abaaab80167ddc5882562. Test HTTP status 403. No Results line. CLI 1179/0. Cause was S8 leaving 403 on the runner.

This head ae0b9851429616e18468ff86ecc16775baa7650a restores 200 after that assert. Agent-local wheels test --core --ci --filter=Authorization crashed before results (Lucee missinginclude on /workspace/public/Application.cfc). Counts from the babysit LuCLI run belong here.

No closer keywords. Do not merge.

Open in Web Open in Cursor 

cursoragent and others added 2 commits August 25, 2026 01:39
Move AuthorizationSpec into a directory so --filter=Authorization
discovers a bundle. S2/S5/S9 fail on the current Policy.scope,
production InvalidCollection fall-through, and authorize(action=scope).
S6/S7/S8 name the DI identity, guest "", and production 403 contracts.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Peter Amiri <peter@alurium.com>
Policy.scope() returns a no-rows Policy instead of whereIn("id", []).
policyScope() does the same after production InvalidCollection, so a
bad collection never reaches whereIn. authorize() and can() skip
Invoke for init and scope and take the existing deny path.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Peter Amiri <peter@alurium.com>
S8 authorize() denial in production sets 403 via $header and left
that status on the runner request. Reset with the same $header
helper after the assert and in afterEach. The 403 contract is
unchanged.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Peter Amiri <peter@alurium.com>
@bpamiri
bpamiri marked this pull request as ready for review August 25, 2026 01:48
@bpamiri
bpamiri merged commit 06acc07 into develop Aug 25, 2026
12 checks passed
@bpamiri
bpamiri deleted the cursor/policy-hardener-s2-s9-5355 branch August 25, 2026 01:48
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.

2 participants