Repository navigation
fix(api): report unrestricted account access on /auth/me/permissions - #1155
Conversation
POST /api/recommendations/refresh refuses callers whose account scope is restricted (LeanerCloud#615), but the frontend fires that refresh automatically for stale data and could not tell a scoped caller apart, so scoped users got a "Recommendations refresh failed: permission denied" toast for an action they never asked for. GET /api/auth/me/permissions now returns unrestricted_account_access, resolved by running requireUnrestrictedAccountScope (the refresh gate) for the caller: the session user, a user API key's owner, or the admin API key. A scope lookup failure is logged and reported as false, which skips the automatic refresh without failing the permissions response. Refs LeanerCloud#619 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
VERDICT: REQUEST_CHANGES 372304d Reviewed: Findings
Correctness: the flag runs the refresh's own gate ( Attacked:
|
|
Independent review 2 (claude-fleet, cold): APPROVE 372304d Findings (none blocking):
Root cause: #619 is that the frontend cannot tell the caller's scope.
Attacked:
Verified locally, on
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
…ssion For a user API key, unrestrictedAccountAccess now runs the refresh gate on the same session authorizeAPIKey builds (owner ID plus key ID), not on an owner-only session, so the flag cannot drift from the refresh if getAccountScope ever reads the key ID. Add a user-API-key parity test that asks /me/permissions and POST /api/recommendations/refresh with the same key and checks they agree. Refs LeanerCloud#619 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressing the VERDICT (372304d) and Independent review 2 (372304d). New head: Merged origin/main (c8bdd46) into the branch as e908fa6. No conflicts. VERDICT findings
Independent review 2
Verification (merged tree, |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rA3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale: none Reviewer: rB9. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Refs #619 (backend half only; the issue stays open for the frontend change below).
Problem
Since #615,
POST /api/recommendations/refreshreturns 403permission denied: refreshing recommendations covers every account and requires unrestricted account accessfor a caller whoseallowed_accountsis a subset. The frontend's stale-data auto-refresh (triggerAutoRefreshIfStale,frontend/src/recommendations.ts) fires for any user withview:recommendations, so scoped users get "Refreshing recommendations…" followed by an error toast on Home and Opportunities. The frontend can't avoid this because nothing tells it the caller's scope:GET /api/auth/me/permissionsreturns onlypermissionsandis_admin.Re-checked on current main (c8bdd46, merged into this branch): the response has no scope information, and
triggerAutoRefreshIfStale/startRecommendationsRefreshare unchanged. No merged PR addresses #619. An older commit, 6b407fb, mentions this issue number in its message, but it is a docs-coverage change from an older numbering, so it doesn't apply here.Change (backend only)
GET /api/auth/me/permissionsnow also returnsunrestricted_account_access(bool, always present). It is computed by running the refresh's own gate,requireUnrestrictedAccountScope, not a copy of it, so the two can't drift:authorizeAPIKey, which builds a session with the owner's ID and the key's ID; the flag runs the gate on that same session shape, so the two stay in step even ifgetAccountScopeever starts reading the key ID;true(getAccountScopereturnsUnrestrictedScope).If the scope lookup fails, the error is logged and the flag is reported as
false. The whole response does not fail. My reasoning: the flag is UX-only, like the permission entries. The refresh endpoint still enforces the scope (fail closed there is unchanged).falseis the safe answer, because the UI then skips a global, paid collection. And failing/me/permissionswould drop the frontend to its coarser group-based fallback gating (app.tscatches that error). If you'd rather fail the request, it's a two-line change.The existing frontend parser (
getUserPermissionsinfrontend/src/api/auth.ts) ignores unknown fields, so this is backward-compatible.Remaining frontend work (not in this PR; I'm not touching
frontend/while UI work is in progress)getUserPermissions: parseunrestricted_account_accessand keep it on the user state.triggerAutoRefreshIfStale: return early (no toast, no POST) when it isfalse. Explicit user refreshes, such as the lookback change, could stay as they are or be hidden the same way.recommendations.test.tscase next to the existingtriggerAutoRefreshIfStaletests (~1622-1680).Once that lands, the issue is fully addressed.
Verification (unit/mock evidence; no real cloud or deployed-stack check)
New file
internal/api/handler_auth_me_permissions_scope_test.go. Requests go throughRouter.Route/HandleRequestwith the shared scoped-auth mocks (grantPermissionsScoped):false, with the permission set unchanged; unrestricted session ->true;unrestricted_account_accessis present asfalsefor a scoped caller;false, unrestricted ownertrue);false, permissions intact;true, with noGetAllowedAccountsAPIcall;allowed_accountsshapes (one account, two accounts, nil, empty,["*"],["*", acct]), the same handler is asked for/me/permissionsand thenPOST /api/recommendations/refresh. The flag istrueexactly when the refresh passes the gate, andfalseexactly when it returns the scope 403 with nothing started;Existing
/me/permissionsmock tests now expect the extraGetAllowedAccountsAPIcall (handler_auth_test.go,router_authuser_test.go,apikey_usage_booking_test.go), and the bearer, admin and admin-key cases assert the flag.Fail-before / mutation checks, run locally:
UnrestrictedAccountAccess undefined);true: the scoped-session, wire-field, scoped-owner API key, lookup-error and parity (scoped shapes) tests fail;getAccountScopemade key-aware (scratch probe) while the flag passes an owner-only session: the user-API-key parity test fails (unrestricted and wildcard owner); with the key session as committed it passes.Commands (all run with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid):There are no DB writes or schema changes, so concurrency isn't affected: the new code is one more read of the existing scope lookup.
Notes / out of scope
getAccountScopealso loads the cloud account list (withRegisteredAccountNames, sec(auth): AccountScope.Allows grants every account that shares an allowed display name #433). That makes one more read per/me/permissionscall for scoped users only.internal/api/handler_auth.gowas already over the 500-line guideline (732 lines). This adds about 30 more.CollectRecommendationscontract with an account filter and a per-account marker, is not attempted. That is still the owner's call, as noted in fix(api): require unrestricted scope to refresh recommendations #615.Maintainers: please mirror #619's triage labels on this PR:
triaged,priority/p3,severity/low,urgency/this-quarter,impact/few,effort/s,type/bug.🤖 Generated with Claude Code