fix(api): route Auth field becomes mandatory + readonly/user roles get access to read endpoints (P0) - #364
Conversation
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).
|
@coderabbitai review |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAll API routes must declare an explicit ChangesExplicit Authentication Enforcement
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
internal/api/router.gointernal/api/router_auth_test.go
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
#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.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
… 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`.
Summary
P0/critical for any non-admin role. A user signed in as
readonly(oruser) 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
AuthLevelhadAuthAdmin = iotaat the zero value with a "secure by default" rationale:Every new read-only endpoint was registered without setting
Auth:and silently inheritedAuthAdminvia 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.
Authbecomes a required Route fieldauthUnsetis now the iota zero.NewRouterpanics at startup if any route was registered without an explicitAuthAdmin/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 (mostlyAuthAdmin, mechanical pass).24 routes flipped to
Auth: AuthUserso signed-in non-admin users can use the app — every GET that feeds a dashboard page:/api/dashboard/summary,/api/dashboard/upcoming/api/configGET,/api/config/service/{p}GET/api/recommendations,.../freshness,.../{id}/detail/api/plansGET,/api/plans/{id}GET,.../{id}/accountsGET/api/purchases/plannedGET,/api/purchases/{id}GET/api/history,.../analytics,.../breakdown/api/accountsGET,.../{id}GET,.../{id}/service-overridesGET/api/groupsGET,/api/groups/{id}GET.../instances,.../utilization,.../reshape-recommendations,.../configGET,.../historyMutating endpoints (POST/PUT/PATCH/DELETE) keep
AuthAdminpending the finer-grained per-role write-permission work (handler-levelrequirePermissionchecks 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.Out of scope (separate follow-ups)
requirePermission(action, resource)exists for a few endpoints — needs to spread to plans / purchases / RI-exchange / etc./api/users,/api/registrations,/api/accountswrites) should ever loosen to non-admin. Default in this PR is: they stay admin-only.requirePermissioncallers to make sure no AuthUser route accidentally surfaces data outside the caller's allowed_accounts grant.Summary by CodeRabbit
Bug Fixes
Tests