Skip to content

fix: restrict admin credit grants to internal API#2113

Open
jonit-dev wants to merge 1 commit into
Cap-go:mainfrom
jonit-dev:hermes/admin-credits-internal-grant
Open

fix: restrict admin credit grants to internal API#2113
jonit-dev wants to merge 1 commit into
Cap-go:mainfrom
jonit-dev:hermes/admin-credits-internal-grant

Conversation

@jonit-dev
Copy link
Copy Markdown

@jonit-dev jonit-dev commented May 10, 2026

Summary

Restricts the admin credit grant endpoint to internal operational callers and removes the browser-facing grant form from the platform admin credits dashboard.

  • Move POST /private/admin_credits/grant behind middlewareAPISecret
  • Keep platform-admin credit dashboard behavior read-only: search, balances, analytics, and history
  • Remove grant-form state/actions from the Vue admin page
  • Add a regression test proving JWT-authenticated platform-admin requests do not reach credit grants

Security rationale

AGENTS.md says platform-admin capabilities must remain read-only except impersonation, and must not mutate billing state. Credit grants mutate billing/accounting state through top_up_usage_credits, so this path should be internal API-secret-only rather than reachable through platform-admin JWT/API-key middleware.

Related to the rewarded security review thread: #1667

Test plan

  • git diff --check
  • /home/joao/.bun/bin/bun test tests/admin-credits-auth-boundary.unit.test.ts
  • /home/joao/.bun/bin/bun lint

Note: I did not run the full tests/admin-credits.test.ts integration suite locally because the Supabase stack was not running on 127.0.0.1:54321 in this environment.

Summary by CodeRabbit

  • New Features

    • Added Organization Credit Lookup feature allowing admins to search and view organization credit balances directly.
  • Changes

    • Removed the admin credit grant form interface from the dashboard.
    • Updated credits section UI labels, descriptions, and related helper text.
    • Credit grants are now processed through internal mechanisms only.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

📝 Walkthrough

Walkthrough

The PR moves admin credit grant authority from authenticated admin users to internal API secret requests. The /grant endpoint switches middleware from admin verification to API secret verification, removes admin-user tracking from metadata and logs, updates localization strings, removes the grant form UI from the admin dashboard, and updates all tests to verify the new authorization boundary.

Changes

Admin Credits Grant Authorization Shift

Layer / File(s) Summary
Localization Updates
messages/en.json
Message keys updated: admin-credits-title renamed to "Credits", admin-credits-description reworded, admin-credits-grant-title removed, and new keys added for organization lookup section, readonly note, recent grants, and search UI.
Backend Authorization & Metadata
supabase/functions/_backend/private/admin_credits.ts
/grant endpoint switched from middlewareV2 admin-user gating to middlewareAPISecret internal API secret gating. Credit source metadata changed from granted_via: 'admin_ui' (with admin_user_id) to granted_via: 'internal_api'. Default grant notes changed to fixed message 'Internal credit grant'. Request, failure, and success logging payloads updated to remove adminUserId field.
Frontend UI Changes
src/pages/admin/dashboard/credits.vue
FormKit import removed. Grant form card header replaced with "Credit Lookup" card header. Entire grant form UI block (input fields for amount, notes, expiration months, and submit button) deleted and replaced with readonly note displayed when selectedOrg is set.
Test Authorization Boundary
tests/admin-credits-auth-boundary.unit.test.ts, tests/admin-credits.test.ts
New test added for auth boundary verification. All existing grant endpoint tests updated to assert 400 status and "Cannot find authorization" error text instead of prior 401 or not_admin JSON errors. Test allowing admin user to grant credits replaced with test asserting admin JWT requests are rejected.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 The grant form hops away so spry,
No longer mortal hands reach high,
Internal secrets guard the door,
And credits live forevermore! 🎫

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: restricting admin credit grants to internal API only.
Description check ✅ Passed The description is comprehensive, covering the summary, security rationale, test plan, and context; however, the checklist section is incomplete.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq Bot commented May 10, 2026

Merging this PR will not alter performance

✅ 43 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing jonit-dev:hermes/admin-credits-internal-grant (f7503e1) with main (1ef9d26)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
supabase/functions/_backend/private/admin_credits.ts (1)

96-108: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

New grants will disappear from /grants-history.

Dropping admin_user_id from source_ref means the existing history query later in this file no longer matches grants created by this route, because it still filters on source_ref->admin_user_id IS NOT NULL. The dashboard stays read-only, but its history view will stop showing all post-change internal grants unless that query is updated to include the new granted_via: 'internal_api' shape (and ideally keep the legacy predicate during migration).

🤖 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 `@supabase/functions/_backend/private/admin_credits.ts` around lines 96 - 108,
The new sourceRef object used when calling
adminSupabase.rpc('top_up_usage_credits') omits admin_user_id which breaks the
grants-history query (it still filters on source_ref->admin_user_id IS NOT
NULL); restore the legacy field by adding admin_user_id (e.g.,
sourceRef.admin_user_id = currentAdmin.id or similar) when building sourceRef
for top_up_usage_credits, and/or update the history query predicate to also
accept grants with source_ref->>'granted_via' = 'internal_api' (preserve the
existing admin_user_id check during migration).
🧹 Nitpick comments (1)
tests/admin-credits.test.ts (1)

317-331: ⚡ Quick win

Please keep one happy-path /grant test for internal callers.

Replacing the old success case with another rejection assertion leaves this endpoint with no visible coverage for the only allowed caller anymore. Add a companion test that sends the expected internal API secret and verifies a grant succeeds, otherwise the route could be accidentally broken and this suite would still stay 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 `@tests/admin-credits.test.ts` around lines 317 - 331, Add a companion
happy-path test for the /private/admin_credits/grant endpoint: keep the existing
"should reject admin JWT credit grants" test (which uses getAdminHeaders and
asserts 400), then add a new test that uses the internal API secret header
expected by the route (the same BASE_URL/PRIVATE path and
TEST_ORG_ID/amount/notes payload as the rejected test), sends the correct
internal auth credential instead of admin JWT, and asserts a successful response
(e.g., 200/201) and that the response body contains confirmation of the grant;
locate the endpoint usage in the test file around the existing test and mirror
its request/response assertions but with the internal API secret header.
🤖 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 `@tests/admin-credits-auth-boundary.unit.test.ts`:
- Around line 5-20: The test currently only asserts the HTTP response; you must
also spy/mock the Supabase admin RPC to ensure top_up_usage_credits is never
invoked: in the test for 'does not allow platform-admin JWTs to reach credit
grants' obtain the mocked supabase admin client (supabaseAdmin) and attach a spy
or mock to its rpc method (the call name 'top_up_usage_credits'), then after
making adminCreditsApp.request assert that supabaseAdmin().rpc was never called
(or rpc was not invoked with 'top_up_usage_credits') so the test proves the
handler never reached the RPC path.

---

Outside diff comments:
In `@supabase/functions/_backend/private/admin_credits.ts`:
- Around line 96-108: The new sourceRef object used when calling
adminSupabase.rpc('top_up_usage_credits') omits admin_user_id which breaks the
grants-history query (it still filters on source_ref->admin_user_id IS NOT
NULL); restore the legacy field by adding admin_user_id (e.g.,
sourceRef.admin_user_id = currentAdmin.id or similar) when building sourceRef
for top_up_usage_credits, and/or update the history query predicate to also
accept grants with source_ref->>'granted_via' = 'internal_api' (preserve the
existing admin_user_id check during migration).

---

Nitpick comments:
In `@tests/admin-credits.test.ts`:
- Around line 317-331: Add a companion happy-path test for the
/private/admin_credits/grant endpoint: keep the existing "should reject admin
JWT credit grants" test (which uses getAdminHeaders and asserts 400), then add a
new test that uses the internal API secret header expected by the route (the
same BASE_URL/PRIVATE path and TEST_ORG_ID/amount/notes payload as the rejected
test), sends the correct internal auth credential instead of admin JWT, and
asserts a successful response (e.g., 200/201) and that the response body
contains confirmation of the grant; locate the endpoint usage in the test file
around the existing test and mirror its request/response assertions but with the
internal API secret header.
🪄 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: 59d26587-ead2-4df9-9648-1f00e23062c5

📥 Commits

Reviewing files that changed from the base of the PR and between 1ef9d26 and f7503e1.

📒 Files selected for processing (5)
  • messages/en.json
  • src/pages/admin/dashboard/credits.vue
  • supabase/functions/_backend/private/admin_credits.ts
  • tests/admin-credits-auth-boundary.unit.test.ts
  • tests/admin-credits.test.ts

Comment on lines +5 to +20
it('does not allow platform-admin JWTs to reach credit grants', async () => {
const response = await adminCreditsApp.request(new Request('http://localhost/grant', {
method: 'POST',
headers: {
'Authorization': 'Bearer test.jwt.value',
'Content-Type': 'application/json',
},
body: JSON.stringify({
org_id: '550e8400-e29b-41d4-a716-446655440000',
amount: 25,
}),
}))

expect(response.status).toBe(400)
await expect(response.text()).resolves.toContain('Cannot find authorization')
})
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This doesn't actually prove top_up_usage_credits was unreachable.

The test only checks the rejected response. If the handler ever regressed into calling supabaseAdmin().rpc('top_up_usage_credits', ...) before failing, this would still pass. Please mock/spy the RPC path and assert it is never invoked so the test matches its title and the PR objective.

🤖 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 `@tests/admin-credits-auth-boundary.unit.test.ts` around lines 5 - 20, The test
currently only asserts the HTTP response; you must also spy/mock the Supabase
admin RPC to ensure top_up_usage_credits is never invoked: in the test for 'does
not allow platform-admin JWTs to reach credit grants' obtain the mocked supabase
admin client (supabaseAdmin) and attach a spy or mock to its rpc method (the
call name 'top_up_usage_credits'), then after making adminCreditsApp.request
assert that supabaseAdmin().rpc was never called (or rpc was not invoked with
'top_up_usage_credits') so the test proves the handler never reached the RPC
path.

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@e4792200-stack e4792200-stack left a comment

Choose a reason for hiding this comment

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

Thanks for tightening this boundary. I verified the route/UI direction and the CI is green, but I found one blocker before merge.

New grants created by POST /private/admin_credits/grant now write source_ref with { granted_via: "internal_api", org_name }, but /grants-history still fetches only manual grants where source_ref->admin_user_id is not null. Because this PR removes admin_user_id from the new source_ref, internal grants created after this change will disappear from the read-only grants history dashboard even though the page still advertises grant history.

Please update the history query to include the new granted_via = internal_api shape while preserving legacy admin_user_id rows. A happy-path test using the internal apisecret header would also cover the only allowed caller and catch this kind of regression. I also checked the patch with git show --check; no whitespace issues.

Copy link
Copy Markdown

@digzrow-coder digzrow-coder left a comment

Choose a reason for hiding this comment

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

The grants-history endpoint still filters on the old UI shape, so the new internal grants disappear from the read-only admin page. /grant now writes source_ref = { granted_via: 'internal_api', org_name: ... } with no admin_user_id, but /grants-history keeps .not('source_ref->admin_user_id', 'is', null). After this change, any credits granted through the only remaining mutation path are excluded from the "Recent Admin Grants" table, so admins can no longer audit the grant history from this page. The filter needs to include the new internal grant shape, or be changed to match the intended manual-credit sources.

Copy link
Copy Markdown

@KCDaemon KCDaemon left a comment

Choose a reason for hiding this comment

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

Rechecked latest head (f7503e1). Moving the grant endpoint behind the internal API secret closes the user-JWT mutation path, but the successful grant is no longer attributable to any actor.

Before this PR, the grant wrote admin_user_id into source_ref and logged adminUserId. Now every grant records only { granted_via: "internal_api", org_name }, and the success/failure logs also omit who or which internal job/operator initiated the credit mutation. For a financial credit grant, a shared secret should authenticate the caller, but it should not be the only audit identity.

Please require an internal actor/source identifier (for example service name, job id, or operator id) and persist it in source_ref and logs, then add a regression asserting grant history retains that attribution for internal grants. Without that, admins can see balances changed but cannot audit who/what granted the credits.

@radiantjade
Copy link
Copy Markdown

I think this leaves the read-only history path out of sync with the new grant writer.

POST /grant now records manual grants with source_ref = { granted_via: 'internal_api', org_name: ... }, but GET /grants-history still filters with .not('source_ref->admin_user_id', 'is', null). Any grant created through the new internal-secret path therefore has source = 'manual' but no admin_user_id, so it is excluded from the history endpoint and the admin Credits page never shows the new grants it is supposed to review.

A quick repro from the code path is: call /private/admin_credits/grant with a valid apisecret; the inserted grant uses the new internal_api source_ref; then /private/admin_credits/grants-history filters it out. I would update the history query to include the new internal marker, for example source = manual plus either the old admin_user_id marker or source_ref->>granted_via = internal_api, and add a positive regression with the API secret grant followed by grants-history returning that row. That would also restore coverage for the successful grant path, which this PR currently removes from the test suite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants