Skip to content

test(api): model the constraint dimension in MockAuthService - #1859

Merged
cristim merged 1 commit into
mainfrom
fix/1762-mock-constraint-args
Aug 19, 2026
Merged

cristim merged 1 commit into
mainfrom
fix/1762-mock-constraint-args

Conversation

@cristim

@cristim cristim commented Aug 19, 2026 •

Copy link
Copy Markdown
Member

MockAuthService.grantPermissionsScoped wired both HasPermissionAPI and HasPermissionForConstraintsAPI to the same closure:

decide := func(action, resource string) bool {
	return authCtx.HasPermission(action, resource)
}

decide takes (action, resource) and never reads the constraint arguments, so the SEC-01 execution-time check answered without them. The constraint dimension (AccountIDs, Providers, Services, Regions, MaxPurchaseAmount) was not modelled at all.

Measurement

Taken before any edit, on unmodified d9d1c1302, comparing real permissionsAllow against the mock's exact decide closure for the same principal:

real mock case
false true execute:ri-exchange constrained Providers:[aws], request Providers:[azure]
true true control: same permission, request Providers:[aws]
false true execute:purchases capped MaxPurchaseAmount:1000, request 5000
true true control: same cap, request 500
false true Regions:[eastus] permitted, request Regions:[eastus,westus]
false true AccountIDs:[acct-a] permitted, request AccountIDs:[acct-b]
false true Services:[ec2] permitted, request Services:[rds]
false false control: admin:* alone on execute:ri-exchange
true true control: admin:* on a non-carved verb with a constraint
false true admin:* plus constrained ri-exchange, request Providers:[azure]

10 cases, 6 divergent. Two things the table shows that matter more than the count:

  • All five constraint dimensions diverge, not just Providers. Row 3 is the money one: a MaxPurchaseAmount of 1000 did not stop a request for 5000.
  • Divergence is strictly one-directional. The mock never denied where production allowed. It was uniformly over-permissive, which is the worse direction for an authorization double: a handler test asserting a constraint refusal was green whether or not the handler enforced anything.

Two corrections to the issue

Recording these because a reviewer checking the issue's reasoning against this diff will otherwise find a mismatch. The issue's central claim is correct and understated; two supporting arguments do not hold. Both are also on the issue itself.

1. The #1758 argument does not hold. The issue says #1758 falsifies the comment by putting a carved-out verb within reach. It does not, and row 8 of the table is the direct disproof: admin:* alone on execute:ri-exchange is false/false, same. Adding a pair to adminCarvedOuts makes "admin holds none of the carved-out verbs" more true. For an admin-only principal the two sides are structurally identical: AuthContext.HasPermission takes no constraint parameter, and permissionsAllow short-circuits on checkAdminPermission before checkPermissionConstraints is reached. Both continue on a carve-out hit and fall through to false. #1758 only changed which verbs they deny in lockstep.

Worth stating precisely, since it is security-relevant: they ignore the constraint arguments for an admin:* permission unconditionally, not because that permission happens to carry no Constraints. Attaching Constraints to an admin:* grant would not make the check bind.

What actually made the comment false: its premise ("a principal holding only admin:*") describes grantAdmin, but it sits on grantPermissionsScoped, which grantPermissions also drives with an arbitrary permission set. It was false from the day it was written, for a reason unrelated to #1758. Related: migration 000096_seed_ri_exchanger_group.up.sql:48-56 documents the seeded RI Exchanger grant as deliberately UNCONSTRAINED.

2. "Every handler test that appears to exercise it is measuring the mock" overstates it. No pre-existing test changed behaviour; all 2170 internal/api tests passed unchanged on the first run after the rewiring. Verified against d9d1c1302 rather than a working tree: concatenating every internal/api/*_test.go at that commit and grepping for the struct field Constraints: returns 0 hits across the whole file set. All 259 grant* call sites pass Constraints == nil permissions, for which checkPermissionConstraints returns true unconditionally, so old and new mock answer identically for every principal any existing test builds.

This was already known: grantadmin_carveout_test.go:176 records it during #1596 review, calling it "a trap for the first constrained-permission test." The divergence was latent, not fired. Worth being accurate about, because describing it as fired invites dismissal by counter-example, which would leave the trap in place.

The change

  • Extract auth.PermissionsAllowForConstraintSets, the decision half of Service.HasPermissionForConstraintsAPI with the permission fetch removed, and answer the mock from it. The mock now models the principal and lets production logic derive the answer, as fix(test): make grantAdmin model the principal instead of stubbing the authorization decision #1744 did for the permission half.
  • permissionsAllow and the four constraint matchers become package functions. Verified against the parent commit that none reads a field off the receiver; s existed only to reach the next method.
  • Extract errNoConstraintSets, now shared by all three fail-closed guards that previously duplicated the format string.
  • Replace the false comment with the corrected account above.

Registering a constraint-blind func(action, resource string) bool on that method now panics, naming the issue. This is the part that keeps the fix from decaying: the original defect was invisible except by measurement, and a future inline registration would have reintroduced it just as silently. Now it fails loudly at the point of the mistake.

Tests

Three tests in grantadmin_carveout_test.go. Each constraint case holds an inside and an outside request in the same case, deliberately: an always-allow mock passes an inside-only assertion and an always-deny mock passes an outside-only one, so only the pair distinguishes them.

Pre-fix proof, with all six source changes stashed so the tests ran against genuine d9d1c1302 code:

[FAIL] .../providers                              Should be false
[FAIL] .../regions_require_every_requested_region Should be false
[FAIL] .../services                               Should be false
[FAIL] .../account_IDs                            Should be false
[FAIL] .../max_purchase_amount_is_the_spend_guard Should be false
[FAIL] AdminDoesNotWidenAConstrainedCarveOut      Should be false
[FAIL] .../wrong_provider           An error is expected but got nil.
[FAIL] .../over_the_spend_cap       An error is expected but got nil.
0 passed, 10 failed

Every failure is by assertion, not by panic. Every inside assertion passed pre-fix, which is what rules out the refusal-only trap. Post-fix 11/11 pass.

Reviewers additionally mutation-tested the committed tree; every mutant was killed by the expected assertion. Neutering matchPurchaseAmountConstraint kills exactly the spend-cap rows; regressing matchAllRegionsConstraint to any-overlap kills exactly the regions row; removing {ActionExecute, ResourceRIExchange} from adminCarvedOuts kills the admin test.

One pre-existing test moved during implementation: service_api_test.go:756 "empty constraint sets fail loud". My first cut left the empty-constraintSets guard only in the shared function, reordering the method to fetch-then-guard. That subtest registers no store expectations, so it asserts not merely "returns an error" but "a caller bug never reaches the store". The test was right; I reverted and the guard is back before the fetch, with error precedence byte-for-byte as at d9d1c1302. The duplication across both entry points is load-bearing, not sloppy: removing either half breaks a different test.

Verification

go build ./... clean; go test ./... 7083 passed across 43 packages; go vet clean; golangci-lint at the CI pin v2.10.1 exit 0 with non-empty 0 issues.; gocyclo -over 10 clean, sanity-checked at -over 5 (884 hits) to confirm the tool actually ran. All re-run after rebasing onto f8ed6bff5; the pre- and post-rebase patch-ids are identical.

Scope

This covers the session branch of requirePermissionConstraints. The user-API-key branch calls HasAPIKeyPermissionForConstraintsAPI, which the mock still stubs to a constant via allowConstraintChecks. Same defect one branch over, on the same money paths; modelling it faithfully needs the key-and-owner permission intersection production computes, so it is deliberately left out.

Separately filed: issue LeanerCloud/cloud-commitments-platform#208, for the one test helper that re-implements matchAllRegionsConstraint's all-match rule rather than asserting it.

Closes #1762

Summary by CodeRabbit

  • Bug Fixes
    • Improved enforcement of permission constraints across providers, regions, purchase amounts, and spending limits.
    • Constrained permissions now allow only matching requests and correctly reject nonmatching requests.
    • Administrative permissions no longer bypass restrictions on constrained grants.
    • Invalid or missing constraint sets now fail closed, returning appropriate authorization errors.

grantPermissionsScoped wired HasPermissionForConstraintsAPI to the same
decide(action, resource) closure as HasPermissionAPI, so the SEC-01
execution-time check answered without ever reading the constraint
arguments. Measured against the real matchers, the mock allowed requests
outside the permission's constraints on all five dimensions: Providers,
Regions, Services, AccountIDs and MaxPurchaseAmount, the last of which is
the spend guard. A handler test asserting a constraint refusal was green
whether or not the handler enforced anything.

Extract auth.PermissionsAllowForConstraintSets, the decision half of
Service.HasPermissionForConstraintsAPI with the permission fetch removed,
and answer the mock from it. permissionsAllow and the four constraint
matchers become package functions: they read nothing off the *Service
receiver, and the exported entry point needs them without one.

Replace the justifying comment. It claimed a principal holding only
admin:* satisfies any constraint set for the verbs it holds, which is
true and stayed true through #1758: both halves short-circuit on the
wildcard before any constraint comparison, so #1758's carve-out only
changed which verbs they deny in lockstep. What it never covered is the
helper it was attached to, since grantPermissionsScoped takes an
arbitrary permission set and a permission carrying Constraints is exactly
what the check bounds.

Registering a constraint-blind func on that method now panics, so the
divergence cannot be reintroduced there silently.

Scope: this covers the session branch of requirePermissionConstraints.
The user-API-key branch calls HasAPIKeyPermissionForConstraintsAPI, which
the mock still stubs to a constant via allowConstraintChecks; modelling it
faithfully needs the key-and-owner permission intersection production
computes, so it is left for a follow-up.

Closes #1762
@cristim cristim added priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/internal Team-internal only effort/m Days type/bug Defect triaged Item has been triaged labels Aug 19, 2026
@coderabbitai

coderabbitai Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7de4c957-9100-4e5f-8101-9ad1555907aa

📥 Commits

Reviewing files that changed from the base of the PR and between f8ed6bf and 931e031.

📒 Files selected for processing (7)
  • internal/api/grantadmin_carveout_test.go
  • internal/api/mocks_test.go
  • internal/auth/service_api.go
  • internal/auth/service_apikeys_api.go
  • internal/auth/service_group.go
  • internal/auth/service_group_test.go
  • internal/auth/service_user.go

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


📝 Walkthrough

Walkthrough

Changes

The change centralizes permission constraint evaluation in package-level helpers. It updates service and mock authorization paths to preserve constraint sets. New regression tests cover constraint dimensions, administrator carve-outs, and handler-level 403 responses.

Constraint-aware authorization

Layer / File(s) Summary
Shared authorization helpers
internal/auth/service_group.go, internal/auth/service_user.go, internal/auth/service_group_test.go
Permission and constraint matchers now use package-level functions. Existing matcher tests call the shared functions directly.
Constraint API evaluation
internal/auth/service_api.go, internal/auth/service_apikeys_api.go
PermissionsAllowForConstraintSets centralizes fail-closed validation and constraint-set evaluation. Service and API-key checks use the shared helpers.
Mock authorization and regression coverage
internal/api/mocks_test.go, internal/api/grantadmin_carveout_test.go
Mock constrained-permission checks use real constraint evaluation. Tests cover all five dimensions, administrator carve-out limits, and handler-level denials.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 931e0

The change makes the mock authorization path honor constrained permissions while preserving production behavior, with targeted regression coverage and successful verification. No actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Handler
  participant MockAuthService
  participant PermissionsAllowForConstraintSets
  participant permissionsAllow
  Handler->>MockAuthService: request constrained permission
  MockAuthService->>PermissionsAllowForConstraintSets: permissions, action, resource, constraint sets
  PermissionsAllowForConstraintSets->>permissionsAllow: evaluate constraint set
  permissionsAllow-->>MockAuthService: allow or deny
  MockAuthService-->>Handler: authorization result
Loading

Possibly related issues

  • LeanerCloud/CUDly#1858 — Both changes use auth.PermissionsAllowForConstraintSets for constraint-aware authorization testing.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: updating MockAuthService to model permission constraints.
Linked Issues check ✅ Passed The changes evaluate real constraint logic, correct the stale comment, add regression coverage, and reject constraint-blind callbacks as required by issue #1762.
Out of Scope Changes check ✅ Passed The refactoring and tests support shared constraint evaluation and remain within the objectives of issue #1762.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1762-mock-constraint-args

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

The push at 06:42Z was skipped with the included review limit reached, so this head has never been reviewed. Checking whether the allowance has refilled. This PR changes production authorization code in internal/auth, not only the test mock, so an independent pass matters here.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I requested a full review so it includes the previously skipped head.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-06T08:28:24.968Z
Learning: In the LeanerCloud/CUDly repository, request CodeRabbit reviews at most once per hour across the repository because the adaptive quota is shared across open pull requests. When a PR head was pushed while automatic review was quota-exhausted and was not retried, use a full review rather than an incremental review so the missed commits are reviewed.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-26T17:21:01.385Z
Learning: For the LeanerCloud/CUDly repository review workflow, when a previous CodeRabbit review pass was skipped or failed to produce findings due to a rate-limit event, use a full review request on the pull request rather than the incremental review form.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-26T17:23:32.317Z
Learning: In this repository, if a CodeRabbit review was throttled or hit a rate limit, the correct recovery is to request `coderabbitai full review` rather than `coderabbitai review`, because incremental review can silently skip the affected in-flight commit and report a false-clean result.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-05T05:27:02.254Z
Learning: For the LeanerCloud/CUDly repository, pace CodeRabbit review requests at one request per hour across the repository. The review quota is adaptive and shared per developer and organization; burst requests can exhaust the quota and tighten the limit. When a PR head was pushed while quota was exhausted, request a full review because an incremental review skips the unreviewed commits.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-04T08:23:48.546Z
Learning: For the LeanerCloud/CUDly repository, CodeRabbit review quota is shared per developer across the organization and adapts to recent review volume. Space review requests to approximately one per hour. When an automatic review did not run because quota was exhausted, use a full review instead of an incremental review so CodeRabbit inspects the missed commits.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-04T22:26:24.782Z
Learning: For pull request `#1716`, when review quota exhaustion prevents automatic review of pushed commits, request a full review instead of an incremental review. An incremental review can omit those unreviewed commits.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-26T15:49:21.368Z
Learning: In this repository's Go auth flow, execution-time permission constraint checks must fail closed: `internal/auth/service_api.go` `Service.HasPermissionForConstraintsAPI` should reject empty `constraintSets`, and `internal/api/handler.go` `Handler.requirePermissionConstraints` should deny on nil session, missing auth service, or auth lookup/check errors before allowing access.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-07-28T15:55:06.444Z
Learning: In `internal/auth/service_group.go`, `PermissionConstraints.Regions` must use all-match semantics: every request-side region must be included in the permission-side region set. This differs from `AccountIDs`, `Providers`, and `Services`, which retain overlap matching because their current execution callers submit a single value. Region comparisons are case- and whitespace-insensitive; empty-list semantics remain unconstrained/unspecified.
✅ Action performed

Full review finished.

@cristim
cristim merged commit b9f0d77 into main Aug 19, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(test): MockAuthService discards constraint arguments — third divergence, and the justifying comment is now false

1 participant