Skip to content
Closed
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
2 changes: 2 additions & 0 deletions supabase/functions/_backend/utils/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
170 changes: 169 additions & 1 deletion supabase/functions/_backend/utils/hono_middleware.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, unknown> {
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<ServicePrincipalInfoRow | null> {
let pgClient: ReturnType<typeof getPgClient> | null = null
try {
pgClient = getPgClient(c, true)
const drizzle = getDrizzleClient(pgClient)
const result = await drizzle.execute<ServicePrincipalInfoRow>(
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<void> {
// 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<typeof getPgClient> | 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<string | null> {
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<void> {
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)

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions supabase/functions/_backend/utils/postgres_schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
22 changes: 22 additions & 0 deletions supabase/functions/_backend/utils/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
114 changes: 114 additions & 0 deletions supabase/migrations/20260317024912_service_principal_apikeys.sql
Original file line number Diff line number Diff line change
@@ -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. '

Check warning on line 113 in supabase/migrations/20260317024912_service_principal_apikeys.sql

View workflow job for this annotation

GitHub Actions / Run tests

"mis" should be "miss" or "mist".
'Only callable by service_role (edge functions with admin client).';
Comment on lines +111 to +114
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

Fix typo flagged by pipeline: "mis-marking" → "mismarking".

The pipeline flagged "mis" as a typo. The correct form is "mismarking" (one word, no hyphen).

📝 Proposed fix
 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. '
+  'The rbac_id parameter acts as a guard to prevent accidental mismarking. '
   'Only callable by service_role (edge functions with admin client).';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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).';
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 mismarking. '
'Only callable by service_role (edge functions with admin client).';
🧰 Tools
🪛 GitHub Actions: Run tests

[warning] 113-113: typos: 'mis' should be 'miss' or 'mist'.

🪛 GitHub Check: Run tests

[warning] 113-113:
"mis" should be "miss" or "mist".

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

In `@supabase/migrations/20260317024912_service_principal_apikeys.sql` around
lines 111 - 114, Update the COMMENT ON FUNCTION for
"mark_service_principal_provisioned" to replace the hyphenated word
"mis-marking" with the correct single-word form "mismarking"; edit the comment
string passed to COMMENT ON FUNCTION (the block referencing function name
mark_service_principal_provisioned and parameters p_apikey_id and p_rbac_id) so
the sentence reads "...acts as a guard to prevent accidental mismarking."
without changing other text.

Loading
Loading