Skip to content

fix(api): route Auth field becomes mandatory + readonly/user roles get access to read endpoints (P0) - #364

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/route-auth-read-endpoints
May 13, 2026
Merged

fix(api): route Auth field becomes mandatory + readonly/user roles get access to read endpoints (P0)#364
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/route-auth-read-endpoints

Conversation

@cristim

@cristim cristim commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

P0/critical for any non-admin role. A user signed in as readonly (or user) saw "admin access required" on every page — Dashboard, Recommendations, Plans, Planned Purchases, Convertible RIs, Reshape Recommendations, Exchange History, Settings. The entire app was admin-gated.

Root cause

AuthLevel had AuthAdmin = iota at the zero value with a "secure by default" rationale:

const (
    AuthAdmin AuthLevel = iota  // 0 — any Route without an explicit Auth field gets this
    AuthUser                    // 1
    AuthPublic                  // 2
)

Every new read-only endpoint was registered without setting Auth: and silently inherited AuthAdmin via the zero value. The whole app's read surface became admin-only without anyone noticing — because admins (the only role testing it) saw nothing wrong.

Fix — two layers

1. Auth becomes a required Route field

authUnset is now the iota zero. NewRouter panics at startup if any route was registered without an explicit AuthAdmin / AuthUser / AuthPublic. The doc comment that called the old pattern "secure by default" is gone — it was the opposite of secure for non-admin roles.

2. Every existing route now declares its Auth level

  • 37 routes had Auth: added explicitly (mostly AuthAdmin, mechanical pass).

  • 24 routes flipped to Auth: AuthUser so signed-in non-admin users can use the app — every GET that feeds a dashboard page:

    Endpoint group Routes
    Dashboard /api/dashboard/summary, /api/dashboard/upcoming
    Settings /api/config GET, /api/config/service/{p} GET
    Recommendations /api/recommendations, .../freshness, .../{id}/detail
    Plans /api/plans GET, /api/plans/{id} GET, .../{id}/accounts GET
    Planned purchases /api/purchases/planned GET, /api/purchases/{id} GET
    History /api/history, .../analytics, .../breakdown
    Accounts /api/accounts GET, .../{id} GET, .../{id}/service-overrides GET
    Groups /api/groups GET, /api/groups/{id} GET
    RI exchange .../instances, .../utilization, .../reshape-recommendations, .../config GET, .../history

Mutating endpoints (POST/PUT/PATCH/DELETE) keep AuthAdmin pending the finer-grained per-role write-permission work (handler-level requirePermission checks already exist for the few endpoints that use them — a follow-up will widen that to plans/purchases/etc.).

Test plan

  • TestNewRouter_PanicsOnUnsetAuth — startup-validation loop replayed on a fixture route with Auth omitted; asserts the panic fires.
  • TestRoute_AllRegisteredHaveExplicitAuth — walks the live route table and asserts every entry declares an explicit Auth level. Catches a forgotten field in CI even if the Lambda never starts locally. Two independent safety nets.
  • go test github.com/LeanerCloud/CUDly/internal/api — passes.
  • go test ./... — full repo suite, no regressions.
  • End-to-end on the deployed env after merge: signed-in readonly user sees the same pages an admin sees (read-only, no mutating buttons).

Out of scope (separate follow-ups)

  • Finer-grained per-role write permissions (readonly = read-only; user = read + write plans; admin = full). Handler-level requirePermission(action, resource) exists for a few endpoints — needs to spread to plans / purchases / RI-exchange / etc.
  • Decide which admin-management endpoints (/api/users, /api/registrations, /api/accounts writes) should ever loosen to non-admin. Default in this PR is: they stay admin-only.
  • Audit requirePermission callers to make sure no AuthUser route accidentally surfaces data outside the caller's allowed_accounts grant.

Summary by CodeRabbit

  • Bug Fixes

    • Enforced explicit authentication level for every API route; startup now fails fast if any route is left unset.
    • Strengthened runtime routing safety to refuse requests when an unexpected auth configuration is encountered.
  • Tests

    • Added tests verifying mandatory auth on routes and that the registered route table has explicit auth set.

Review Change Stack

Symptom (live env, 2026-05-14): a user signed in as role=readonly saw
"admin access required" on Dashboard, Recommendations, Plans, Planned
Purchases, Convertible RIs, Reshape Recommendations, Exchange History,
and Settings — every page, not just admin screens.

Root cause: AuthLevel had AuthAdmin = iota at the zero value. Any
Route registered without an explicit Auth field silently inherited
AuthAdmin via the field's zero value. The team added every new
read-only endpoint without setting Auth, so the entire app was
admin-gated by default. The "secure by default" comment that
justified the pattern hid the failure mode: non-admin roles couldn't
do anything.

Two-layer fix:

1. **AuthLevel becomes a required Route field.** authUnset (zero
   value) is now the sentinel; NewRouter panics at startup if any
   route was registered with it. AuthAdmin / AuthUser / AuthPublic
   are the only valid choices and one must be declared explicitly
   per route. Doc comment updated to drop the misleading "secure
   by default" framing.

2. **All currently-AuthAdmin-via-zero-value routes get Auth: AuthAdmin
   added explicitly** (37 routes, a mechanical pass). All read-only
   GET endpoints that surface data needed by Dashboard / Recs / Plans /
   Planned Purchases / Convertible RIs / Reshape / Exchange History /
   Settings get flipped to Auth: AuthUser so signed-in non-admin users
   can actually use the app:

     - /api/dashboard/summary, /api/dashboard/upcoming
     - /api/config (GET), /api/config/service/{p} (GET)
     - /api/recommendations (GET), /api/recommendations/freshness,
       /api/recommendations/{id}/detail
     - /api/plans (GET), /api/plans/{id} (GET),
       /api/plans/{id}/accounts (GET)
     - /api/purchases/planned (GET), /api/purchases/{id} (GET)
     - /api/history, /api/history/analytics, /api/history/breakdown
     - /api/accounts (GET), /api/accounts/{id} (GET),
       /api/accounts/{id}/service-overrides (GET)
     - /api/groups (GET), /api/groups/{id} (GET)
     - /api/ri-exchange/instances, /api/ri-exchange/utilization,
       /api/ri-exchange/reshape-recommendations,
       /api/ri-exchange/config (GET), /api/ri-exchange/history

Mutating endpoints (POST/PUT/PATCH/DELETE) stay AuthAdmin pending the
finer-grained per-role write-permission work (separate follow-up).

Tests:
  TestNewRouter_PanicsOnUnsetAuth — replays the startup validation loop
    on a fixture route with Auth omitted; asserts the panic fires.
  TestRoute_AllRegisteredHaveExplicitAuth — walks the live route table
    built by registerRoutes() and asserts every entry has an explicit
    Auth level (catches a forgotten field in CI even if the lambda is
    never started locally).
@cristim cristim added bug Something isn't working triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/s Hours type/bug Defect labels May 13, 2026
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dbbb7248-77dd-4cc9-a2e3-c49aecd7ab5d

📥 Commits

Reviewing files that changed from the base of the PR and between 237b111 and a752658.

📒 Files selected for processing (2)
  • internal/api/router.go
  • internal/api/router_auth_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/api/router.go

📝 Walkthrough

Walkthrough

All API routes must declare an explicit AuthLevel. An internal authUnset zero value was added; NewRouter panics if any route leaves Auth unset. registerRoutes now sets Auth per endpoint, runtime dispatch rejects unknown auth values, and tests verify the enforcement.

Changes

Explicit Authentication Enforcement

Layer / File(s) Summary
AuthLevel contract and startup validation
internal/api/router.go
Introduces internal authUnset zero value; updates Route.Auth docs to REQUIRED; adds validateRoutes and NewRouter startup panic when any route leaves Auth unset.
Route registration with explicit authentication
internal/api/router.go
Updates registerRoutes entries across multiple endpoint groups to explicitly set Auth: AuthAdmin, AuthUser, or AuthPublic as appropriate.
Runtime authentication dispatch
internal/api/router.go
Updates Router.Route docs and adds a defensive default branch to refuse dispatch with a 500 internal routing error for unknown/unset auth values.
Test coverage for authentication enforcement
internal/api/router_auth_test.go
Adds TestValidateRoutes_PanicsOnUnsetAuth, TestValidateRoutes_AcceptsAllExplicitLevels, and TestRoute_AllRegisteredHaveExplicitAuth to validate startup panic behavior and that all registered routes have explicit Auth.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

severity/high, impact/many

Poem

🐰
Every route now wears a tag so bright,
No silent gaps in the authing night.
authUnset hid the zero's sly groove,
Startup checks make sure each route moves.
Hops of safety, neat and light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: making the route Auth field mandatory and granting user/readonly roles access to read endpoints. It is specific, concise, and directly related to the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/route-auth-read-endpoints

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

@coderabbitai

coderabbitai Bot commented May 13, 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.

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/router_auth_test.go`:
- Around line 15-28: The test TestNewRouter_PanicsOnUnsetAuth duplicates
NewRouter's validation loop; extract that route-auth check into a single helper
(e.g., validateRoutes or validateRouteAuth) and have NewRouter call that helper,
then update the test to call the helper (or NewRouter directly) to assert the
panic instead of replaying the loop inline; reference the Router type, NewRouter
function, and the route.Auth/authUnset check so you remove the duplicated loop
and centralize validation.
🪄 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: e83c4588-d255-4c85-9026-33333cba3b0c

📥 Commits

Reviewing files that changed from the base of the PR and between 44365bb and 237b111.

📒 Files selected for processing (2)
  • internal/api/router.go
  • internal/api/router_auth_test.go

Comment thread internal/api/router_auth_test.go Outdated
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 13, 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.

#364)

CodeRabbit on PR #364 caught that TestNewRouter_PanicsOnUnsetAuth was
replaying the validation loop inline — so the test passed even if a
refactor stopped NewRouter from calling that validation. The test
wasn't actually a regression net for the production code.

Fix per the reviewer's suggested shape:

  1. Extract the validator into validateRoutes(routes []Route) so
     production code and tests share one implementation.
  2. NewRouter now calls validateRoutes; the inline loop is gone.
  3. New TestValidateRoutes_PanicsOnUnsetAuth calls validateRoutes
     directly with a fixture route that has Auth omitted — if
     someone removes the panic from validateRoutes (or removes the
     call from NewRouter and the helper falls out of use), this
     test fails immediately.
  4. New TestValidateRoutes_AcceptsAllExplicitLevels covers the
     happy path for each of AuthAdmin / AuthUser / AuthPublic so
     a regression that over-rejects also breaks the test.

The structural test (TestRoute_AllRegisteredHaveExplicitAuth) stays —
it asserts the live registerRoutes() output, complementing the
validator-level tests.
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 13, 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 merged commit d44c04c into feat/multicloud-web-frontend May 13, 2026
4 checks passed
cristim added a commit that referenced this pull request May 14, 2026
… access required" (closes #365) (#452)

* refactor(frontend): extract permissions helper from userList (#365)

Introduce `frontend/src/permissions.ts` with `canAccess(action, resource)`
+ `getRolePermissions(role)` mirroring the backend's
`DefaultAdminPermissions` / `DefaultUserPermissions` /
`DefaultReadOnlyPermissions` (`internal/auth/types.go:367-415`).

Drive `auth.ts:isAdmin` and `users/userList.ts:effectivePermissions`
from the new shared module so the UI badge in the admin Users page
and the global UI gates that follow in subsequent commits stay in
lockstep.

Side-effect: the badge in the Users page now also shows the
`cancel-own:purchases` / `retry-own:purchases` / `approve-own:purchases`
entries for `user`-role accounts that were previously missing from the
local mirror in `effectivePermissions`. The backend already grants
these per PR #364; this catches the badge up.

Unknown roles previously fell through to `user` defaults in
`effectivePermissions`. Now they return the empty permission set,
matching the backend's deny-by-default behaviour. No production role
falls in this bucket today; the change closes a latent fail-open.

Tests enumerate every entry in each role default so a future drift
between this mirror and the backend fails CI rather than at runtime.

* feat(frontend): hide the sidebar Admin tab for non-admins (#365)

Mark `#admin-tab-btn` with `admin-only` so the existing
`auth.ts:updateUserUI` toggle hides it for non-admin sessions. Before
this commit a readonly or user-role login saw the Admin sidebar entry,
clicked it, and got a stack of red 403 toasts on every nested API
call because the page itself stayed admin-only.

Switch `.admin-only.visible` from `display: block` to `display: revert`
so each element falls back to its UA / cascade default. Sections still
render as block, buttons as inline-block, and the sidebar tab buttons
inherit the inline-flex from `.app-sidebar-nav .tab-btn`. With the old
`display: block` rule the sidebar Admin button would have flattened
the sidebar's flex layout for admins.

* feat(frontend/plans): hide plan write actions for sessions without the verb (#365)

Gate three classes of buttons on the Plans page by the same
permission a click on each one would require, so a non-admin
(readonly especially) never sees a button whose only outcome is a 403.

* `#new-plan-btn` (top-level "New Plan"): hidden unless
  `create:plans` (admin + user keep it; readonly loses it).
* Plan-card Add Purchases / Edit / toggle-plan: gated by
  `update:plans` (admin + user keep them; readonly loses all three).
* Plan-card Delete: gated by `delete:plans` (admin only).
* Plan-card View History stays for every role: read-only inspection.
* Planned-purchase row Run / Pause / Resume / Edit: gated by
  `update:plans`.
* Planned-purchase row Disable: gated by `delete:plans`.

Permission lookups are cached once per render rather than re-evaluated
per card so a 100-plan list doesn't bounce through the helper 600
times.

Backend `requirePermission` checks stay untouched. The frontend gates
are pure UX defense in depth.

* feat(frontend/recommendations): hide bulk Purchase + Create Plan for sessions without the verb (#365)

The Opportunities-tab bottom-action box renders two CTAs:
`#bulk-purchase-btn` ("Purchase" one-off) and `#create-plan-btn`
("Create Plan"). Both stayed visible for every signed-in session,
and a readonly user clicking either got a 403 toast.

Gate each CTA by the underlying verb:
* Purchase: `execute:purchases` (admin only by default).
* Create Plan: `create:plans` (admin + user keep it; readonly loses it).

The action box itself stays visible for every role so readonly users
still get the selection summary and the capacity input as read-only
browsing aids. Only the mutating buttons disappear.

* feat(frontend/riexchange): hide Exchange buttons for non-admin sessions (#365)

The convertible-RI table and the reshape-recommendations table both
render per-row "Exchange" buttons that hit admin-only backend
endpoints. A readonly or user-role session clicking either currently
gets a 403 "admin access required" toast.

Gate both buttons by `admin:*` (the verb the backend handler
requires). For non-admin sessions also drop the Actions column
header so the table doesn't render with a dangling empty column.

* feat(frontend/settings): render Settings read-only for non-admin sessions (#365)

The /admin/general and /admin/purchasing sub-tabs are reachable for
every signed-in role (only /admin/accounts and /admin/users get the
navigation.ts non-admin redirect). Previously a user-role or readonly
session could open Settings, edit fields, and only learn the values
weren't saved when Save returned 403.

Render the form read-only for non-admin sessions:
* Disable every input / select / textarea / button under
  `#global-settings-form`.
* Hide `#save-settings-btn` and `#reset-settings-btn` via the HTML
  hidden attribute.

The form stays visible so non-admin sessions can still inspect the
configured providers, defaults, and grace windows as a reference.
Backend remains authoritative and 403s any attempted write.

* feat(frontend/permissions): generate role-default sets from Go source

The hand-written `frontend/src/permissions.ts` mirrored the backend's
DefaultAdminPermissions / DefaultUserPermissions / DefaultReadOnlyPermissions
in `internal/auth/types.go`, but `permissions.test.ts` enumerated entries
against the TS mirror itself, so a drift in the Go defaults would not be
caught by the existing tests.

This change splits the data portion (the three default permission sets)
into a generated file `permissions.generated.ts` produced by
`go run ./cmd/gen-permissions`, which imports `internal/auth` and sorts
entries by (action, resource) for a stable diff. The wrapper
`permissions.ts` keeps the hand-written closed-union Action / Resource
types and the canAccess / isAdmin / getRolePermissions helpers and
imports the sets from the generated file. Existing callers in plans.ts,
settings.ts, recommendations.ts, riexchange.ts, auth.ts and
users/userList.ts plus the permissions.test.ts suite continue to work
unchanged.

A new `permissions-codegen` pre-commit hook re-runs the generator and
`git diff --exit-code` against the committed file so a stale copy
fails locally on commit, and CI catches the same drift via the existing
pre-commit GitHub Actions workflow that runs `pre-commit run --all-files`.
@cristim
cristim deleted the fix/route-auth-read-endpoints branch June 3, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant