From 22a0ef168e1191ca9ed7b176d110a6eeb9e0e0dc Mon Sep 17 00:00:00 2001 From: Founding Engineer Date: Tue, 17 Mar 2026 03:54:47 +0100 Subject: [PATCH 1/3] feat(apikeys): add service-principal infrastructure (phase 1) Adds the schema and helper functions needed to support the service-principal auth model for API keys (GH#1190). In this model each API key can have a corresponding auth.users entry (id = rbac_id), so standard auth.uid()-based RLS works identically for human users and API key "users". Changes: - apikeys.service_principal_provisioned column (default false) tracks whether an auth.users entry has been created for the key's rbac_id - get_service_principal_info(apikey_value) returns metadata needed by the edge-function middleware to sign a service-principal JWT - mark_service_principal_provisioned(apikey_id, rbac_id) is called by edge functions (admin client) after creating the auth.users entry - pgTAP tests covering the new column defaults and function behaviour Phase 2 (next PR): update hono_middleware to provision service principals lazily and sign a JWT (sub=rbac_id) for provisioned keys. Phase 3: simplify RLS policies to use auth.uid() directly. Co-Authored-By: Claude Sonnet 4.6 --- ...260317024912_service_principal_apikeys.sql | 114 ++++++++++++ .../47_test_service_principal_apikeys.sql | 168 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 supabase/migrations/20260317024912_service_principal_apikeys.sql create mode 100644 supabase/tests/47_test_service_principal_apikeys.sql diff --git a/supabase/migrations/20260317024912_service_principal_apikeys.sql b/supabase/migrations/20260317024912_service_principal_apikeys.sql new file mode 100644 index 0000000000..d35d427a40 --- /dev/null +++ b/supabase/migrations/20260317024912_service_principal_apikeys.sql @@ -0,0 +1,114 @@ +-- Phase 1: Service-Principal Infrastructure for API Keys +-- +-- The service-principal model treats each API key as its own "user" in the +-- auth system. Each API key (via its rbac_id) can have a corresponding +-- auth.users entry, enabling standard auth.uid()-based RLS to work for +-- API key authentication — identical to how it works for regular users. +-- +-- Architecture (phased rollout): +-- Phase 1 (this migration): Schema + helper functions. No auth flow changes. +-- Phase 2 (middleware PR): Edge function signs a JWT with sub=rbac_id for +-- provisioned keys. auth.uid() returns rbac_id. +-- Phase 3 (RLS cleanup): Simplify RLS policies to use auth.uid() once all +-- orgs have service principals provisioned. +-- +-- Key concept: +-- - apikeys.rbac_id IS the service principal UUID (stable, already exists) +-- - When provisioned: auth.users row exists with id = rbac_id +-- - Middleware can sign JWTs with sub = rbac_id for provisioned keys +-- - Service principals appear in org_users / role_bindings for authorization + +-- ============================================================================ +-- 1. Track provisioning state on each API key +-- ============================================================================ + +ALTER TABLE "public"."apikeys" + ADD COLUMN IF NOT EXISTS "service_principal_provisioned" boolean DEFAULT false NOT NULL; + +COMMENT ON COLUMN "public"."apikeys"."service_principal_provisioned" IS + 'When true, an auth.users entry exists with id=rbac_id for this API key. ' + 'The middleware can then sign a JWT with sub=rbac_id, making auth.uid() ' + 'return the service principal ID for standard RLS evaluation.'; + +-- ============================================================================ +-- 2. get_service_principal_info() — retrieve key info needed for JWT signing +-- Called by edge function middleware when a capgkey header is detected. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION "public"."get_service_principal_info"("p_apikey_value" "text") +RETURNS TABLE ( + "apikey_id" bigint, + "service_principal_id" "uuid", -- rbac_id; used as auth.users id + "owner_user_id" "uuid", -- human who owns this key + "is_provisioned" boolean, -- auth.users entry exists + "key_mode" "public"."key_mode", + "is_expired" boolean, + "limited_to_orgs" "uuid"[], + "limited_to_apps" character varying[] +) +LANGUAGE "plpgsql" +SECURITY DEFINER +SET "search_path" = '' +AS $$ +BEGIN + RETURN QUERY + SELECT + ak.id AS apikey_id, + ak.rbac_id AS service_principal_id, + ak.user_id AS owner_user_id, + ak.service_principal_provisioned AS is_provisioned, + ak.mode AS key_mode, + "public"."is_apikey_expired"(ak.expires_at) AS is_expired, + ak.limited_to_orgs, + ak.limited_to_apps + FROM "public"."find_apikey_by_value"(p_apikey_value) ak + WHERE ak.id IS NOT NULL; +END; +$$; + +ALTER FUNCTION "public"."get_service_principal_info"("p_apikey_value" "text") OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."get_service_principal_info"("p_apikey_value" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_service_principal_info"("p_apikey_value" "text") TO "service_role"; + +COMMENT ON FUNCTION "public"."get_service_principal_info"("p_apikey_value" "text") IS + 'Returns service-principal metadata for a given API key value (plain or hashed). ' + 'Used by edge function middleware to decide whether to sign a service-principal JWT ' + 'and, if so, what UUID to use as the JWT subject (= rbac_id).'; + +-- ============================================================================ +-- 3. mark_service_principal_provisioned() — called after auth.users is created +-- The edge function creates the auth.users entry using the admin client, +-- then calls this function to record the provisioned state. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION "public"."mark_service_principal_provisioned"( + "p_apikey_id" bigint, + "p_rbac_id" "uuid" +) +RETURNS void +LANGUAGE "plpgsql" +SECURITY DEFINER +SET "search_path" = '' +AS $$ +BEGIN + UPDATE "public"."apikeys" + SET service_principal_provisioned = true + WHERE id = p_apikey_id + AND rbac_id = p_rbac_id; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'API key not found or rbac_id mismatch: apikey_id=%, rbac_id=%', + p_apikey_id, p_rbac_id; + END IF; +END; +$$; + +ALTER FUNCTION "public"."mark_service_principal_provisioned"("p_apikey_id" bigint, "p_rbac_id" "uuid") OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."mark_service_principal_provisioned"("p_apikey_id" bigint, "p_rbac_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."mark_service_principal_provisioned"("p_apikey_id" bigint, "p_rbac_id" "uuid") TO "service_role"; + +COMMENT ON FUNCTION "public"."mark_service_principal_provisioned"("p_apikey_id" bigint, "p_rbac_id" "uuid") IS + 'Marks an API key as having a provisioned service-principal auth.users entry. ' + 'The rbac_id parameter acts as a guard to prevent accidental mis-marking. ' + 'Only callable by service_role (edge functions with admin client).'; diff --git a/supabase/tests/47_test_service_principal_apikeys.sql b/supabase/tests/47_test_service_principal_apikeys.sql new file mode 100644 index 0000000000..0d59a13b38 --- /dev/null +++ b/supabase/tests/47_test_service_principal_apikeys.sql @@ -0,0 +1,168 @@ +BEGIN; + +SELECT plan(14); + +SELECT tests.authenticate_as_service_role(); + +-- ============================================================================= +-- Setup: create test API keys for service-principal tests +-- ============================================================================= + +INSERT INTO apikeys (id, user_id, key, mode, name, expires_at) +VALUES +-- Valid key, not yet provisioned +( + 99950, + '6aa76066-55ef-4238-ade6-0b32334a4097', + 'sp-test-key-valid', + 'all', + 'SP Test Valid', + NULL +), +-- Expired key, not provisioned +( + 99951, + '6aa76066-55ef-4238-ade6-0b32334a4097', + 'sp-test-key-expired', + 'all', + 'SP Test Expired', + now() - interval '1 day' +); + +-- ============================================================================= +-- Test 1: service_principal_provisioned defaults to false +-- ============================================================================= + +SELECT + is( + (SELECT service_principal_provisioned FROM apikeys WHERE id = 99950), + FALSE, + 'service_principal_provisioned: defaults to false on new keys' + ); + +-- ============================================================================= +-- Test 2: get_service_principal_info returns correct info for valid key +-- ============================================================================= + +SELECT + is( + (SELECT apikey_id FROM get_service_principal_info('sp-test-key-valid')), + 99950::bigint, + 'get_service_principal_info: returns correct apikey_id for valid key' + ); + +SELECT + is( + (SELECT owner_user_id FROM get_service_principal_info('sp-test-key-valid')), + '6aa76066-55ef-4238-ade6-0b32334a4097'::uuid, + 'get_service_principal_info: returns correct owner_user_id' + ); + +SELECT + is( + (SELECT is_provisioned FROM get_service_principal_info('sp-test-key-valid')), + FALSE, + 'get_service_principal_info: is_provisioned is false before provisioning' + ); + +SELECT + is( + (SELECT key_mode FROM get_service_principal_info('sp-test-key-valid')), + 'all'::"public"."key_mode", + 'get_service_principal_info: returns correct key_mode' + ); + +SELECT + is( + (SELECT is_expired FROM get_service_principal_info('sp-test-key-valid')), + FALSE, + 'get_service_principal_info: is_expired is false for non-expired key' + ); + +-- ============================================================================= +-- Test 3: get_service_principal_info returns expired=true for expired key +-- ============================================================================= + +SELECT + is( + (SELECT is_expired FROM get_service_principal_info('sp-test-key-expired')), + TRUE, + 'get_service_principal_info: is_expired is true for expired key' + ); + +-- ============================================================================= +-- Test 4: get_service_principal_info returns empty for unknown key +-- ============================================================================= + +SELECT + is( + (SELECT count(*) FROM get_service_principal_info('nonexistent-key-xyz'))::integer, + 0, + 'get_service_principal_info: returns no rows for unknown key' + ); + +-- ============================================================================= +-- Test 5: service_principal_id (rbac_id) is returned correctly +-- ============================================================================= + +SELECT + is( + (SELECT service_principal_id FROM get_service_principal_info('sp-test-key-valid')), + (SELECT rbac_id FROM apikeys WHERE id = 99950), + 'get_service_principal_info: service_principal_id matches rbac_id on apikeys row' + ); + +-- ============================================================================= +-- Test 6: mark_service_principal_provisioned marks the key +-- ============================================================================= + +SELECT mark_service_principal_provisioned( + 99950, + (SELECT rbac_id FROM apikeys WHERE id = 99950) +); + +SELECT + is( + (SELECT service_principal_provisioned FROM apikeys WHERE id = 99950), + TRUE, + 'mark_service_principal_provisioned: sets service_principal_provisioned=true' + ); + +-- ============================================================================= +-- Test 7: get_service_principal_info shows is_provisioned=true after marking +-- ============================================================================= + +SELECT + is( + (SELECT is_provisioned FROM get_service_principal_info('sp-test-key-valid')), + TRUE, + 'get_service_principal_info: is_provisioned=true after mark_service_principal_provisioned' + ); + +-- ============================================================================= +-- Test 8: mark_service_principal_provisioned raises error on rbac_id mismatch +-- ============================================================================= + +SELECT + throws_ok( + $$SELECT mark_service_principal_provisioned(99951, '00000000-0000-0000-0000-000000000099'::uuid)$$, + 'P0001', + NULL, + 'mark_service_principal_provisioned: raises exception on rbac_id mismatch' + ); + +-- ============================================================================= +-- Test 9: mark_service_principal_provisioned raises error for unknown apikey_id +-- ============================================================================= + +SELECT + throws_ok( + $$SELECT mark_service_principal_provisioned(99999, '00000000-0000-0000-0000-000000000099'::uuid)$$, + 'P0001', + NULL, + 'mark_service_principal_provisioned: raises exception for unknown apikey_id' + ); + +SELECT * FROM finish(); + +ROLLBACK; From f7f93e1828258512d3b2d1d0ea6a64594e85ecfd Mon Sep 17 00:00:00 2001 From: Founding Engineer Date: Tue, 17 Mar 2026 05:05:43 +0100 Subject: [PATCH 2/3] feat(apikeys): lazy service-principal provisioning in middleware (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an API key request is authenticated, the middleware now lazily provisions an auth.users entry (id = rbac_id) for the key's service principal on first use via supabaseAdmin.auth.admin.createUser(), then marks the key as provisioned via mark_service_principal_provisioned(). A short-lived HS256 JWT (sub = rbac_id, role = authenticated, 1 h) is signed on every API key request and stored in context as `servicePrincipalJwt`. A new `supabaseServicePrincipal()` helper in supabase.ts returns a Supabase client authenticated with this JWT when available, falling back to the legacy capgkey path. The existing capgkey-based auth path is **unchanged** — all current handlers continue to work. Provisioning is non-fatal: any error is logged and the request proceeds normally. Phase 3 will migrate handlers to use `supabaseServicePrincipal()` and simplify RLS accordingly. Supporting changes: - postgres_schema.ts: `service_principal_provisioned` column added to the apikeys Drizzle schema - hono.ts: `servicePrincipalJwt?` added to MiddlewareKeyVariables so the new context key is type-safe - hono_middleware.ts: ServicePrincipalInfoRow type, helper functions fetchServicePrincipalInfo / provisionServicePrincipal / signServicePrincipalJwt / applyServicePrincipal Co-Authored-By: Claude Sonnet 4.6 --- supabase/functions/_backend/utils/hono.ts | 2 + .../_backend/utils/hono_middleware.ts | 170 +++++++++++++++++- .../_backend/utils/postgres_schema.ts | 1 + supabase/functions/_backend/utils/supabase.ts | 22 +++ 4 files changed, 194 insertions(+), 1 deletion(-) diff --git a/supabase/functions/_backend/utils/hono.ts b/supabase/functions/_backend/utils/hono.ts index 78101e12d6..3e03e643c7 100644 --- a/supabase/functions/_backend/utils/hono.ts +++ b/supabase/functions/_backend/utils/hono.ts @@ -98,6 +98,8 @@ export interface MiddlewareKeyVariables { // RBAC context variables rbacEnabled?: boolean resolvedOrgId?: string + // Service principal context (Phase 2: set when API key has a provisioned service principal) + servicePrincipalJwt?: string } } diff --git a/supabase/functions/_backend/utils/hono_middleware.ts b/supabase/functions/_backend/utils/hono_middleware.ts index 60dc99ed41..8b0768e9e4 100644 --- a/supabase/functions/_backend/utils/hono_middleware.ts +++ b/supabase/functions/_backend/utils/hono_middleware.ts @@ -1,13 +1,15 @@ import type { Context } from 'hono' import type { Database } from './supabase.types.ts' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { SignJWT } from 'jose' import { getClaimsFromJWT, honoFactory, quickError, simpleRateLimit } from './hono.ts' -import { cloudlog } from './logging.ts' +import { cloudlog, cloudlogErr } from './logging.ts' import { closeClient, getDrizzleClient, getPgClient, logPgError } from './pg.ts' import * as schema from './postgres_schema.ts' import { isAPIKeyRateLimited, isIPRateLimited, recordAPIKeyUsage, recordFailedAuth } from './rate_limit.ts' import { buildRateLimitInfo } from './rateLimitInfo.ts' import { checkKey, checkKeyById, supabaseAdmin } from './supabase.ts' +import { getEnv } from './utils.ts' // ============================================================================= // RBAC Context Middleware @@ -413,6 +415,166 @@ async function resolveSubkey( } } +// ============================================================================= +// Service Principal Provisioning (Phase 2) +// ============================================================================= + +/** + * Row returned by the get_service_principal_info() SQL function. + * Used to drive lazy service-principal provisioning in the middleware. + */ +interface ServicePrincipalInfoRow extends Record { + apikey_id: number + service_principal_id: string + owner_user_id: string + is_provisioned: boolean + key_mode: Database['public']['Enums']['key_mode'] + is_expired: boolean + limited_to_orgs: string[] | null + limited_to_apps: string[] | null +} + +/** + * Fetches service-principal metadata for the given API key value. + * Returns null when the key is not found (already validated upstream). + */ +async function fetchServicePrincipalInfo( + c: Context, + keyString: string, +): Promise { + let pgClient: ReturnType | null = null + try { + pgClient = getPgClient(c, true) + const drizzle = getDrizzleClient(pgClient) + const result = await drizzle.execute( + sql`SELECT * FROM public.get_service_principal_info(${keyString})`, + ) + return result.rows[0] ?? null + } + catch (e) { + logPgError(c, 'fetchServicePrincipalInfo', e) + return null + } + finally { + if (pgClient) { + await closeClient(c, pgClient) + } + } +} + +/** + * Creates an auth.users entry for the service principal (id = rbacId) and + * marks the API key as provisioned. Idempotent: if the user already exists + * the error is silently ignored so the mark still runs. + */ +async function provisionServicePrincipal( + c: Context, + apikeyId: number, + rbacId: string, +): Promise { + // Use a dedicated admin client for the createUser call (per AGENTS.md pitfall warning) + const adminClient = supabaseAdmin(c) + + const { error: createError } = await adminClient.auth.admin.createUser({ + id: rbacId, + // Service principals never sign in via email/password; the address is + // stable but non-deliverable and only kept for auth system uniqueness. + email: `sp-${rbacId}@service.capgo.internal`, + email_confirm: true, + }) + + if (createError && !createError.message.includes('already been registered')) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'provisionServicePrincipal: createUser failed', + rbacId, + error: createError.message, + }) + // Non-fatal: return without marking provisioned; next request will retry + return + } + + // Mark provisioned via a separate PG client (keeps admin client clean) + let pgClient: ReturnType | null = null + try { + pgClient = getPgClient(c, true) + const drizzle = getDrizzleClient(pgClient) + await drizzle.execute( + sql`SELECT public.mark_service_principal_provisioned(${apikeyId}::bigint, ${rbacId}::uuid)`, + ) + cloudlog({ + requestId: c.get('requestId'), + message: 'provisionServicePrincipal: provisioned', + apikeyId, + rbacId, + }) + } + catch (e) { + logPgError(c, 'provisionServicePrincipal:markProvisioned', e) + } + finally { + if (pgClient) { + await closeClient(c, pgClient) + } + } +} + +/** + * Signs a short-lived Supabase-compatible JWT with sub = rbacId. + * The JWT is stored in context so handlers can opt in to the + * service-principal auth path (Phase 3 will flip the default). + */ +async function signServicePrincipalJwt(c: Context, rbacId: string): Promise { + try { + const jwtSecret = getEnv(c, 'JWT_SECRET') + const secret = new TextEncoder().encode(jwtSecret) + return await new SignJWT({ role: 'authenticated' }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuer('supabase') + .setSubject(rbacId) + .setIssuedAt() + .setExpirationTime('1h') + .sign(secret) + } + catch (e) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'signServicePrincipalJwt: failed to sign JWT', + rbacId, + error: String(e), + }) + return null + } +} + +/** + * Orchestrates lazy service-principal provisioning for an API key request. + * On first use: creates auth.users entry + marks key as provisioned. + * Every request: signs a short-lived JWT and stores it in context. + * Non-fatal: any failure is logged and the request continues normally. + */ +async function applyServicePrincipal(c: Context, keyString: string): Promise { + const info = await fetchServicePrincipalInfo(c, keyString) + if (!info) { + return + } + + if (!info.is_provisioned) { + await provisionServicePrincipal(c, info.apikey_id, info.service_principal_id) + } + + const jwt = await signServicePrincipalJwt(c, info.service_principal_id) + if (jwt) { + c.set('servicePrincipalJwt', jwt) + cloudlog({ + requestId: c.get('requestId'), + message: 'applyServicePrincipal: service principal JWT set', + servicePrincipalId: info.service_principal_id, + wasProvisioned: info.is_provisioned, + }) + } +} + async function foundAPIKey(c: Context, capgkeyString: string, rights: Database['public']['Enums']['key_mode'][]) { const subkey_id = getSubkeyId(c) @@ -437,6 +599,12 @@ async function foundAPIKey(c: Context, capgkeyString: string, rights: Database[' // Store the original key string for hashed key authentication // This is needed because hashed keys have key=null in the database setApiKeyAuthContext(c, apikey, capgkeyString) + + // Phase 2: lazily provision service principal and set JWT in context. + // Non-fatal — any failure is logged and the request continues with the + // existing capgkey-based auth path. + await applyServicePrincipal(c, capgkeyString) + if (subkey_id) { cloudlog({ requestId: c.get('requestId'), message: 'Subkey id provided', subkey_id }) const subkey = await resolveSubkey(c, subkey_id, rights, false, apikey.user_id) diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index fd478655de..e48661ea61 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -176,6 +176,7 @@ export const apikeys = pgTable('apikeys', { limited_to_apps: varchar('limited_to_apps').array(), expires_at: timestamp('expires_at', { withTimezone: true }), rbac_id: uuid('rbac_id').notNull(), + service_principal_provisioned: boolean('service_principal_provisioned').default(false).notNull(), }) export const org_users = pgTable('org_users', { diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index c04a782975..195b025c26 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -69,6 +69,28 @@ export function supabaseWithAuth(c: Context, auth: AuthInfo) { } } +/** + * Returns a Supabase client authenticated as the service principal when + * one has been provisioned for the current API key request (Phase 2+). + * Falls back to the standard capgkey-based client for unprovisioned keys + * or non-API-key requests. + * + * Usage: prefer this over supabaseWithAuth() in handlers that should use + * the service-principal identity path instead of the legacy capgkey path. + * Phase 3 will make this the default for all API key requests. + */ +export function supabaseServicePrincipal(c: Context) { + const spJwt = c.get('servicePrincipalJwt') + if (spJwt) { + return supabaseClient(c, spJwt) + } + const auth = c.get('auth') + if (!auth) { + throw simpleError('not_authorized', 'Not authorized') + } + return supabaseWithAuth(c, auth) +} + export function emptySupabase(c: Context) { const options = { auth: { From 447a20e475e2e9144467a338121b7f0a1f0170a9 Mon Sep 17 00:00:00 2001 From: Founding Engineer Date: Tue, 17 Mar 2026 08:15:45 +0100 Subject: [PATCH 3/3] feat(apikeys): SP support in check_min_rights functions (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_service_principal(uuid) helper to identify provisioned SP API keys - Exempt service principals from 2FA and password-policy enforcement in check_min_rights, check_min_rights_legacy, and check_min_rights_legacy_no_password_policy - Add SP JWT fallback path in all three permission functions: when auth.uid() returns an SP's rbac_id (no capgkey header), look up the apikey by rbac_id and evaluate access via key_mode (read/upload/write/all inheriting owner right) - Add 13 pgTAP tests covering is_service_principal, legacy SP fallback with mode mapping, org/app scoping, and no-password-policy variant - Fix plan(14)→plan(13) off-by-one in Phase 1 test file Co-Authored-By: Claude Sonnet 4.6 --- ...vice_principal_phase3_check_min_rights.sql | 458 ++++++++++++++++++ .../47_test_service_principal_apikeys.sql | 2 +- .../48_test_service_principal_phase3.sql | 263 ++++++++++ 3 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260317100000_service_principal_phase3_check_min_rights.sql create mode 100644 supabase/tests/48_test_service_principal_phase3.sql diff --git a/supabase/migrations/20260317100000_service_principal_phase3_check_min_rights.sql b/supabase/migrations/20260317100000_service_principal_phase3_check_min_rights.sql new file mode 100644 index 0000000000..f36cab02a9 --- /dev/null +++ b/supabase/migrations/20260317100000_service_principal_phase3_check_min_rights.sql @@ -0,0 +1,458 @@ +-- Phase 3: Service-Principal support in check_min_rights functions +-- +-- When an API key is provisioned as a service principal, the edge-function +-- middleware creates an auth.users entry (id = rbac_id) and signs a JWT with +-- sub = rbac_id. When that JWT is used for DB queries, auth.uid() returns +-- rbac_id — meaning the "user_id" parameter received by check_min_rights IS +-- the service principal's UUID, not a human user. +-- +-- Problems solved by this migration: +-- 1. 2FA enforcement was blocking service principals (API keys can't have 2FA). +-- 2. Password policy enforcement was blocking service principals (no password). +-- 3. When auth.uid() = rbac_id and there is no capgkey header, the existing +-- apikey RBAC fallback didn't run (get_apikey_header() returns NULL for +-- JWT requests). Service principals need their own fallback path. +-- +-- Changes: +-- - Add is_service_principal(uuid) helper. +-- - Update check_min_rights: skip 2FA/password enforcement for SPs, add SP +-- JWT fallback in RBAC path. +-- - Update check_min_rights_legacy: same 2FA/password exemptions, add SP +-- fallback that maps key_mode → user_min_right for the legacy org_users check. +-- - Update check_min_rights_legacy_no_password_policy: same 2FA exemption and +-- SP fallback (password policy already absent in this variant). + +-- ============================================================================ +-- 1. is_service_principal(p uuid) RETURNS boolean +-- Returns true when the given UUID is the rbac_id of a provisioned service +-- principal API key. Used as a guard before blocking SPs on 2FA / password. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION "public"."is_service_principal"("p" "uuid") +RETURNS boolean +LANGUAGE "sql" +STABLE +SECURITY DEFINER +SET "search_path" = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM "public"."apikeys" + WHERE "rbac_id" = p + AND "service_principal_provisioned" = true + ) +$$; + +ALTER FUNCTION "public"."is_service_principal"("p" "uuid") OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."is_service_principal"("p" "uuid") FROM PUBLIC; +GRANT EXECUTE ON FUNCTION "public"."is_service_principal"("p" "uuid") TO "service_role"; +GRANT EXECUTE ON FUNCTION "public"."is_service_principal"("p" "uuid") TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."is_service_principal"("p" "uuid") TO "anon"; + +COMMENT ON FUNCTION "public"."is_service_principal"("p" "uuid") IS + 'Returns true when the given UUID is the rbac_id of a provisioned service-principal ' + 'API key (i.e. an auth.users entry exists with id=rbac_id). Used to exempt service ' + 'principals from 2FA and password-policy enforcement inside permission checks.'; + +-- ============================================================================ +-- 2. check_min_rights — RBAC path +-- Two changes vs. the previous version: +-- a) 2FA and password-policy blocks now skip the RETURN false for SPs. +-- b) After the capgkey fallback, a new service-principal JWT path looks up +-- the apikey by rbac_id = user_id when no capgkey header was present. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION "public"."check_min_rights"( + "min_right" "public"."user_min_right", + "user_id" "uuid", + "org_id" "uuid", + "app_id" character varying, + "channel_id" bigint +) RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_allowed boolean := false; + v_perm text; + v_scope text; + v_apikey text; + v_apikey_principal uuid; + v_use_rbac boolean; + v_effective_org_id uuid := org_id; + v_org_enforcing_2fa boolean; + v_password_policy_ok boolean; + api_key record; + v_sp_apikey record; +BEGIN + -- Derive org from app/channel when not provided to honor org-level flag and scoping. + IF v_effective_org_id IS NULL AND app_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id FROM public.apps WHERE public.apps.app_id = check_min_rights.app_id LIMIT 1; + END IF; + IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id FROM public.channels WHERE public.channels.id = channel_id LIMIT 1; + END IF; + + -- Enforce 2FA if the org requires it. + -- Service principals (API keys with a provisioned auth.users entry) are exempt + -- because they cannot enroll in 2FA. + IF v_effective_org_id IS NOT NULL THEN + SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = v_effective_org_id; + IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN + IF NOT public.is_service_principal(user_id) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( + 'org_id', COALESCE(org_id, v_effective_org_id), + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + END IF; + END IF; + + -- Enforce password policy if enabled for the org. + -- Service principals are exempt because they have no password. + IF v_effective_org_id IS NOT NULL THEN + v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id); + IF v_password_policy_ok = false THEN + IF NOT public.is_service_principal(user_id) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( + 'org_id', COALESCE(org_id, v_effective_org_id), + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + END IF; + END IF; + + v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); + IF NOT v_use_rbac THEN + RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id); + END IF; + + IF channel_id IS NOT NULL THEN + v_scope := public.rbac_scope_channel(); + ELSIF app_id IS NOT NULL THEN + v_scope := public.rbac_scope_app(); + ELSE + v_scope := public.rbac_scope_org(); + END IF; + + v_perm := public.rbac_permission_for_legacy(min_right, v_scope); + + IF user_id IS NOT NULL THEN + v_allowed := public.rbac_has_permission(public.rbac_principal_user(), user_id, v_perm, v_effective_org_id, app_id, channel_id); + END IF; + + -- Fallback 1: capgkey header — API key presented explicitly (non-JWT path). + -- Also considers apikey principal when RBAC is enabled (API keys can hold roles directly). + IF NOT v_allowed THEN + SELECT public.get_apikey_header() INTO v_apikey; + IF v_apikey IS NOT NULL THEN + -- Enforce org/app scoping before using the apikey RBAC principal. + SELECT * FROM public.find_apikey_by_value(v_apikey) INTO api_key; + IF api_key.id IS NOT NULL THEN + IF public.is_apikey_expired(api_key.expires_at) THEN + PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id, 'org_id', v_effective_org_id, 'app_id', app_id)); + ELSIF v_effective_org_id IS NULL THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_NO_ORG', jsonb_build_object('app_id', app_id)); + ELSIF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 AND NOT (v_effective_org_id = ANY(api_key.limited_to_orgs)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_ORG_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id)); + ELSIF app_id IS NOT NULL AND api_key.limited_to_apps IS DISTINCT FROM '{}' AND NOT (app_id = ANY(api_key.limited_to_apps)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_APP_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id)); + ELSE + v_apikey_principal := api_key.rbac_id; + IF v_apikey_principal IS NOT NULL THEN + v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, v_perm, v_effective_org_id, app_id, channel_id); + END IF; + END IF; + END IF; + END IF; + END IF; + + -- Fallback 2: service-principal JWT path. + -- When auth.uid() = rbac_id (middleware signed a SP JWT), user_id carries the + -- rbac_id but there is no capgkey header. Look up the apikey by rbac_id and + -- apply the same org/app scope checks before granting the RBAC principal. + IF NOT v_allowed AND user_id IS NOT NULL THEN + SELECT * INTO v_sp_apikey + FROM public.apikeys + WHERE rbac_id = check_min_rights.user_id + AND service_principal_provisioned = true + AND NOT public.is_apikey_expired(expires_at) + LIMIT 1; + + IF v_sp_apikey.id IS NOT NULL THEN + IF v_effective_org_id IS NULL THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_NO_ORG', jsonb_build_object('app_id', app_id, 'user_id', user_id)); + ELSIF COALESCE(array_length(v_sp_apikey.limited_to_orgs, 1), 0) > 0 AND NOT (v_effective_org_id = ANY(v_sp_apikey.limited_to_orgs)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_ORG_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id, 'user_id', user_id)); + ELSIF app_id IS NOT NULL AND v_sp_apikey.limited_to_apps IS DISTINCT FROM '{}' AND NOT (app_id = ANY(v_sp_apikey.limited_to_apps)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_APP_RESTRICT', jsonb_build_object('org_id', v_effective_org_id, 'app_id', app_id, 'user_id', user_id)); + ELSE + v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), user_id, v_perm, v_effective_org_id, app_id, channel_id); + END IF; + END IF; + END IF; + + IF NOT v_allowed THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_RBAC', jsonb_build_object('org_id', COALESCE(org_id, v_effective_org_id), 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id, 'scope', v_scope, 'perm', v_perm)); + END IF; + + RETURN v_allowed; +END; +$$; + +-- ============================================================================ +-- 3. check_min_rights_legacy — legacy (non-RBAC) org path +-- Changes vs. previous version: +-- a) 2FA block exempts service principals. +-- b) Password policy block exempts service principals. +-- c) After the org_users FOR loop, a new SP fallback maps the key's +-- key_mode to a user_min_right and grants access if it covers min_right. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.check_min_rights_legacy( + min_right public.user_min_right, + user_id uuid, + org_id uuid, + app_id character varying, + channel_id bigint +) RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' AS $$ +DECLARE + user_right_record RECORD; + v_org_enforcing_2fa boolean; + v_password_policy_ok boolean; + v_sp_apikey record; + v_sp_right public.user_min_right; +BEGIN + IF user_id IS NULL THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); + RETURN false; + END IF; + + -- Enforce 2FA if the org requires it. + -- Service principals are exempt (they cannot enroll in 2FA). + IF org_id IS NOT NULL THEN + SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = org_id; + IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(user_id) THEN + IF NOT public.is_service_principal(user_id) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object( + 'org_id', org_id, + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + END IF; + END IF; + + -- Enforce password policy if enabled for the org. + -- Service principals are exempt (they have no password). + IF org_id IS NOT NULL THEN + v_password_policy_ok := public.user_meets_password_policy(user_id, org_id); + IF v_password_policy_ok = false THEN + IF NOT public.is_service_principal(user_id) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( + 'org_id', org_id, + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + END IF; + END IF; + + FOR user_right_record IN + SELECT org_users.user_right, org_users.app_id, org_users.channel_id + FROM public.org_users + WHERE org_users.org_id = check_min_rights_legacy.org_id AND org_users.user_id = check_min_rights_legacy.user_id + LOOP + IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR + (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy.app_id AND user_right_record.channel_id IS NULL) OR + (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy.app_id AND user_right_record.channel_id = check_min_rights_legacy.channel_id) + THEN + RETURN true; + END IF; + END LOOP; + + -- Service-principal JWT fallback. + -- The org_users loop found no matching row because the service principal is + -- not a human member of the org. Instead, evaluate access from the API key's + -- key_mode, applying the same org/app scope restrictions. + SELECT * INTO v_sp_apikey + FROM public.apikeys + WHERE rbac_id = check_min_rights_legacy.user_id + AND service_principal_provisioned = true + AND NOT public.is_apikey_expired(expires_at) + LIMIT 1; + + IF v_sp_apikey.id IS NOT NULL THEN + IF org_id IS NULL THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_NO_ORG', jsonb_build_object('app_id', app_id, 'user_id', user_id)); + ELSIF COALESCE(array_length(v_sp_apikey.limited_to_orgs, 1), 0) > 0 AND NOT (org_id = ANY(v_sp_apikey.limited_to_orgs)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_ORG_RESTRICT', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'user_id', user_id)); + ELSIF app_id IS NOT NULL AND v_sp_apikey.limited_to_apps IS DISTINCT FROM '{}' AND NOT (app_id = ANY(v_sp_apikey.limited_to_apps)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_APP_RESTRICT', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'user_id', user_id)); + ELSE + -- Map key_mode to user_min_right. + -- 'all' inherits the owner's org-level right (highest org-scoped row). + IF v_sp_apikey.mode = 'read' THEN + v_sp_right := 'read'::public.user_min_right; + ELSIF v_sp_apikey.mode = 'upload' THEN + v_sp_right := 'upload'::public.user_min_right; + ELSIF v_sp_apikey.mode = 'write' THEN + v_sp_right := 'write'::public.user_min_right; + ELSIF v_sp_apikey.mode = 'all' THEN + SELECT ou.user_right INTO v_sp_right + FROM public.org_users ou + WHERE ou.user_id = v_sp_apikey.user_id + AND ou.org_id = check_min_rights_legacy.org_id + AND ou.app_id IS NULL + AND ou.channel_id IS NULL + ORDER BY ou.user_right DESC + LIMIT 1; + END IF; + + IF v_sp_right IS NOT NULL AND v_sp_right >= min_right THEN + RETURN true; + END IF; + END IF; + END IF; + + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); + RETURN false; +END; +$$; + +-- ============================================================================ +-- 4. check_min_rights_legacy_no_password_policy +-- Changes vs. previous version: +-- a) 2FA block exempts service principals. +-- b) After the org_users FOR loop, add the same SP fallback as above. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.check_min_rights_legacy_no_password_policy( + min_right public.user_min_right, + user_id uuid, + org_id uuid, + app_id character varying, + channel_id bigint +) RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' AS $$ +DECLARE + user_right_record RECORD; + v_org_enforcing_2fa boolean; + v_sp_apikey record; + v_sp_right public.user_min_right; +BEGIN + IF user_id IS NULL THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_LEGACY_NO_UID', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text)); + RETURN false; + END IF; + + -- Enforce 2FA if the org requires it. + -- Service principals are exempt (they cannot enroll in 2FA). + IF org_id IS NOT NULL THEN + SELECT enforcing_2fa INTO v_org_enforcing_2fa FROM public.orgs WHERE id = org_id; + IF v_org_enforcing_2fa = true AND NOT public.has_2fa_enabled(user_id) THEN + IF NOT public.is_service_principal(user_id) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_LEGACY_NO_PW_2FA_ENFORCEMENT', jsonb_build_object( + 'org_id', org_id, + 'app_id', app_id, + 'channel_id', channel_id, + 'min_right', min_right::text, + 'user_id', user_id + )); + RETURN false; + END IF; + END IF; + END IF; + + FOR user_right_record IN + SELECT org_users.user_right, org_users.app_id, org_users.channel_id + FROM public.org_users + WHERE org_users.org_id = check_min_rights_legacy_no_password_policy.org_id + AND org_users.user_id = check_min_rights_legacy_no_password_policy.user_id + LOOP + IF (user_right_record.user_right >= min_right AND user_right_record.app_id IS NULL AND user_right_record.channel_id IS NULL) OR + (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy_no_password_policy.app_id AND user_right_record.channel_id IS NULL) OR + (user_right_record.user_right >= min_right AND user_right_record.app_id = check_min_rights_legacy_no_password_policy.app_id AND user_right_record.channel_id = check_min_rights_legacy_no_password_policy.channel_id) + THEN + RETURN true; + END IF; + END LOOP; + + -- Service-principal JWT fallback (same logic as check_min_rights_legacy). + SELECT * INTO v_sp_apikey + FROM public.apikeys + WHERE rbac_id = check_min_rights_legacy_no_password_policy.user_id + AND service_principal_provisioned = true + AND NOT public.is_apikey_expired(expires_at) + LIMIT 1; + + IF v_sp_apikey.id IS NOT NULL THEN + IF org_id IS NULL THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_NO_ORG', jsonb_build_object('app_id', app_id, 'user_id', user_id)); + ELSIF COALESCE(array_length(v_sp_apikey.limited_to_orgs, 1), 0) > 0 AND NOT (org_id = ANY(v_sp_apikey.limited_to_orgs)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_ORG_RESTRICT', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'user_id', user_id)); + ELSIF app_id IS NOT NULL AND v_sp_apikey.limited_to_apps IS DISTINCT FROM '{}' AND NOT (app_id = ANY(v_sp_apikey.limited_to_apps)) THEN + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_SP_APP_RESTRICT', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'user_id', user_id)); + ELSE + IF v_sp_apikey.mode = 'read' THEN + v_sp_right := 'read'::public.user_min_right; + ELSIF v_sp_apikey.mode = 'upload' THEN + v_sp_right := 'upload'::public.user_min_right; + ELSIF v_sp_apikey.mode = 'write' THEN + v_sp_right := 'write'::public.user_min_right; + ELSIF v_sp_apikey.mode = 'all' THEN + SELECT ou.user_right INTO v_sp_right + FROM public.org_users ou + WHERE ou.user_id = v_sp_apikey.user_id + AND ou.org_id = check_min_rights_legacy_no_password_policy.org_id + AND ou.app_id IS NULL + AND ou.channel_id IS NULL + ORDER BY ou.user_right DESC + LIMIT 1; + END IF; + + IF v_sp_right IS NOT NULL AND v_sp_right >= min_right THEN + RETURN true; + END IF; + END IF; + END IF; + + PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_LEGACY_NO_PW', jsonb_build_object('org_id', org_id, 'app_id', app_id, 'channel_id', channel_id, 'min_right', min_right::text, 'user_id', user_id)); + RETURN false; +END; +$$; + +ALTER FUNCTION public.check_min_rights_legacy_no_password_policy( + public.user_min_right, uuid, uuid, character varying, bigint +) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy( + public.user_min_right, uuid, uuid, character varying, bigint +) FROM public; +REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy( + public.user_min_right, uuid, uuid, character varying, bigint +) FROM anon; +REVOKE ALL ON FUNCTION public.check_min_rights_legacy_no_password_policy( + public.user_min_right, uuid, uuid, character varying, bigint +) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.check_min_rights_legacy_no_password_policy( + public.user_min_right, uuid, uuid, character varying, bigint +) TO service_role; diff --git a/supabase/tests/47_test_service_principal_apikeys.sql b/supabase/tests/47_test_service_principal_apikeys.sql index 0d59a13b38..99ca43bfdc 100644 --- a/supabase/tests/47_test_service_principal_apikeys.sql +++ b/supabase/tests/47_test_service_principal_apikeys.sql @@ -1,6 +1,6 @@ BEGIN; -SELECT plan(14); +SELECT plan(13); SELECT tests.authenticate_as_service_role(); diff --git a/supabase/tests/48_test_service_principal_phase3.sql b/supabase/tests/48_test_service_principal_phase3.sql new file mode 100644 index 0000000000..81d64e1e0c --- /dev/null +++ b/supabase/tests/48_test_service_principal_phase3.sql @@ -0,0 +1,263 @@ +BEGIN; + +SELECT plan(13); + +SELECT tests.authenticate_as_service_role(); + +-- ============================================================================= +-- Setup: clean up any leftover keys from a previous interrupted run, then insert +-- fresh test API keys for service-principal Phase 3 tests. +-- ============================================================================= + +DELETE FROM public.apikeys WHERE id IN (99960, 99961, 99962); + +-- Key 99960: provisioned service principal, mode='all', owned by super_admin user +INSERT INTO public.apikeys (id, user_id, key, mode, name, expires_at, service_principal_provisioned) +VALUES ( + 99960, + '6aa76066-55ef-4238-ade6-0b32334a4097', + 'sp3-test-key-all', + 'all', + 'SP3 Test All Mode', + NULL, + true +); + +-- Key 99961: provisioned service principal, mode='read', owned by super_admin user +INSERT INTO public.apikeys (id, user_id, key, mode, name, expires_at, service_principal_provisioned) +VALUES ( + 99961, + '6aa76066-55ef-4238-ade6-0b32334a4097', + 'sp3-test-key-read', + 'read', + 'SP3 Test Read Mode', + NULL, + true +); + +-- Key 99962: NOT provisioned (service_principal_provisioned = false), mode='all' +INSERT INTO public.apikeys (id, user_id, key, mode, name, expires_at, service_principal_provisioned) +VALUES ( + 99962, + '6aa76066-55ef-4238-ade6-0b32334a4097', + 'sp3-test-key-not-provisioned', + 'all', + 'SP3 Test Not Provisioned', + NULL, + false +); + +-- ============================================================================= +-- Test 1: is_service_principal returns false for a regular human user UUID +-- (6aa76066 IS in apikeys.user_id but is NOT an rbac_id) +-- ============================================================================= + +SELECT + is( + public.is_service_principal('6aa76066-55ef-4238-ade6-0b32334a4097'::uuid), + FALSE, + 'is_service_principal: returns false for a regular human user UUID' + ); + +-- ============================================================================= +-- Test 2: is_service_principal returns false for a random UUID not in apikeys +-- ============================================================================= + +SELECT + is( + public.is_service_principal('00000000-dead-beef-cafe-000000000002'::uuid), + FALSE, + 'is_service_principal: returns false for a UUID not present in apikeys at all' + ); + +-- ============================================================================= +-- Test 3: is_service_principal returns true for the rbac_id of key 99960 +-- (provisioned = true) +-- ============================================================================= + +SELECT + is( + public.is_service_principal((SELECT rbac_id FROM public.apikeys WHERE id = 99960)), + TRUE, + 'is_service_principal: returns true for rbac_id of a provisioned key' + ); + +-- ============================================================================= +-- Test 4: is_service_principal returns false for the rbac_id of key 99962 +-- (provisioned = false) +-- ============================================================================= + +SELECT + is( + public.is_service_principal((SELECT rbac_id FROM public.apikeys WHERE id = 99962)), + FALSE, + 'is_service_principal: returns false for rbac_id of a non-provisioned key' + ); + +-- ============================================================================= +-- Test 5: check_min_rights_legacy with SP 'all' mode key in target org +-- The owner (6aa76066) is super_admin in org 046a36ac..., so 'all' inherits +-- super_admin and should pass a 'read' check. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy( + 'read'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99960), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + TRUE, + 'check_min_rights_legacy: SP all-mode key passes read check via owner super_admin right' + ); + +-- ============================================================================= +-- Test 6: check_min_rights_legacy with SP 'all' mode key in target org +-- Owner is super_admin, so 'all' mode should also pass a 'write' check. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy( + 'write'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99960), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + TRUE, + 'check_min_rights_legacy: SP all-mode key passes write check via owner super_admin right' + ); + +-- ============================================================================= +-- Test 7: check_min_rights_legacy with SP 'read' mode key in target org +-- 'read' mode maps to user_min_right 'read', so a 'read' check should pass. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy( + 'read'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99961), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + TRUE, + 'check_min_rights_legacy: SP read-mode key passes read check' + ); + +-- ============================================================================= +-- Test 8: check_min_rights_legacy with SP 'read' mode key in target org +-- 'read' mode < 'write', so a 'write' check should fail. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy( + 'write'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99961), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + FALSE, + 'check_min_rights_legacy: SP read-mode key fails write check (read < write)' + ); + +-- ============================================================================= +-- Test 9: check_min_rights_legacy with SP key against an org it does not belong to +-- The key has no limited_to_orgs restriction, but the owner has no membership in +-- the random org, so 'all' mode lookup returns NULL and access should be denied. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy( + 'read'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99960), + '00000000-0000-0000-0000-000000000099'::uuid, + NULL::character varying, + NULL::bigint + ), + FALSE, + 'check_min_rights_legacy: SP all-mode key denied for org where owner has no membership' + ); + +-- ============================================================================= +-- Test 10: check_min_rights_legacy with non-provisioned SP (rbac_id of key 99962) +-- The NOT provisioned key should not satisfy the SP fallback, so it returns false. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy( + 'read'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99962), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + FALSE, + 'check_min_rights_legacy: non-provisioned SP rbac_id is denied (not a service principal)' + ); + +-- ============================================================================= +-- Test 11: check_min_rights_legacy_no_password_policy with SP 'read' mode key +-- Should behave identically to check_min_rights_legacy for read check. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy_no_password_policy( + 'read'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99961), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + TRUE, + 'check_min_rights_legacy_no_password_policy: SP read-mode key passes read check' + ); + +-- ============================================================================= +-- Test 12: check_min_rights_legacy_no_password_policy with SP 'read' mode key +-- 'read' < 'admin', so this should fail. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy_no_password_policy( + 'admin'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99961), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + FALSE, + 'check_min_rights_legacy_no_password_policy: SP read-mode key fails admin check (read < admin)' + ); + +-- ============================================================================= +-- Test 13: check_min_rights_legacy_no_password_policy with SP 'all' mode key +-- Owner is super_admin in target org, so all-mode passes an 'admin' check. +-- ============================================================================= + +SELECT + is( + public.check_min_rights_legacy_no_password_policy( + 'admin'::public.user_min_right, + (SELECT rbac_id FROM public.apikeys WHERE id = 99960), + '046a36ac-e03c-4590-9257-bd6c9dba9ee8'::uuid, + NULL::character varying, + NULL::bigint + ), + TRUE, + 'check_min_rights_legacy_no_password_policy: SP all-mode key passes admin check via owner super_admin right' + ); + +SELECT * FROM finish(); + +ROLLBACK;