Skip to content

feat: add RBAC API key management and app creation permissions#1863

Open
Dalanir wants to merge 6 commits intomainfrom
rbac-api-keys
Open

feat: add RBAC API key management and app creation permissions#1863
Dalanir wants to merge 6 commits intomainfrom
rbac-api-keys

Conversation

@Dalanir
Copy link
Copy Markdown
Contributor

@Dalanir Dalanir commented Mar 25, 2026

Summary (AI generated)

  • Fixes organization API key RBAC binding and listing behavior.
  • Adds the organization settings UI to create and manage API Keys v2 alongside legacy keys.
  • Adds backend CLI permission wrappers and introduces org.create_app with RLS support for app creation.
  • Keeps the work split into 3 commits for easier review:
    • fix(rbac): support org api keys with bindings
    • feat(api-keys): add organization rbac management ui
    • feat(rbac): add cli permission wrappers and app creation

Motivation (AI generated)

API Keys v2 and the CLI were still partially tied to legacy authorization paths. This change aligns the organization API key management flow with RBAC, fixes missing bindings/listing issues, and adds the backend permission primitives needed to migrate app creation away from legacy role proxies.

Business Impact (AI generated)

This reduces authorization inconsistencies between the console, backend, and CLI, makes RBAC API keys usable in production workflows, and gives Capgo a cleaner path to ship fine-grained permissions without relying on the old mode model.

Test Plan (AI generated)

  • Run bun lint:backend
  • Run bun typecheck
  • Create an API Key v2 from organization settings
  • Verify org and app role_bindings are created as expected
  • Verify organization API key listing returns RBAC-bound keys
  • Verify org.create_app allows app creation for RBAC org members
  • Verify legacy fallback behavior still works for non-RBAC or non-bound keys

Generated with AI

Summary by CodeRabbit

  • New Features

    • Added a dedicated Organization > API Keys section with RBAC-aware UI to view, create, edit, regenerate, delete, and copy org-scoped API keys (v2 + legacy).
    • Introduced API Keys v2 (granular permissions) alongside legacy keys and a route for /settings/organization/api-keys.
    • Improved data table action tooltips and mobile pagination.
  • Localization

    • Added translated UI text (titles/descriptions) for v2 and legacy API keys across 15+ languages and a generic "Copy" label.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 34afd5f4-2449-4872-aa16-8048960b1a22

📥 Commits

Reviewing files that changed from the base of the PR and between bcadfd3 and 9cb5c26.

📒 Files selected for processing (20)
  • messages/de.json
  • messages/en.json
  • messages/es.json
  • messages/fr.json
  • messages/hi.json
  • messages/id.json
  • messages/it.json
  • messages/ja.json
  • messages/ko.json
  • messages/pl.json
  • messages/pt-br.json
  • messages/ru.json
  • messages/tr.json
  • messages/vi.json
  • messages/zh-cn.json
  • src/components.d.ts
  • src/route-map.d.ts
  • supabase/functions/_backend/private/role_bindings.ts
  • supabase/functions/_backend/public/app/post.ts
  • supabase/functions/_backend/utils/rbac.ts
✅ Files skipped from review due to trivial changes (17)
  • supabase/functions/_backend/public/app/post.ts
  • messages/id.json
  • messages/fr.json
  • messages/it.json
  • messages/en.json
  • messages/de.json
  • messages/pl.json
  • messages/ru.json
  • messages/tr.json
  • messages/es.json
  • messages/ja.json
  • messages/vi.json
  • messages/zh-cn.json
  • messages/ko.json
  • messages/hi.json
  • messages/pt-br.json
  • src/components.d.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • supabase/functions/_backend/private/role_bindings.ts
  • src/route-map.d.ts

📝 Walkthrough

Walkthrough

Adds organization-scoped API Keys v2 with RBAC bindings: new UI components and pages, localized strings across many languages, DataTable tweaks, new routes/tab, backend Supabase function and migration changes to prioritize API-key role bindings, CLI DB wrappers, role_bindings access updates, and tests.

Changes

Cohort / File(s) Summary
Localization
messages/de.json, messages/en.json, messages/es.json, messages/fr.json, messages/hi.json, messages/id.json, messages/it.json, messages/ja.json, messages/ko.json, messages/pl.json, messages/pt-br.json, messages/ru.json, messages/tr.json, messages/vi.json, messages/zh-cn.json
Added api-keys-v2-title, api-keys-v2-description, api-keys-legacy-title, api-keys-legacy-description (and copy in select locales) to support v2 vs legacy API key UI copy.
New UI component
src/components/organization/ApiKeyRbacManager.vue
Added organization-scoped API key manager (props: orgId, orgName, canManage) handling fetch, classification (v2 vs legacy), search, pagination, regenerate/delete flows, and role-binding management.
Pages & Routes
src/pages/settings/organization/ApiKeys.vue, src/pages/settings/organization/ApiKeys.[id].vue, src/pages/ApiKeys.vue, src/route-map.d.ts
Added pages and route mappings for /settings/organization/api-keys and /settings/organization/api-keys/:id; reorganized ApiKeys page modal layout and added new edit/create page with RBAC-aware create/update flows.
Component types & DataTable
src/components.d.ts, src/components/DataTable.vue
Declared global ApiKeyRbacManager in TS declarations; DataTable: added mobileFixedPagination prop, paginationClass computed, centralized getActionTitle helper, and tooltip wrapper for action buttons.
Navigation / Layout
src/constants/organizationTabs.ts, src/layouts/settings.vue
Added api-keys organization tab and inserted conditional logic to place the tab among existing org tabs in the settings layout.
Backend: role bindings & app creation auth
supabase/functions/_backend/private/role_bindings.ts, supabase/functions/_backend/public/app/post.ts, supabase/functions/_backend/utils/rbac.ts
validatePrincipalAccess extended with apikey branch (resolve owning user and verify org membership or user-scoped binding); app creation now checks org.create_app; added org.create_app Permission and legacy-right mapping.
Database migrations & RBAC functions
supabase/migrations/20260305120000_rbac_apikey_bindings_priority.sql, supabase/migrations/20260325153223_cli_rbac_permission_wrappers.sql, supabase/migrations/20260325161611_add_org_create_app_permission.sql
Major migration rewriting rbac_check_permission_direct to prioritize API-key role bindings, enforce scope/2FA/password rules, added get_org_perm_for_apikey_v2 and get_org_apikeys; added CLI wrapper functions and org.create_app permission + RLS/permission wrappers.
DB tests
supabase/tests/45_test_org_create_app_permission.sql
New SQL test validating org.create_app permission seeding and enforcement, plus API-key fallback behavior for app creation.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as ApiKeyRbacManager (Frontend)
    participant Backend as Supabase Functions (serverless)
    participant DB as PostgreSQL (RBAC / apikeys)
    participant Storage as RoleBindings / Apps

    User->>UI: Open "Create API Key" form
    UI->>User: Display roles/apps selection
    User->>UI: Submit form (roles, apps, expiration)
    UI->>Backend: call serverless `apikey` function (create)
    Backend->>DB: INSERT apikeys, create rbac_id, store metadata
    Backend->>Storage: INSERT role_bindings (org / app scope)
    DB-->>Backend: success
    Backend-->>UI: return plain key (one-time)
    UI->>User: show copy modal
Loading
sequenceDiagram
    participant Client as External Client (uses API key)
    participant API as Server (request handler / CLI wrapper)
    participant RBAC as rbac_check_permission_direct
    participant DB as PostgreSQL (role_bindings, apikeys, apps)

    Client->>API: Request with apikey header
    API->>DB: resolve apikey -> user_id
    API->>RBAC: rbac_check_permission_direct(permission, user_id, org, app, channel, apikey)
    RBAC->>DB: query role_bindings where rbac applies (apikey first)
    alt apikey has explicit role_bindings
        RBAC->>RBAC: evaluate permission against apikey bindings only
    else
        RBAC->>DB: enforce limited_to_orgs/apps, then fallback to owner user's permissions
    end
    RBAC-->>API: allow / deny boolean
    API-->>Client: respond or 403
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 Hops the rabbit, key in paw,

V2 bindings stitched with careful law,
Legacy hums, still in the stream,
DB and UI join the team,
Copy, paste — secure the dream. 🔑🫧

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add RBAC API key management and app creation permissions' is concise, specific, and clearly describes the main feature additions in the changeset.
Description check ✅ Passed The PR description includes a comprehensive summary, motivation, business impact, and test plan that align well with the required template sections and changeset scope.

✏️ 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 rbac-api-keys

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.

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: 14

🧹 Nitpick comments (4)
supabase/functions/_backend/private/role_bindings.ts (1)

150-175: Align org membership validation with RBAC system's group support.

The validatePrincipalAccess check uses hard-coded org_users + direct role_bindings lookups but doesn't include group-derived membership. Meanwhile, the RBAC system (via rbac_has_permission) includes group_members in permission checks at all scopes (org, app, channel). A user with only group-based org access would pass RBAC checks but fail this validation. Consider replacing the hard-coded org eligibility logic with a shared helper that routes through the same RBAC path used by checkPermission().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/role_bindings.ts` around lines 150 - 175,
The org membership check in validatePrincipalAccess currently queries org_users
and role_bindings directly (see usage of schema.org_users and
schema.role_bindings) which misses group-derived membership; replace this
hard-coded logic with a call into the same RBAC evaluation used elsewhere (e.g.
rbac_has_permission / checkPermission) or extract a shared helper (e.g.
isPrincipalInOrgViaRbac) that considers schema.group_members and role_bindings
when validating org eligibility so users who have only group-based org access
pass the check.
src/layouts/settings.vue (1)

128-145: Remove the redundant API keys tab reinsertion block.

organizationTabs is already initialized from [...]baseOrgTabs, and src/constants/organizationTabs.ts:12-23 already includes /settings/organization/api-keys. This branch no longer changes behavior, but it leaves a second place to maintain tab presence and ordering.

♻️ Suggested simplification
-  const needsApiKeys = true
-  const hasApiKeys = organizationTabs.value.find(tab => tab.key === '/settings/organization/api-keys')
-  if (needsApiKeys && !hasApiKeys) {
-    const base = baseOrgTabs.find(t => t.key === '/settings/organization/api-keys')
-    const insertAfterKeys = [
-      '/settings/organization/groups',
-      '/settings/organization/members',
-      '/settings/organization',
-    ]
-    const insertAfterIndex = insertAfterKeys
-      .map(key => organizationTabs.value.findIndex(tab => tab.key === key))
-      .find(index => index >= 0) ?? -1
-
-    if (base && insertAfterIndex >= 0)
-      organizationTabs.value.splice(insertAfterIndex + 1, 0, { ...base })
-    else if (base)
-      organizationTabs.value.push({ ...base })
-  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/layouts/settings.vue` around lines 128 - 145, Remove the redundant API
keys reinsertion logic: delete the whole conditional block that defines
needsApiKeys, computes hasApiKeys, finds base from baseOrgTabs, builds
insertAfterKeys/insertAfterIndex and calls organizationTabs.value.splice or
push; organizationTabs is already initialized from baseOrgTabs (which contains
'/settings/organization/api-keys'), so keep only the initial organizationTabs
initialization and remove the code referencing needsApiKeys, hasApiKeys,
baseOrgTabs lookup, insertAfterKeys, insertAfterIndex, and the splice/push logic
to avoid duplicate maintenance locations.
supabase/tests/45_test_org_create_app_permission.sql (1)

31-50: This file still doesn't exercise a directly bound API key.

The seeded key never gets a role_bindings row with principal_type = public.rbac_principal_apikey(), so the final insert only proves the non-bound/legacy fallback. A regression in the new bound-key path would still pass this test. Adding a second key with a direct API-key binding would close that gap.

Also applies to: 124-144

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/tests/45_test_org_create_app_permission.sql` around lines 31 - 50,
Add a second API key and a matching direct role_binding entry so the bound-key
path is exercised: insert a new row into public.apikeys (e.g., id 45002, a
distinct key string and same limited_to_orgs value) and then insert into
public.role_bindings using principal_type = public.rbac_principal_apikey(),
principal_id = the new apikey id, role_id from public.roles (same selection as
the existing member binding), scope_type = public.rbac_scope_org(), org_id =
'70000000-0000-4000-8000-000000000001', and granted_by =
tests.get_supabase_uid('org_create_app_admin') so the test covers the
directly-bound API key path.
src/pages/ApiKeys.vue (1)

850-897: Switch these new controls to the repo’s d- DaisyUI classes.

These radios and checkboxes are using unprefixed DaisyUI classes (radio, checkbox, form-control, label). In this repo, interactive primitives are expected to use the d- prefixed variants, so this modal can drift from the standard Capgo styling/behavior. As per coding guidelines Use DaisyUI (d- prefixed classes) for buttons, inputs, and other interactive primitives to keep behavior and spacing consistent.

Also applies to: 903-929, 937-971, 981-987

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/ApiKeys.vue` around lines 850 - 897, The radio inputs in the
ApiKeys modal (the blocks binding v-model="selectedKeyType" with name="key-type"
and surrounding divs using classes like "form-control", "label", "radio",
"radio-primary", "label-text") are using unprefixed DaisyUI classes; replace
those with the repo's d- prefixed counterparts (e.g., "d-form-control",
"d-label", "d-radio", "d-radio-primary", "d-label-text" or the repo's exact
d-variants) so the interactive primitives use the standard styling/behavior;
apply the same replacements to the other similar blocks referenced (lines
~903-929, 937-971, 981-987) to keep consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@messages/en.json`:
- Line 482: Add the new "copy" translation key to all locale bundles so
t('copy') in ApiKeys.[id].vue resolves in every language; update each
messages/*.json (e.g., messages/hi.json and any other locale files present) to
include the "copy" key with an appropriate localized string (matching the
English "Copy" entry) so no locale falls back to the raw key.

In `@src/components/DataTable.vue`:
- Around line 598-612: Make the action tooltip keyboard-accessible by making the
wrapper focusable and wiring the tooltip to the button via aria-describedby: add
tabindex="0" to the outer div (the element with class "relative inline-flex
group"), generate or derive a stable tooltip id (e.g., using the action and elem
index) and set that id on the span (role="tooltip"), then add
:aria-describedby="getActionTitle(action, elem) ? tooltipIdFor(action, elem) :
undefined" to the <button> instead of relying on disabled title behavior; also
ensure the wrapper forwards Enter/Space to invoke action.onClick(elem) when the
action is not disabled (handle keydown on the wrapper to call
action.onClick(elem) only if !(action.disabled && action.disabled(elem))). Use
getActionTitle(action, elem), action.onClick, action.icon and elem to locate
code.

In `@src/components/organization/ApiKeyRbacManager.vue`:
- Around line 126-160: The column definitions in computedV2Columns set sortable:
true but DataTable only emits column state and does not reorder elementList, so
clicking headers only toggles icons; either remove sortable: true from the
columns (in computedV2Columns and the analogous legacy columns around lines
203-236) or implement a sorted computed like the one in src/pages/ApiKeys.vue
that listens to DataTable's emitted column state and returns a sorted
v2Keys/legacyKeys array; update the component to use that sorted computed for
the table's rows (reference computedV2Columns, v2Keys, legacyKeys, and the
sorting logic in src/pages/ApiKeys.vue).
- Around line 181-184: Replace inline English fallbacks passed to the i18n
helper by using translation keys only: in ApiKeyRbacManager.vue update the t()
call used in the menu item (icon: IconWrench, title: t('manage', 'Manage')) to
title: t('manage'), and update the error notification call where
t('error-removing-apikey', 'Error removing API key') is used to
t('error-removing-apikey'); then add a corresponding "error-removing-apikey"
entry to messages/en.json (with the English string) so the new key resolves.

In `@src/pages/ApiKeys.vue`:
- Around line 952-978: The selectedApps set can contain apps that are no longer
visible after the allowed orgs shrink (so createApiKey() can submit hidden app
IDs); fix by pruning displayStore.selectedApps whenever the allowed-org
selection changes or right before submission: when filteredAppsForSelectedOrgs
updates (or inside the watcher for displayStore.selectedOrganizations) remove
any entries from displayStore.selectedApps that are not present in
filteredAppsForSelectedOrgs; alternatively, ensure createApiKey() filters
displayStore.selectedApps to only include apps whose app_id appears in
filteredAppsForSelectedOrgs before building the request. Reference symbols:
displayStore.selectedApps, filteredAppsForSelectedOrgs, handleAppSelection,
createApiKey.

In `@src/pages/settings/organization/ApiKeys`.[id].vue:
- Around line 411-468: The current flow calls
supabase.functions.invoke('apikey') to create the API key and then issues
separate supabase.functions.invoke('private/role_bindings') calls for org and
app roles, which can leave a half-configured credential if any binding fails;
update the flow to either (A) move creation + bindings into a single backend
transaction by adding/using an API that accepts name, expires_at, hashed,
selectedOrgRole and pendingAppBindings and returns the created rbac_id only
after all bindings succeed, or (B) implement compensating cleanup in this
client: if any role binding (the calls that reference 'private/role_bindings',
newRbacId, selectedOrgRole, pendingAppBindings) fails, immediately call the
backend to revoke/delete the created API key (use the existing 'apikey' function
or a new DELETE/cleanup function) and surface the original error to the user;
ensure special handling for plaintext vs hashed (createAsHashed) so plaintext
secrets are never lost on partial failure.
- Line 202: The code uses inline fallback strings passed as a second argument to
the t() translation calls (e.g., the assignment to displayStore.NavTitle =
t('create-api-key', 'Create API key') and other t(...) usages noted), which
violates the i18n guideline; remove the inline English fallbacks from all t()
calls in ApiKeys.[id].vue so each call is t('translation.key') only, then add
the corresponding English copy into messages/en.json under the same keys (e.g.,
create-api-key and the other keys referenced at the reported locations) so
missing translations come from the messages file rather than inline fallbacks;
update any tests or imports if needed to ensure keys are present and reload the
i18n messages.
- Around line 294-296: The current logic in fetchRoleBindings() sets
selectedOrgRole.value to '' when keyOrgBinding.value?.role_name is in
unsupportedApiKeyOrgRoles, and saveOrgRole() treats an empty selectedOrgRole as
an instruction to delete the org binding; this causes unsupported roles like
"org_billing_admin" to be silently removed on save. Preserve the original
unsupported role by storing it in a separate variable (e.g.,
originalUnsupportedOrgRole) when populating selectedOrgRole in
fetchRoleBindings(), display selectedOrgRole as '' but in saveOrgRole() only
perform the delete if the user actually changed selectedOrgRole away from the
original value (compare against originalUnsupportedOrgRole and
keyOrgBinding.value?.role_name), and apply the same fix for the duplicate logic
around lines 531-536 so unsupported roles are not removed unless explicitly
changed by the user.

In `@supabase/functions/_backend/private/role_bindings.ts`:
- Around line 146-178: Collapse the two distinct API-key failure responses into
a single client-facing error: when apiKey is missing, when membership is absent,
or when ownerRbacAccess is absent, return the same generic { ok: false, status:
400, error: '<generic message>' } (choose a neutral message like "Invalid API
key or access") while preserving detailed reasons only in server logs; update
the checks around the apiKey variable, the membership query result, and the
ownerRbacAccess check in role_bindings.ts (referencing the apiKey variable, the
membership destructure, and the ownerRbacAccess destructure) so they all yield
the same public error, and add processLogger.error or similar log statements
that include the specific condition (missing apiKey, membership not found,
ownerRbacAccess missing) for internal debugging.

In `@supabase/functions/_backend/public/app/post.ts`:
- Around line 29-30: Ensure owner_org presence is validated before the RBAC
check: add an explicit check for body.owner_org at the top of the creation flow
and throw a quickError when it's missing (so the request fails
deterministically), then keep the existing permission call to checkPermission(c,
'org.create_app', { orgId: body.owner_org }) unchanged; this guarantees
authorization is always evaluated only when owner_org is present and avoids
failing later at insert time.

In `@supabase/migrations/20260305120000_rbac_apikey_bindings_priority.sql`:
- Around line 411-416: get_org_apikeys() currently allows any org reader because
it only calls public.check_min_rights('read', ...); change the RPC to require
the same management permission used by the UI (org.update_user_roles) or
add/require a dedicated right like 'manage_api_keys' instead of 'read' — replace
the check_min_rights call in get_org_apikeys() to call
public.check_min_rights('org.update_user_roles'::public.user_min_right,
auth.uid(), p_org_id, NULL::varchar, NULL::bigint) (or 'manage_api_keys' if you
introduce that new right) and keep the existing RAISE EXCEPTION flow so callers
without the management permission cannot enumerate API keys.
- Around line 434-451: The predicate currently only checks for user ownership or
user-scoped role_bindings (role_bindings with principal_type =
public.rbac_principal_user()), which lets API keys explicitly bound to the org
via role_bindings (principal_type = public.rbac_principal_apikey()) drop out of
get_org_apikeys() when the owner relation changes; update the EXISTS check that
queries public.role_bindings (the block referencing rb.principal_type =
public.rbac_principal_user(), rb.scope_type = public.rbac_scope_org(),
rb.principal_id = ak.user_id, rb.org_id = p_org_id) to also consider bindings
where principal_type = public.rbac_principal_apikey() and the principal_id
matches the api key identifier (ak.id or the correct apikey identifier field) so
keys with apikey-scoped bindings remain included in the predicate.
- Around line 72-75: The legacy API-key branch resolves v_effective_user_id from
find_apikey_by_value(p_apikey) but continues to call the legacy helpers with the
original p_user_id, causing capgkey-only requests to be treated as NULL; update
the legacy-check calls (the places that invoke the legacy helpers referenced by
rbac_check_permission_request() and the second occurrence around lines 282-287)
to pass v_effective_user_id when it is set (i.e., use COALESCE-like behavior:
use v_effective_user_id if NOT NULL, otherwise fall back to p_user_id) so the
resolved key owner is used for permission checks.

In `@supabase/migrations/20260325161611_add_org_create_app_permission.sql`:
- Around line 67-68: The migration changed the legacy mapping for org.create_app
to return rbac_right_admin(), which breaks non-RBAC write/all users and keys
that previously relied on legacy write-level access; update the mapping so
public.rbac_perm_org_create_app() returns rbac_right_write() (use the
rbac_right_write() symbol instead of rbac_right_admin()) and apply the same
change to the other occurrences of this mapping (the other
rbac_perm_org_create_app() branches around the referenced ranges) so the
pre-create storage.objects and public.apps INSERT paths preserve the legacy
write access.

---

Nitpick comments:
In `@src/layouts/settings.vue`:
- Around line 128-145: Remove the redundant API keys reinsertion logic: delete
the whole conditional block that defines needsApiKeys, computes hasApiKeys,
finds base from baseOrgTabs, builds insertAfterKeys/insertAfterIndex and calls
organizationTabs.value.splice or push; organizationTabs is already initialized
from baseOrgTabs (which contains '/settings/organization/api-keys'), so keep
only the initial organizationTabs initialization and remove the code referencing
needsApiKeys, hasApiKeys, baseOrgTabs lookup, insertAfterKeys, insertAfterIndex,
and the splice/push logic to avoid duplicate maintenance locations.

In `@src/pages/ApiKeys.vue`:
- Around line 850-897: The radio inputs in the ApiKeys modal (the blocks binding
v-model="selectedKeyType" with name="key-type" and surrounding divs using
classes like "form-control", "label", "radio", "radio-primary", "label-text")
are using unprefixed DaisyUI classes; replace those with the repo's d- prefixed
counterparts (e.g., "d-form-control", "d-label", "d-radio", "d-radio-primary",
"d-label-text" or the repo's exact d-variants) so the interactive primitives use
the standard styling/behavior; apply the same replacements to the other similar
blocks referenced (lines ~903-929, 937-971, 981-987) to keep consistency.

In `@supabase/functions/_backend/private/role_bindings.ts`:
- Around line 150-175: The org membership check in validatePrincipalAccess
currently queries org_users and role_bindings directly (see usage of
schema.org_users and schema.role_bindings) which misses group-derived
membership; replace this hard-coded logic with a call into the same RBAC
evaluation used elsewhere (e.g. rbac_has_permission / checkPermission) or
extract a shared helper (e.g. isPrincipalInOrgViaRbac) that considers
schema.group_members and role_bindings when validating org eligibility so users
who have only group-based org access pass the check.

In `@supabase/tests/45_test_org_create_app_permission.sql`:
- Around line 31-50: Add a second API key and a matching direct role_binding
entry so the bound-key path is exercised: insert a new row into public.apikeys
(e.g., id 45002, a distinct key string and same limited_to_orgs value) and then
insert into public.role_bindings using principal_type =
public.rbac_principal_apikey(), principal_id = the new apikey id, role_id from
public.roles (same selection as the existing member binding), scope_type =
public.rbac_scope_org(), org_id = '70000000-0000-4000-8000-000000000001', and
granted_by = tests.get_supabase_uid('org_create_app_admin') so the test covers
the directly-bound API key path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aad52d00-25bd-46ed-b268-c6087ca895b5

📥 Commits

Reviewing files that changed from the base of the PR and between 200e9e0 and bcadfd3.

📒 Files selected for processing (31)
  • messages/de.json
  • messages/en.json
  • messages/es.json
  • messages/fr.json
  • messages/hi.json
  • messages/id.json
  • messages/it.json
  • messages/ja.json
  • messages/ko.json
  • messages/pl.json
  • messages/pt-br.json
  • messages/ru.json
  • messages/tr.json
  • messages/vi.json
  • messages/zh-cn.json
  • src/components.d.ts
  • src/components/DataTable.vue
  • src/components/organization/ApiKeyRbacManager.vue
  • src/constants/organizationTabs.ts
  • src/layouts/settings.vue
  • src/pages/ApiKeys.vue
  • src/pages/settings/organization/ApiKeys.[id].vue
  • src/pages/settings/organization/ApiKeys.vue
  • src/route-map.d.ts
  • supabase/functions/_backend/private/role_bindings.ts
  • supabase/functions/_backend/public/app/post.ts
  • supabase/functions/_backend/utils/rbac.ts
  • supabase/migrations/20260305120000_rbac_apikey_bindings_priority.sql
  • supabase/migrations/20260325153223_cli_rbac_permission_wrappers.sql
  • supabase/migrations/20260325161611_add_org_create_app_permission.sql
  • supabase/tests/45_test_org_create_app_permission.sql

Comment on lines +181 to +184
{
icon: IconWrench,
title: t('manage', 'Manage'),
onClick: (apiKey: ApiKeyRow) => router.push(`/settings/organization/api-keys/${apiKey.rbac_id}`),
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.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/organization/ApiKeyRbacManager.vue | sed -n '175,190p'

Repository: Cap-go/capgo

Length of output: 657


🏁 Script executed:

cat -n src/components/organization/ApiKeyRbacManager.vue | sed -n '400,410p'

Repository: Cap-go/capgo

Length of output: 419


🏁 Script executed:

find . -name "messages" -o -name "*en.json" | head -20

Repository: Cap-go/capgo

Length of output: 328


🏁 Script executed:

cat ./messages/en.json | grep -E '"(manage|error-removing-apikey)"'

Repository: Cap-go/capgo

Length of output: 77


Remove inline English fallbacks from t() calls.

These should use translation keys only. At line 183, t('manage', 'Manage') should be t('manage') since the key already exists in messages/en.json. At line 407, add error-removing-apikey to messages/en.json and change t('error-removing-apikey', 'Error removing API key') to t('error-removing-apikey').

Minimal cleanup
-          title: t('manage', 'Manage'),
+          title: t('manage'),
...
-    toast.error(t('error-removing-apikey', 'Error removing API key'))
+    toast.error(t('error-removing-apikey'))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/organization/ApiKeyRbacManager.vue` around lines 181 - 184,
Replace inline English fallbacks passed to the i18n helper by using translation
keys only: in ApiKeyRbacManager.vue update the t() call used in the menu item
(icon: IconWrench, title: t('manage', 'Manage')) to title: t('manage'), and
update the error notification call where t('error-removing-apikey', 'Error
removing API key') is used to t('error-removing-apikey'); then add a
corresponding "error-removing-apikey" entry to messages/en.json (with the
English string) so the new key resolves.

Comment on lines +29 to 30
if (body.owner_org && !(await checkPermission(c, 'org.create_app', { orgId: body.owner_org }))) {
throw quickError(403, 'cannot_access_organization', 'You can\'t access this organization', { org_id: body.owner_org })
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

Validate owner_org before the permission check.

At Line 29, an empty/missing owner_org skips RBAC enforcement (body.owner_org && ...) and only fails later at insert time. Add an explicit required check first so authorization is always evaluated deterministically.

Suggested patch
+  if (!body.owner_org) {
+    throw simpleError('missing_owner_org', 'Missing owner_org', { body })
+  }
+
   // Check if the user is allowed to create an app in this organization (auth context set by middlewareKey)
-  if (body.owner_org && !(await checkPermission(c, 'org.create_app', { orgId: body.owner_org }))) {
+  if (!(await checkPermission(c, 'org.create_app', { orgId: body.owner_org }))) {
     throw quickError(403, 'cannot_access_organization', 'You can\'t access this organization', { org_id: body.owner_org })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/public/app/post.ts` around lines 29 - 30, Ensure
owner_org presence is validated before the RBAC check: add an explicit check for
body.owner_org at the top of the creation flow and throw a quickError when it's
missing (so the request fails deterministically), then keep the existing
permission call to checkPermission(c, 'org.create_app', { orgId: body.owner_org
}) unchanged; this guarantees authorization is always evaluated only when
owner_org is present and avoids failing later at insert time.

Comment on lines +72 to +75
IF v_effective_user_id IS NULL AND p_apikey IS NOT NULL THEN
SELECT user_id INTO v_effective_user_id
FROM public.find_apikey_by_value(p_apikey)
LIMIT 1;
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 | 🟠 Major

Legacy API-key fallback drops the resolved owner user.

You resolve v_effective_user_id from find_apikey_by_value() at Lines 72-75, but the legacy branch still passes the original p_user_id into the legacy helpers. rbac_check_permission_request() supplies auth.uid(), so capgkey-only requests hit this branch with NULL and org-scoped legacy checks deny even though the key owner was resolved.

⚙️ Suggested fix
-      RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, p_user_id, p_apikey);
+      RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, v_effective_user_id, p_apikey);
@@
-      RETURN public.check_min_rights_legacy(v_legacy_right, p_user_id, v_effective_org_id, p_app_id, p_channel_id);
+      RETURN public.check_min_rights_legacy(v_legacy_right, v_effective_user_id, v_effective_org_id, p_app_id, p_channel_id);

Also applies to: 282-287

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260305120000_rbac_apikey_bindings_priority.sql` around
lines 72 - 75, The legacy API-key branch resolves v_effective_user_id from
find_apikey_by_value(p_apikey) but continues to call the legacy helpers with the
original p_user_id, causing capgkey-only requests to be treated as NULL; update
the legacy-check calls (the places that invoke the legacy helpers referenced by
rbac_check_permission_request() and the second occurrence around lines 282-287)
to pass v_effective_user_id when it is set (i.e., use COALESCE-like behavior:
use v_effective_user_id if NOT NULL, otherwise fall back to p_user_id) so the
resolved key owner is used for permission checks.

Comment on lines +411 to +416
-- Permission check: caller must be a member of the org with at least read access.
IF NOT public.check_min_rights(
'read'::public.user_min_right, auth.uid(), p_org_id, NULL::varchar, NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;
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 | 🟠 Major

get_org_apikeys() is readable by callers who cannot manage API keys.

The RPC only enforces check_min_rights('read', ...), but the management page itself gates access on org.update_user_roles in src/pages/settings/organization/ApiKeys.[id].vue Lines 76-80. That means any authenticated org reader who can call RPCs can bypass the page guard and enumerate key names plus owner_email. The RPC should enforce the same management permission as the UI, or a dedicated manage-api-keys permission.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260305120000_rbac_apikey_bindings_priority.sql` around
lines 411 - 416, get_org_apikeys() currently allows any org reader because it
only calls public.check_min_rights('read', ...); change the RPC to require the
same management permission used by the UI (org.update_user_roles) or add/require
a dedicated right like 'manage_api_keys' instead of 'read' — replace the
check_min_rights call in get_org_apikeys() to call
public.check_min_rights('org.update_user_roles'::public.user_min_right,
auth.uid(), p_org_id, NULL::varchar, NULL::bigint) (or 'manage_api_keys' if you
introduce that new right) and keep the existing RAISE EXCEPTION flow so callers
without the management permission cannot enumerate API keys.

Comment on lines +434 to +451
(
EXISTS (
SELECT 1
FROM public.org_users ou
WHERE ou.user_id = ak.user_id
AND ou.org_id = p_org_id
)
OR EXISTS (
SELECT 1
FROM public.role_bindings rb
WHERE rb.principal_type = public.rbac_principal_user()
AND rb.scope_type = public.rbac_scope_org()
AND rb.principal_id = ak.user_id
AND rb.org_id = p_org_id
)
)
-- Key scope: either unlimited (no org restriction) or includes this org
AND (ak.limited_to_orgs IS NULL OR cardinality(ak.limited_to_orgs) = 0 OR p_org_id = ANY(ak.limited_to_orgs))
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 | 🟠 Major

Keys with explicit RBAC bindings can still disappear from the org listing.

This predicate only keeps keys whose owner is an org member or has an org-scoped user binding, then applies limited_to_orgs. A key that is explicitly bound to this org/app via public.role_bindings stays authorized by public.rbac_check_permission_direct() but can drop out of get_org_apikeys() once the owner relationship changes, leaving an active org key unlistable and uneditable. Include principal_type = public.rbac_principal_apikey() bindings in the relevance check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260305120000_rbac_apikey_bindings_priority.sql` around
lines 434 - 451, The predicate currently only checks for user ownership or
user-scoped role_bindings (role_bindings with principal_type =
public.rbac_principal_user()), which lets API keys explicitly bound to the org
via role_bindings (principal_type = public.rbac_principal_apikey()) drop out of
get_org_apikeys() when the owner relation changes; update the EXISTS check that
queries public.role_bindings (the block referencing rb.principal_type =
public.rbac_principal_user(), rb.scope_type = public.rbac_scope_org(),
rb.principal_id = ak.user_id, rb.org_id = p_org_id) to also consider bindings
where principal_type = public.rbac_principal_apikey() and the principal_id
matches the api key identifier (ak.id or the correct apikey identifier field) so
keys with apikey-scoped bindings remain included in the predicate.

Comment on lines +67 to +68
WHEN public.rbac_perm_org_create_app() THEN RETURN public.rbac_right_admin();
WHEN public.rbac_perm_org_update_settings() THEN RETURN public.rbac_right_admin();
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 | 🟠 Major

org.create_app no longer preserves the old legacy access level.

rbac_check_permission_request() now backs both public.apps inserts and the pre-create storage.objects branch, but the legacy mapping for org.create_app is admin. The previous public.apps INSERT policy in supabase/migrations/20260203201308_rbac_org_member_no_app_access.sql:305-322 required legacy write, so non-RBAC write/all users and keys will lose app-creation behavior after this migration.

⚙️ Suggested fix
-    WHEN public.rbac_perm_org_create_app() THEN RETURN public.rbac_right_admin();
+    WHEN public.rbac_perm_org_create_app() THEN RETURN public.rbac_right_write();

Also applies to: 131-139, 174-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260325161611_add_org_create_app_permission.sql` around
lines 67 - 68, The migration changed the legacy mapping for org.create_app to
return rbac_right_admin(), which breaks non-RBAC write/all users and keys that
previously relied on legacy write-level access; update the mapping so
public.rbac_perm_org_create_app() returns rbac_right_write() (use the
rbac_right_write() symbol instead of rbac_right_admin()) and apply the same
change to the other occurrences of this mapping (the other
rbac_perm_org_create_app() branches around the referenced ranges) so the
pre-create storage.objects and public.apps INSERT paths preserve the legacy
write access.

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq bot commented Mar 28, 2026

Merging this PR will not alter performance

✅ 28 untouched benchmarks


Comparing rbac-api-keys (331350b) with main (a843519)

Open in CodSpeed

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
4.5% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

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.

2 participants