Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions supabase/functions/_backend/public/apikey/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { closeClient, getDrizzleClient, getPgClient } from '../../utils/pg.ts'
import { checkPermission } from '../../utils/rbac.ts'
import { supabaseWithAuth, validateExpirationAgainstOrgPolicies, validateExpirationDate } from '../../utils/supabase.ts'
import { parseApiKeyGlobalPermissions, replaceApiKeyGlobalPermissions, validateApiKeyGlobalPermissionsForBindings } from './global_permissions.ts'
import { requireApiKeyManagementAuth } from './scope.ts'
import { ensureApiKeyManagementAllowed, requireApiKeyManagementAuth } from './scope.ts'

interface BindingInput {
role_name: string
Expand Down Expand Up @@ -68,16 +68,16 @@ async function createApiKeyRecord(

app.post('/', middlewareAuth(), async (c) => {
const auth = requireApiKeyManagementAuth(c, 'not_authorized', 'API key management requires authentication')
const authApikey = c.get('apikey') as ApiKeyRow | undefined

await ensureApiKeyManagementAllowed(c, auth, authApikey, 'cannot_create_apikey')

const body = await parseBody<any>(c)

const name = body.name ?? ''

if (auth.authType !== 'jwt' || !auth.userId) {
if (auth.authType === 'apikey') {
throw simpleError('cannot_create_apikey', 'API keys cannot create other API keys')
}
throw simpleError('not_authorized', 'Only user sessions can create API keys')
if (!auth.userId) {
throw simpleError('not_authorized', 'API key management requires authentication')
}
const expiresAt = body.expires_at ?? null
const isHashed = body.hashed === true
Expand Down Expand Up @@ -131,15 +131,17 @@ app.post('/', middlewareAuth(), async (c) => {
try {
// Check RBAC permission for each unique org in the bindings before creating anything.
for (const bindingOrgId of allOrgIds) {
if (!(await checkPermission(c, 'org.update_user_roles', { orgId: bindingOrgId }))) {
throw quickError(403, 'forbidden_binding', `Forbidden - Admin rights required for org ${bindingOrgId}`)
if (!(await checkPermission(c, 'org.manage_apikeys', { orgId: bindingOrgId }))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent apikey managers from minting app admins

When the caller is an API key bound only to the new apikey_manager role, this org-level org.manage_apikeys check is the only authorization before arbitrary requested bindings are created. The later createRoleBindingForPrincipal guard only compares priority_rank, so apikey_manager (rank 78) can POST a lower-ranked app_admin or channel_admin binding and mint a key that can update apps/channels even though the caller itself only had API-key-management rights. Please also verify the caller can grant each requested role/scope, or restrict the roles this manager can assign.

Useful? React with 👍 / 👎.

throw quickError(403, 'forbidden_binding', `Forbidden - API key management rights required for org ${bindingOrgId}`)
}
}

pgClient = getPgClient(c)
const drizzle = getDrizzleClient(pgClient)
const createdBindings: unknown[] = []
const callerPrincipalId = auth.userId
const bindingAuthType = auth.authType === 'apikey' ? 'apikey' : 'jwt'
const callerApikeyRbacId = auth.authType === 'apikey' ? auth.apikey?.rbac_id : undefined

await drizzle.transaction(async (tx) => {
apikeyData = await createApiKeyRecord(tx, {
Expand Down Expand Up @@ -189,8 +191,8 @@ app.post('/', middlewareAuth(), async (c) => {
tx as unknown as ReturnType<typeof getDrizzleClient>,
bindingParams,
auth.userId,
'jwt',
callerPrincipalId,
bindingAuthType,
bindingAuthType === 'apikey' && callerApikeyRbacId ? callerApikeyRbacId : callerPrincipalId,
)

if (!result.ok) {
Expand Down
2 changes: 1 addition & 1 deletion supabase/functions/_backend/public/apikey/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async function getApiKeyManageableOrgIds(
const callerOrgIds = (await loadApiKeyBindingOrgIdsForRbacIds(c, [authApikey.rbac_id])).get(authApikey.rbac_id) ?? []
const manageableOrgIds = new Set<string>()
for (const orgId of callerOrgIds) {
if (await checkPermission(c, 'org.update_user_roles', { orgId })) {
if (await checkPermission(c, 'org.manage_apikeys', { orgId })) {
manageableOrgIds.add(orgId)
}
}
Expand Down
1 change: 1 addition & 0 deletions supabase/functions/_backend/utils/rbac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type Permission
| 'org.delete'
| 'org.read_members'
| 'org.invite_user'
| 'org.manage_apikeys'
| 'org.update_user_roles'
| 'org.read_billing'
| 'org.update_billing'
Expand Down
114 changes: 79 additions & 35 deletions supabase/migrations/20260616183501_harden_rbac_compat_cleanup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,60 @@ DROP FUNCTION IF EXISTS public.rbac_rollback_org(uuid);
ALTER TABLE public.orgs
DROP COLUMN IF EXISTS use_new_rbac;

CREATE OR REPLACE FUNCTION public.rbac_perm_org_manage_apikeys() RETURNS text
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
SET search_path = ''
AS $$ SELECT 'org.manage_apikeys'::text $$;

ALTER FUNCTION public.rbac_perm_org_manage_apikeys() OWNER TO postgres;

INSERT INTO public.permissions (key, scope_type, description)
VALUES (
public.rbac_perm_org_manage_apikeys(),
public.rbac_scope_org(),
'Create, list, update metadata, rotate, and delete API keys for the org without assigning user roles'
)
ON CONFLICT (key) DO NOTHING;

CREATE OR REPLACE FUNCTION public.rbac_role_apikey_manager() RETURNS text
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
SET search_path = ''
AS $$ SELECT 'apikey_manager'::text $$;

ALTER FUNCTION public.rbac_role_apikey_manager() OWNER TO postgres;

INSERT INTO public.roles (name, scope_type, description, priority_rank, is_assignable, created_by)
VALUES (
public.rbac_role_apikey_manager(),
public.rbac_scope_org(),
'Manage API keys for CI/CD without org role assignment rights',
78,
true,
NULL
)
ON CONFLICT (name) DO UPDATE
SET
scope_type = EXCLUDED.scope_type,
description = EXCLUDED.description,
priority_rank = EXCLUDED.priority_rank,
is_assignable = EXCLUDED.is_assignable;

INSERT INTO public.role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM public.roles
INNER JOIN public.permissions
ON permissions.key = public.rbac_perm_org_manage_apikeys()
WHERE roles.name IN (
public.rbac_role_apikey_manager(),
public.rbac_role_org_admin(),
public.rbac_role_org_super_admin()
Comment on lines +60 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce org expiration policies for apikey_manager

Callers that only have the new apikey_manager role get org.manage_apikeys but not org.read, while POST /apikey validates expiration policies by selecting orgs through the caller's RLS client and silently skips enforcement when no org rows are visible. In an org with require_apikey_expiration or max_apikey_expiration_days, an apikey_manager user/API key can therefore create a non-expiring or over-long API key; either include the read permission needed for that policy lookup or perform the expiration-policy lookup through a server-side path before creating the key.

Useful? React with 👍 / 👎.

)
ON CONFLICT DO NOTHING;

-- Direct channel table updates are intentionally admin/channel-admin only.
-- App developers can upload/promote bundles, but must not mutate channel settings
-- through the anon API-key RLS path.
Expand All @@ -36,6 +90,18 @@ INNER JOIN public.permissions
WHERE roles.name = public.rbac_role_app_reader()
ON CONFLICT DO NOTHING;

-- Upload-only principals can promote bundles to channels without channel settings writes.
INSERT INTO public.role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM public.roles
INNER JOIN public.permissions
ON permissions.key IN (
public.rbac_perm_channel_read(),
public.rbac_perm_channel_promote_bundle()
)
WHERE roles.name = public.rbac_role_app_uploader()
ON CONFLICT DO NOTHING;

DROP POLICY IF EXISTS "Allow update for auth (admin+)" ON public.orgs;
DROP POLICY IF EXISTS "Allow org settings update via RBAC" ON public.orgs;
CREATE POLICY "Allow org settings update via RBAC"
Expand Down Expand Up @@ -146,16 +212,6 @@ BEGIN

v_effective_user_id := v_api_key.user_id;

IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id)
AND NOT public.has_2fa_enabled(v_effective_user_id)
THEN
RETURN false;
END IF;

IF public.user_meets_password_policy(v_effective_user_id, v_effective_org_id) = false THEN
RETURN false;
END IF;

v_allowed := public.rbac_has_permission(
public.rbac_principal_apikey(),
v_api_key.rbac_id,
Expand Down Expand Up @@ -312,12 +368,6 @@ BEGIN

v_effective_user_id := v_api_key.user_id;

IF (SELECT enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id)
AND NOT public.has_2fa_enabled(v_effective_user_id)
THEN
RETURN false;
END IF;

RETURN public.rbac_has_permission(
public.rbac_principal_apikey(),
v_api_key.rbac_id,
Expand Down Expand Up @@ -640,11 +690,8 @@ ON public.app_versions
FOR SELECT
TO anon, authenticated
USING (
public.rbac_check_permission_request(
public.rbac_perm_app_read_bundles(),
owner_org,
app_id,
NULL::bigint
app_id = ANY(
COALESCE((SELECT public.app_versions_readable_app_ids()), '{}'::character varying[])
)
);

Expand Down Expand Up @@ -1249,11 +1296,8 @@ USING (
SELECT 1
FROM public.app_versions av
WHERE av.id = manifest.app_version_id
AND public.rbac_check_permission_request(
public.rbac_perm_app_read_bundles(),
av.owner_org,
av.app_id,
NULL::bigint
AND av.app_id = ANY(
COALESCE((SELECT public.app_versions_readable_app_ids()), '{}'::character varying[])
)
)
);
Expand Down Expand Up @@ -4349,10 +4393,10 @@ SET rbac_role_name = COALESCE(
WHEN 'super_admin' THEN 'channel_admin'
WHEN 'invite_admin' THEN 'channel_admin'
WHEN 'admin' THEN 'channel_admin'
WHEN 'invite_write' THEN 'channel_developer'
WHEN 'write' THEN 'channel_developer'
WHEN 'invite_upload' THEN 'channel_uploader'
WHEN 'upload' THEN 'channel_uploader'
WHEN 'invite_write' THEN 'channel_admin'
WHEN 'write' THEN 'channel_admin'
WHEN 'invite_upload' THEN 'channel_admin'
WHEN 'upload' THEN 'channel_admin'
Comment on lines +4396 to +4399

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid upgrading legacy channel writers to admins

For existing org_users rows scoped to a specific channel, this migration now maps legacy write/upload rights to channel_admin. That role has full channel-control permissions such as channel.update_settings/delete, and the channel update RLS policy accepts channel.update_settings, so a legacy upload-only or writer collaborator is promoted to full channel admin during migration. Preserve the old distinction by adding/mapping to narrower channel developer/uploader roles instead of channel_admin.

Useful? React with 👍 / 👎.

ELSE 'channel_reader'
END
WHEN app_id IS NOT NULL THEN
Expand Down Expand Up @@ -5731,7 +5775,7 @@ BEGIN
ON role_permissions.role_id = role_closure.effective_role_id
INNER JOIN public.permissions
ON permissions.id = role_permissions.permission_id
WHERE permissions.key = public.rbac_perm_app_read()
WHERE permissions.key = public.rbac_perm_app_read_bundles()
),
scoped_apps AS (
SELECT apps.app_id, apps.owner_org
Expand All @@ -5758,11 +5802,11 @@ BEGIN
SELECT orgs.id
FROM candidate_orgs
INNER JOIN public.orgs ON orgs.id = candidate_orgs.owner_org
WHERE (
orgs.enforcing_2fa IS NOT TRUE
OR (v_user_id IS NOT NULL AND public.has_2fa_enabled(v_user_id))
WHERE v_principal_type = public.rbac_principal_apikey()
OR (
(orgs.enforcing_2fa IS NOT TRUE OR (v_user_id IS NOT NULL AND public.has_2fa_enabled(v_user_id)))
AND public.user_meets_password_policy(v_user_id, orgs.id) IS DISTINCT FROM false
)
AND public.user_meets_password_policy(v_user_id, orgs.id) IS DISTINCT FROM false
)
SELECT COALESCE(array_agg(DISTINCT scoped_apps.app_id), '{}'::character varying[])
INTO v_allowed
Expand Down
71 changes: 63 additions & 8 deletions supabase/seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,8 @@ BEGIN
-- Dedicated user and API key for encrypted bundles tests (isolated to prevent interference)
(111, NOW(), 'f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f708193', 'b8c9d0e1-f2a3-4b4c-9d5e-6f7a8b9c0d14', NOW(), 'encrypted test org super admin'),
-- Dedicated user and API key for apikeys.test.ts API-key compatibility management
(112, NOW(), 'd0f1a2b3-c4d5-4e6f-8a90-b1c2d3e4f506', 'c9d0e1f2-a3b4-4c5d-8e6f-7a8b9c0d1e25', NOW(), 'apikey management test org super admin');
(112, NOW(), 'd0f1a2b3-c4d5-4e6f-8a90-b1c2d3e4f506', 'c9d0e1f2-a3b4-4c5d-8e6f-7a8b9c0d1e25', NOW(), 'apikey management test org super admin'),
(113, NOW(), 'd0f1a2b3-c4d5-4e6f-8a90-b1c2d3e4f506', 'd1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6', NOW(), 'apikey management test apikey_manager');

-- Hashed API key for testing (hash of 'test-hashed-apikey-for-auth-test')
-- Used by 07_auth_functions.sql tests
Expand Down Expand Up @@ -620,7 +621,8 @@ BEGIN
(102, public.rbac_role_org_super_admin()),
(110, public.rbac_role_org_super_admin()),
(111, public.rbac_role_org_super_admin()),
(112, public.rbac_role_org_super_admin())
(112, public.rbac_role_org_super_admin()),
(113, public.rbac_role_apikey_manager())
)
INSERT INTO public.role_bindings (
principal_type,
Expand Down Expand Up @@ -738,7 +740,7 @@ BEGIN

-- Drop replicated orgs but keet the the seed ones
DELETE from "public"."orgs" where POSITION('organization' in orgs.name)=1;
PERFORM setval('public.apikeys_id_seq', 112, true);
PERFORM setval('public.apikeys_id_seq', 113, true);
PERFORM setval('public.app_versions_id_seq', 16, true);
PERFORM setval('public.channel_id_seq', 6, false);
PERFORM setval('public.deploy_history_id_seq', 5, false);
Expand Down Expand Up @@ -1196,6 +1198,7 @@ BEGIN
(public.rbac_perm_org_read_members(), public.rbac_scope_org(), 'Read org membership list'),
(public.rbac_perm_org_invite_user(), public.rbac_scope_org(), 'Invite or add members to org'),
(public.rbac_perm_org_update_user_roles(), public.rbac_scope_org(), 'Change org/member roles'),
(public.rbac_perm_org_manage_apikeys(), public.rbac_scope_org(), 'Manage API keys for the org without assigning user roles'),
(public.rbac_perm_org_read_billing(), public.rbac_scope_org(), 'Read org billing settings'),
(public.rbac_perm_org_update_billing(), public.rbac_scope_org(), 'Update org billing settings'),
(public.rbac_perm_org_read_invoices(), public.rbac_scope_org(), 'Read invoices'),
Expand Down Expand Up @@ -1232,7 +1235,7 @@ BEGIN
INSERT INTO public.role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM public.roles r
JOIN public.permissions p ON p.key IN (
public.rbac_perm_org_read(), public.rbac_perm_org_create_app(), public.rbac_perm_org_update_settings(), public.rbac_perm_org_delete(), public.rbac_perm_org_read_members(), public.rbac_perm_org_invite_user(), public.rbac_perm_org_update_user_roles(),
public.rbac_perm_org_read(), public.rbac_perm_org_create_app(), public.rbac_perm_org_update_settings(), public.rbac_perm_org_delete(), public.rbac_perm_org_read_members(), public.rbac_perm_org_invite_user(), public.rbac_perm_org_update_user_roles(), public.rbac_perm_org_manage_apikeys(),
public.rbac_perm_org_read_billing(), public.rbac_perm_org_update_billing(), public.rbac_perm_org_read_invoices(), public.rbac_perm_org_read_audit(), public.rbac_perm_org_read_billing_audit(),
public.rbac_perm_app_read(), public.rbac_perm_app_update_settings(), public.rbac_perm_app_delete(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(),
public.rbac_perm_app_create_channel(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_manage_devices(), public.rbac_perm_app_read_devices(),
Expand All @@ -1247,12 +1250,12 @@ BEGIN
INSERT INTO public.role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM public.roles r
JOIN public.permissions p ON p.key IN (
public.rbac_perm_org_read(), public.rbac_perm_org_create_app(), public.rbac_perm_org_update_settings(), public.rbac_perm_org_read_members(), public.rbac_perm_org_invite_user(), public.rbac_perm_org_update_user_roles(),
public.rbac_perm_org_read(), public.rbac_perm_org_create_app(), public.rbac_perm_org_update_settings(), public.rbac_perm_org_read_members(), public.rbac_perm_org_invite_user(), public.rbac_perm_org_update_user_roles(), public.rbac_perm_org_manage_apikeys(),
public.rbac_perm_org_read_billing(), public.rbac_perm_org_read_invoices(), public.rbac_perm_org_read_audit(), public.rbac_perm_org_read_billing_audit(),
public.rbac_perm_app_read(), public.rbac_perm_app_update_settings(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(),
public.rbac_perm_app_create_channel(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_manage_devices(), public.rbac_perm_app_read_devices(),
public.rbac_perm_app_build_native(), public.rbac_perm_app_read_audit(), public.rbac_perm_app_update_user_roles(),
public.rbac_perm_channel_read(), public.rbac_perm_channel_update_settings(), public.rbac_perm_channel_read_history(),
public.rbac_perm_channel_read(), public.rbac_perm_channel_read_history(),
public.rbac_perm_channel_promote_bundle(), public.rbac_perm_channel_rollback_bundle(), public.rbac_perm_channel_manage_forced_devices(), public.rbac_perm_channel_read_forced_devices(), public.rbac_perm_channel_read_audit()
)
WHERE r.name = public.rbac_role_org_admin()
Expand Down Expand Up @@ -1308,15 +1311,23 @@ BEGIN
WHERE r.name = public.rbac_role_app_developer()
ON CONFLICT DO NOTHING;

-- app_uploader: upload only
-- app_uploader: upload only plus channel promote without settings writes
INSERT INTO public.role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM public.roles r
JOIN public.permissions p ON p.key IN (
public.rbac_perm_app_read(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_read_devices(), public.rbac_perm_app_read_audit()
public.rbac_perm_app_read(), public.rbac_perm_app_read_bundles(), public.rbac_perm_app_upload_bundle(), public.rbac_perm_app_read_channels(), public.rbac_perm_app_read_logs(), public.rbac_perm_app_read_devices(), public.rbac_perm_app_read_audit(),
public.rbac_perm_channel_read(), public.rbac_perm_channel_promote_bundle()
)
WHERE r.name = public.rbac_role_app_uploader()
ON CONFLICT DO NOTHING;

-- apikey_manager: manage API keys without role assignment rights
INSERT INTO public.role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM public.roles r
JOIN public.permissions p ON p.key = public.rbac_perm_org_manage_apikeys()
WHERE r.name = public.rbac_role_apikey_manager()
ON CONFLICT DO NOTHING;

-- app_reader: read-only app access plus read-only access to every channel in the app
INSERT INTO public.role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM public.roles r
Expand Down Expand Up @@ -1347,6 +1358,50 @@ BEGIN
WHERE r.name = public.rbac_role_channel_reader()
ON CONFLICT DO NOTHING;


-- Ensure dedicated apikey management test keys retain explicit RBAC bindings
-- after permission repopulation (seed_key_roles insert can be skipped silently).
DELETE FROM public.role_bindings rb
USING public.apikeys ak
WHERE rb.principal_type = public.rbac_principal_apikey()
AND rb.principal_id = ak.rbac_id
AND rb.scope_type = public.rbac_scope_org()
AND ak.key IN (
'c9d0e1f2-a3b4-4c5d-8e6f-7a8b9c0d1e25',
'd1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6'
);

INSERT INTO public.role_bindings (
principal_type,
principal_id,
role_id,
scope_type,
org_id,
granted_by,
reason,
is_direct
)
SELECT
public.rbac_principal_apikey(),
ak.rbac_id,
roles.id,
public.rbac_scope_org(),
'f1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a50'::uuid,
ak.user_id,
'Seeded apikey management test binding',
true
FROM public.apikeys ak
JOIN public.roles roles
ON roles.scope_type = public.rbac_scope_org()
AND roles.name = CASE ak.key
WHEN 'c9d0e1f2-a3b4-4c5d-8e6f-7a8b9c0d1e25' THEN public.rbac_role_org_super_admin()
WHEN 'd1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6' THEN public.rbac_role_apikey_manager()
END
WHERE ak.key IN (
'c9d0e1f2-a3b4-4c5d-8e6f-7a8b9c0d1e25',
'd1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6'
);

RAISE NOTICE 'RBAC permissions populated: % permissions, % role_permissions',
(SELECT COUNT(*) FROM public.permissions),
(SELECT COUNT(*) FROM public.role_permissions);
Expand Down
Loading
Loading