From 6b4fe9d7cd8f0c26f9762c5698a200a3dee4782f Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 23 Mar 2026 12:19:27 -0400 Subject: [PATCH 1/5] Phase 4b: Invite SSO enforcement + SSO routing --- .claude/skills/setup-mocksaml/SKILL.md | 61 ++- ...24d620692f75f4bdad1bdaa607c42445d93f.json} | 12 +- ...275f088de07743cedd6b96d88c59a8e63256e.json | 23 + ...3e3c180ff3b564cf55aea9dd06c07e0324421.json | 22 + .../src/fixtures/sso_tenant.sql | 60 +++ .../src/publications/quotas.rs | 6 +- .../src/server/public/graphql/invite_links.rs | 402 ++++++++++++++++-- .../src/server/public/graphql/mod.rs | 19 +- .../public/graphql/publication_history.rs | 15 +- ...inks__test__redeem_no_sso_enforcement.snap | 12 + ...vite_links__test__redeem_sso_matching.snap | 12 + ...e_links__test__redeem_sso_no_identity.snap | 21 + ...__test__redeem_sso_sub_prefix_allowed.snap | 12 + ..._test__redeem_sso_sub_prefix_rejected.snap | 21 + ...inks__test__redeem_sso_wrong_provider.snap | 21 + crates/flow-client/control-plane-api.graphql | 6 + crates/models/src/lib.rs | 1 + crates/models/src/references.rs | 9 + 18 files changed, 673 insertions(+), 62 deletions(-) rename .sqlx/{query-5c35576f65d7ef76f367b31b2238b49d8fdb0cca095fa3bb08380a62b238c1f3.json => query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json} (62%) create mode 100644 .sqlx/query-62c24bfd77101313d6711554b2a275f088de07743cedd6b96d88c59a8e63256e.json create mode 100644 .sqlx/query-d66323852c5757f5102115ab5c73e3c180ff3b564cf55aea9dd06c07e0324421.json create mode 100644 crates/control-plane-api/src/fixtures/sso_tenant.sql create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_no_sso_enforcement.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_matching.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_no_identity.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_allowed.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_rejected.snap create mode 100644 crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_wrong_provider.snap diff --git a/.claude/skills/setup-mocksaml/SKILL.md b/.claude/skills/setup-mocksaml/SKILL.md index 971f21643a0..06c14a2b039 100644 --- a/.claude/skills/setup-mocksaml/SKILL.md +++ b/.claude/skills/setup-mocksaml/SKILL.md @@ -2,7 +2,6 @@ name: setup-mocksaml description: Set up MockSAML as a local SAML IdP for testing SSO flows (grant migration, invite enforcement). Run when setting up a fresh local env or after `supabase db reset`. disable-model-invocation: true -allowed-tools: Bash(*), Read --- # Set Up MockSAML for Local SSO Testing @@ -50,6 +49,18 @@ If `GOTRUE_SAML_ENABLED=true` is already set, skip to step 6. openssl genrsa 2048 > /tmp/saml_key.pem ``` +GoTrue requires PKCS#1 format. Check the header of the generated key: + +```bash +head -1 /tmp/saml_key.pem +``` + +- `BEGIN RSA PRIVATE KEY` → PKCS#1, good to go. +- `BEGIN PRIVATE KEY` → PKCS#8 (OpenSSL 3.x default). Convert it: + ```bash + openssl rsa -traditional -in /tmp/saml_key.pem -out /tmp/saml_key.pem + ``` + Strip to raw base64 (no PEM headers, no newlines): ```bash @@ -67,10 +78,28 @@ Capture the current container's env vars, image, and network: docker inspect supabase_auth_flow --format '{{json .NetworkSettings.Networks}}' ``` -Stop and remove the old container, then recreate with all original env vars -plus the SAML vars. **Important:** override `API_EXTERNAL_URL` to include the -`/auth/v1` prefix — GoTrue uses this to generate the SAML ACS callback URL, -and without the prefix Kong won't route the callback correctly. +Build an env file for the new container. Using `--env-file` avoids shell +parsing issues with values that contain template syntax (e.g. +`GOTRUE_SMS_TEMPLATE=Your code is {{ .Code }}`). + +```bash +grep -v -E '^(PATH=|API_EXTERNAL_URL=)' /tmp/auth_env.txt > /tmp/auth_env_filtered.txt +echo "GOTRUE_SAML_ENABLED=true" >> /tmp/auth_env_filtered.txt +echo "GOTRUE_SAML_PRIVATE_KEY=$SAML_KEY_B64" >> /tmp/auth_env_filtered.txt +echo "API_EXTERNAL_URL=http://127.0.0.1:5431/auth/v1" >> /tmp/auth_env_filtered.txt +``` + +**Important:** the `API_EXTERNAL_URL` override includes the `/auth/v1` prefix — +GoTrue uses this to generate the SAML ACS callback URL, and without the prefix +Kong won't route the callback correctly. + +If using Lima, copy the env file into the VM before running docker: + +```bash +limactl copy /tmp/auth_env_filtered.txt :/tmp/auth_env_filtered.txt +``` + +Stop and remove the old container, then recreate: ```bash docker stop supabase_auth_flow && docker rm supabase_auth_flow @@ -79,10 +108,7 @@ and without the prefix Kong won't route the callback correctly. --name supabase_auth_flow \ --network \ --restart always \ - -e GOTRUE_SAML_ENABLED=true \ - -e GOTRUE_SAML_PRIVATE_KEY=$SAML_KEY_B64 \ - -e API_EXTERNAL_URL=http://127.0.0.1:5431/auth/v1 \ - \ + --env-file /tmp/auth_env_filtered.txt \ auth ``` @@ -102,6 +128,15 @@ supabase status --output json Extract `SERVICE_ROLE_KEY` from the output. +If `supabase status` fails (e.g. Docker runs inside a Lima VM), read the key +from Kong's config instead — it's always accessible since Kong handles routing: + +```bash + docker exec supabase_kong_flow cat /home/kong/kong.yml +``` + +Look for the `service_role` JWT in the authorization header rewriting rules. + ### 7. Check if MockSAML is already registered ```bash @@ -114,6 +149,12 @@ register a new one. If reusing, skip to step 9. ### 8. Register MockSAML as an SSO provider +Ask the user which email domain to associate with the SSO provider (default: +`example.com`). This controls which email addresses are routed through SAML +login. MockSAML's default test user is `jackson@example.com`, so `example.com` +works out of the box — but the user may want a different domain to match their +test data. + ```bash curl -X POST 'http://127.0.0.1:5431/auth/v1/admin/sso/providers' \ -H 'Authorization: Bearer ' \ @@ -121,7 +162,7 @@ curl -X POST 'http://127.0.0.1:5431/auth/v1/admin/sso/providers' \ -d '{ "type": "saml", "metadata_url": "https://mocksaml.com/api/saml/metadata", - "domains": ["example.com"] + "domains": [""] }' ``` diff --git a/.sqlx/query-5c35576f65d7ef76f367b31b2238b49d8fdb0cca095fa3bb08380a62b238c1f3.json b/.sqlx/query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json similarity index 62% rename from .sqlx/query-5c35576f65d7ef76f367b31b2238b49d8fdb0cca095fa3bb08380a62b238c1f3.json rename to .sqlx/query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json index e2b620540b6..421838e53fb 100644 --- a/.sqlx/query-5c35576f65d7ef76f367b31b2238b49d8fdb0cca095fa3bb08380a62b238c1f3.json +++ b/.sqlx/query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n token,\n catalog_prefix AS \"catalog_prefix!: String\",\n capability AS \"capability!: models::Capability\",\n single_use AS \"single_use!: bool\",\n detail,\n created_at AS \"created_at!: chrono::DateTime\"\n FROM internal.invite_links\n WHERE catalog_prefix::text ^@ ANY($1)\n AND ($5::text IS NULL OR catalog_prefix::text ^@ $5)\n AND ($4::bool IS NULL OR single_use = $4)\n AND ($2::uuid IS NULL OR token > $2)\n ORDER BY token\n LIMIT $3 + 1\n ", + "query": "\n SELECT\n il.token,\n il.catalog_prefix AS \"catalog_prefix!: String\",\n il.capability AS \"capability!: models::Capability\",\n il.single_use AS \"single_use!: bool\",\n il.detail,\n il.created_at AS \"created_at!: chrono::DateTime\",\n t.sso_provider_id\n FROM internal.invite_links il\n LEFT JOIN tenants t ON il.catalog_prefix::text ^@ t.tenant\n WHERE il.catalog_prefix::text ^@ ANY($1)\n AND ($5::text IS NULL OR il.catalog_prefix::text ^@ $5)\n AND ($4::bool IS NULL OR il.single_use = $4)\n AND ($2::uuid IS NULL OR il.token > $2)\n ORDER BY il.token\n LIMIT $3 + 1\n ", "describe": { "columns": [ { @@ -71,6 +71,11 @@ "ordinal": 5, "name": "created_at!: chrono::DateTime", "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "sso_provider_id", + "type_info": "Uuid" } ], "parameters": { @@ -88,8 +93,9 @@ false, false, true, - false + false, + true ] }, - "hash": "5c35576f65d7ef76f367b31b2238b49d8fdb0cca095fa3bb08380a62b238c1f3" + "hash": "4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f" } diff --git a/.sqlx/query-62c24bfd77101313d6711554b2a275f088de07743cedd6b96d88c59a8e63256e.json b/.sqlx/query-62c24bfd77101313d6711554b2a275f088de07743cedd6b96d88c59a8e63256e.json new file mode 100644 index 00000000000..46661eed687 --- /dev/null +++ b/.sqlx/query-62c24bfd77101313d6711554b2a275f088de07743cedd6b96d88c59a8e63256e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT true AS \"exists!\"\n FROM tenants t\n WHERE t.tenant = $1\n AND t.sso_provider_id IS NOT NULL\n AND NOT EXISTS (\n SELECT 1 FROM auth.identities ai\n WHERE ai.user_id = $2\n AND ai.provider = 'sso:' || t.sso_provider_id::text\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "62c24bfd77101313d6711554b2a275f088de07743cedd6b96d88c59a8e63256e" +} diff --git a/.sqlx/query-d66323852c5757f5102115ab5c73e3c180ff3b564cf55aea9dd06c07e0324421.json b/.sqlx/query-d66323852c5757f5102115ab5c73e3c180ff3b564cf55aea9dd06c07e0324421.json new file mode 100644 index 00000000000..1b8d7dd81e8 --- /dev/null +++ b/.sqlx/query-d66323852c5757f5102115ab5c73e3c180ff3b564cf55aea9dd06c07e0324421.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.sso_provider_id\n FROM tenants t\n WHERE t.tenant = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sso_provider_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "d66323852c5757f5102115ab5c73e3c180ff3b564cf55aea9dd06c07e0324421" +} diff --git a/crates/control-plane-api/src/fixtures/sso_tenant.sql b/crates/control-plane-api/src/fixtures/sso_tenant.sql new file mode 100644 index 00000000000..b6be6069b62 --- /dev/null +++ b/crates/control-plane-api/src/fixtures/sso_tenant.sql @@ -0,0 +1,60 @@ +do $$ +declare + data_plane_one_id flowid := '111111111111'; + + alice_uid uuid := '11111111-1111-1111-1111-111111111111'; + bob_uid uuid := '22222222-2222-2222-2222-222222222222'; + carol_uid uuid := '33333333-3333-3333-3333-333333333333'; + + acme_provider_id uuid := 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + other_provider_id uuid := 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + + last_pub_id flowid := '000000000002'; + +begin + + -- SSO providers + insert into auth.sso_providers (id) values + (acme_provider_id), + (other_provider_id) + ; + + -- Users + insert into auth.users (id, email) values + (alice_uid, 'alice@acme.co'), + (bob_uid, 'bob@other.co'), + (carol_uid, 'carol@example.com') + ; + + -- Alice has an SSO identity matching acme's provider. + -- GoTrue stores SSO identities with provider = 'sso:'. + insert into auth.identities (user_id, provider, provider_id) values + (alice_uid, 'sso:' || acme_provider_id::text, acme_provider_id::text) + ; + + -- Bob has an SSO identity, but for a different provider. + insert into auth.identities (user_id, provider, provider_id) values + (bob_uid, 'sso:' || other_provider_id::text, other_provider_id::text) + ; + + -- Carol has no SSO identity (social login only). + + -- Tenants: acmeCo/ with SSO, openCo/ without SSO. + insert into public.tenants (id, tenant, sso_provider_id) values + (internal.id_generator(), 'acmeCo/', acme_provider_id), + (internal.id_generator(), 'openCo/', null) + ; + + -- Alice is admin on acmeCo/ and openCo/. + insert into public.user_grants (user_id, object_role, capability) values + (alice_uid, 'acmeCo/', 'admin'), + (alice_uid, 'openCo/', 'admin') + ; + + -- Give alice read on data planes so specs can resolve. + insert into public.role_grants (subject_role, object_role, capability) values + ('acmeCo/', 'ops/dp/public/', 'read') + ; + +end +$$; diff --git a/crates/control-plane-api/src/publications/quotas.rs b/crates/control-plane-api/src/publications/quotas.rs index e5f646cf3f2..94e176020b2 100644 --- a/crates/control-plane-api/src/publications/quotas.rs +++ b/crates/control-plane-api/src/publications/quotas.rs @@ -74,11 +74,7 @@ fn get_deltas(built: &build::Output) -> BTreeMap<&str, (i32, i32)> { } fn tenant(name: &impl AsRef) -> &str { - let idx = name - .as_ref() - .find('/') - .expect("catalog name must contain at least one /"); - name.as_ref().split_at(idx + 1).0 + models::tenant_from(name.as_ref()) } pub async fn check_resource_quotas( diff --git a/crates/control-plane-api/src/server/public/graphql/invite_links.rs b/crates/control-plane-api/src/server/public/graphql/invite_links.rs index 319faa2247b..8162d3afeca 100644 --- a/crates/control-plane-api/src/server/public/graphql/invite_links.rs +++ b/crates/control-plane-api/src/server/public/graphql/invite_links.rs @@ -1,4 +1,4 @@ -use super::filters; +use super::{TimestampCursor, filters}; use async_graphql::{Context, types::connection}; /// An invite link that grants access to a catalog prefix. @@ -16,6 +16,10 @@ pub struct InviteLink { pub detail: Option, /// When this invite link was created. pub created_at: chrono::DateTime, + /// The SSO provider ID for the invite's tenant, if any. + /// When present, the frontend should route the user directly into the SSO + /// flow using this provider ID (e.g. via `supabase.auth.signInWithSSO`). + pub sso_provider_id: Option, } /// Result of redeeming an invite link. @@ -28,7 +32,7 @@ pub struct RedeemInviteLinkResult { } pub type PaginatedInviteLinks = connection::Connection< - String, + TimestampCursor, InviteLink, connection::EmptyFields, connection::EmptyFields, @@ -89,41 +93,36 @@ impl InviteLinksQuery { )); } - connection::query( + connection::query_with::( after, None, first, None, - |after: Option, _, first, _| async move { - let after_token: Option = match after { - Some(s) => Some( - s.parse() - .map_err(|_| async_graphql::Error::new("invalid cursor"))?, - ), - None => None, - }; - + |after, _, first, _| async move { + let after_created_at = after.map(|c| c.0); let limit = first.unwrap_or(DEFAULT_PAGE_SIZE); let rows = sqlx::query!( r#" SELECT - token, - catalog_prefix AS "catalog_prefix!: String", - capability AS "capability!: models::Capability", - single_use AS "single_use!: bool", - detail, - created_at AS "created_at!: chrono::DateTime" - FROM internal.invite_links - WHERE catalog_prefix::text ^@ ANY($1) - AND ($5::text IS NULL OR catalog_prefix::text ^@ $5) - AND ($4::bool IS NULL OR single_use = $4) - AND ($2::uuid IS NULL OR token > $2) - ORDER BY token + il.token, + il.catalog_prefix AS "catalog_prefix!: String", + il.capability AS "capability!: models::Capability", + il.single_use AS "single_use!: bool", + il.detail, + il.created_at AS "created_at!: chrono::DateTime", + t.sso_provider_id + FROM internal.invite_links il + LEFT JOIN tenants t ON il.catalog_prefix::text ^@ t.tenant + WHERE il.catalog_prefix::text ^@ ANY($1) + AND ($5::text IS NULL OR il.catalog_prefix::text ^@ $5) + AND ($4::bool IS NULL OR il.single_use = $4) + AND ($2::timestamptz IS NULL OR il.created_at < $2) + ORDER BY il.created_at DESC LIMIT $3 + 1 "#, &admin_prefixes, - after_token, + after_created_at, limit as i64, single_use_eq, prefix_starts_with.as_deref(), @@ -137,9 +136,8 @@ impl InviteLinksQuery { .into_iter() .take(limit) .map(|r| { - let cursor = r.token.to_string(); connection::Edge::new( - cursor, + TimestampCursor(r.created_at), InviteLink { token: r.token, catalog_prefix: models::Prefix::new(&r.catalog_prefix), @@ -147,14 +145,15 @@ impl InviteLinksQuery { single_use: r.single_use, detail: r.detail, created_at: r.created_at, + sso_provider_id: r.sso_provider_id, }, ) }) .collect(); - let mut conn = connection::Connection::new(after_token.is_some(), has_next); + let mut conn = connection::Connection::new(after_created_at.is_some(), has_next); conn.edges = edges; - async_graphql::Result::::Ok(conn) + Ok(conn) }, ) .await @@ -203,6 +202,22 @@ impl InviteLinksMutation { .fetch_one(&env.pg_pool) .await?; + // Look up the tenant's SSO provider so the frontend can route the + // invite recipient directly into the correct SSO flow. + let tenant = models::tenant_from(catalog_prefix.as_str()); + + let sso_provider_id = sqlx::query_scalar!( + r#" + SELECT t.sso_provider_id + FROM tenants t + WHERE t.tenant = $1 + "#, + tenant, + ) + .fetch_optional(&env.pg_pool) + .await? + .flatten(); + tracing::info!( %catalog_prefix, ?capability, @@ -217,6 +232,7 @@ impl InviteLinksMutation { single_use, detail, created_at: row.created_at, + sso_provider_id, }) } @@ -255,6 +271,40 @@ impl InviteLinksMutation { } }; + // If the invite's tenant has an SSO provider configured, verify the + // redeeming user has an identity linked to that tenant's SSO provider. + // + // We check auth.identities rather than session-level claims (e.g. amr) + // because Supabase Auth excludes SAML SSO from identity linking — a user + // with an SSO identity row can only have obtained it by authenticating + // through SAML. If this assumption changes, we should check the JWT's + // amr claim to verify the current session used SSO. + let tenant = models::tenant_from(&invite.catalog_prefix); + + let sso_requirement_not_satisfied = sqlx::query_scalar!( + r#" + SELECT true AS "exists!" + FROM tenants t + WHERE t.tenant = $1 + AND t.sso_provider_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM auth.identities ai + WHERE ai.user_id = $2 + AND ai.provider = 'sso:' || t.sso_provider_id::text + ) + "#, + tenant, + claims.sub, + ) + .fetch_optional(&mut *txn) + .await?; + + if sso_requirement_not_satisfied.is_some() { + return Err(async_graphql::Error::new(format!( + "This organization requires SSO authentication. Please sign in via SSO to redeem this invite." + ))); + } + // Delete single-use invite links upon redemption. if invite.single_use { sqlx::query!("DELETE FROM internal.invite_links WHERE token = $1", token,) @@ -1118,4 +1168,298 @@ mod test { "parent prefix filter returns all invite links under the grant" ); } + + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "sso_tenant")) + )] + async fn test_redeem_invite_sso_enforcement(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + let server = test_server::TestServer::start( + pool.clone(), + test_server::snapshot(pool.clone(), true).await, + ) + .await; + + let alice_token = + server.make_access_token(uuid::Uuid::from_bytes([0x11; 16]), Some("alice@acme.co")); + let bob_token = + server.make_access_token(uuid::Uuid::from_bytes([0x22; 16]), Some("bob@other.co")); + let carol_token = server.make_access_token( + uuid::Uuid::from_bytes([0x33; 16]), + Some("carol@example.com"), + ); + + // Alice (matching SSO) creates an invite link for acmeCo/. + // The response should include ssoProviderId. + let create_response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($prefix: Prefix!, $capability: Capability!) { + createInviteLink( + catalogPrefix: $prefix + capability: $capability + singleUse: false + ) { token ssoProviderId } + }"#, + "variables": { + "prefix": "acmeCo/", + "capability": "write" + } + }), + Some(&alice_token), + ) + .await; + + let invite_token = create_response["data"]["createInviteLink"]["token"] + .as_str() + .expect("should have token"); + + // ssoProviderId should be the acme provider UUID. + assert_eq!( + create_response["data"]["createInviteLink"]["ssoProviderId"], + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "invite for SSO tenant should include ssoProviderId" + ); + + // Bob (SSO identity for a different provider) is rejected. + let bob_redeem: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": invite_token } + }), + Some(&bob_token), + ) + .await; + + insta::assert_json_snapshot!("redeem_sso_wrong_provider", bob_redeem); + + // Carol (no SSO identity) is rejected. + let carol_redeem: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": invite_token } + }), + Some(&carol_token), + ) + .await; + + insta::assert_json_snapshot!("redeem_sso_no_identity", carol_redeem); + + // Alice (matching SSO identity) succeeds. + let alice_redeem: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": invite_token } + }), + Some(&alice_token), + ) + .await; + + insta::assert_json_snapshot!("redeem_sso_matching", alice_redeem); + + // Create an invite on openCo/ (no SSO) — Bob can redeem it. + // Insert directly since Bob lacks admin on openCo. + let open_token: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO internal.invite_links (catalog_prefix, capability, single_use) \ + VALUES ('openCo/', 'read', false) RETURNING token", + ) + .fetch_one(&pool) + .await + .unwrap(); + + let bob_open_redeem: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": open_token } + }), + Some(&bob_token), + ) + .await; + + insta::assert_json_snapshot!("redeem_no_sso_enforcement", bob_open_redeem); + + // Sub-prefix invite: acmeCo/production/ should still be covered by + // the SSO enforcement on tenant acmeCo/. + let sub_prefix_token: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO internal.invite_links (catalog_prefix, capability, single_use) \ + VALUES ('acmeCo/production/', 'read', false) RETURNING token", + ) + .fetch_one(&pool) + .await + .unwrap(); + + // Bob (wrong SSO provider) is rejected for the sub-prefix too. + let bob_sub_prefix: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": sub_prefix_token } + }), + Some(&bob_token), + ) + .await; + + insta::assert_json_snapshot!("redeem_sso_sub_prefix_rejected", bob_sub_prefix); + + // Alice (matching SSO) succeeds for the sub-prefix. + let alice_sub_prefix: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": sub_prefix_token } + }), + Some(&alice_token), + ) + .await; + + insta::assert_json_snapshot!("redeem_sso_sub_prefix_allowed", alice_sub_prefix); + + // Verify createInviteLink for a non-SSO tenant returns null ssoProviderId. + // Alice already has admin on openCo/ via fixture. + let open_create: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($prefix: Prefix!, $capability: Capability!) { + createInviteLink( + catalogPrefix: $prefix + capability: $capability + ) { token ssoProviderId } + }"#, + "variables": { + "prefix": "openCo/", + "capability": "read" + } + }), + Some(&alice_token), + ) + .await; + + assert!( + open_create["data"]["createInviteLink"]["ssoProviderId"].is_null(), + "invite for non-SSO tenant should have null ssoProviderId" + ); + + // createInviteLink for a sub-prefix under an SSO tenant should still + // return the tenant's ssoProviderId (the tenant lookup strips to the + // root prefix before the first '/'). + let sub_create: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($prefix: Prefix!, $capability: Capability!) { + createInviteLink( + catalogPrefix: $prefix + capability: $capability + ) { token ssoProviderId } + }"#, + "variables": { + "prefix": "acmeCo/production/", + "capability": "read" + } + }), + Some(&alice_token), + ) + .await; + + assert_eq!( + sub_create["data"]["createInviteLink"]["ssoProviderId"], + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "invite for sub-prefix under SSO tenant should include ssoProviderId" + ); + + // Single-use invite on SSO tenant: rejection should NOT consume the + // invite. Create a single-use invite, have a non-SSO user attempt to + // redeem it (rejected), then verify the matching SSO user can still + // redeem it successfully. + let single_use_create: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($prefix: Prefix!, $capability: Capability!) { + createInviteLink( + catalogPrefix: $prefix + capability: $capability + singleUse: true + ) { token } + }"#, + "variables": { + "prefix": "acmeCo/", + "capability": "read" + } + }), + Some(&alice_token), + ) + .await; + + let single_use_token = single_use_create["data"]["createInviteLink"]["token"] + .as_str() + .expect("should have token"); + + // Carol (no SSO identity) is rejected — invite should survive. + let carol_single_use: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": single_use_token } + }), + Some(&carol_token), + ) + .await; + + assert!( + carol_single_use["errors"].is_array(), + "non-SSO user should be rejected for SSO tenant single-use invite" + ); + + // Alice (matching SSO) can still redeem the single-use invite, + // proving the earlier rejection did not consume it. + let alice_single_use: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($token: UUID!) { + redeemInviteLink(token: $token) { catalogPrefix capability } + }"#, + "variables": { "token": single_use_token } + }), + Some(&alice_token), + ) + .await; + + assert!( + alice_single_use["data"]["redeemInviteLink"]["catalogPrefix"] + .as_str() + .is_some(), + "matching SSO user should redeem single-use invite after prior SSO rejection" + ); + } } diff --git a/crates/control-plane-api/src/server/public/graphql/mod.rs b/crates/control-plane-api/src/server/public/graphql/mod.rs index 8f1ac23f770..e2122654235 100644 --- a/crates/control-plane-api/src/server/public/graphql/mod.rs +++ b/crates/control-plane-api/src/server/public/graphql/mod.rs @@ -1,8 +1,25 @@ //! GraphQL API //! //! The `QueryRoot` -use async_graphql::{EmptySubscription, Schema}; +use async_graphql::{EmptySubscription, Schema, types::connection}; use axum::response::IntoResponse; +use chrono::{DateTime, Utc}; + +/// A `CursorType` that is just a RFC3339 UTC timestamp. +/// Used by any paginated connection that cursors on `created_at` or similar. +pub struct TimestampCursor(pub DateTime); +impl connection::CursorType for TimestampCursor { + type Error = chrono::ParseError; + + fn decode_cursor(s: &str) -> Result { + let dt = DateTime::parse_from_rfc3339(s)?; + Ok(Self(dt.to_utc())) + } + + fn encode_cursor(&self) -> String { + self.0.to_rfc3339() + } +} mod alert_subscriptions; mod alerts; diff --git a/crates/control-plane-api/src/server/public/graphql/publication_history.rs b/crates/control-plane-api/src/server/public/graphql/publication_history.rs index 11548269067..7de64bd36cf 100644 --- a/crates/control-plane-api/src/server/public/graphql/publication_history.rs +++ b/crates/control-plane-api/src/server/public/graphql/publication_history.rs @@ -98,20 +98,7 @@ impl async_graphql::dataloader::Loader for PgDataLoader } } -/// A `CursorType` that is just a RFC3339 UTC timestamp -pub struct TimestampCursor(DateTime); -impl connection::CursorType for TimestampCursor { - type Error = chrono::ParseError; - - fn decode_cursor(s: &str) -> Result { - let dt = DateTime::parse_from_rfc3339(s)?; - Ok(Self(dt.to_utc())) - } - - fn encode_cursor(&self) -> String { - self.0.to_rfc3339() - } -} +use super::TimestampCursor; pub type SpecHistoryConnection = async_graphql::connection::Connection< TimestampCursor, diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_no_sso_enforcement.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_no_sso_enforcement.snap new file mode 100644 index 00000000000..76228294027 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_no_sso_enforcement.snap @@ -0,0 +1,12 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/invite_links.rs +expression: bob_open_redeem +--- +{ + "data": { + "redeemInviteLink": { + "capability": "read", + "catalogPrefix": "openCo/" + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_matching.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_matching.snap new file mode 100644 index 00000000000..042009a7787 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_matching.snap @@ -0,0 +1,12 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/invite_links.rs +expression: alice_redeem +--- +{ + "data": { + "redeemInviteLink": { + "capability": "write", + "catalogPrefix": "acmeCo/" + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_no_identity.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_no_identity.snap new file mode 100644 index 00000000000..89b6df5212b --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_no_identity.snap @@ -0,0 +1,21 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/invite_links.rs +expression: carol_redeem +--- +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 25, + "line": 3 + } + ], + "message": "This organization requires SSO authentication. Please sign in via SSO to redeem this invite.", + "path": [ + "redeemInviteLink" + ] + } + ] +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_allowed.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_allowed.snap new file mode 100644 index 00000000000..922838408d7 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_allowed.snap @@ -0,0 +1,12 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/invite_links.rs +expression: alice_sub_prefix +--- +{ + "data": { + "redeemInviteLink": { + "capability": "read", + "catalogPrefix": "acmeCo/production/" + } + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_rejected.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_rejected.snap new file mode 100644 index 00000000000..f311dae49db --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_sub_prefix_rejected.snap @@ -0,0 +1,21 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/invite_links.rs +expression: bob_sub_prefix +--- +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 25, + "line": 3 + } + ], + "message": "This organization requires SSO authentication. Please sign in via SSO to redeem this invite.", + "path": [ + "redeemInviteLink" + ] + } + ] +} diff --git a/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_wrong_provider.snap b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_wrong_provider.snap new file mode 100644 index 00000000000..ac35e42bdf7 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/snapshots/control_plane_api__server__public__graphql__invite_links__test__redeem_sso_wrong_provider.snap @@ -0,0 +1,21 @@ +--- +source: crates/control-plane-api/src/server/public/graphql/invite_links.rs +expression: bob_redeem +--- +{ + "data": null, + "errors": [ + { + "locations": [ + { + "column": 25, + "line": 3 + } + ], + "message": "This organization requires SSO authentication. Please sign in via SSO to redeem this invite.", + "path": [ + "redeemInviteLink" + ] + } + ] +} diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 09959b5e4b5..88f65e0604e 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -557,6 +557,12 @@ type InviteLink { When this invite link was created. """ createdAt: DateTime! + """ + The SSO provider ID for the invite's tenant, if any. + When present, the frontend should route the user directly into the SSO + flow using this provider ID (e.g. via `supabase.auth.signInWithSSO`). + """ + ssoProviderId: UUID } type InviteLinkConnection { diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index aa332276592..c63d799d205 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -54,6 +54,7 @@ pub use raw_value::RawValue; pub use references::{ CATALOG_PREFIX_RE, Capture, Collection, CompositeKey, Field, JsonPointer, Materialization, Name, PartitionField, Prefix, RelativeUrl, StorageEndpoint, TOKEN_RE, Test, Token, Transform, + tenant_from, }; pub use schemas::Schema; pub use shards::ShardTemplate; diff --git a/crates/models/src/references.rs b/crates/models/src/references.rs index 869b94bac1d..829e018e735 100644 --- a/crates/models/src/references.rs +++ b/crates/models/src/references.rs @@ -233,6 +233,15 @@ string_reference_types! { pub struct StorageEndpoint("StorageEndpoint::schema", pattern = ENDPOINT_RE, example = "storage.example.com"); } +/// Extract the tenant prefix (e.g. "acmeCo/") from a catalog name or prefix. +/// The input must contain at least one '/'. +pub fn tenant_from(prefix: &str) -> &str { + let idx = prefix + .find('/') + .expect("prefix must contain at least one /"); + &prefix[..idx + 1] +} + impl RelativeUrl { pub fn example_relative() -> Self { Self("../path/to/local.yaml".to_owned()) From e8081abee4852c037f1624068948aaa12386f1b6 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 19 Mar 2026 23:07:08 -0400 Subject: [PATCH 2/5] Phase 4c: Soft SSO login nudge (access token hook) --- supabase/config.toml | 4 + .../20260320120000_sso_access_token_hook.sql | 73 ++++++++++++ supabase/tests/sso_access_token_hook.test.sql | 109 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 supabase/migrations/20260320120000_sso_access_token_hook.sql create mode 100644 supabase/tests/sso_access_token_hook.test.sql diff --git a/supabase/config.toml b/supabase/config.toml index 7ae6c17c3ea..5da7ee1b9f3 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -45,6 +45,10 @@ jwt_expiry = 604800 # Allow/disallow new user signups to your project. enable_signup = true +[auth.hook.custom_access_token] +enabled = true +uri = "pg-functions://postgres/public/custom_access_token_hook" + [auth.email] # Allow/disallow new user signups via email to your project. enable_signup = true diff --git a/supabase/migrations/20260320120000_sso_access_token_hook.sql b/supabase/migrations/20260320120000_sso_access_token_hook.sql new file mode 100644 index 00000000000..57b5c231536 --- /dev/null +++ b/supabase/migrations/20260320120000_sso_access_token_hook.sql @@ -0,0 +1,73 @@ +-- Add a customize_access_token hook that embeds SSO provider IDs into the JWT +-- when a non-SSO user has grants on tenants with SSO configured. +-- +-- The dashboard reads the `sso_not_satisfied` claim on login/refresh and can show +-- an interstitial prompting the user to re-authenticate via SSO. +-- Only provider UUIDs are included — no tenant names — to avoid leaking +-- which tenants the user has grants on. +-- +-- Keyed on sso_provider_id IS NOT NULL (not enforce_sso) so users get nudged +-- as soon as SSO is configured, giving them runway before hard enforcement. + +begin; + +create or replace function public.check_sso_requirement(event jsonb) +returns jsonb +language plpgsql +stable +security definer +set search_path to '' +as $$ +declare + claims jsonb; + target_user_id uuid; + provider_id uuid; +begin + target_user_id = (event->>'user_id')::uuid; + claims = event->'claims'; + + -- Find the SSO provider for the tenant where this user has grants but + -- lacks the matching SSO identity. We expect at most one SSO-enabled + -- tenant per user; LIMIT 1 makes that assumption explicit. + select t.sso_provider_id + into provider_id + from public.user_grants ug + join public.tenants t on ug.object_role ^@ t.tenant + where ug.user_id = target_user_id + and t.sso_provider_id is not null + and not exists ( + select 1 from auth.identities ai + where ai.user_id = target_user_id + and ai.provider = 'sso:' || t.sso_provider_id::text + ) + limit 1; + + if provider_id is not null then + claims = jsonb_set(claims, '{sso_not_satisfied}', to_jsonb(provider_id)); + else + claims = claims - 'sso_not_satisfied'; + end if; + + event = jsonb_set(event, '{claims}', claims); + return event; + +exception when others then + raise warning 'check_sso_requirement failed for user %: %', target_user_id, SQLERRM; + return event; +end; +$$; + +-- The hook is invoked by GoTrue as the supabase_auth_admin role. +grant usage on schema public to supabase_auth_admin; +grant execute on function public.check_sso_requirement(jsonb) to supabase_auth_admin; + +-- The function reads from these tables. +grant select on public.user_grants to supabase_auth_admin; +grant select on public.tenants to supabase_auth_admin; +grant select on auth.identities to supabase_auth_admin; + +-- Anon and authenticated roles have execute privileges by default - revoke them. +-- check_sso_requirement is exclusively for supabase_auth_admin. +revoke execute on function public.check_sso_requirement(jsonb) from authenticated, anon; + +commit; diff --git a/supabase/tests/sso_access_token_hook.test.sql b/supabase/tests/sso_access_token_hook.test.sql new file mode 100644 index 00000000000..111e6308ca5 --- /dev/null +++ b/supabase/tests/sso_access_token_hook.test.sql @@ -0,0 +1,109 @@ +-- Tests for the check_sso_requirement that adds sso_not_satisfied claim. +create function tests.test_sso_access_token_hook() +returns setof text as $$ +declare + provider_acme uuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + alice_id uuid = '11111111-1111-1111-1111-111111111111'; + result jsonb; +begin + -- Setup: test user. + insert into auth.users (id, email) values + (alice_id, 'alice@example.com') + on conflict do nothing; + + -- Setup: SSO provider for acmeCo. + insert into auth.sso_providers (id) values (provider_acme) + on conflict do nothing; + + -- Tenants: acmeCo has SSO configured, openCo does not. + delete from tenants; + insert into tenants (tenant, sso_provider_id) values + ('acmeCo/', provider_acme), + ('openCo/', null); + + -- Alice has grants on both tenants, no SSO identity yet. + delete from user_grants; + insert into user_grants (user_id, object_role, capability) values + (alice_id, 'acmeCo/', 'admin'), + (alice_id, 'openCo/', 'admin'); + + delete from auth.identities where user_id = alice_id; + + -- No SSO identity — should get sso_not_satisfied with acmeCo's provider. + select public.check_sso_requirement(jsonb_build_object( + 'user_id', alice_id, + 'claims', jsonb_build_object('sub', alice_id) + )) into result; + + return next is( + result->'claims'->'sso_not_satisfied', + to_jsonb(provider_acme), + 'Non-SSO user on SSO tenant gets sso_not_satisfied' + ); + + -- Add SSO identity — sso_not_satisfied should disappear. + insert into auth.identities (user_id, provider, provider_id, identity_data) values + (alice_id, 'sso:' || provider_acme::text, provider_acme::text, '{}'::jsonb); + + select public.check_sso_requirement(jsonb_build_object( + 'user_id', alice_id, + 'claims', jsonb_build_object('sub', alice_id) + )) into result; + + return next ok( + result->'claims'->'sso_not_satisfied' is null, + 'SSO user on own tenant has no sso_not_satisfied claim' + ); + + -- Only open-tenant grants: no sso_not_satisfied. + delete from user_grants where user_id = alice_id; + delete from auth.identities where user_id = alice_id; + insert into user_grants (user_id, object_role, capability) values + (alice_id, 'openCo/', 'admin'); + + select public.check_sso_requirement(jsonb_build_object( + 'user_id', alice_id, + 'claims', jsonb_build_object('sub', alice_id) + )) into result; + + return next ok( + result->'claims'->'sso_not_satisfied' is null, + 'User with only open-tenant grants has no sso_not_satisfied claim' + ); + + -- Sub-prefix grant on acmeCo/reports/ should still trigger sso_not_satisfied. + delete from user_grants where user_id = alice_id; + insert into user_grants (user_id, object_role, capability) values + (alice_id, 'acmeCo/reports/', 'read'); + + select public.check_sso_requirement(jsonb_build_object( + 'user_id', alice_id, + 'claims', jsonb_build_object('sub', alice_id) + )) into result; + + return next is( + result->'claims'->'sso_not_satisfied', + to_jsonb(provider_acme), + 'Sub-prefix grant on acmeCo/reports/ triggers sso_not_satisfied' + ); + + -- Malformed event (null user_id) should not throw — the exception handler + -- returns the event unmodified so JWT issuance is never blocked. + select public.check_sso_requirement(jsonb_build_object( + 'user_id', null, + 'claims', jsonb_build_object('sub', 'bogus') + )) into result; + + return next ok( + result->'claims'->>'sub' = 'bogus', + 'Malformed event returns claims unchanged (exception handler fires)' + ); + + return next ok( + result->'claims'->'sso_not_satisfied' is null, + 'Malformed event does not inject sso_not_satisfied' + ); + + return; +end +$$ language plpgsql; From 04613b0d8bea04d0f2c2dd4c16267442eb11b745 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 23 Mar 2026 21:01:55 -0400 Subject: [PATCH 3/5] Phase 4d: Hard SSO enforcement --- ...93141c03a7f704c461194f45047636867855.json} | 6 +- ...a88c065a59f973f1dbcf8201e6d1d0bce5f4.json} | 4 +- ...9de98bc61be2843033a74a9bf0c5c9d44c410.json | 2 +- ...78286d1024dd706152c005983637982365277.json | 2 +- .../control-plane-api/src/server/snapshot.rs | 12 ++- .../20260323120000_enforce_sso_authz.sql | 56 ++++++++++ supabase/tests/sso_enforcement.test.sql | 100 ++++++++++++++++++ 7 files changed, 174 insertions(+), 8 deletions(-) rename .sqlx/{query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json => query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json} (91%) rename .sqlx/{query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json => query-89ddccb479df7e98076c7d00abc0a88c065a59f973f1dbcf8201e6d1d0bce5f4.json} (76%) create mode 100644 supabase/migrations/20260323120000_enforce_sso_authz.sql create mode 100644 supabase/tests/sso_enforcement.test.sql diff --git a/.sqlx/query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json b/.sqlx/query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json similarity index 91% rename from .sqlx/query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json rename to .sqlx/query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json index 421838e53fb..ddb4940baa4 100644 --- a/.sqlx/query-4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f.json +++ b/.sqlx/query-6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n il.token,\n il.catalog_prefix AS \"catalog_prefix!: String\",\n il.capability AS \"capability!: models::Capability\",\n il.single_use AS \"single_use!: bool\",\n il.detail,\n il.created_at AS \"created_at!: chrono::DateTime\",\n t.sso_provider_id\n FROM internal.invite_links il\n LEFT JOIN tenants t ON il.catalog_prefix::text ^@ t.tenant\n WHERE il.catalog_prefix::text ^@ ANY($1)\n AND ($5::text IS NULL OR il.catalog_prefix::text ^@ $5)\n AND ($4::bool IS NULL OR il.single_use = $4)\n AND ($2::uuid IS NULL OR il.token > $2)\n ORDER BY il.token\n LIMIT $3 + 1\n ", + "query": "\n SELECT\n il.token,\n il.catalog_prefix AS \"catalog_prefix!: String\",\n il.capability AS \"capability!: models::Capability\",\n il.single_use AS \"single_use!: bool\",\n il.detail,\n il.created_at AS \"created_at!: chrono::DateTime\",\n t.sso_provider_id\n FROM internal.invite_links il\n LEFT JOIN tenants t ON il.catalog_prefix::text ^@ t.tenant\n WHERE il.catalog_prefix::text ^@ ANY($1)\n AND ($5::text IS NULL OR il.catalog_prefix::text ^@ $5)\n AND ($4::bool IS NULL OR il.single_use = $4)\n AND ($2::timestamptz IS NULL OR il.created_at < $2)\n ORDER BY il.created_at DESC\n LIMIT $3 + 1\n ", "describe": { "columns": [ { @@ -81,7 +81,7 @@ "parameters": { "Left": [ "TextArray", - "Uuid", + "Timestamptz", "Int4", "Bool", "Text" @@ -97,5 +97,5 @@ true ] }, - "hash": "4f26f42a2519a323aefd30139d4124d620692f75f4bdad1bdaa607c42445d93f" + "hash": "6b980602f82b52e0f186d532fd5f93141c03a7f704c461194f45047636867855" } diff --git a/.sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json b/.sqlx/query-89ddccb479df7e98076c7d00abc0a88c065a59f973f1dbcf8201e6d1d0bce5f4.json similarity index 76% rename from .sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json rename to .sqlx/query-89ddccb479df7e98076c7d00abc0a88c065a59f973f1dbcf8201e6d1d0bce5f4.json index 1ad4ac4015f..81dbf80a7df 100644 --- a/.sqlx/query-bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf.json +++ b/.sqlx/query-89ddccb479df7e98076c7d00abc0a88c065a59f973f1dbcf8201e6d1d0bce5f4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n g.user_id AS \"user_id: uuid::Uuid\",\n g.object_role AS \"object_role: models::Prefix\",\n g.capability AS \"capability: models::Capability\"\n FROM user_grants g\n ", + "query": "\n SELECT\n g.user_id AS \"user_id: uuid::Uuid\",\n g.object_role AS \"object_role: models::Prefix\",\n g.capability AS \"capability: models::Capability\"\n FROM user_grants g\n WHERE NOT EXISTS (\n SELECT 1 FROM tenants t\n WHERE t.tenant ^@ g.object_role\n AND t.enforce_sso\n AND NOT EXISTS (\n SELECT 1 FROM auth.identities ai\n WHERE ai.user_id = g.user_id\n AND ai.provider = 'sso:' || t.sso_provider_id::text\n )\n )\n ", "describe": { "columns": [ { @@ -67,5 +67,5 @@ false ] }, - "hash": "bf114fcbbb3b0c8bd057869c92b7c6ceee073f467105083ec751a75c11478adf" + "hash": "89ddccb479df7e98076c7d00abc0a88c065a59f973f1dbcf8201e6d1d0bce5f4" } diff --git a/.sqlx/query-b10fda873a90630129d968ed7369de98bc61be2843033a74a9bf0c5c9d44c410.json b/.sqlx/query-b10fda873a90630129d968ed7369de98bc61be2843033a74a9bf0c5c9d44c410.json index 4828299c4ee..32826828169 100644 --- a/.sqlx/query-b10fda873a90630129d968ed7369de98bc61be2843033a74a9bf0c5c9d44c410.json +++ b/.sqlx/query-b10fda873a90630129d968ed7369de98bc61be2843033a74a9bf0c5c9d44c410.json @@ -95,7 +95,7 @@ false, true, false, - false, + true, false, false, true, diff --git a/.sqlx/query-e9c8d33f3b85a5964538fc00fa678286d1024dd706152c005983637982365277.json b/.sqlx/query-e9c8d33f3b85a5964538fc00fa678286d1024dd706152c005983637982365277.json index f76bd045e58..8f9e316830b 100644 --- a/.sqlx/query-e9c8d33f3b85a5964538fc00fa678286d1024dd706152c005983637982365277.json +++ b/.sqlx/query-e9c8d33f3b85a5964538fc00fa678286d1024dd706152c005983637982365277.json @@ -64,7 +64,7 @@ false, null, false, - false, + true, true, false, null, diff --git a/crates/control-plane-api/src/server/snapshot.rs b/crates/control-plane-api/src/server/snapshot.rs index ae26226c6db..d6f34286094 100644 --- a/crates/control-plane-api/src/server/snapshot.rs +++ b/crates/control-plane-api/src/server/snapshot.rs @@ -496,11 +496,21 @@ pub async fn try_fetch( g.object_role AS "object_role: models::Prefix", g.capability AS "capability: models::Capability" FROM user_grants g + WHERE NOT EXISTS ( + SELECT 1 FROM tenants t + WHERE g.object_role ^@ t.tenant + AND t.enforce_sso + AND NOT EXISTS ( + SELECT 1 FROM auth.identities ai + WHERE ai.user_id = g.user_id + AND ai.provider = 'sso:' || t.sso_provider_id::text + ) + ) "#, ) .fetch_all(pg_pool) .await - .context("failed to fetch role_grants")?; + .context("failed to fetch user_grants")?; let tasks = sqlx::query_as!( SnapshotTask, diff --git a/supabase/migrations/20260323120000_enforce_sso_authz.sql b/supabase/migrations/20260323120000_enforce_sso_authz.sql new file mode 100644 index 00000000000..6199671c6c4 --- /dev/null +++ b/supabase/migrations/20260323120000_enforce_sso_authz.sql @@ -0,0 +1,56 @@ +-- Phase 4d: Hard SSO enforcement at the authorization layer. +-- +-- When a tenant has enforce_sso = true, grants on that tenant are excluded +-- unless the user has an SSO identity matching the tenant's configured provider. +-- This is enforced in the base case of internal.user_roles(), so transitive +-- grants through SSO-enforced tenants are also excluded. + +begin; + +alter table public.tenants + add column enforce_sso boolean not null default false; + +comment on column public.tenants.enforce_sso is + 'When true, only users with an SSO identity matching sso_provider_id may access this tenant''s resources'; + +-- Replace internal.user_roles to exclude grants on SSO-enforced tenants unless +-- the user authenticated via the tenant's specific SSO provider. +create or replace function internal.user_roles( + target_user_id uuid, + min_capability public.grant_capability default 'x_00'::public.grant_capability +) +returns table(role_prefix public.catalog_prefix, capability public.grant_capability) +language sql stable +as $$ + with recursive + all_roles(role_prefix, capability) as ( + select object_role, capability from user_grants + where user_id = target_user_id + and capability >= min_capability + -- Exclude grants on SSO-enforced tenants unless the user has an + -- identity linked to that tenant's specific SSO provider. + and not exists ( + select 1 from tenants t + where t.tenant ^@ user_grants.object_role + and t.enforce_sso + and not exists ( + select 1 from auth.identities ai + where ai.user_id = target_user_id + and ai.provider = 'sso:' || t.sso_provider_id::text + ) + ) + union + -- Recursive case: for each object_role granted as 'admin', + -- project through grants where object_role acts as the subject_role. + select role_grants.object_role, role_grants.capability + from role_grants, all_roles + where role_grants.subject_role ^@ all_roles.role_prefix + and role_grants.capability >= min_capability + and all_roles.capability = 'admin' + ) + select role_prefix, max(capability) from all_roles + group by role_prefix + order by role_prefix; +$$; + +commit; diff --git a/supabase/tests/sso_enforcement.test.sql b/supabase/tests/sso_enforcement.test.sql new file mode 100644 index 00000000000..fe4e7f4c427 --- /dev/null +++ b/supabase/tests/sso_enforcement.test.sql @@ -0,0 +1,100 @@ +-- SSO enforcement: per-tenant provider checks in user_roles(). +create function tests.test_sso_enforcement() +returns setof text as $$ +declare + provider_acme uuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + provider_bigcorp uuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; +begin + + -- Create two SSO providers. + insert into auth.sso_providers (id) values (provider_acme), (provider_bigcorp); + + -- Create tenants: acmeCo enforces SSO, bigcorpCo enforces SSO, openCo does not. + delete from tenants; + insert into tenants (tenant, sso_provider_id, enforce_sso) values + ('acmeCo/', provider_acme, true), + ('bigcorpCo/', provider_bigcorp, true), + ('openCo/', null, false); + + -- Alice has grants on all three tenants. + delete from user_grants; + insert into user_grants (user_id, object_role, capability) values + ('11111111-1111-1111-1111-111111111111', 'acmeCo/', 'admin'), + ('11111111-1111-1111-1111-111111111111', 'bigcorpCo/', 'read'), + ('11111111-1111-1111-1111-111111111111', 'openCo/', 'admin'); + + -- Give Alice an SSO identity for Acme only. + delete from auth.identities where user_id = '11111111-1111-1111-1111-111111111111'; + insert into auth.identities (user_id, provider, provider_id, identity_data) values + ('11111111-1111-1111-1111-111111111111', 'sso:' || provider_acme::text, provider_acme::text, '{}'::jsonb); + + -- Alice sees acmeCo (matching SSO) and openCo (no SSO enforced), + -- but NOT bigcorpCo (enforces SSO with a different provider). + return next results_eq( + $i$ select role_prefix::text, capability::text + from internal.user_roles('11111111-1111-1111-1111-111111111111') $i$, + $i$ values ('acmeCo/', 'admin'), ('openCo/', 'admin') $i$, + 'SSO user sees matching SSO tenant + open tenant, not mismatched SSO tenant' + ); + + -- Bob has a grant on acmeCo but no SSO identity at all. + insert into user_grants (user_id, object_role, capability) values + ('22222222-2222-2222-2222-222222222222', 'acmeCo/', 'read'), + ('22222222-2222-2222-2222-222222222222', 'openCo/', 'read'); + + delete from auth.identities where user_id = '22222222-2222-2222-2222-222222222222'; + + -- Bob only sees openCo. + return next results_eq( + $i$ select role_prefix::text, capability::text + from internal.user_roles('22222222-2222-2222-2222-222222222222') $i$, + $i$ values ('openCo/', 'read') $i$, + 'non-SSO user excluded from SSO-enforced tenant' + ); + + -- Hypothetical: GoTrue doesn't support multiple SSO identities per user + -- today, but verify user_roles() behaves correctly if that ever changes. + insert into auth.identities (user_id, provider, provider_id, identity_data) values + ('11111111-1111-1111-1111-111111111111', 'sso:' || provider_bigcorp::text, provider_bigcorp::text, '{}'::jsonb); + + return next results_eq( + $i$ select role_prefix::text, capability::text + from internal.user_roles('11111111-1111-1111-1111-111111111111') $i$, + $i$ values ('acmeCo/', 'admin'), ('bigcorpCo/', 'read'), ('openCo/', 'admin') $i$, + 'user with both SSO identities sees both SSO-enforced tenants' + ); + + delete from auth.identities + where user_id = '11111111-1111-1111-1111-111111111111' + and provider = 'sso:' || provider_bigcorp::text; + + -- Transitive grant: Alice has admin on bigcorpCo which projects through + -- a role_grant to acmeCo/shared/. Since Alice lacks BigCorp SSO, the base + -- grant is excluded and the transitive grant is unreachable. + delete from user_grants where user_id = '11111111-1111-1111-1111-111111111111'; + insert into user_grants (user_id, object_role, capability) values + ('11111111-1111-1111-1111-111111111111', 'bigcorpCo/', 'admin'); + + delete from role_grants; + insert into role_grants (subject_role, object_role, capability) values + ('bigcorpCo/', 'acmeCo/shared/', 'read'); + + return next is_empty( + $i$ select role_prefix::text, capability::text + from internal.user_roles('11111111-1111-1111-1111-111111111111') $i$, + 'transitive grants through SSO-enforced tenant are excluded when provider mismatches' + ); + + -- Tenant with sso_provider_id set but enforce_sso = false should NOT filter. + update tenants set enforce_sso = false where tenant = 'bigcorpCo/'; + + return next results_eq( + $i$ select role_prefix::text, capability::text + from internal.user_roles('11111111-1111-1111-1111-111111111111') $i$, + $i$ values ('acmeCo/shared/', 'read'), ('bigcorpCo/', 'admin') $i$, + 'enforce_sso=false does not filter even when sso_provider_id is set' + ); + + return; +end +$$ language plpgsql; From e2e53745208efa3399e5facadf10f3bc5bc3df54 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 24 Mar 2026 15:44:18 -0400 Subject: [PATCH 4/5] breadcrumb for greg --- crates/tables/src/behaviors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tables/src/behaviors.rs b/crates/tables/src/behaviors.rs index e9c8f669121..70b9034aa46 100644 --- a/crates/tables/src/behaviors.rs +++ b/crates/tables/src/behaviors.rs @@ -120,7 +120,7 @@ impl super::UserGrant { .map(|grant| grant.capability) } - /// Given a user, determine if they're authorized to the object name for the given capability. + /// Given a user, determine if they're authorized to the object name for the given capability. CONSUME HERE pub fn is_authorized<'a>( role_grants: &'a [super::RoleGrant], user_grants: &'a [super::UserGrant], From 739a4eb614ad6b56acf1ea509206945dcde3d55e Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 24 Mar 2026 16:00:26 -0400 Subject: [PATCH 5/5] add tenants.tenant index --- supabase/migrations/20260323120000_enforce_sso_authz.sql | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/supabase/migrations/20260323120000_enforce_sso_authz.sql b/supabase/migrations/20260323120000_enforce_sso_authz.sql index 6199671c6c4..bd923ade4ca 100644 --- a/supabase/migrations/20260323120000_enforce_sso_authz.sql +++ b/supabase/migrations/20260323120000_enforce_sso_authz.sql @@ -13,6 +13,13 @@ alter table public.tenants comment on column public.tenants.enforce_sso is 'When true, only users with an SSO identity matching sso_provider_id may access this tenant''s resources'; +-- Partial SP-GiST index for the ^@ prefix lookups in the SSO enforcement +-- WHERE clauses (internal.user_roles and the snapshot query). Only tenants +-- with enforce_sso = true participate, so this stays tiny. +create index idx_tenants_tenant_enforce_sso_spgist + on public.tenants using spgist (tenant) + where enforce_sso; + -- Replace internal.user_roles to exclude grants on SSO-enforced tenants unless -- the user authenticated via the tenant's specific SSO provider. create or replace function internal.user_roles(