Skip to content

feat(auth): add Purchaser group + carve execute/approve-any/retry-any out of admin wildcard (closes #923) - #924

Merged
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
feat/923-purchaser-group
Jun 3, 2026
Merged

feat(auth): add Purchaser group + carve execute/approve-any/retry-any out of admin wildcard (closes #923)#924
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
feat/923-purchaser-group

Conversation

@cristim

@cristim cristim commented Jun 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds a system-managed Purchaser group (UUID 00000000-0000-5000-8000-000000000005) that holds the three money-spending verbs: execute:purchases, approve-any:purchases, retry-any:purchases.
  • Carves those three verbs out of the admin:* wildcard in HasPermission and on the frontend canAccess. An admin who is not in the Purchaser group is denied these verbs.
  • Migration 000058 seeds the group, adds system_managed column to groups table, and auto-assigns all current Administrators-group members to the Purchaser group so upgrade preserves behavior.

Carve-out rationale

cancel-any stays on admin (cleanup, no money out). The three carved verbs all spend money; requiring explicit group membership for each means a compromised admin account alone cannot drain commitments.

Migration plan

  • Up: ALTER TABLE adds system_managed column; INSERT Purchaser group; UPDATE users to join all existing Administrators-group members.
  • Down: array_remove Purchaser from user group_ids; DELETE the group row; DROP COLUMN system_managed.
  • Backward-compatible on upgrade: existing admins auto-enrolled, behavior unchanged until they are explicitly removed.

Test plan

  • TestAdminWildcardCarveOuts -- admin:* returns false for all 3 carved verbs, true for cancel-any and other admin verbs
  • TestPurchaserGroupCoversExecutePurchases -- Purchaser group alone grants execute/approve-any/retry-any
  • TestAdminWithoutPurchaserCannotExecutePurchases -- admin-only context denied execute:purchases
  • TestAdminAndPurchaserCanExecutePurchases -- both groups together grants everything
  • TestDefaultPermissions/DefaultPurchaserPermissions contains carved verbs and view grants
  • go build ./... clean
  • go test ./internal/auth/... -- 513 passed
  • go test ./internal/api/... -- 1385 passed
  • npx tsc --noEmit -- no errors

Summary by CodeRabbit

  • New Features

    • Added a Purchaser role to control who can execute, approve, or retry purchases.
    • UI banners and informational notices show when you can view but not execute purchases.
    • First-run admin prompt explains separation-of-duties when a Purchaser role exists.
  • Chores

    • tightened authorization so admin status alone no longer grants purchase-spending actions.
  • Style

    • New info-banner styling for purchase-access notices.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 4 minutes and 38 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e4d55904-be31-4866-a779-23459ba6329f

📥 Commits

Reviewing files that changed from the base of the PR and between b108e58 and ba8f8d2.

📒 Files selected for processing (18)
  • cmd/gen-permissions/main.go
  • frontend/src/__tests__/history-approve-button.test.ts
  • frontend/src/__tests__/history-retry-button.test.ts
  • frontend/src/__tests__/permissions.test.ts
  • frontend/src/__tests__/recommendations-permissions.test.ts
  • frontend/src/api/types.ts
  • frontend/src/history.ts
  • frontend/src/permissions.generated.ts
  • frontend/src/permissions.ts
  • frontend/src/recommendations.ts
  • frontend/src/styles/components.css
  • frontend/src/users/userActions.ts
  • internal/auth/store_postgres.go
  • internal/auth/store_postgres_test.go
  • internal/auth/types.go
  • internal/auth/types_test.go
  • internal/database/postgres/migrations/000058_seed_purchaser_group.down.sql
  • internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql
📝 Walkthrough

Walkthrough

Adds a system-managed Purchaser group, carves execute/approve-any/retry-any on purchases out of admin:* in backend auth, seeds Purchaser and system_managed metadata in the DB, updates frontend permission helpers/generated constants, and applies gating/UI notices and tests that reflect the new Purchaser semantics.

Changes

Authorization Model & Carve-Outs

Layer / File(s) Summary
Backend group metadata and authorization carve-outs
internal/auth/types.go
Group gains SystemManaged. AuthContext.HasPermission carves execute/approve-any/retry-any on purchases out of admin:*. DefaultPurchaserPermissions() and Purchaser constants added.
Backend group persistence and test support
internal/auth/store_postgres.go, internal/auth/store_postgres_test.go
GetGroup/ListGroups/selects and scanGroup include system_managed. Test helper createMockRowWithGroup updated to match new scan column ordering.
Database migration: Purchaser group and system-managed flag
internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql, .down.sql
Adds groups.system_managed, marks seeded groups system-managed, seeds Purchaser group with fixed UUID and permissions, backfills admin members, and defines rollback.
Backend authorization tests: carve-outs and Purchaser coverage
internal/auth/types_test.go
New tests assert DefaultPurchaserPermissions contents and separation-of-duties behaviors (admin wildcard carve-outs, purchaser-only, admin+ purchaser combinations).

Frontend Permission Model & Code Generation

Layer / File(s) Summary
Frontend permission constants and isPurchaser helper
frontend/src/permissions.ts
Adds PURCHASER_GROUP_ID, ADMIN_CARVED_OUTS, and isPurchaser(); updates canAccess() to require explicit carved-out grants when effectivePermissions are present, otherwise fall back to Purchaser-group membership for carved-outs.
Generated permission constants and generator updates
cmd/gen-permissions/main.go, frontend/src/permissions.generated.ts
Generator docs reference DefaultPurchaserPermissions and now emits PURCHASER_PERMS; generated frontend file exports PURCHASER_PERMS.
API type updates for system_managed metadata
frontend/src/api/types.ts
APIGroup adds optional system_managed?: boolean documenting seeded groups' immutability in the UI.
Frontend permission behavior tests: carve-outs and group checks
frontend/src/__tests__/permissions.test.ts
Tests rewritten/extended to validate fallback vs effectivePermissions semantics for carved-out purchase verbs, wildcard-resource matching, and isPurchaser behavior.

Frontend Action Authorization and User-Facing Gating

Layer / File(s) Summary
History approve/retry action gating via Purchaser membership
frontend/src/history.ts
Replaces isAdmin()-based approve/retry gating with `canAccess('approve-any'
History button test fixtures: admin users now include Purchaser group
frontend/src/__tests__/history-approve-button.test.ts, frontend/src/__tests__/history-retry-button.test.ts
ADMIN fixtures updated to include PURCHASER_GROUP_ID; added ADMIN_NO_PURCH fixture and tests asserting admin without Purchaser cannot approve/retry others' rows.
Recommendations execute:purchases gating and info banner
frontend/src/recommendations.ts
Hides Purchase CTA when canAccess('execute','purchases') is false and appends an .info-banner guiding users to Settings → Users to request Purchaser access.
Recommendations permission test fixtures
frontend/src/__tests__/recommendations-permissions.test.ts
mockUser('admin') updated to include Purchaser, plus new mockUserWithGroups helper and tests for admin-without-Purchaser and custom-group grants.
Info banner styling for read-only access notices
frontend/src/styles/components.css
Adds .info-banner CSS for informational banner appearance.
Separation of duties admin prompt in user management
frontend/src/users/userActions.ts
Adds a one-time localStorage-gated confirmDialog for admins who are not in Purchaser when the Purchaser group exists.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant canAccess
  participant effectivePermissions
  participant adminCarvedOuts
  participant userGroups
  
  User->>canAccess: check execute:purchases
  
  alt with effectivePermissions loaded
    canAccess->>effectivePermissions: lookup admin:*
    effectivePermissions-->>canAccess: found admin:*
    canAccess->>adminCarvedOuts: is execute:purchases carved-out?
    adminCarvedOuts-->>canAccess: yes, carved out from admin:*
    canAccess->>effectivePermissions: require exact execute:purchases entry
    effectivePermissions-->>canAccess: entry found or not
  else without effectivePermissions (fallback)
    canAccess->>adminCarvedOuts: is execute:purchases carved-out?
    adminCarvedOuts-->>canAccess: yes, carved out
    canAccess->>userGroups: isPurchaser() check
    userGroups-->>canAccess: user in Purchaser group?
  end
  
  canAccess-->>User: grant or deny based on Purchaser membership
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • LeanerCloud/CUDly#922: Introduces effectivePermissions-based canAccess() logic that this PR builds upon for Purchaser carve-outs.
  • LeanerCloud/CUDly#912: Related changes to AuthContext.HasPermission and admin authorization behavior.
  • LeanerCloud/CUDly#168: Prior work on retry-any:purchases RBAC and History retry flow used/updated by this PR.

Suggested labels

effort/m, type/feat, type/security

Poem

🐰 A Purchaser hops in, polite and spry,
Carve-outs keep coins from a single sky,
Admins can watch but must ask to spend,
Banners and prompts help duties not bend,
Hooray — the rules hop forward, neat and sly.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% 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 accurately summarizes the main changes: introducing a Purchaser group and carving three purchase-related verbs out of the admin wildcard to enforce separation of duties.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/923-purchaser-group

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

@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/all-users Affects every user enhancement New feature or request triaged Item has been triaged labels Jun 2, 2026
@cristim

cristim commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim force-pushed the feat/923-purchaser-group branch from b1aea61 to 7a40be8 Compare June 3, 2026 10:53
@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Rebased on feat/multicloud-web-frontend to pick up PR #922 (GET /api/auth/me/permissions + frontend canAccess wiring).

Resolved conflicts in:

  • frontend/src/api/types.ts (additive, auto-merged)
  • frontend/src/permissions.ts (semantic, manual)
  • frontend/src/__tests__/permissions.test.ts (semantic, manual)

Security-sensitive merge note: PR #922's new canAccess consults user.effectivePermissions directly. The base check p.action === 'admin' && p.resource === '*' would have silently overridden the carve-out for sessions where effectivePermissions is loaded, surfacing active execute/approve-any/retry-any buttons on Recommendations + History for admins not in the Purchaser group (the backend would 403 on click but the UX would mismatch the enforcement).

The merged canAccess now enforces the carve-out in both paths so the frontend exactly mirrors the backend's HasPermission:

  1. Effective-permissions path (post /me/permissions fetch): admin:* no longer matches when the action:resource pair is in ADMIN_CARVED_OUTS. The function falls through to looking for an explicit (action, resource) entry, which the backend only emits when the user is in the Purchaser group (or a custom group that grants the verb directly).
  2. Fallback path (effectivePermissions not yet loaded): admin-group membership grants everything EXCEPT the carved-out verbs; those require isPurchaser() to be true.

Tests added under describe('canAccess - effective permissions ...'):

  • admin wildcard ... grants everything except carved-out spending verbs — asserts the new guard
  • admin wildcard plus explicit Purchaser grants ... cover everything — the real shape the backend returns for admin+purchaser users
  • Purchaser explicit grants ... allow spending without admin:* — covers a Purchaser-only user (no admin) seeing the seven DefaultPurchaserPermissions verbs

Tests under describe('canAccess - fallback ...'):

  • Extended Administrators group member passes non-spending checks via group-membership fallback to assert all three carved-out verbs deny
  • Added Purchaser-only (no admin) passes carved-out verbs but not other admin actions

Stash-verified the new effective-permissions guard: removing && !isCarvedOut from the admin:* check causes admin wildcard ... grants everything except carved-out spending verbs to fail with Expected: false, Received: true. Confirms the test exercises the contract.

Also fixed one em-dash in a code comment in internal/auth/types.go (DefaultPurchaserPermissions) introduced by the original commit.

Verification:

  • gofmt: clean
  • go vet: clean
  • go test ./internal/auth/...: 513 pass
  • go test ./internal/api/...: 1394 pass
  • npx jest: 2226 pass / 0 fail / 1 skipped
  • npx tsc --noEmit: clean

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cristim: Understood — I'll kick off an incremental review of the post-rebase commits. A few things I'll pay particular attention to given your merge notes:

  1. canAccess effective-permissions path — verifying the && !isCarvedOut guard is correctly placed so admin:* never matches the three carved-out spending verbs.
  2. canAccess fallback path — confirming isPurchaser() is the only route to execute:purchases, approve-any:purchases, and retry-any:purchases when effectivePermissions isn't yet loaded.
  3. New permissions.test.ts cases — checking that the effective-permissions and fallback test suites cover both the deny path (admin-only) and the grant path (admin + Purchaser, Purchaser-only).
  4. Migration 000058 — re-checking the system_managed column DDL, the Purchaser group seed, and the array_remove down-migration for correctness after the rebase.

🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/recommendations.ts (1)

2861-2863: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-sync purchase visibility after the box is already mounted.

mountBottomActionBox() snapshots purchaseBtn.hidden and the no-Purchaser banner once, then returns early on every later render. If the current user gains Purchaser membership in-session, updateBottomActionBox() never unhides the Purchase CTA or removes the notice. Since the new copy explicitly sends people to Settings → Users to fix access, this leaves the page stale until a full reload.

Also applies to: 2904-2919, 3018-3132

🤖 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 `@frontend/src/recommendations.ts` around lines 2861 - 2863,
mountBottomActionBox() currently returns early if the DOM element
'recommendations-action-box' already exists, which prevents re-syncing
purchaseBtn.hidden and the "no-Purchaser" banner when membership changes
in-session; remove the early return (or explicitly call updateBottomActionBox())
so that on subsequent renders you re-evaluate and set purchase CTA visibility
and banner state (inspect and update purchaseBtn.hidden, the no-Purchaser banner
element, and any cached snapshot logic inside
mountBottomActionBox/updateBottomActionBox) so membership changes are applied
without a full reload; apply the same fix pattern to the other duplicated blocks
referenced (the similar logic around lines ~2904-2919 and ~3018-3132).
🧹 Nitpick comments (4)
frontend/src/__tests__/permissions.test.ts (1)

243-287: ⚡ Quick win

Add one non-seeded custom-group case for the carved-out verbs.

These new cases prove the seeded Purchaser group works, but they still don't exercise the contract described in permissions.ts for a user who gets execute/approve-any/retry-any:purchases from some other custom group. A fixture with a non-PURCHASER_GROUP_ID group plus those explicit effectivePermissions would lock in the intended behavior and catch regressions around the helper mismatch above.

🤖 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 `@frontend/src/__tests__/permissions.test.ts` around lines 243 - 287, Add a new
test that mirrors the "Purchaser explicit grants" case but uses a non-seeded
custom group id instead of PURCHASER_GROUP_ID to ensure carved-out verbs work
when provided by any group; use mockUserWithGroups with a custom group id and an
effectivePermissions array containing execute/approve-any/retry-any:purchases
(and the four view:purchases/plans/recommendations/history as needed), then
assert canAccess allows those spending and view verbs and denies admin/*,
delete:plans, view:users, cancel-any:purchases; this will exercise the
permission contract in permissions.ts and the canAccess helper for
non-PURCHASER_GROUP_ID sources.
frontend/src/__tests__/history-approve-button.test.ts (1)

68-77: ⚡ Quick win

Add the admin-without-Purchaser regression case here.

This fixture now only preserves migrated admin + Purchaser behaviour. The new contract in frontend/src/history.ts is that admin alone should not get approve-any, so this suite is missing the negative path that would catch a reintroduction of isAdmin() here.

🤖 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 `@frontend/src/__tests__/history-approve-button.test.ts` around lines 68 - 77,
Add a negative fixture for an admin without Purchaser to cover the regression:
declare a new test constant (e.g. ADMIN_NO_PURCH) with id/email and groups
containing only ADMINISTRATORS_GROUP_ID (no PURCHASER_GROUP_ID), and update the
test(s) that currently reference ADMIN_USER/REG_USER to include a case that uses
ADMIN_NO_PURCH (via setupDOM or the existing test harness) asserting that this
user does NOT receive approve-any/purchases permissions; reference ADMIN_USER,
ADMIN_NO_PURCH, REG_USER, OTHER_UUID and setupDOM to locate where to add the new
fixture and test case.
frontend/src/__tests__/recommendations-permissions.test.ts (1)

68-77: ⚡ Quick win

Please cover the split-admin UI state explicitly.

mockUser('admin') now means “admin + Purchaser”, so this suite no longer checks the new frontend contract for admin-only sessions: Purchase hidden, Create Plan still visible, and the Purchaser notice shown. That is the highest-risk regression for this screen.

🤖 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 `@frontend/src/__tests__/recommendations-permissions.test.ts` around lines 68 -
77, Current tests use mockUser('admin') which sets both ADMINISTRATORS_GROUP_ID
and PURCHASER_GROUP_ID, so they miss the admin-only UI state; add an explicit
test that simulates an admin without Purchaser by calling mockUserWithGroups (or
extend mockUser to accept a special flag) and set state.getCurrentUser to return
{ id: 'u', email: 'u@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; assert
Purchase button/section is hidden, Create Plan remains visible, and the
Purchaser notice is shown to cover the split-admin UI contract.
frontend/src/__tests__/history-retry-button.test.ts (1)

74-77: ⚡ Quick win

Add the admin-without-Purchaser regression case here.

This suite now only exercises migrated admin + Purchaser behaviour. The carve-out in frontend/src/history.ts is that admin alone should lose retry-any, so please add the negative case; otherwise switching back to isAdmin() would still leave these tests green.

🤖 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 `@frontend/src/__tests__/history-retry-button.test.ts` around lines 74 - 77,
Add a negative test case for an admin user that does NOT have the Purchaser
group: define a new fixture (e.g., ADMIN_USER_NO_PURCHASER or
ADMIN_USER_WITHOUT_PURCHASER) using the same id/email but with groups:
[ADMINISTRATORS_GROUP_ID] only, and add assertions mirroring the existing
positive case that verify the retry-any:purchases permission is NOT present for
that user; update the test suite in history-retry-button.test.ts to exercise
this case so the behavior in history.ts (admin without Purchaser should lose
retry-any) is covered.
🤖 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 `@frontend/src/history.ts`:
- Around line 423-430: The UI is using isPurchaser() to gate "any" approve/retry
paths which diverges from backend auth; update canApprovePendingRow (and the
analogous checks at the other mentioned blocks) to call canAccess('approve-any',
'purchases') for the approve-any branch (and canAccess('retry-any', 'purchases')
for the retry-any branch) instead of isPurchaser(), keep the existing early
returns (status and getCurrentUser) intact, and ensure any banner/readonly
message is derived from those same canAccess checks so the UI uses the exact
capability checks the backend expects.

In `@frontend/src/permissions.ts`:
- Around line 106-118: isPurchaser() currently only checks PURCHASER_GROUP_ID
via state.getCurrentUser().user.groups; change it to derive from the user's
effectivePermissions when present: fetch user = state.getCurrentUser(), return
false if no user, then if user.effectivePermissions is defined check for any of
the purchase verbs ('execute:purchases', 'approve-any:purchases',
'retry-any:purchases') via includes/Set and return true if present; if
effectivePermissions is missing fall back to the existing
Array.isArray(user.groups) && user.groups.includes(PURCHASER_GROUP_ID) behavior;
keep function name isPurchaser() and ensure callers relying on canAccess() are
unchanged.

In `@frontend/src/recommendations.ts`:
- Around line 2907-2919: The banner's visibility is using isPurchaser() while
the Purchase CTA uses canAccess('execute','purchases'), causing mismatches;
update the banner condition to mirror the CTA by replacing the isPurchaser()
check with canAccess('execute', 'purchases') (negated) so the noPurchaseBanner
is only shown when !canAccess('execute', 'purchases'); keep the existing
noPurchaseBanner creation (className, role, textContent, append to box) and
remove the old isPurchaser() branch to ensure consistent behavior with the
`#bulk-purchase-btn` permission logic.

In `@internal/auth/types.go`:
- Around line 318-319: The GroupPurchaser constant currently uses "purchaser"
but the DB seed in 000058_seed_purchaser_group.up.sql inserts "Purchaser",
causing mismatches; update the GroupPurchaser constant in internal/auth/types.go
to exactly "Purchaser" to match the seeded group name (and run/adjust any tests
or comparisons that assume the old lowercase value). Ensure references to
GroupPurchaser throughout the codebase remain correct after the value change.

In `@internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql`:
- Around line 24-42: The INSERT currently uses a blanket "ON CONFLICT DO
NOTHING" which hides the case where another row already has the name "Purchaser"
but a different id, causing the seeded UUID
('00000000-0000-5000-8000-000000000005') to be missing and skipping the admin
backfill; change the migration to explicitly detect a name collision and fail
instead of silently skipping: implement a conditional/transactional check that
if a row exists with name = 'Purchaser' and id <>
'00000000-0000-5000-8000-000000000005' then RAISE EXCEPTION (or otherwise
abort), otherwise perform the INSERT with ON CONFLICT (id) DO NOTHING so only
the intended id conflict is tolerated; ensure this touches the same INSERT into
groups and preserves the admin backfill logic that relies on
DefaultPurchaserGroupID.

---

Outside diff comments:
In `@frontend/src/recommendations.ts`:
- Around line 2861-2863: mountBottomActionBox() currently returns early if the
DOM element 'recommendations-action-box' already exists, which prevents
re-syncing purchaseBtn.hidden and the "no-Purchaser" banner when membership
changes in-session; remove the early return (or explicitly call
updateBottomActionBox()) so that on subsequent renders you re-evaluate and set
purchase CTA visibility and banner state (inspect and update purchaseBtn.hidden,
the no-Purchaser banner element, and any cached snapshot logic inside
mountBottomActionBox/updateBottomActionBox) so membership changes are applied
without a full reload; apply the same fix pattern to the other duplicated blocks
referenced (the similar logic around lines ~2904-2919 and ~3018-3132).

---

Nitpick comments:
In `@frontend/src/__tests__/history-approve-button.test.ts`:
- Around line 68-77: Add a negative fixture for an admin without Purchaser to
cover the regression: declare a new test constant (e.g. ADMIN_NO_PURCH) with
id/email and groups containing only ADMINISTRATORS_GROUP_ID (no
PURCHASER_GROUP_ID), and update the test(s) that currently reference
ADMIN_USER/REG_USER to include a case that uses ADMIN_NO_PURCH (via setupDOM or
the existing test harness) asserting that this user does NOT receive
approve-any/purchases permissions; reference ADMIN_USER, ADMIN_NO_PURCH,
REG_USER, OTHER_UUID and setupDOM to locate where to add the new fixture and
test case.

In `@frontend/src/__tests__/history-retry-button.test.ts`:
- Around line 74-77: Add a negative test case for an admin user that does NOT
have the Purchaser group: define a new fixture (e.g., ADMIN_USER_NO_PURCHASER or
ADMIN_USER_WITHOUT_PURCHASER) using the same id/email but with groups:
[ADMINISTRATORS_GROUP_ID] only, and add assertions mirroring the existing
positive case that verify the retry-any:purchases permission is NOT present for
that user; update the test suite in history-retry-button.test.ts to exercise
this case so the behavior in history.ts (admin without Purchaser should lose
retry-any) is covered.

In `@frontend/src/__tests__/permissions.test.ts`:
- Around line 243-287: Add a new test that mirrors the "Purchaser explicit
grants" case but uses a non-seeded custom group id instead of PURCHASER_GROUP_ID
to ensure carved-out verbs work when provided by any group; use
mockUserWithGroups with a custom group id and an effectivePermissions array
containing execute/approve-any/retry-any:purchases (and the four
view:purchases/plans/recommendations/history as needed), then assert canAccess
allows those spending and view verbs and denies admin/*, delete:plans,
view:users, cancel-any:purchases; this will exercise the permission contract in
permissions.ts and the canAccess helper for non-PURCHASER_GROUP_ID sources.

In `@frontend/src/__tests__/recommendations-permissions.test.ts`:
- Around line 68-77: Current tests use mockUser('admin') which sets both
ADMINISTRATORS_GROUP_ID and PURCHASER_GROUP_ID, so they miss the admin-only UI
state; add an explicit test that simulates an admin without Purchaser by calling
mockUserWithGroups (or extend mockUser to accept a special flag) and set
state.getCurrentUser to return { id: 'u', email: 'u@example.com', groups:
[ADMINISTRATORS_GROUP_ID] }; assert Purchase button/section is hidden, Create
Plan remains visible, and the Purchaser notice is shown to cover the split-admin
UI contract.
🪄 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: f27348dc-e2f9-40f3-b981-ee0b187454da

📥 Commits

Reviewing files that changed from the base of the PR and between 6943207 and 7a40be8.

📒 Files selected for processing (18)
  • cmd/gen-permissions/main.go
  • frontend/src/__tests__/history-approve-button.test.ts
  • frontend/src/__tests__/history-retry-button.test.ts
  • frontend/src/__tests__/permissions.test.ts
  • frontend/src/__tests__/recommendations-permissions.test.ts
  • frontend/src/api/types.ts
  • frontend/src/history.ts
  • frontend/src/permissions.generated.ts
  • frontend/src/permissions.ts
  • frontend/src/recommendations.ts
  • frontend/src/styles/components.css
  • frontend/src/users/userActions.ts
  • internal/auth/store_postgres.go
  • internal/auth/store_postgres_test.go
  • internal/auth/types.go
  • internal/auth/types_test.go
  • internal/database/postgres/migrations/000058_seed_purchaser_group.down.sql
  • internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql

Comment thread frontend/src/history.ts
Comment thread frontend/src/permissions.ts
Comment thread frontend/src/recommendations.ts
Comment thread internal/auth/types.go Outdated
Comment thread internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql Outdated
cristim added a commit that referenced this pull request Jun 3, 2026
Address the five Major findings from CodeRabbit's pass on PR #924 so
the frontend/backend authorization contract for the carved-out
money-spending verbs (execute / approve-any / retry-any on purchases)
stays consistent end-to-end.

F1 (migration 000058): the seed used a bare `ON CONFLICT DO NOTHING`
which treated "row with seeded UUID already exists" and "some other
row already owns the name Purchaser" the same way. The latter case
left DefaultPurchaserGroupID absent and silently broke the admin
backfill. Detect a name-collision under a different UUID and RAISE
EXCEPTION; narrow the conflict target to (id) so only the intended
re-run is tolerated.

F2 (internal/auth/types.go): `GroupPurchaser` constant was the
lowercase string "purchaser" while the seeded row carries "Purchaser".
Any name-based lookup would have missed the row. Align the constant
with the seeded literal and document the contract in the comment.

F3 (frontend/src/recommendations.ts): the no-Purchaser banner was
predicated on `isPurchaser()` while the Purchase CTA used
`canAccess('execute', 'purchases')`. Custom roles that hold
execute:purchases via a non-seeded group saw both a live Purchase
button AND the contradictory "you can view but not execute" notice.
Drive the banner from the same predicate as the CTA.

F4 (frontend/src/permissions.ts): `isPurchaser()` only checked
PURCHASER_GROUP_ID membership, so the helper denied users who hold
the carved-out verbs via custom groups. Extend it to consult
effectivePermissions when present (true if any of the three
carved-out verbs is granted), keeping the seeded-group fallback for
the loading window so it agrees with canAccess()'s carve-out path.

F5 (frontend/src/history.ts): canApprovePendingRow /
canRetryFailedRow and the read-only banner all hard-coded
isPurchaser(). A user granted approve-any:purchases or
retry-any:purchases via another group was rejected by the UI even
though the backend allowed the action. Gate each path on the exact
canAccess(verb, 'purchases') the buttons need, and derive the banner
from the same predicate so UI and enforcement stay in lockstep.

Regression coverage:

* permissions.test.ts: new isPurchaser() suite (seeded-group fallback,
  explicit-grant via execute / approve-any / retry-any in
  effectivePermissions, admin-without-explicit-carve-out denied,
  empty-effective-permissions denied, null-user denied) + custom-group
  carve-out coverage in the canAccess suite.
* history-approve-button.test.ts: admin-without-Purchaser MUST NOT see
  Approve on rows they did not create (catches a future regression to
  isAdmin() / isPurchaser() group-only gates).
* history-retry-button.test.ts: matching admin-without-Purchaser
  retry-any negative case.
* recommendations-permissions.test.ts: admin-without-Purchaser hides
  Purchase, keeps Create Plan, shows the no-Purchaser banner; custom
  non-seeded group with explicit execute:purchases shows Purchase and
  no banner (F3 + F4 together).
@cristim
cristim force-pushed the feat/923-purchaser-group branch from 7a40be8 to b108e58 Compare June 3, 2026 13:10
@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Addressed CR pass-1 findings (5 Major + 4 nitpick regression tests)

Rebased the branch onto the latest feat/multicloud-web-frontend and applied the five Major findings in commit b108e5873. The intermediate-rebase commits are pre-existing on the branch; the new fixes commit is on top.

F1 — migration 000058 silent skip on name collision (internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql)
Added a DO $$ BEGIN ... RAISE EXCEPTION ... END $$ guard that aborts the migration when a row already owns the name Purchaser under a different UUID. Narrowed the ON CONFLICT target from a blanket DO NOTHING to (id) DO NOTHING, so only the intended id-collision (re-applying the seed) is tolerated. The DO block aborts inside the migration transaction, so the ALTER TABLE / UPDATE before it roll back cleanly.

F2 — GroupPurchaser constant case mismatch (internal/auth/types.go)
Changed const GroupPurchaser = "purchaser" to "Purchaser" so it agrees with the literal name inserted by migration 000058. Added a doc comment calling out the cross-file invariant.

F3 — recommendations.ts banner predicate drift (frontend/src/recommendations.ts)
Changed the no-Purchaser banner condition from !isPurchaser() to !canAccess('execute', 'purchases') so it mirrors the Purchase CTA. A custom role granted execute:purchases via a non-seeded group now sees the live Purchase button without a contradictory "you can view but not execute" notice. Dropped the now-unused isPurchaser import.

F4 — isPurchaser() only checked seeded-group membership (frontend/src/permissions.ts)
Extended isPurchaser() so when effectivePermissions is populated it returns true if ANY of the three carved-out verbs (execute/approve-any/retry-any on purchases) is granted, including via a {action, *} wildcard entry (to match canAccess()'s resource-wildcard semantics). The seeded-group fallback stays in place for the loading window so the helper still agrees with canAccess()'s carve-out fallback there.

F5 — history.ts hard-coded isPurchaser() (frontend/src/history.ts)
canApprovePendingRow now gates on canAccess('approve-any', 'purchases'); canRetryFailedRow gates on canAccess('retry-any', 'purchases'). The read-only banner is driven by the same two canAccess calls (!canApproveAny && !canRetryAny) so the notice stays in lockstep with the buttons users actually see. Dropped the now-unused isPurchaser / isAdmin imports.

Regression coverage (CR nitpicks 1-4)

  • permissions.test.ts: new isPurchaser suite covering seeded-group fallback, explicit grants via execute/approve-any/retry-any in effectivePermissions, the wildcard-resource case, admin-without-explicit-carve-out denied, empty-effective-permissions denied, null-user denied; plus a custom-group carve-out case in the canAccess suite (CR nitpick 1).
  • history-approve-button.test.ts: admin-without-Purchaser MUST NOT see Approve on rows they did not create (CR nitpick 2).
  • history-retry-button.test.ts: matching admin-without-Purchaser negative case for retry-any (CR nitpick 4).
  • recommendations-permissions.test.ts: admin-without-Purchaser hides Purchase, keeps Create Plan, shows the no-Purchaser banner; custom non-seeded group with explicit execute:purchases shows Purchase and no banner (CR nitpick 3 + F3/F4 together).

Pre-fix stash-verify

Stashed only permissions.ts (the F4 change) and reran the isPurchaser suite: 5+ new tests fail pre-fix (explicit-grant tests, wildcard-resource test, empty-effectivePermissions-with-PURCHASER_GROUP_ID test). Restored the fix and the suite is green again.

Out-of-diff finding (mountBottomActionBox cache)

CodeRabbit's "Re-sync purchase visibility after the box is already mounted" note (recommendations.ts line ~2861) is real, but the membership-mutates-in-session flow is also broken on the existing Create Plan / Capacity controls in the same function, and the fix touches the entire mountBottomActionBox lifecycle (remount or full updateBottomActionBox re-render path). Filing it as a separate GitHub issue rather than expanding this PR's scope; the in-session-without-reload UX gap predates the Purchaser carve-out.

Verification

  • npx jest <4 perm suites>: 79 passed / 0 failed (was 41 before the new regression coverage).
  • npx tsc --noEmit: clean.
  • go build ./...: clean.
  • go test ./internal/auth/...: 513 passed.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cristim: Excellent — the pass-1 fixes look thorough and well-reasoned. Quick acknowledgement of each:

  • F1 (migration name-collision guard): The RAISE EXCEPTION inside the DO $$ block is the right approach — it aborts inside the migration transaction so the preceding ALTER TABLE / UPDATE roll back atomically. Narrowing ON CONFLICT to (id) DO NOTHING is strictly more correct.
  • F2 (GroupPurchaser case): Critical cross-file invariant now documented at the constant — good catch.
  • F3 (banner predicate): Driving the banner from canAccess('execute', 'purchases') instead of isPurchaser() is the right single source of truth, and correctly handles custom non-seeded groups. Dropping the now-unused import is clean.
  • F4 (isPurchaser() wildcard-resource): Extending the check to any of the three carved-out verbs (including {action, *} wildcards) keeps isPurchaser() semantically consistent with canAccess(). The seeded-group fallback for the loading window is the right bridge.
  • F5 (history.ts): Gating canApprovePendingRow/canRetryFailedRow on canAccess() directly, and tying the banner to the same two calls, gives a single code path for both button visibility and notice display. Clean.

The stash-verify on F4 and the 79/0 jest result give good confidence in the regression coverage.

Regarding the mountBottomActionBox cache finding — I agree that's the right call to defer. Would you like me to open a GitHub issue for it now so it's tracked? I can include the PR and the specific recommendations.ts line as context.

Kicking off an incremental review of commit b108e5873.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Followed up the out-of-diff finding (mountBottomActionBox re-sync gap) as #928 with full repro + acceptance criteria.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/__tests__/permissions.test.ts (1)

27-28: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

UUID collision: STD_GID equals PURCHASER_GROUP_ID.

STD_GID is defined as '00000000-0000-5000-8000-000000000005', which is the same UUID as the Purchaser group. This causes tests using STD_GID to inadvertently grant Purchaser membership during the fallback path (when effectivePermissions is absent). Consider using a distinct UUID for the standard-user fixture (e.g., '00000000-0000-5000-8000-000000000002' which is the actual Standard Users group from migration 000057).

Proposed fix
 const ADMIN_GID = ADMINISTRATORS_GROUP_ID;
-const STD_GID = '00000000-0000-5000-8000-000000000005';
+const STD_GID = '00000000-0000-5000-8000-000000000002';
 const RO_GID = '00000000-0000-5000-8000-000000000006';
🤖 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 `@frontend/src/__tests__/permissions.test.ts` around lines 27 - 28, STD_GID in
the test fixtures is colliding with the real Purchaser group ID and causing
accidental Purchaser membership when effectivePermissions is missing; change the
STD_GID constant (used in permissions.test.ts) to a distinct UUID (for example
'00000000-0000-5000-8000-000000000002' or another unique value) so it no longer
equals PURCHASER_GROUP_ID, and update any tests that rely on STD_GID to use the
new constant; ensure tests that exercise the fallback path (where
effectivePermissions is absent) still assert expected non-Purchaser behavior
after the change.
🤖 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 `@frontend/src/__tests__/permissions.test.ts`:
- Around line 27-28: STD_GID in the test fixtures is colliding with the real
Purchaser group ID and causing accidental Purchaser membership when
effectivePermissions is missing; change the STD_GID constant (used in
permissions.test.ts) to a distinct UUID (for example
'00000000-0000-5000-8000-000000000002' or another unique value) so it no longer
equals PURCHASER_GROUP_ID, and update any tests that rely on STD_GID to use the
new constant; ensure tests that exercise the fallback path (where
effectivePermissions is absent) still assert expected non-Purchaser behavior
after the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7cf4c9db-df4a-4970-853c-7b2c6753eeb2

📥 Commits

Reviewing files that changed from the base of the PR and between 7a40be8 and b108e58.

📒 Files selected for processing (18)
  • cmd/gen-permissions/main.go
  • frontend/src/__tests__/history-approve-button.test.ts
  • frontend/src/__tests__/history-retry-button.test.ts
  • frontend/src/__tests__/permissions.test.ts
  • frontend/src/__tests__/recommendations-permissions.test.ts
  • frontend/src/api/types.ts
  • frontend/src/history.ts
  • frontend/src/permissions.generated.ts
  • frontend/src/permissions.ts
  • frontend/src/recommendations.ts
  • frontend/src/styles/components.css
  • frontend/src/users/userActions.ts
  • internal/auth/store_postgres.go
  • internal/auth/store_postgres_test.go
  • internal/auth/types.go
  • internal/auth/types_test.go
  • internal/database/postgres/migrations/000058_seed_purchaser_group.down.sql
  • internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql
🚧 Files skipped from review as they are similar to previous changes (11)
  • frontend/src/styles/components.css
  • frontend/src/api/types.ts
  • frontend/src/permissions.generated.ts
  • internal/auth/store_postgres_test.go
  • cmd/gen-permissions/main.go
  • frontend/src/users/userActions.ts
  • internal/auth/types_test.go
  • internal/auth/store_postgres.go
  • internal/database/postgres/migrations/000058_seed_purchaser_group.up.sql
  • internal/database/postgres/migrations/000058_seed_purchaser_group.down.sql
  • internal/auth/types.go

cristim added a commit that referenced this pull request Jun 3, 2026
Address the five Major findings from CodeRabbit's pass on PR #924 so
the frontend/backend authorization contract for the carved-out
money-spending verbs (execute / approve-any / retry-any on purchases)
stays consistent end-to-end.

F1 (migration 000058): the seed used a bare `ON CONFLICT DO NOTHING`
which treated "row with seeded UUID already exists" and "some other
row already owns the name Purchaser" the same way. The latter case
left DefaultPurchaserGroupID absent and silently broke the admin
backfill. Detect a name-collision under a different UUID and RAISE
EXCEPTION; narrow the conflict target to (id) so only the intended
re-run is tolerated.

F2 (internal/auth/types.go): `GroupPurchaser` constant was the
lowercase string "purchaser" while the seeded row carries "Purchaser".
Any name-based lookup would have missed the row. Align the constant
with the seeded literal and document the contract in the comment.

F3 (frontend/src/recommendations.ts): the no-Purchaser banner was
predicated on `isPurchaser()` while the Purchase CTA used
`canAccess('execute', 'purchases')`. Custom roles that hold
execute:purchases via a non-seeded group saw both a live Purchase
button AND the contradictory "you can view but not execute" notice.
Drive the banner from the same predicate as the CTA.

F4 (frontend/src/permissions.ts): `isPurchaser()` only checked
PURCHASER_GROUP_ID membership, so the helper denied users who hold
the carved-out verbs via custom groups. Extend it to consult
effectivePermissions when present (true if any of the three
carved-out verbs is granted), keeping the seeded-group fallback for
the loading window so it agrees with canAccess()'s carve-out path.

F5 (frontend/src/history.ts): canApprovePendingRow /
canRetryFailedRow and the read-only banner all hard-coded
isPurchaser(). A user granted approve-any:purchases or
retry-any:purchases via another group was rejected by the UI even
though the backend allowed the action. Gate each path on the exact
canAccess(verb, 'purchases') the buttons need, and derive the banner
from the same predicate so UI and enforcement stay in lockstep.

Regression coverage:

* permissions.test.ts: new isPurchaser() suite (seeded-group fallback,
  explicit-grant via execute / approve-any / retry-any in
  effectivePermissions, admin-without-explicit-carve-out denied,
  empty-effective-permissions denied, null-user denied) + custom-group
  carve-out coverage in the canAccess suite.
* history-approve-button.test.ts: admin-without-Purchaser MUST NOT see
  Approve on rows they did not create (catches a future regression to
  isAdmin() / isPurchaser() group-only gates).
* history-retry-button.test.ts: matching admin-without-Purchaser
  retry-any negative case.
* recommendations-permissions.test.ts: admin-without-Purchaser hides
  Purchase, keeps Create Plan, shows the no-Purchaser banner; custom
  non-seeded group with explicit execute:purchases shows Purchase and
  no banner (F3 + F4 together).
@cristim
cristim force-pushed the feat/923-purchaser-group branch from b108e58 to a4e1cb4 Compare June 3, 2026 14:31
@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Rebased on feat/multicloud-web-frontend to pick up the merged execute:ri-exchange split (#660). The only content conflict was in frontend/src/__tests__/permissions.test.ts, resolved additively so both contracts hold simultaneously:

Addressed CR pass-2 findings:

  • permissions.test.ts STD_GID UUID collision (...005 == PURCHASER_GROUP_ID) -> fixed: STD_GID now uses the Standard Users group UUID ...002 (migration 000057), so the fallback path no longer grants accidental Purchaser membership to the standard-user fixture.

CR pass-1 findings (all already addressed in earlier commits, confirmed stale):

  • internal/auth/types.go GroupPurchaser casing, migration 000058 ON CONFLICT name-collision guard, isPurchaser() / history.ts / recommendations.ts banner-vs-CTA consistency -> all marked "Addressed in commits 40fd854 to b108e58" by CR.
  • 4 nitpick regression-coverage cases (admin-without-Purchaser in history-approve/retry + recommendations-permissions, non-seeded custom-group in permissions.test) -> already present in the PR test files (history-approve-button.test.ts:382, history-retry-button.test.ts:442, recommendations-permissions.test.ts:171, permissions.test.ts:347).

Verification: npx jest on the 4 touched suites = 123 passing; npx tsc --noEmit clean; go build/vet/test ./internal/auth/... = 514 passing.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cristim: The rebase looks clean — the additive conflict resolution in permissions.test.ts is exactly the right approach, keeping both the #660 execute:ri-exchange isolation tests and the #923 admin-wildcard carve-out contracts live simultaneously.

The STD_GID UUID fix (...005...002) is an important correctness fix: ...005 coinciding with PURCHASER_GROUP_ID would have silently granted Purchaser membership to the standard-user fixture, making the fallback-path negative assertions vacuous. Using the actual Standard Users seed UUID (...002, migration 000057) removes that false assurance.

Kicking off an incremental review of the post-rebase commits now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 3 commits June 3, 2026 23:16
… out of admin wildcard (closes #923)

Implements separation of duties for money-spending operations. The three
carved verbs (execute:purchases, approve-any:purchases, retry-any:purchases)
are removed from the admin:* wildcard in HasPermission; a user must hold
them via explicit group membership (the new Purchaser group or any custom
group that grants them).

Migration 000058 seeds the Purchaser group (UUID 000000000005, system_managed)
and auto-assigns existing Administrators-group members so upgrades preserve
current behavior. Adds system_managed column to the groups table.

Frontend: canAccess respects the carve-out; isPurchaser() checks Purchaser
group membership; info banners on Recommendations and History pages for
sessions lacking execute:purchases; first-run dialog for admins not yet
in the Purchaser group.
…hase execution

The Purchaser-group carve-out (issue #923) removed execute:purchases,
approve-any:purchases, and retry-any:purchases from the admin:* wildcard.
Four tests were written before that contract existed and expected admin-alone
membership to grant those verbs.

- permissions.test.ts: split the "Administrators group member passes all checks"
  test into two -- admin-alone now asserts execute:purchases === false (regression
  guard for the carve-out), admin+Purchaser asserts all three carved-out verbs pass
- recommendations-permissions.test.ts: mockUser('admin') now includes Purchaser
  group membership, matching the auto-migration path for existing admins
- history-approve-button.test.ts: ADMIN_USER gains Purchaser membership so
  approve-any:purchases is granted (approve button visible on all pending rows)
- history-retry-button.test.ts: ADMIN_USER gains Purchaser membership so
  retry-any:purchases is granted (retry button visible on all failed rows)
Address the five Major findings from CodeRabbit's pass on PR #924 so
the frontend/backend authorization contract for the carved-out
money-spending verbs (execute / approve-any / retry-any on purchases)
stays consistent end-to-end.

F1 (migration 000058): the seed used a bare `ON CONFLICT DO NOTHING`
which treated "row with seeded UUID already exists" and "some other
row already owns the name Purchaser" the same way. The latter case
left DefaultPurchaserGroupID absent and silently broke the admin
backfill. Detect a name-collision under a different UUID and RAISE
EXCEPTION; narrow the conflict target to (id) so only the intended
re-run is tolerated.

F2 (internal/auth/types.go): `GroupPurchaser` constant was the
lowercase string "purchaser" while the seeded row carries "Purchaser".
Any name-based lookup would have missed the row. Align the constant
with the seeded literal and document the contract in the comment.

F3 (frontend/src/recommendations.ts): the no-Purchaser banner was
predicated on `isPurchaser()` while the Purchase CTA used
`canAccess('execute', 'purchases')`. Custom roles that hold
execute:purchases via a non-seeded group saw both a live Purchase
button AND the contradictory "you can view but not execute" notice.
Drive the banner from the same predicate as the CTA.

F4 (frontend/src/permissions.ts): `isPurchaser()` only checked
PURCHASER_GROUP_ID membership, so the helper denied users who hold
the carved-out verbs via custom groups. Extend it to consult
effectivePermissions when present (true if any of the three
carved-out verbs is granted), keeping the seeded-group fallback for
the loading window so it agrees with canAccess()'s carve-out path.

F5 (frontend/src/history.ts): canApprovePendingRow /
canRetryFailedRow and the read-only banner all hard-coded
isPurchaser(). A user granted approve-any:purchases or
retry-any:purchases via another group was rejected by the UI even
though the backend allowed the action. Gate each path on the exact
canAccess(verb, 'purchases') the buttons need, and derive the banner
from the same predicate so UI and enforcement stay in lockstep.

Regression coverage:

* permissions.test.ts: new isPurchaser() suite (seeded-group fallback,
  explicit-grant via execute / approve-any / retry-any in
  effectivePermissions, admin-without-explicit-carve-out denied,
  empty-effective-permissions denied, null-user denied) + custom-group
  carve-out coverage in the canAccess suite.
* history-approve-button.test.ts: admin-without-Purchaser MUST NOT see
  Approve on rows they did not create (catches a future regression to
  isAdmin() / isPurchaser() group-only gates).
* history-retry-button.test.ts: matching admin-without-Purchaser
  retry-any negative case.
* recommendations-permissions.test.ts: admin-without-Purchaser hides
  Purchase, keeps Create Plan, shows the no-Purchaser banner; custom
  non-seeded group with explicit execute:purchases shows Purchase and
  no banner (F3 + F4 together).
@cristim
cristim force-pushed the feat/923-purchaser-group branch from a4e1cb4 to ba8f8d2 Compare June 3, 2026 21:17
@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Rebased onto the latest feat/multicloud-web-frontend (now at #936's c00445c79), head is now ba8f8d2d7.

Clean rebase (no content conflicts); it picks up #936's fix to recommendations-lookback.test.ts mockUser (groups instead of legacy role, the base-branch test bug tracked as #935) that was the sole cause of this PR's UNSTABLE CI. No changes to this PR's own commits.

Verified locally on the rebased head: recommendations-lookback.test.ts (17/17 pass), auth tests pass, tsc --noEmit clean.

Note: not re-pinging CodeRabbit this round because the org-level CR usage is currently rate-limited (billing); this PR was already CR-clean before the rebase and the rebase introduced no code changes to its own commits, so there is nothing new on the diff for CR to review. Will request a fresh CR pass once org CR credits are restored.

@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit b6c2733 into feat/multicloud-web-frontend Jun 3, 2026
6 checks passed
@cristim
cristim deleted the feat/923-purchaser-group branch June 3, 2026 22:19
cristim added a commit that referenced this pull request Jun 3, 2026
…000059 (base collision from #803+#924)

The base feat/multicloud-web-frontend merged two 000058 migrations from the
recent wave, which the check-migration-conflicts pre-commit hook rejects:
- 000058_purchase_executions_direct_execute_audit (#803, schema change)
- 000058_seed_purchaser_group (#924, data seed)

Keep the schema-change audit migration at 000058 and renumber the seed
migration to the next free slot, 000059 (base jumps 000058 -> 000063, so
000059-000062 are open). golang-migrate discovers migrations by filename
glob, so no embed list needs updating; only two self-references were
adjusted:
- auth/types.go GroupPurchaser doc comment pointing at the filename
- the self-referencing error message inside the up.sql

Safe to renumber without applied-state reconciliation: the base has not
compiled since the #803/#907 break, so neither 000058 migration has been
applied to any database yet.
cristim added a commit that referenced this pull request Jun 4, 2026
…ect gate (closes #940) (#941)

* fix(api/purchases): drop removed session.Role shortcut in execute-direct gate (closes #940)

PR #912 (#907) removed the Role field from api.Session, leaving
authorizeSessionExecuteDirect with a dead `if session.Role == "admin"`
reference that prevented the base branch from compiling.

Remove the three-line shortcut and update the gate-logic doc comment to
match the sibling authorizeSessionApprove/authorizeSessionCancel pattern:
admins pass via the execute-any HasPermissionAPI check because the
Administrators group carries the {admin,*} wildcard permission.

Also fix all test Session struct literals that still set Role (now an
unknown field), update the admin-role-shortcut test to properly stub the
HasPermissionAPI path, and add three regression tests:
- AdminGroupViaExecuteAny: confirms admin users still pass via execute-any
- NoGrant: confirms non-admin without execute-any/own gets 403
- NilAuth: confirms nil auth component returns 500 (fail-closed)

* fix(db): renumber duplicate migration 000058_seed_purchaser_group to 000059 (base collision from #803+#924)

The base feat/multicloud-web-frontend merged two 000058 migrations from the
recent wave, which the check-migration-conflicts pre-commit hook rejects:
- 000058_purchase_executions_direct_execute_audit (#803, schema change)
- 000058_seed_purchaser_group (#924, data seed)

Keep the schema-change audit migration at 000058 and renumber the seed
migration to the next free slot, 000059 (base jumps 000058 -> 000063, so
000059-000062 are open). golang-migrate discovers migrations by filename
glob, so no embed list needs updating; only two self-references were
adjusted:
- auth/types.go GroupPurchaser doc comment pointing at the filename
- the self-referencing error message inside the up.sql

Safe to renumber without applied-state reconciliation: the base has not
compiled since the #803/#907 break, so neither 000058 migration has been
applied to any database yet.
cristim added a commit that referenced this pull request Jun 4, 2026
…ple bootstrap from migration step (closes #945) (#947)

* fix(api/purchases): drop removed session.Role shortcut in execute-direct gate (closes #940)

PR #912 (#907) removed the Role field from api.Session, leaving
authorizeSessionExecuteDirect with a dead `if session.Role == "admin"`
reference that prevented the base branch from compiling.

Remove the three-line shortcut and update the gate-logic doc comment to
match the sibling authorizeSessionApprove/authorizeSessionCancel pattern:
admins pass via the execute-any HasPermissionAPI check because the
Administrators group carries the {admin,*} wildcard permission.

Also fix all test Session struct literals that still set Role (now an
unknown field), update the admin-role-shortcut test to properly stub the
HasPermissionAPI path, and add three regression tests:
- AdminGroupViaExecuteAny: confirms admin users still pass via execute-any
- NoGrant: confirms non-admin without execute-any/own gets 403
- NilAuth: confirms nil auth component returns 500 (fail-closed)

* fix(db): renumber duplicate migration 000058_seed_purchaser_group to 000059 (base collision from #803+#924)

The base feat/multicloud-web-frontend merged two 000058 migrations from the
recent wave, which the check-migration-conflicts pre-commit hook rejects:
- 000058_purchase_executions_direct_execute_audit (#803, schema change)
- 000058_seed_purchaser_group (#924, data seed)

Keep the schema-change audit migration at 000058 and renumber the seed
migration to the next free slot, 000059 (base jumps 000058 -> 000063, so
000059-000062 are open). golang-migrate discovers migrations by filename
glob, so no embed list needs updating; only two self-references were
adjusted:
- auth/types.go GroupPurchaser doc comment pointing at the filename
- the self-referencing error message inside the up.sql

Safe to renumber without applied-state reconciliation: the base has not
compiled since the #803/#907 break, so neither 000058 migration has been
applied to any database yet.

* fix(db/migrate): admin-upsert SQL parity with post-057 schema + decouple bootstrap from migration step (closes #945)

Migration 000057 dropped the users.role column and sessions.role column
(PR #912). The Go bootstrap functions in migrate.go were not updated at
the time, leaving three SQL statements that reference the dropped column:

- ensureAdminUser: INSERT INTO users included `role` in column list / VALUES
- ensureAdminUserWithPassword: same INSERT pattern
- assignAdminGroupAndWarn: two queries used WHERE role = 'admin'

This caused every Lambda cold start to fail the admin-upsert step with
"column role of relation users does not exist (SQLSTATE 42703)".

The admin-upsert failure also masked a second deployment gap: migration
000058 (purchase_executions direct-execute audit columns) was missing
from the deployed image, causing all purchase-execution reads/writes to
fail with column-not-found errors. The image rebuild (ops action) will
apply 000058 on the next cold start.

Changes:
- Remove `role` from INSERT column list and VALUES in both
  ensureAdminUser and ensureAdminUserWithPassword
- Replace `WHERE role = 'admin'` in assignAdminGroupAndWarn with
  `WHERE (group_ids IS NULL OR cardinality(group_ids) = 0)`;
  post-057, the users_min_one_group CHECK makes this a permanent no-op
  so the backfill path is defence-in-depth for rollback scenarios only
- Decouple admin-bootstrap failure from RunMigrations return value:
  if m.Up() succeeds but ensureAdminUser fails, RunMigrations now logs
  a WARNING and returns nil so the health endpoint correctly reports
  migration success and the app boots cleanly
- Update ensure_admin_user_test.go: the pre-057 drift scenario (empty
  group_ids on an existing admin row) is structurally impossible
  post-057 due to the CHECK constraint; replace with an idempotency
  test that verifies re-running RunMigrations on an already-correct
  admin row is a no-op
- Update migrate_security_integration_test.go: the same drift simulation
  (INSERT with role + empty group_ids after migrations) is impossible
  post-057; test now exercises the "Admin user created" log path instead

* fix(db/migrate): address CR feedback on PR #947

- Scope assignAdminGroupAndWarn to the bootstrap admin email only;
  the previous UPDATE/COUNT targeted all users with empty group_ids,
  which would assign the Administrators group to non-admin users in
  pre-057 or rollback states (CR finding, major severity)
- Remove dropped `role = 'admin'` predicate from test SQL in the
  operator-customisation sub-case; the column was dropped by
  migration 000057 so the WHERE clause would always return zero rows
  on the current schema (CR outside-diff finding)
- Deferred: frontend 000058->000059 comment update is out of PR scope;
  filed as issue #955
cristim added a commit that referenced this pull request Jun 5, 2026
… follow-up) (#972)

PR #924's test-fix wave updated history-approve-button.test.ts,
history-retry-button.test.ts, recommendations-permissions.test.ts, and
permissions.test.ts, but missed three suites that also broke under the
#923 carve-out contract:

- execute-mode-toggle.test.ts: mockUser('admin') produced
  { role: 'admin' } with no groups array. The toggle check calls
  canAccess('execute-any', 'purchases') which falls back to isAdmin()
  (not a carved-out verb), and isAdmin() requires ADMINISTRATORS_GROUP_ID
  in groups. Without groups the toggle was never rendered.

- purchase-execution-toast.test.ts: the jest.mock('../recommendations')
  factory was missing getExecuteMode and clearExecuteMode, both of which
  app.ts calls in handleExecutePurchase (added in the #289 / #924 wave).
  Every single-record test threw TypeError at line 328 of app.ts before
  reaching the toast assertions.

- recommendations.test.ts: the default state mock used
  groups: ['...000000000001'] (Administrators only). After #923, the
  execute-mode toggle checks canAccess('execute-any', 'purchases') which
  calls isAdmin() -- not a carved-out verb -- so admin-group membership
  alone caused the toggle to render. The three tests that assert the
  approval-required note ('shows purchase summary', 'modal body carries
  the approval-required explanation', 'approval-required note renders
  with its dedicated class') saw the toggle instead of the note.

Fixes applied:
  1. execute-mode-toggle: import ADMINISTRATORS_GROUP_ID + PURCHASER_GROUP_ID
     from permissions; mockUser('admin') now produces
     groups: [ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID].
  2. purchase-execution-toast: add getExecuteMode (returns '') and
     clearExecuteMode to the recommendations mock factory.
  3. recommendations: the three approval-required note tests override
     getCurrentUser to a non-admin user (groups: []) and restore the
     admin user after each test to prevent mock state from leaking into
     sibling describe blocks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request impact/all-users Affects every user priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant