You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(api/auth): finer-grained per-role write permissions across plans/purchases/RI-exchange #660
PR #364 made the route Auth field mandatory and flipped 24 read endpoints to AuthUser so non-admin roles can use the app. All mutating endpoints (POST/PUT/PATCH/DELETE) were kept at AuthAdmin pending finer-grained per-role write permissions.
PR #364 listed this under "Out of scope (separate follow-ups)" without filing an issue. Capturing it now.
Spread the existing handler-level requirePermission(action, resource) checks (currently on only a few endpoints) to plans / purchases / RI-exchange / etc., so role semantics become: readonly = read-only; user = read + write plans; admin = full.
Decide which admin-management endpoints (/api/users, /api/registrations, /api/accounts writes) should ever loosen to non-admin (default today: stay admin-only).
Audit requirePermission callers so no AuthUser route accidentally surfaces data outside the caller's allowed_accounts grant.
Out of scope when deferred: PR #364 was scoped to fixing the P0 (whole read surface was admin-gated) by making Auth explicit; the per-role write matrix is a larger design.
Design for issue #660 — per-role write permissions
Read-only investigation done against feat/multicloud-web-frontend (HEAD 5abf40bef). The good news: the permission model already exists in code and most handler-level verb checks are already in place. The work is mostly router gate flips plus a few targeted decisions and frontend visibility wiring, not a schema rebuild.
1. Current model
Schema (internal/auth/types.go):
User.Role is one of RoleAdmin, RoleUser, RoleReadOnly.
Group carries Permissions []Permission (JSON column on auth_groups in Postgres). Users get a union of their group permissions plus their role's defaults.
Permission{Action, Resource, Constraints} already supports per-account/provider/service/region scoping and MaxPurchaseAmount.
AuthContext.HasPermission(action, resource) (types.go:111) admin-shortcuts then walks the merged permission slice; ActionAdmin+ResourceAll is the wildcard.
Defaults:
DefaultAdminPermissions -> {admin, *}.
DefaultUserPermissions (types.go:389) already includes view recs/plans/purchases/history, create/update plans, plus cancel-own/retry-own/approve-own purchases.
DefaultReadOnlyPermissions -> view-only on recs, plans, history.
internal/api/handler.go:181 — requirePermission(ctx, req, action, resource) does the fine-grained verb check inside each handler, with admin shortcuts for both the admin API key and admin role.
Coverage of requirePermission today (grepped, file:line):
RI-exchange: handler_ri_exchange.go:115,198,239,260,368,473,534,683,704,738 — covered (view purchases for reads, execute purchases for execute, view/update config for the config endpoint).
Registrations approve/reject: handler_registrations.go:204 uses requireAdmin via setReviewMetadata.
The chokepoint. PR #364 left every mutating route at Auth: AuthAdmin (router.go:117,119,145,147,149,151,152,153,156,177,178,179,180,192,220-224,230,231,233-235,237-238,245,247,248,265,266,268,277-282,233). Router.Route calls requireAdmin first, so even when the handler's requirePermission would let a non-admin user with update:plans through, the router rejects with admin required before the handler runs. The follow-up work is mostly flipping these route guards from AuthAdmin to AuthUser (so the handler's verb check becomes authoritative).
2. Proposed schema changes
None. The Permission model, the Group.Permissions JSON column, and the requirePermission plumbing all exist. No DB migration needed.
Two small additions to default sets in types.go:
RoleUser already gets create/update plans. Add delete plans if we want the user role to delete plans (PR fix(api): route Auth field becomes mandatory + readonly/user roles get access to read endpoints (P0) #364's "user = read + write plans" wording reads as full plan CRUD). Or keep delete admin-only by omitting it from DefaultUserPermissions; non-admin users would then see plan-write but not plan-delete after the router flip.
For account writes, update/delete accounts are not in any default role today. The default policy stays admin-only; custom groups can grant {update, accounts} to delegated operators without changing roles.
Open verb-design questions (call out for product):
Plan delete for user role: yes or admin-only? (My read: yes — symmetric with create/update.)
Planned-purchase ops (pause, resume, run, delete): the handlers already gate on update/execute/delete purchases. DefaultUserPermissions does NOT include these. Either (a) add them to user defaults so the "user" role can manage its own planned purchases, or (b) keep them admin-only and require explicit group grant. Recommend (a) for update purchases (pause/resume) and (b) for execute/delete purchases (those are higher-blast-radius).
RI-exchange quote/execute/config writes: keep admin-only by default; expose via group grants only. Same argument as for execute purchases.
Recommendations refresh: already AuthUser+handler-gated. No change.
3. Decisions on admin-management endpoints (scope item 2 in issue body)
/api/users writes — stay AuthAdmin. User management is org-admin territory; no roadmap need for non-admin user CRUD.
/api/groups writes — stay AuthAdmin. Editing groups means editing the permission system itself.
/api/registrations writes — stay AuthAdmin. Approving a federation registration grants access to new accounts.
/api/accounts create/credentials/test — stay AuthAdmin by default; allow custom groups to grant create/update accounts to delegated operators (no router flip; the handler's verb gate already supports this once the route is AuthUser). If we don't want non-admin operators ever editing accounts, keep AuthAdmin on the route — the handler verb check then never fires for non-admins, which is fine.
/api/config PUT — stay AuthAdmin for global config. The RI-exchange config PUT (which already uses update:config) can flip to AuthUser since the handler enforces.
/api/analytics/collect — admin-only; no change. Bulk collection is operational.
4. Audit task (scope item 3)
AuthUser routes that read account-scoped data already call requirePermission and then resolve getAllowedAccounts + scope the query — confirmed in handler_plans.go, handler_purchases.go, handler_analytics.go, handler_federation.go. The flips proposed below only enable mutations on rows the user is already authorized to see, so the scoping invariant is preserved. The audit is a focused per-handler check (mainly: setPlanAccounts, pausePlannedPurchase, resumePlannedPurchase, deletePlannedPurchase, executePurchase, updateRIExchangeConfig) confirming each calls getAllowedAccounts(session) and rejects out-of-scope IDs before writing.
5. Effort + recommended PR breakdown
Estimate: medium-to-large. Three PRs. Not a single small PR.
PR-A — router flips + verb defaults (medium). No migration, no frontend change.
internal/auth/types.go: extend DefaultUserPermissions with delete:plans, update:purchases (planned-purchase pause/resume), and (if we choose to) leave execute:purchases/delete:purchases out so non-admin users can't fire purchases or wipe planned ones without an explicit group grant.
internal/api/router.go: flip the following from AuthAdmin to AuthUser (handler-level requirePermission then enforces):
Plans: POST/PUT/PATCH/DELETE /api/plans and /api/plans/{id}, plus POST /api/plans/{id}/purchases and PUT /api/plans/{id}/accounts.
Planned purchases: POST pause/resume/run, DELETE /api/purchases/planned/{id}.
Purchases: POST /api/purchases/execute.
RI-exchange: POST quote/execute, PUT /api/ri-exchange/config.
Keep AuthAdmin on: /api/config PUT, all /api/users, /api/groups, /api/registrations, /api/accounts writes, /api/accounts/discover-org, /api/analytics/collect.
Tests: extend internal/api/router_authuser_test.go + router_auth_test.go with role-permutation cases (user vs readonly vs admin -> 200/403 on each flipped route). Audit-scoping unit tests already exist per handler.
PR-B — frontend visibility (medium). Once non-admin users can hit write endpoints, the UI needs to surface the right buttons.
Either ship /api/auth/me/permissions (small endpoint returning the merged permission list — handler reuse trivial) and gate buttons on hasPermission(action, resource), or expand UserInfo in LoginResponse//api/auth/me to include permissions: Permission[]. The latter avoids an extra round trip; payload is small.
This is the bulk of the user-visible change. Should be its own PR for review focus.
PR-C — admin UI for granular grants (large, optional). Add a "Permissions" tab to the existing group editor so admins can compose {action, resource, constraints} rows without hand-editing JSON. Skip if the only consumers are admins willing to use the API directly; revisit when a real operator persona shows up. This is the only piece that needs significant new UI.
Land PR-A and PR-B in that order; defer PR-C until there's a concrete operator persona. PR-A alone (router flips + default-permission tweaks) is what unblocks the "user role can actually manage plans" goal. PR-B turns it into a clean UX.
Stopping here per the issue's "design comment first" instruction; no code changes pushed.
Verification: cannot-verify-autopilot - Partially shipped. PR #726 (merged 2026-05-27) is PR-A: handler-level requirePermission gates wired into mutating routes. PR body explicitly defers PR-B (frontend visibility of role-gated actions) and PR-C (optional admin grant UI). The issue's full acceptance criteria (finer-grained per-role write permissions across plans/purchases/RI-exchange visible and configurable) is not complete.
Activity
cristim commentedon May 25, 2026
Design for issue #660 — per-role write permissions
Read-only investigation done against
feat/multicloud-web-frontend(HEAD5abf40bef). The good news: the permission model already exists in code and most handler-level verb checks are already in place. The work is mostly router gate flips plus a few targeted decisions and frontend visibility wiring, not a schema rebuild.1. Current model
Schema (
internal/auth/types.go):User.Roleis one ofRoleAdmin,RoleUser,RoleReadOnly.GroupcarriesPermissions []Permission(JSON column onauth_groupsin Postgres). Users get a union of their group permissions plus their role's defaults.Permission{Action, Resource, Constraints}already supports per-account/provider/service/region scoping andMaxPurchaseAmount.AuthContext.HasPermission(action, resource)(types.go:111) admin-shortcuts then walks the merged permission slice;ActionAdmin+ResourceAllis the wildcard.DefaultAdminPermissions->{admin, *}.DefaultUserPermissions(types.go:389) already includesview recs/plans/purchases/history,create/update plans, pluscancel-own/retry-own/approve-own purchases.DefaultReadOnlyPermissions-> view-only on recs, plans, history.Enforcement (two layers):
internal/api/router.go—Route.Authlevels (AuthAdmin/AuthUser/AuthPublic) checked inRouter.Route(router.go:321). After PR fix(api): route Auth field becomes mandatory + readonly/user roles get access to read endpoints (P0) #364 this field is mandatory.internal/api/handler.go:181—requirePermission(ctx, req, action, resource)does the fine-grained verb check inside each handler, with admin shortcuts for both the admin API key and admin role.Coverage of
requirePermissiontoday (grepped, file:line):handler_plans.go:20,66,95,123,174,195,364(view/create/update/delete) — fully covered.handler_accounts.go:1216,1255(update/view plans) — covered.handler_purchases.go:88,186,207,228,254,1031,1075(view/update/execute/delete purchases) — covered.handler_ri_exchange.go:115,198,239,260,368,473,534,683,704,738— covered (view purchases for reads, execute purchases for execute, view/update config for the config endpoint).handler_accounts.go:91,172,248,469,488,533,606,697,956,985,1117— covered (view/create/update/delete accounts).handler_users.go:17,31,100,119,143— covered.handler_groups.go:16,30,65,84,108— covered.handler_config.go:80,203— covered.handler_recommendations_refresh.go:57.handler_federation.go:128.handler_inventory.go:29.handler_analytics.go:34,87; analytics POST collect usesrequireAdmin(handler_analytics.go:151).handler_accounts.go:1303usesrequireAdmin.handler_registrations.go:204usesrequireAdminviasetReviewMetadata.The chokepoint. PR #364 left every mutating route at
Auth: AuthAdmin(router.go:117,119,145,147,149,151,152,153,156,177,178,179,180,192,220-224,230,231,233-235,237-238,245,247,248,265,266,268,277-282,233).Router.RoutecallsrequireAdminfirst, so even when the handler'srequirePermissionwould let a non-admin user withupdate:plansthrough, the router rejects withadmin requiredbefore the handler runs. The follow-up work is mostly flipping these route guards fromAuthAdmintoAuthUser(so the handler's verb check becomes authoritative).2. Proposed schema changes
None. The
Permissionmodel, theGroup.PermissionsJSON column, and therequirePermissionplumbing all exist. No DB migration needed.Two small additions to default sets in
types.go:RoleUseralready getscreate/update plans. Adddelete plansif we want the user role to delete plans (PR fix(api): route Auth field becomes mandatory + readonly/user roles get access to read endpoints (P0) #364's "user = read + write plans" wording reads as full plan CRUD). Or keep delete admin-only by omitting it fromDefaultUserPermissions; non-admin users would then see plan-write but not plan-delete after the router flip.update/delete accountsare not in any default role today. The default policy stays admin-only; custom groups can grant{update, accounts}to delegated operators without changing roles.Open verb-design questions (call out for product):
userrole: yes or admin-only? (My read: yes — symmetric with create/update.)pause,resume,run,delete): the handlers already gate onupdate/execute/delete purchases.DefaultUserPermissionsdoes NOT include these. Either (a) add them to user defaults so the "user" role can manage its own planned purchases, or (b) keep them admin-only and require explicit group grant. Recommend (a) forupdate purchases(pause/resume) and (b) forexecute/delete purchases(those are higher-blast-radius).execute purchases.3. Decisions on admin-management endpoints (scope item 2 in issue body)
/api/userswrites — stay AuthAdmin. User management is org-admin territory; no roadmap need for non-admin user CRUD./api/groupswrites — stay AuthAdmin. Editing groups means editing the permission system itself./api/registrationswrites — stay AuthAdmin. Approving a federation registration grants access to new accounts./api/accountscreate/credentials/test — stay AuthAdmin by default; allow custom groups to grantcreate/update accountsto delegated operators (no router flip; the handler's verb gate already supports this once the route isAuthUser). If we don't want non-admin operators ever editing accounts, keepAuthAdminon the route — the handler verb check then never fires for non-admins, which is fine./api/configPUT — stay AuthAdmin for global config. The RI-exchangeconfigPUT (which already usesupdate:config) can flip toAuthUsersince the handler enforces./api/analytics/collect— admin-only; no change. Bulk collection is operational.4. Audit task (scope item 3)
AuthUserroutes that read account-scoped data already callrequirePermissionand then resolvegetAllowedAccounts+ scope the query — confirmed inhandler_plans.go,handler_purchases.go,handler_analytics.go,handler_federation.go. The flips proposed below only enable mutations on rows the user is already authorized to see, so the scoping invariant is preserved. The audit is a focused per-handler check (mainly:setPlanAccounts,pausePlannedPurchase,resumePlannedPurchase,deletePlannedPurchase,executePurchase,updateRIExchangeConfig) confirming each callsgetAllowedAccounts(session)and rejects out-of-scope IDs before writing.5. Effort + recommended PR breakdown
Estimate: medium-to-large. Three PRs. Not a single small PR.
PR-A — router flips + verb defaults (medium). No migration, no frontend change.
internal/auth/types.go: extendDefaultUserPermissionswithdelete:plans,update:purchases(planned-purchase pause/resume), and (if we choose to) leaveexecute:purchases/delete:purchasesout so non-admin users can't fire purchases or wipe planned ones without an explicit group grant.internal/api/router.go: flip the following fromAuthAdmintoAuthUser(handler-levelrequirePermissionthen enforces):/api/plansand/api/plans/{id}, plus POST/api/plans/{id}/purchasesand PUT/api/plans/{id}/accounts.pause/resume/run, DELETE/api/purchases/planned/{id}./api/purchases/execute.quote/execute, PUT/api/ri-exchange/config.AuthAdminon:/api/configPUT, all/api/users,/api/groups,/api/registrations,/api/accountswrites,/api/accounts/discover-org,/api/analytics/collect.internal/api/router_authuser_test.go+router_auth_test.gowith role-permutation cases (user vs readonly vs admin -> 200/403 on each flipped route). Audit-scoping unit tests already exist per handler.PR-B — frontend visibility (medium). Once non-admin users can hit write endpoints, the UI needs to surface the right buttons.
user.role === 'admin'(see issue Surface inline Cancel button to non-admin operator roles with cancel-any (frontend permission visibility) #158 for the exact pattern —frontend/src/history.ts:303and similar). After PR-A, those hides are wrong./api/auth/me/permissions(small endpoint returning the merged permission list — handler reuse trivial) and gate buttons onhasPermission(action, resource), or expandUserInfoinLoginResponse//api/auth/meto includepermissions: Permission[]. The latter avoids an extra round trip; payload is small.frontend/src/: plans page (create/edit/delete buttons), planned-purchases (pause/resume/run/delete), history (already partially scoped viacancel-own/retry-own/approve-own— issue Surface inline Cancel button to non-admin operator roles with cancel-any (frontend permission visibility) #158 is the open thread here), RI-exchange (quote/execute/config).PR-C — admin UI for granular grants (large, optional). Add a "Permissions" tab to the existing group editor so admins can compose
{action, resource, constraints}rows without hand-editing JSON. Skip if the only consumers are admins willing to use the API directly; revisit when a real operator persona shows up. This is the only piece that needs significant new UI.6. Related work to fold in
execute-{any,own}verbs) overlaps with theexecute:purchasesdecision in PR-A — keep them coordinated; the granularexecute-ownverb is the right long-term shape for delegated buyers and should land as part of PR-A or right after.cancel-anybutton surfacing) is a strict subset of PR-B's frontend visibility sweep — close-or-merge depending on which lands first.Recommendation
Land PR-A and PR-B in that order; defer PR-C until there's a concrete operator persona. PR-A alone (router flips + default-permission tweaks) is what unblocks the "user role can actually manage plans" goal. PR-B turns it into a clean UX.
Stopping here per the issue's "design comment first" instruction; no code changes pushed.
test(frontend/perms): update permission test fixtures for new user de…
feat(api/auth): flip mutating-route gate to handler-level requirePerm…
cristim commentedon May 28, 2026
Verification: cannot-verify-autopilot - Partially shipped. PR #726 (merged 2026-05-27) is PR-A: handler-level
requirePermissiongates wired into mutating routes. PR body explicitly defers PR-B (frontend visibility of role-gated actions) and PR-C (optional admin grant UI). The issue's full acceptance criteria (finer-grained per-role write permissions across plans/purchases/RI-exchange visible and configurable) is not complete.feat(api/auth): split execute:ri-exchange from execute:purchases (closes
feat(api/auth): split execute:ri-exchange from execute:purchases (closes
feat(api/auth): split execute:ri-exchange from execute:purchases (closes
fix(purchases): gate scheduled-purchase management on creator-scope o…