fix(auth): enforce per-permission constraints on execute paths - #1210
Conversation
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (12)
🚧 Files skipped from review as they are similar to previous changes (12)
📝 WalkthroughWalkthroughAdds execution-time permission constraint enforcement for purchase and RI-exchange requests. The change introduces a constraint-aware auth API, shared handler checks, request-derived purchase and exchange constraints, unattributed-account handling, and regression coverage. ChangesConstraint-aware permission enforcement
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant Client
participant PurchaseHandler
participant AuthService
participant PermissionStore
Client->>PurchaseHandler: execute purchase or RI exchange
PurchaseHandler->>AuthService: validate base permission
AuthService->>PermissionStore: load effective permissions
PermissionStore-->>AuthService: permissions
PurchaseHandler->>AuthService: evaluate request constraints
AuthService-->>PurchaseHandler: allowed or denied
PurchaseHandler-->>Client: execute operation or HTTP 403
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
internal/api/handler.go (1)
246-271: 💤 Low valueConsider adding a defensive nil check for the session parameter.
The method dereferences
session.UserIDat line 257 without checking ifsessionis nil. While the documented calling pattern requires callers to pass the session fromrequirePermission(which returns non-nil on success), a defensive nil check would catch programmer errors more gracefully.🛡️ Suggested defensive nil check
func (h *Handler) requirePermissionConstraints(ctx context.Context, session *Session, action, resource string, constraintSets []auth.PermissionConstraints) error { + if session == nil { + return fmt.Errorf("internal error: session is nil") + } if session.UserID == apiKeyAdminUserID { return nil }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/api/handler.go` around lines 246 - 271, The function requirePermissionConstraints dereferences session.UserID without checking session for nil; add a defensive nil check at the start of requirePermissionConstraints (before comparing session.UserID to apiKeyAdminUserID) to detect a nil session and return an appropriate error (e.g., an internal/server error indicating a missing session) so programmer errors are caught gracefully; update any tests or callers if necessary to preserve behavior when session is non-nil.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/api/handler_purchases.go`:
- Around line 1552-1583: The helper purchaseConstraintSets drops the AccountIDs
dimension when rec.CloudAccountID is empty, which lets the auth matcher treat
the account constraint as satisfied; always populate
PermissionConstraints.AccountIDs: when rec.CloudAccountID is non-empty set
c.AccountIDs = []string{*rec.CloudAccountID}, and when nil/empty set
c.AccountIDs to an explicit non-nil sentinel slice (e.g. []string{""}) so the
AccountIDs field is present and cannot be interpreted as omitted by the auth
matcher.
In `@internal/api/handler_ri_exchange.go`:
- Around line 740-751: The permission-constraint block passed to
h.requirePermissionConstraints is missing AccountIDs so account-scoped
execute:ri-exchange permissions can be bypassed; modify the constraints literal
in this call (the []auth.PermissionConstraints entry) to include AccountIDs
populated from the current session (e.g., AccountIDs:
[]string{session.AccountID}) so the deployment account is enforced by the auth
check.
---
Nitpick comments:
In `@internal/api/handler.go`:
- Around line 246-271: The function requirePermissionConstraints dereferences
session.UserID without checking session for nil; add a defensive nil check at
the start of requirePermissionConstraints (before comparing session.UserID to
apiKeyAdminUserID) to detect a nil session and return an appropriate error
(e.g., an internal/server error indicating a missing session) so programmer
errors are caught gracefully; update any tests or callers if necessary to
preserve behavior when session is non-nil.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 434a3d76-23a6-4d69-9784-02206e098421
📒 Files selected for processing (12)
internal/api/handler.gointernal/api/handler_per_account_perms_test.gointernal/api/handler_purchases.gointernal/api/handler_purchases_test.gointernal/api/handler_ri_exchange.gointernal/api/handler_ri_exchange_test.gointernal/api/mocks_test.gointernal/api/types.gointernal/auth/service_api.gointernal/auth/service_api_test.gointernal/auth/service_group.gointernal/server/app.go
The auth matcher treats an empty request-side list as "dimension not specified = satisfied", so constraint sets that omitted AccountIDs let a permission constrained to specific accounts authorize the request anyway (CR findings on PR #1210, SEC-01 / issue #1141): - purchases: a recommendation without a cloud_account_id omitted the AccountIDs dimension entirely. It now always carries a single-value AccountIDs list, using the new unattributedAccountConstraint sentinel for unattributed recs so an AccountIDs-constrained permission denies them (fail closed) while unconstrained permissions are unaffected. - ri-exchange: the request-derived set never populated AccountIDs. It now carries the registered cloud account the running deployment resolves to (resolveReshapeCloudAccountID), failing closed on a resolution error and falling back to the sentinel when the deployment maps to no registered account. The sentinel is preferred over rejecting unattributed requests with a 400 because executing unattributed recommendations is a documented, legitimate flow for sessions without account restrictions (validatePurchaseRecommendationScope only rejects them for scoped sessions); only permissions that actually constrain AccountIDs should deny. Regression tests pin the new constraint-set shape on both paths plus the resolver fail-closed and sentinel branches; all confirmed to FAIL against the pre-fix code and pass after.
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/api/handler_purchases_test.go (1)
3360-3363:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAssert provider and service on the second constraint set too.
The matcher currently verifies
sets[1].Regionsbut notsets[1].Providersorsets[1].Services, so a regression that drops those dimensions from the second recommendation would still pass.🧪 Proposed matcher tightening
return assert.ObjectsAreEqual([]string{"aws"}, sets[0].Providers) && assert.ObjectsAreEqual([]string{"ec2"}, sets[0].Services) && assert.ObjectsAreEqual([]string{"us-east-1"}, sets[0].Regions) && + assert.ObjectsAreEqual([]string{"aws"}, sets[1].Providers) && + assert.ObjectsAreEqual([]string{"ec2"}, sets[1].Services) && assert.ObjectsAreEqual([]string{"eu-west-1"}, sets[1].Regions) })).Return(false, nil)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/api/handler_purchases_test.go` around lines 3360 - 3363, The assertion block in the matcher is incomplete because it validates sets[0].Providers, sets[0].Services, and sets[0].Regions but only validates sets[1].Regions without checking sets[1].Providers and sets[1].Services. Add assertions for sets[1].Providers and sets[1].Services using the same assert.ObjectsAreEqual pattern used for the other fields to ensure the second constraint set is fully validated and prevent regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/api/handler_purchases_test.go`:
- Around line 3360-3363: The assertion block in the matcher is incomplete
because it validates sets[0].Providers, sets[0].Services, and sets[0].Regions
but only validates sets[1].Regions without checking sets[1].Providers and
sets[1].Services. Add assertions for sets[1].Providers and sets[1].Services
using the same assert.ObjectsAreEqual pattern used for the other fields to
ensure the second constraint set is fully validated and prevent regressions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 77211abf-7c86-4d91-b268-f0a55683d2d7
⛔ Files ignored due to path filters (3)
go.sumis excluded by!**/*.sumproviders/azure/go.sumis excluded by!**/*.sumproviders/gcp/go.sumis excluded by!**/*.sum
📒 Files selected for processing (11)
go.modinternal/api/handler.gointernal/api/handler_purchases.gointernal/api/handler_purchases_test.gointernal/api/handler_ri_exchange.gointernal/api/handler_ri_exchange_test.gointernal/config/store_postgres_recommendations_test.gointernal/database/postgres/testhelpers/postgres.gointernal/server/integration_test.goproviders/azure/go.modproviders/gcp/go.mod
🚧 Files skipped from review as they are similar to previous changes (2)
- internal/api/handler.go
- internal/api/handler_ri_exchange.go
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/config/store_postgres_recommendations.go (1)
338-350:⚠️ Potential issue | 🟠 Major | ⚡ Quick winUse explicit
OnDemandCostbefore rejectingTerm == 0.Line 338 makes records with a valid provider
OnDemandCostlook “uncomputable” whenTermis zero, even thoughTermis only needed for amortizing upfront cost during reconstruction. BecauseListStoredRecommendationskeeps rows whenrecEffectiveSavingsPctreturnsok=false, this can let records bypass amin_savings_pctfloor despite having a usable canonical baseline.🐛 Proposed fix
func recOnDemandBaseline(rec *RecommendationRecord) (float64, bool) { - if rec.Term == 0 { - return 0, false - } hasOnDemand := rec.OnDemandCost != nil && *rec.OnDemandCost > 0 + if hasOnDemand { + return *rec.OnDemandCost, true + } + if rec.Term == 0 { + return 0, false + } if !hasOnDemand && rec.MonthlyCost == nil { return 0, false } if rec.Provider == providerAWS && !hasOnDemand { return 0, false } - if hasOnDemand { - return *rec.OnDemandCost, true - } amortized := rec.UpfrontCost / float64(rec.Term*12) return *rec.MonthlyCost + rec.Savings + amortized, true }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/config/store_postgres_recommendations.go` around lines 338 - 350, The function is rejecting records with a zero Term value too early, before checking if the record has a usable OnDemandCost that doesn't require Term for computation. Reorganize the logic so that the Term == 0 check (line 338) is performed after verifying whether OnDemandCost is available. Specifically, move the Term == 0 rejection to only apply when OnDemandCost is not usable (when either hasOnDemand is false or the record lacks alternative cost data), since Term is only needed for amortizing upfront costs, not for returning a direct OnDemandCost value. This way, records with valid OnDemandCost values will not be incorrectly rejected based on missing Term information.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/config/store_postgres_recommendations.go`:
- Around line 338-350: The function is rejecting records with a zero Term value
too early, before checking if the record has a usable OnDemandCost that doesn't
require Term for computation. Reorganize the logic so that the Term == 0 check
(line 338) is performed after verifying whether OnDemandCost is available.
Specifically, move the Term == 0 rejection to only apply when OnDemandCost is
not usable (when either hasOnDemand is false or the record lacks alternative
cost data), since Term is only needed for amortizing upfront costs, not for
returning a direct OnDemandCost value. This way, records with valid OnDemandCost
values will not be incorrectly rejected based on missing Term information.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 58cc39fa-1e79-4330-b9d3-da6d2e8d77eb
📒 Files selected for processing (3)
internal/api/handler.gointernal/api/handler_purchases_test.gointernal/config/store_postgres_recommendations.go
🚧 Files skipped from review as they are similar to previous changes (2)
- internal/api/handler.go
- internal/api/handler_purchases_test.go
The auth matcher treats an empty request-side list as "dimension not specified = satisfied", so constraint sets that omitted AccountIDs let a permission constrained to specific accounts authorize the request anyway (CR findings on PR #1210, SEC-01 / issue #1141): - purchases: a recommendation without a cloud_account_id omitted the AccountIDs dimension entirely. It now always carries a single-value AccountIDs list, using the new unattributedAccountConstraint sentinel for unattributed recs so an AccountIDs-constrained permission denies them (fail closed) while unconstrained permissions are unaffected. - ri-exchange: the request-derived set never populated AccountIDs. It now carries the registered cloud account the running deployment resolves to (resolveReshapeCloudAccountID), failing closed on a resolution error and falling back to the sentinel when the deployment maps to no registered account. The sentinel is preferred over rejecting unattributed requests with a 400 because executing unattributed recommendations is a documented, legitimate flow for sessions without account restrictions (validatePurchaseRecommendationScope only rejects them for scoped sessions); only permissions that actually constrain AccountIDs should deny. Regression tests pin the new constraint-set shape on both paths plus the resolver fail-closed and sentinel branches; all confirmed to FAIL against the pre-fix code and pass after.
165d1f2 to
0256828
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai full review |
✅ Action performedFull review finished. |
The auth matcher treats an empty request-side list as "dimension not specified = satisfied", so constraint sets that omitted AccountIDs let a permission constrained to specific accounts authorize the request anyway (CR findings on PR #1210, SEC-01 / issue #1141): - purchases: a recommendation without a cloud_account_id omitted the AccountIDs dimension entirely. It now always carries a single-value AccountIDs list, using the new unattributedAccountConstraint sentinel for unattributed recs so an AccountIDs-constrained permission denies them (fail closed) while unconstrained permissions are unaffected. - ri-exchange: the request-derived set never populated AccountIDs. It now carries the registered cloud account the running deployment resolves to (resolveReshapeCloudAccountID), failing closed on a resolution error and falling back to the sentinel when the deployment maps to no registered account. The sentinel is preferred over rejecting unattributed requests with a 400 because executing unattributed recommendations is a documented, legitimate flow for sessions without account restrictions (validatePurchaseRecommendationScope only rejects them for scoped sessions); only permissions that actually constrain AccountIDs should deny. Regression tests pin the new constraint-set shape on both paths plus the resolver fail-closed and sentinel branches; all confirmed to FAIL against the pre-fix code and pass after.
0256828 to
afa683a
Compare
Adversarial review (PR #1210)Reviewed against the SEC-01 brief surfaces (constraint encoding, every-handler enforcement, Verified
Fixed in-scope and pushed
Follow-up filed
UNSTABLE statePre-existing failing checks on
Not caused by this PR. Not blocking. VerdictLGTM after the rebase fix above. CR re-ping posted ( |
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
Tip For best results, initiate chat on the files or code changes.
Good catch on the rebase regression around Noted as well that the remaining risk has been split cleanly into follow-up issue ᕱᕱ Thanks for the thorough rabbit-hole dive. ✏️ Learnings added
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
The auth matcher treats an empty request-side list as "dimension not specified = satisfied", so constraint sets that omitted AccountIDs let a permission constrained to specific accounts authorize the request anyway (CR findings on PR #1210, SEC-01 / issue #1141): - purchases: a recommendation without a cloud_account_id omitted the AccountIDs dimension entirely. It now always carries a single-value AccountIDs list, using the new unattributedAccountConstraint sentinel for unattributed recs so an AccountIDs-constrained permission denies them (fail closed) while unconstrained permissions are unaffected. - ri-exchange: the request-derived set never populated AccountIDs. It now carries the registered cloud account the running deployment resolves to (resolveReshapeCloudAccountID), failing closed on a resolution error and falling back to the sentinel when the deployment maps to no registered account. The sentinel is preferred over rejecting unattributed requests with a 400 because executing unattributed recommendations is a documented, legitimate flow for sessions without account restrictions (validatePurchaseRecommendationScope only rejects them for scoped sessions); only permissions that actually constrain AccountIDs should deny. Regression tests pin the new constraint-set shape on both paths plus the resolver fail-closed and sentinel branches; all confirmed to FAIL against the pre-fix code and pass after.
4bb6406 to
d243def
Compare
|
@coderabbitai review |
Per-permission Constraints (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs) configured on a group's execute:purchases or execute:ri-exchange permission were never enforced at execution time: HasPermissionAPI hardcoded nil request-side constraints, so the constraint matchers in checkPermissionConstraints were unreachable dead code and a group member could execute purchases of any size in any provider/service/region up to the global 10M sanity cap, silently ignoring the operator-configured spend cap (SEC-01). Fix: - Add Service.HasPermissionForConstraintsAPI, which evaluates every request-derived constraint set against the user's effective permissions (single fetch, union semantics per set) and fails closed on an empty set list or lookup error. HasPermission's matching loop is extracted into permissionsAllow for reuse. - Add Handler.requirePermissionConstraints and call it from validateExecutePurchaseRequest with one constraint set per recommendation (provider/service/region/account plus the batch's total upfront cost, so the cap cannot be evaded by splitting), and from executeExchange with the AWS EC2 scope, request region, and the max_payment_due_usd guardrail as the amount. - The stateless admin API key bypasses the check exactly as it bypasses requirePermission's per-user lookup. Regression tests replicate the real failing scenario (constrained permission passes the bare verb/resource gate, constraint check must 403 before any execution is persisted) and were confirmed to fail against the pre-fix code on both paths. Closes #1141
The auth matcher treats an empty request-side list as "dimension not specified = satisfied", so constraint sets that omitted AccountIDs let a permission constrained to specific accounts authorize the request anyway (CR findings on PR #1210, SEC-01 / issue #1141): - purchases: a recommendation without a cloud_account_id omitted the AccountIDs dimension entirely. It now always carries a single-value AccountIDs list, using the new unattributedAccountConstraint sentinel for unattributed recs so an AccountIDs-constrained permission denies them (fail closed) while unconstrained permissions are unaffected. - ri-exchange: the request-derived set never populated AccountIDs. It now carries the registered cloud account the running deployment resolves to (resolveReshapeCloudAccountID), failing closed on a resolution error and falling back to the sentinel when the deployment maps to no registered account. The sentinel is preferred over rejecting unattributed requests with a 400 because executing unattributed recommendations is a documented, legitimate flow for sessions without account restrictions (validatePurchaseRecommendationScope only rejects them for scoped sessions); only permissions that actually constrain AccountIDs should deny. Regression tests pin the new constraint-set shape on both paths plus the resolver fail-closed and sentinel branches; all confirmed to FAIL against the pre-fix code and pass after.
- Add defensive nil-session guard in requirePermissionConstraints before dereferencing session.UserID (CR nitpick, handler.go:269) - Assert Providers and Services on sets[1] in the constraint denial test to prevent regressions dropping those dimensions (CR outside-diff, handler_purchases_test.go:3362)
During rebase onto main, two items needed manual cleanup: - store_postgres_recommendations.go: the `if rec.Term == 0` guard in recEffectiveSavingsPct was dropped when resolving the conflict with main's #1254 refactor (which already extracted recOnDemandBaseline). The guard lives in the caller per the new docstring; restore it so term=0 rows still return (0, false) as the test asserts. - 000074_audit_actor_stamps.{up,down}.sql: these files were already on the branch at its branching point and collide with 000074_repair_partial_migration_058_067 (already on main). They were renumbered to 000077 on main via fix/migration-74-collision; remove the stale 000074 files here so there is no duplicate migration number.
d243def to
91ba9ff
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
purchaseConstraintSets capped MaxPurchaseAmount against a batch's total upfront cost only. A no-upfront or partial-upfront commitment's real cost is the recurring monthly charge over the term, not the (possibly zero) upfront alone, so an honest no-upfront purchase could evade an otherwise-binding cap. Fix the basis to the batch's total commitment (upfront plus monthly_cost * term_months, recTotalCommitment) and reject a batch whose computed total is exactly zero rather than silently reading MaxPurchaseAmount==0 as "unconstrained" (requireNonZeroCommitment). Also reject a negative monthly_cost at the request boundary, since it would otherwise offset the recurring leg and reopen the same evasion. retryPurchase never consulted the execute:purchases permission Constraints at all, so a retry-any/retry-own session could replay another user's over-cap failed purchase, and a permission tightened after the original submission never applied to a replay. Route both the direct-execute and retry paths through a new shared enforcePurchaseConstraints helper so they check identical Constraints. Adversarial review follow-up to #1210 (SEC-01, issue #1141).
purchaseConstraintSets capped MaxPurchaseAmount against a batch's total upfront cost only. A no-upfront or partial-upfront commitment's real cost is the recurring monthly charge over the term, not the (possibly zero) upfront alone, so an honest no-upfront purchase could evade an otherwise-binding cap. Fix the basis to the batch's total commitment (upfront plus monthly_cost * term_months, recTotalCommitment) and reject a batch whose computed total is exactly zero rather than silently reading MaxPurchaseAmount==0 as "unconstrained" (requireNonZeroCommitment). Also reject a negative monthly_cost at the request boundary, since it would otherwise offset the recurring leg and reopen the same evasion. retryPurchase never consulted the execute:purchases permission Constraints at all, so a retry-any/retry-own session could replay another user's over-cap failed purchase, and a permission tightened after the original submission never applied to a replay. Route both the direct-execute and retry paths through a new shared enforcePurchaseConstraints helper so they check identical Constraints. Adversarial review follow-up to #1210 (SEC-01, issue #1141).
…ry (#1477) purchaseConstraintSets capped MaxPurchaseAmount against a batch's total upfront cost only. A no-upfront or partial-upfront commitment's real cost is the recurring monthly charge over the term, not the (possibly zero) upfront alone, so an honest no-upfront purchase could evade an otherwise-binding cap. Fix the basis to the batch's total commitment (upfront plus monthly_cost * term_months, recTotalCommitment) and reject a batch whose computed total is exactly zero rather than silently reading MaxPurchaseAmount==0 as "unconstrained" (requireNonZeroCommitment). Also reject a negative monthly_cost at the request boundary, since it would otherwise offset the recurring leg and reopen the same evasion. retryPurchase never consulted the execute:purchases permission Constraints at all, so a retry-any/retry-own session could replay another user's over-cap failed purchase, and a permission tightened after the original submission never applied to a replay. Route both the direct-execute and retry paths through a new shared enforcePurchaseConstraints helper so they check identical Constraints. Adversarial review follow-up to #1210 (SEC-01, issue #1141).
Problem
Closes #1141 (review finding SEC-01, P1).
Per-permission Constraints (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs) configured on a group's
execute:purchasesorexecute:ri-exchangepermission were never enforced at execution time.HasPermissionAPIhardcoded nil request-side constraints, socheckPermissionConstraintsshort-circuited to allow and the constraint matchers (matchPurchaseAmountConstraint, the Providers/Services/Regions/AccountIDs matchers) were unreachable dead code on every request path. A group member with a constrained execute permission could execute a purchase of any size (up to the hardcoded global $10M sanity cap), in any provider/service/region: a silent fail-open on a money-mutation path, while the configured cap appeared set in the UI.Fix
internal/auth/service_api.go: newService.HasPermissionForConstraintsAPI(ctx, userID, action, resource, constraintSets)evaluates every request-derived constraint set against the user's effective permissions (single permission fetch; each set must be granted by at least one permission, matching the union semantics of group grants). Fails closed: an empty set list returns an explicit error, never an allow.internal/auth/service_group.go:HasPermission's matching loop is extracted intopermissionsAllowso the batch caller reuses the exact same matching logic (admin short-circuit, action/resource match, constraint match) instead of duplicating it.internal/api/handler.go: newHandler.requirePermissionConstraintsre-checks an already-authenticated session against constraint sets, returning 403 on denial. The stateless admin API key bypasses it exactly as it bypassesrequirePermission's per-user lookup.internal/api/handler_purchases.go:validateExecutePurchaseRequestnow builds one constraint set per recommendation (single-value provider/service/region/account lists, so a batch cannot pass on the strength of one in-scope rec) with the batch's total upfront cost as the amount on every set (so the cap cannot be evaded by splitting a purchase across recs), and enforces them before any execution is persisted.internal/api/handler_ri_exchange.go:executeExchangeenforces a constraint set carrying the AWS EC2 scope, the request's region, and themax_payment_due_usdguardrail as the amount (whichExecuteExchangeindependently enforces against the actual quoted payment due).internal/api/types.go/internal/server/app.go/internal/api/mocks_test.go: interface, adapter, and test mock wiring.Any residual token mismatch denies (fail closed); the request side normalizes provider casing before the constraint sets are built.
Test evidence
TestHandler_executePurchase_PermissionConstraintsDenied(also pins the constraint-set shape: one set per rec, batch total upfront on each)TestExecuteExchange_PermissionConstraintsDeniedTestService_HasPermissionForConstraintsAPI(over-cap denied, in-scope allowed, out-of-scope provider denied, batch with one out-of-scope set denied, admin allowed, empty sets error, store error denies)go build ./...clean;go test ./internal/api/... ./internal/auth/... ./internal/server/... -count=1: 2561 passed, 0 failed;go vetandgofmtclean.Summary by CodeRabbit
New Features
Bug Fixes
Tests