diff --git a/.sqlx/query-1b30ac4809c98a811126f32e259a88b400c59e785272a1a0866d74d5f6336418.json b/.sqlx/query-1b30ac4809c98a811126f32e259a88b400c59e785272a1a0866d74d5f6336418.json new file mode 100644 index 00000000000..50944395b9d --- /dev/null +++ b/.sqlx/query-1b30ac4809c98a811126f32e259a88b400c59e785272a1a0866d74d5f6336418.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n t.tenant AS \"tenant!: String\",\n t.sso_provider_id AS \"sso_provider_id: uuid::Uuid\"\n FROM internal.scim_tokens st\n JOIN tenants t ON t.id = st.tenant_id\n WHERE st.token_hash = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "tenant!: String", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "sso_provider_id: uuid::Uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "1b30ac4809c98a811126f32e259a88b400c59e785272a1a0866d74d5f6336418" +} diff --git a/.sqlx/query-27366d95ca96e3e3a654b4ced44662d52cd77f3b78d5b3b5a9fa244377a84039.json b/.sqlx/query-27366d95ca96e3e3a654b4ced44662d52cd77f3b78d5b3b5a9fa244377a84039.json new file mode 100644 index 00000000000..1d82c031afd --- /dev/null +++ b/.sqlx/query-27366d95ca96e3e3a654b4ced44662d52cd77f3b78d5b3b5a9fa244377a84039.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_grants\n WHERE object_role = $1\n AND capability = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "grant_capability", + "kind": { + "Enum": [ + "x_00", + "x_01", + "x_02", + "x_03", + "x_04", + "x_05", + "x_06", + "x_07", + "x_08", + "x_09", + "read", + "x_11", + "x_12", + "x_13", + "x_14", + "x_15", + "x_16", + "x_17", + "x_18", + "x_19", + "write", + "x_21", + "x_22", + "x_23", + "x_24", + "x_25", + "x_26", + "x_27", + "x_28", + "x_29", + "admin" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "27366d95ca96e3e3a654b4ced44662d52cd77f3b78d5b3b5a9fa244377a84039" +} diff --git a/.sqlx/query-3dba41688ec8c109db22414e7366bba7a94b8d78bc7f852258beb9cd9c8d3ec4.json b/.sqlx/query-3dba41688ec8c109db22414e7366bba7a94b8d78bc7f852258beb9cd9c8d3ec4.json new file mode 100644 index 00000000000..898bf255c29 --- /dev/null +++ b/.sqlx/query-3dba41688ec8c109db22414e7366bba7a94b8d78bc7f852258beb9cd9c8d3ec4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM auth.sessions WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3dba41688ec8c109db22414e7366bba7a94b8d78bc7f852258beb9cd9c8d3ec4" +} diff --git a/.sqlx/query-43f645e77183b3fa42b99df3bdb2bc85b661378a4c7b540520c9fb6cc2597541.json b/.sqlx/query-43f645e77183b3fa42b99df3bdb2bc85b661378a4c7b540520c9fb6cc2597541.json new file mode 100644 index 00000000000..b63b520121e --- /dev/null +++ b/.sqlx/query-43f645e77183b3fa42b99df3bdb2bc85b661378a4c7b540520c9fb6cc2597541.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n u.id AS \"id!: uuid::Uuid\",\n u.email AS \"email!: String\",\n u.raw_user_meta_data->>'full_name' AS \"display_name: String\"\n FROM auth.users u\n JOIN auth.identities i\n ON i.user_id = u.id\n AND i.provider = 'sso:' || $1::uuid::text\n WHERE u.id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: uuid::Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email!: String", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "43f645e77183b3fa42b99df3bdb2bc85b661378a4c7b540520c9fb6cc2597541" +} diff --git a/.sqlx/query-5d84a89b7b7234cc622d16c6ec4a6647018d808886c254658c31f021d37607b7.json b/.sqlx/query-5d84a89b7b7234cc622d16c6ec4a6647018d808886c254658c31f021d37607b7.json new file mode 100644 index 00000000000..fb672cf66aa --- /dev/null +++ b/.sqlx/query-5d84a89b7b7234cc622d16c6ec4a6647018d808886c254658c31f021d37607b7.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ug.user_id AS \"user_id!: uuid::Uuid\",\n u.email AS \"email: String\"\n FROM user_grants ug\n LEFT JOIN auth.users u ON u.id = ug.user_id\n WHERE ug.object_role = $1\n AND ug.capability = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id!: uuid::Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email: String", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "grant_capability", + "kind": { + "Enum": [ + "x_00", + "x_01", + "x_02", + "x_03", + "x_04", + "x_05", + "x_06", + "x_07", + "x_08", + "x_09", + "read", + "x_11", + "x_12", + "x_13", + "x_14", + "x_15", + "x_16", + "x_17", + "x_18", + "x_19", + "write", + "x_21", + "x_22", + "x_23", + "x_24", + "x_25", + "x_26", + "x_27", + "x_28", + "x_29", + "admin" + ] + } + } + } + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "5d84a89b7b7234cc622d16c6ec4a6647018d808886c254658c31f021d37607b7" +} diff --git a/.sqlx/query-6611080f8c58a83402a63113c8256024618e84900eabde6f52ef7aeb54b53e43.json b/.sqlx/query-6611080f8c58a83402a63113c8256024618e84900eabde6f52ef7aeb54b53e43.json new file mode 100644 index 00000000000..9f42958a0f4 --- /dev/null +++ b/.sqlx/query-6611080f8c58a83402a63113c8256024618e84900eabde6f52ef7aeb54b53e43.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.id AS \"id!: uuid::Uuid\"\n FROM auth.users u\n JOIN auth.identities i\n ON i.user_id = u.id\n AND i.provider = 'sso:' || $1::uuid::text\n WHERE u.id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: uuid::Uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6611080f8c58a83402a63113c8256024618e84900eabde6f52ef7aeb54b53e43" +} diff --git a/.sqlx/query-6f8b83c29fb3fb8c1ed29586ede874094cb1f34f017d2b62d50194b965ac4e01.json b/.sqlx/query-6f8b83c29fb3fb8c1ed29586ede874094cb1f34f017d2b62d50194b965ac4e01.json new file mode 100644 index 00000000000..b533cbbef07 --- /dev/null +++ b/.sqlx/query-6f8b83c29fb3fb8c1ed29586ede874094cb1f34f017d2b62d50194b965ac4e01.json @@ -0,0 +1,55 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_grants\n WHERE user_id = $1\n AND object_role = $2\n AND capability = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + { + "Custom": { + "name": "grant_capability", + "kind": { + "Enum": [ + "x_00", + "x_01", + "x_02", + "x_03", + "x_04", + "x_05", + "x_06", + "x_07", + "x_08", + "x_09", + "read", + "x_11", + "x_12", + "x_13", + "x_14", + "x_15", + "x_16", + "x_17", + "x_18", + "x_19", + "write", + "x_21", + "x_22", + "x_23", + "x_24", + "x_25", + "x_26", + "x_27", + "x_28", + "x_29", + "admin" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "6f8b83c29fb3fb8c1ed29586ede874094cb1f34f017d2b62d50194b965ac4e01" +} diff --git a/.sqlx/query-77b7fa71315ea7d015df56bab71d78a4d5acb35bad052714237453b11cd67423.json b/.sqlx/query-77b7fa71315ea7d015df56bab71d78a4d5acb35bad052714237453b11cd67423.json new file mode 100644 index 00000000000..45425ac446e --- /dev/null +++ b/.sqlx/query-77b7fa71315ea7d015df56bab71d78a4d5acb35bad052714237453b11cd67423.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM refresh_tokens WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "77b7fa71315ea7d015df56bab71d78a4d5acb35bad052714237453b11cd67423" +} diff --git a/.sqlx/query-98d397e5b81d3f9180de6abb805c531fc9fa31f72094ac57517d730aa909909e.json b/.sqlx/query-98d397e5b81d3f9180de6abb805c531fc9fa31f72094ac57517d730aa909909e.json new file mode 100644 index 00000000000..b874db59556 --- /dev/null +++ b/.sqlx/query-98d397e5b81d3f9180de6abb805c531fc9fa31f72094ac57517d730aa909909e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM user_grants WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "98d397e5b81d3f9180de6abb805c531fc9fa31f72094ac57517d730aa909909e" +} 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-bf6f9716b7a92ce5d6e883f0fce76cb1ee6c9cf3c39c19626693dbf72208f013.json b/.sqlx/query-bf6f9716b7a92ce5d6e883f0fce76cb1ee6c9cf3c39c19626693dbf72208f013.json new file mode 100644 index 00000000000..c2bba892146 --- /dev/null +++ b/.sqlx/query-bf6f9716b7a92ce5d6e883f0fce76cb1ee6c9cf3c39c19626693dbf72208f013.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_grants\n WHERE object_role = $1 AND capability = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "grant_capability", + "kind": { + "Enum": [ + "x_00", + "x_01", + "x_02", + "x_03", + "x_04", + "x_05", + "x_06", + "x_07", + "x_08", + "x_09", + "read", + "x_11", + "x_12", + "x_13", + "x_14", + "x_15", + "x_16", + "x_17", + "x_18", + "x_19", + "write", + "x_21", + "x_22", + "x_23", + "x_24", + "x_25", + "x_26", + "x_27", + "x_28", + "x_29", + "admin" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "bf6f9716b7a92ce5d6e883f0fce76cb1ee6c9cf3c39c19626693dbf72208f013" +} diff --git a/.sqlx/query-c13b49612cc9092272efaea3919d6e54d8453fc271a98c736d98ebe928f62ea1.json b/.sqlx/query-c13b49612cc9092272efaea3919d6e54d8453fc271a98c736d98ebe928f62ea1.json new file mode 100644 index 00000000000..14777ac9572 --- /dev/null +++ b/.sqlx/query-c13b49612cc9092272efaea3919d6e54d8453fc271a98c736d98ebe928f62ea1.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH remove_email_identity AS (\n DELETE FROM auth.identities\n WHERE user_id = $1 AND provider = 'email'\n ),\n mark_sso AS (\n UPDATE auth.users SET is_sso_user = true WHERE id = $1\n )\n INSERT INTO auth.identities (id, user_id, provider, provider_id, identity_data, last_sign_in_at, created_at, updated_at)\n VALUES (gen_random_uuid(), $1, $2, $3, '{}'::jsonb, now(), now(), now())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c13b49612cc9092272efaea3919d6e54d8453fc271a98c736d98ebe928f62ea1" +} diff --git a/.sqlx/query-d2f434e7aa6e272da50b031948d031a63808330475d57e85a5a2fe41bb99b849.json b/.sqlx/query-d2f434e7aa6e272da50b031948d031a63808330475d57e85a5a2fe41bb99b849.json new file mode 100644 index 00000000000..9ee100c1c3f --- /dev/null +++ b/.sqlx/query-d2f434e7aa6e272da50b031948d031a63808330475d57e85a5a2fe41bb99b849.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n u.id AS \"id!: uuid::Uuid\",\n u.email AS \"email!: String\",\n u.raw_user_meta_data->>'full_name' AS \"display_name: String\"\n FROM auth.users u\n JOIN auth.identities i\n ON i.user_id = u.id\n AND i.provider = 'sso:' || $1::uuid::text\n WHERE ($2::text IS NULL OR u.email = $2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: uuid::Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email!: String", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "d2f434e7aa6e272da50b031948d031a63808330475d57e85a5a2fe41bb99b849" +} diff --git a/.sqlx/query-d30fd06b7a63dcf2455b073496a4b49ba9f49e30f013a870cf1670faf41f714c.json b/.sqlx/query-d30fd06b7a63dcf2455b073496a4b49ba9f49e30f013a870cf1670faf41f714c.json new file mode 100644 index 00000000000..6ee0a9a8379 --- /dev/null +++ b/.sqlx/query-d30fd06b7a63dcf2455b073496a4b49ba9f49e30f013a870cf1670faf41f714c.json @@ -0,0 +1,67 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT\n object_role AS \"object_role!: String\",\n capability AS \"capability!: models::Capability\"\n FROM user_grants\n WHERE object_role LIKE $1 || '%'\n ORDER BY object_role, capability\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "object_role!: String", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "capability!: models::Capability", + "type_info": { + "Custom": { + "name": "grant_capability", + "kind": { + "Enum": [ + "x_00", + "x_01", + "x_02", + "x_03", + "x_04", + "x_05", + "x_06", + "x_07", + "x_08", + "x_09", + "read", + "x_11", + "x_12", + "x_13", + "x_14", + "x_15", + "x_16", + "x_17", + "x_18", + "x_19", + "write", + "x_21", + "x_22", + "x_23", + "x_24", + "x_25", + "x_26", + "x_27", + "x_28", + "x_29", + "admin" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "d30fd06b7a63dcf2455b073496a4b49ba9f49e30f013a870cf1670faf41f714c" +} 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/.sqlx/query-e9cbb622e0a44d1c2c167c056a4f429769b2d5fa0be877bd03a8227359ccee7c.json b/.sqlx/query-e9cbb622e0a44d1c2c167c056a4f429769b2d5fa0be877bd03a8227359ccee7c.json new file mode 100644 index 00000000000..f805d925111 --- /dev/null +++ b/.sqlx/query-e9cbb622e0a44d1c2c167c056a4f429769b2d5fa0be877bd03a8227359ccee7c.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n u.id AS \"id!: uuid::Uuid\",\n u.email AS \"email!: String\",\n u.raw_user_meta_data->>'full_name' AS \"display_name: String\"\n FROM auth.users u\n WHERE u.email = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: uuid::Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email!: String", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "e9cbb622e0a44d1c2c167c056a4f429769b2d5fa0be877bd03a8227359ccee7c" +} diff --git a/Cargo.lock b/Cargo.lock index 980e2bef795..6e813968d28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2055,6 +2055,7 @@ dependencies = [ "flow-client-next", "futures", "gazette", + "hex", "humantime", "humantime-serde", "insta", @@ -2079,6 +2080,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2", "sources", "sqlx", "tables", diff --git a/crates/agent/src/integration_tests/harness.rs b/crates/agent/src/integration_tests/harness.rs index 6f94a7b991d..f78576270ee 100644 --- a/crates/agent/src/integration_tests/harness.rs +++ b/crates/agent/src/integration_tests/harness.rs @@ -1683,6 +1683,8 @@ impl TestHarness { self.pool.clone(), self.publisher.clone(), snapshot_watch.clone(), + "http://invalid-gotrue-url".to_string(), + String::new(), )); self.control_plane_app = Some(app); diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs index 13ae34ee4bf..1e12dfd5a8c 100644 --- a/crates/agent/src/main.rs +++ b/crates/agent/src/main.rs @@ -268,6 +268,10 @@ async fn async_main(args: Args) -> Result<(), anyhow::Error> { .context("querying for agent user id")?; let jwt_secret: String = std::env::var("CONTROL_PLANE_JWT_SECRET").context("missing CONTROL_PLANE_JWT_SECRET")?; + let gotrue_url: String = + std::env::var("GOTRUE_URL").unwrap_or_else(|_| "http://127.0.0.1:5431/auth/v1".to_string()); + let gotrue_service_role_key: String = + std::env::var("GOTRUE_SERVICE_ROLE_KEY").unwrap_or_default(); if args.builds_root.scheme() == "file" { std::fs::create_dir_all(args.builds_root.path()) @@ -323,6 +327,8 @@ async fn async_main(args: Args) -> Result<(), anyhow::Error> { pg_pool.clone(), publisher.clone(), snapshot_watch, + gotrue_url, + gotrue_service_role_key, )); let api_router = control_plane_api::build_router(api_app.clone(), &args.allow_origin)?; let api_server = axum::serve(api_listener, api_router).with_graceful_shutdown(shutdown.clone()); diff --git a/crates/control-plane-api/Cargo.toml b/crates/control-plane-api/Cargo.toml index 5f6a3629b01..daef5807d53 100644 --- a/crates/control-plane-api/Cargo.toml +++ b/crates/control-plane-api/Cargo.toml @@ -45,6 +45,7 @@ clap = { workspace = true } colored_json = { workspace = true } derivative = { workspace = true } futures = { workspace = true } +hex = { workspace = true } humantime = { workspace = true } humantime-serde = { workspace = true } itertools = { workspace = true } @@ -57,6 +58,7 @@ rustls = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } sqlx = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index dfe22025980..e007f627fed 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -39,6 +39,12 @@ pub struct App { pub pg_pool: sqlx::PgPool, pub publisher: crate::publications::Publisher, pub snapshot: Arc>, + /// HTTP client for calling GoTrue admin API. + pub http_client: reqwest::Client, + /// Base URL for GoTrue (e.g. "http://127.0.0.1:5431/auth/v1"). + pub gotrue_url: String, + /// Supabase service role key for GoTrue admin API authentication. + pub gotrue_service_role_key: String, } impl App { @@ -48,6 +54,8 @@ impl App { pg_pool: sqlx::PgPool, publisher: crate::publications::Publisher, snapshot: Arc>, + gotrue_url: String, + gotrue_service_role_key: String, ) -> Self { Self { _id_generator: std::sync::Mutex::new(id_generator), @@ -56,6 +64,9 @@ impl App { pg_pool, publisher, snapshot, + http_client: reqwest::Client::new(), + gotrue_url, + gotrue_service_role_key, } } } diff --git a/crates/control-plane-api/src/server/public/mod.rs b/crates/control-plane-api/src/server/public/mod.rs index f6e717b41d2..1641b4fa89a 100644 --- a/crates/control-plane-api/src/server/public/mod.rs +++ b/crates/control-plane-api/src/server/public/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; pub mod graphql; mod open_metrics; +pub mod scim; pub mod status; /// Creates a router for the public API that can be merged into an existing router. @@ -74,6 +75,8 @@ pub(crate) fn api_v1_router(app: Arc) -> axum::Router Json { + Json(serde_json::json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "documentationUri": "https://docs.estuary.dev", + "patch": { "supported": true }, + "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 }, + "filter": { "supported": true, "maxResults": 100 }, + "changePassword": { "supported": false }, + "sort": { "supported": false }, + "etag": { "supported": false }, + "authenticationSchemes": [{ + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Authentication scheme using the OAuth Bearer Token Standard", + "specUri": "https://www.rfc-editor.org/info/rfc6750", + "primary": true, + }], + })) +} + +/// GET /Schemas — describes the User and Group schemas we support. +pub async fn schemas() -> Json { + Json(serde_json::json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 2, + "Resources": [user_schema(), group_schema()], + })) +} + +/// GET /ResourceTypes — describes the User and Group resource types. +pub async fn resource_types() -> Json { + Json(serde_json::json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 2, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "User", + "name": "User", + "endpoint": "/Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + }, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", + }, + ], + })) +} + +fn user_schema() -> serde_json::Value { + serde_json::json!({ + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "userName", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "server", + }, + { + "name": "active", + "type": "boolean", + "multiValued": false, + "required": false, + "mutability": "readWrite", + "returned": "default", + }, + { + "name": "displayName", + "type": "string", + "multiValued": false, + "required": false, + "mutability": "readWrite", + "returned": "default", + }, + ], + }) +} + +fn group_schema() -> serde_json::Value { + serde_json::json!({ + "id": "urn:ietf:params:scim:schemas:core:2.0:Group", + "name": "Group", + "description": "Group (maps to a catalog prefix + capability)", + "attributes": [ + { + "name": "displayName", + "type": "string", + "multiValued": false, + "required": true, + "caseExact": true, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "server", + "description": "Catalog prefix and capability, e.g. 'acmeCo/widgets/:admin'", + }, + { + "name": "members", + "type": "complex", + "multiValued": true, + "required": false, + "mutability": "readWrite", + "returned": "default", + "subAttributes": [ + { + "name": "value", + "type": "string", + "multiValued": false, + "required": true, + "description": "User UUID", + }, + { + "name": "display", + "type": "string", + "multiValued": false, + "required": false, + "description": "User email", + }, + ], + }, + ], + }) +} diff --git a/crates/control-plane-api/src/server/public/scim/groups.rs b/crates/control-plane-api/src/server/public/scim/groups.rs new file mode 100644 index 00000000000..0f74230db18 --- /dev/null +++ b/crates/control-plane-api/src/server/public/scim/groups.rs @@ -0,0 +1,579 @@ +//! SCIM 2.0 Groups endpoints. +//! +//! Groups are stateless — the group display name encodes the catalog prefix and +//! capability (e.g. `acmeCo/widgets:admin`). The SCIM group ID is the +//! base64url encoding of the display name, so no storage is needed. +//! +//! Group membership maps directly to `user_grants`: adding a member creates a +//! grant, removing a member deletes it. + +use super::ScimContext; +use axum::Json; +use base64::Engine; +use super::users::ScimError; + +// --- Group name ↔ prefix + capability --- + +/// Parse a group display name like `acmeCo/:admin` into a catalog prefix +/// and capability. The prefix must end with `/`. +fn parse_group_name(display_name: &str) -> Result<(String, models::Capability), ScimError> { + let (prefix, cap_str) = display_name.rsplit_once(':').ok_or_else(|| { + ScimError::BadRequest(format!( + "group displayName must be 'prefix/:capability', got: {display_name}" + )) + })?; + + if !prefix.ends_with('/') { + return Err(ScimError::BadRequest(format!( + "group prefix must end with '/', got: {prefix}" + ))); + } + + let capability = match cap_str { + "read" => models::Capability::Read, + "write" => models::Capability::Write, + "admin" => models::Capability::Admin, + other => { + return Err(ScimError::BadRequest(format!( + "unknown capability '{other}', expected read/write/admin" + ))); + } + }; + + Ok((prefix.to_string(), capability)) +} + +/// Validate that a prefix is under the SCIM token's tenant. +fn validate_prefix_for_tenant(prefix: &str, tenant: &str) -> Result<(), ScimError> { + if !prefix.starts_with(tenant) { + return Err(ScimError::BadRequest(format!( + "prefix '{prefix}' is not under tenant '{tenant}'" + ))); + } + Ok(()) +} + +// --- Group ID encoding --- + +const BASE64_ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD; + +fn encode_group_id(display_name: &str) -> String { + BASE64_ENGINE.encode(display_name.as_bytes()) +} + +fn decode_group_id(id: &str) -> Result { + let bytes = BASE64_ENGINE.decode(id).map_err(|_| { + ScimError::BadRequest(format!("invalid group id (not valid base64url): {id}")) + })?; + String::from_utf8(bytes).map_err(|_| { + ScimError::BadRequest(format!("invalid group id (not valid utf-8): {id}")) + }) +} + +// --- Handlers --- + +/// POST /Groups — create a group (validates the display name, returns the resource). +pub async fn create_group( + ctx: ScimContext, + Json(body): Json, +) -> Result<(axum::http::StatusCode, Json), ScimError> { + let (prefix, capability) = parse_group_name(&body.display_name)?; + validate_prefix_for_tenant(&prefix, &ctx.tenant)?; + + // Create grants for any members included in the request. + if !body.members.is_empty() { + let mut txn = ctx.pg_pool.begin().await.map_err(ScimError::internal)?; + for member in &body.members { + let user_id = parse_member_id(&member.value)?; + validate_member_exists(user_id, &ctx).await?; + crate::directives::grant::upsert_user_grant( + user_id, + &prefix, + capability, + Some(format!("SCIM group {}", body.display_name)), + &mut txn, + ) + .await + .map_err(ScimError::internal)?; + } + txn.commit().await.map_err(ScimError::internal)?; + } + + tracing::info!( + tenant = %ctx.tenant, + display_name = %body.display_name, + members = body.members.len(), + "SCIM created group" + ); + + let resource = group_resource(&body.display_name, &fetch_members(&ctx, &prefix, capability).await?); + Ok((axum::http::StatusCode::CREATED, Json(resource))) +} + +/// GET /Groups — list all groups for this tenant, optionally filtered by displayName. +/// +/// Since groups are stateless (derived from user_grants), this returns one group +/// per distinct (object_role, capability) pair that has at least one grant. +pub async fn list_groups( + ctx: ScimContext, + axum::extract::Query(params): axum::extract::Query, +) -> Result, ScimError> { + let display_name_filter = parse_optional_display_name_filter(¶ms.filter)?; + + let rows = sqlx::query!( + r#" + SELECT DISTINCT + object_role AS "object_role!: String", + capability AS "capability!: models::Capability" + FROM user_grants + WHERE object_role LIKE $1 || '%' + ORDER BY object_role, capability + "#, + ctx.tenant, + ) + .fetch_all(&ctx.pg_pool) + .await + .map_err(ScimError::internal)?; + + let mut resources = Vec::new(); + for row in &rows { + let display_name = format!("{}:{}", row.object_role, capability_str(row.capability)); + + if let Some(filter) = &display_name_filter { + if display_name != *filter { + continue; + } + } + + let members = fetch_members(&ctx, &row.object_role, row.capability).await?; + resources.push(group_resource(&display_name, &members)); + } + + Ok(Json(serde_json::json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": resources.len(), + "Resources": resources, + }))) +} + +/// GET /Groups/{id} — get a single group by its base64url-encoded ID. +pub async fn get_group( + ctx: ScimContext, + axum::extract::Path(group_id): axum::extract::Path, +) -> Result, ScimError> { + let display_name = decode_group_id(&group_id)?; + let (prefix, capability) = parse_group_name(&display_name)?; + validate_prefix_for_tenant(&prefix, &ctx.tenant)?; + + let members = fetch_members(&ctx, &prefix, capability).await?; + Ok(Json(group_resource(&display_name, &members))) +} + +/// PATCH /Groups/{id} — add or remove members. +pub async fn patch_group( + ctx: ScimContext, + axum::extract::Path(group_id): axum::extract::Path, + Json(patch): Json, +) -> Result, ScimError> { + let display_name = decode_group_id(&group_id)?; + let (prefix, capability) = parse_group_name(&display_name)?; + validate_prefix_for_tenant(&prefix, &ctx.tenant)?; + + let mut txn = ctx.pg_pool.begin().await.map_err(ScimError::internal)?; + + for op in &patch.operations { + match op.op.to_lowercase().as_str() { + "add" => { + let members = extract_members_from_value(&op.value)?; + for member in members { + let user_id = parse_member_id(&member.value)?; + validate_member_exists(user_id, &ctx).await?; + crate::directives::grant::upsert_user_grant( + user_id, + &prefix, + capability, + Some(format!("SCIM group {display_name}")), + &mut txn, + ) + .await + .map_err(ScimError::internal)?; + } + } + "remove" => { + // SCIM PATCH remove with path "members[value eq \"\"]" + let user_id = parse_member_filter(op.path.as_deref())?; + sqlx::query!( + r#" + DELETE FROM user_grants + WHERE user_id = $1 + AND object_role = $2 + AND capability = $3 + "#, + user_id, + prefix.as_str(), + capability as models::Capability, + ) + .execute(&mut *txn) + .await + .map_err(ScimError::internal)?; + } + "replace" => { + // Full member replacement via replace on "members". + let members = extract_members_from_value(&op.value)?; + + // Delete all existing grants for this prefix+capability. + sqlx::query!( + r#" + DELETE FROM user_grants + WHERE object_role = $1 + AND capability = $2 + "#, + prefix.as_str(), + capability as models::Capability, + ) + .execute(&mut *txn) + .await + .map_err(ScimError::internal)?; + + // Re-create grants for the new member list. + for member in members { + let user_id = parse_member_id(&member.value)?; + validate_member_exists(user_id, &ctx).await?; + crate::directives::grant::upsert_user_grant( + user_id, + &prefix, + capability, + Some(format!("SCIM group {display_name}")), + &mut txn, + ) + .await + .map_err(ScimError::internal)?; + } + } + other => { + return Err(ScimError::BadRequest(format!( + "unsupported SCIM group operation: {other}" + ))); + } + } + } + + txn.commit().await.map_err(ScimError::internal)?; + + tracing::info!( + tenant = %ctx.tenant, + %display_name, + operations = patch.operations.len(), + "SCIM patched group" + ); + + let members = fetch_members(&ctx, &prefix, capability).await?; + Ok(Json(group_resource(&display_name, &members))) +} + +/// PUT /Groups/{id} — full replacement of group membership. +pub async fn replace_group( + ctx: ScimContext, + axum::extract::Path(group_id): axum::extract::Path, + Json(body): Json, +) -> Result, ScimError> { + let display_name = decode_group_id(&group_id)?; + + if body.display_name != display_name { + return Err(ScimError::BadRequest(format!( + "displayName in body '{}' does not match group id '{}' (decoded: '{}')", + body.display_name, group_id, display_name, + ))); + } + + let (prefix, capability) = parse_group_name(&display_name)?; + validate_prefix_for_tenant(&prefix, &ctx.tenant)?; + + let mut txn = ctx.pg_pool.begin().await.map_err(ScimError::internal)?; + + // Delete all existing grants for this prefix+capability. + sqlx::query!( + r#" + DELETE FROM user_grants + WHERE object_role = $1 AND capability = $2 + "#, + prefix.as_str(), + capability as models::Capability, + ) + .execute(&mut *txn) + .await + .map_err(ScimError::internal)?; + + // Re-create from the PUT body. + for member in &body.members { + let user_id = parse_member_id(&member.value)?; + validate_member_exists(user_id, &ctx).await?; + crate::directives::grant::upsert_user_grant( + user_id, + &prefix, + capability, + Some(format!("SCIM group {display_name}")), + &mut txn, + ) + .await + .map_err(ScimError::internal)?; + } + + txn.commit().await.map_err(ScimError::internal)?; + + tracing::info!( + tenant = %ctx.tenant, + %display_name, + members = body.members.len(), + "SCIM replaced group membership" + ); + + let members = fetch_members(&ctx, &prefix, capability).await?; + Ok(Json(group_resource(&display_name, &members))) +} + +/// DELETE /Groups/{id} — remove all grants for this prefix+capability. +pub async fn delete_group( + ctx: ScimContext, + axum::extract::Path(group_id): axum::extract::Path, +) -> Result { + let display_name = decode_group_id(&group_id)?; + let (prefix, capability) = parse_group_name(&display_name)?; + validate_prefix_for_tenant(&prefix, &ctx.tenant)?; + + let deleted = sqlx::query!( + r#" + DELETE FROM user_grants + WHERE object_role = $1 AND capability = $2 + "#, + prefix.as_str(), + capability as models::Capability, + ) + .execute(&ctx.pg_pool) + .await + .map_err(ScimError::internal)? + .rows_affected(); + + tracing::info!( + tenant = %ctx.tenant, + %display_name, + %deleted, + "SCIM deleted group" + ); + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +// --- Types --- + +#[derive(serde::Deserialize)] +pub struct ListGroupsParams { + #[serde(default)] + filter: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupBody { + pub display_name: String, + #[serde(default)] + pub members: Vec, + #[allow(dead_code)] + #[serde(default)] + pub schemas: Vec, +} + +#[derive(serde::Deserialize, Clone)] +pub struct MemberRef { + pub value: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupPatchOp { + #[allow(dead_code)] + #[serde(default)] + pub schemas: Vec, + #[serde(alias = "Operations")] + pub operations: Vec, +} + +#[derive(serde::Deserialize)] +pub struct GroupOperation { + pub op: String, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub value: serde_json::Value, +} + +// --- Helpers --- + +/// Parse an optional SCIM filter like `displayName eq "gregCo/us/prod:read"`. +fn parse_optional_display_name_filter(filter: &str) -> Result, ScimError> { + let filter = filter.trim(); + if filter.is_empty() { + return Ok(None); + } + + let parts: Vec<&str> = filter.splitn(3, ' ').collect(); + if parts.len() != 3 { + return Err(ScimError::BadRequest(format!( + "invalid filter syntax: {filter}" + ))); + } + + if !parts[0].eq_ignore_ascii_case("displayName") || !parts[1].eq_ignore_ascii_case("eq") { + return Err(ScimError::BadRequest(format!( + "only 'displayName eq' filter is supported for groups, got: {filter}" + ))); + } + + let value = parts[2] + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .ok_or_else(|| ScimError::BadRequest(format!("filter value must be quoted: {filter}")))?; + + Ok(Some(value.to_string())) +} + +fn capability_str(cap: models::Capability) -> &'static str { + match cap { + models::Capability::Read => "read", + models::Capability::Write => "write", + models::Capability::Admin => "admin", + } +} + +fn parse_member_id(value: &str) -> Result { + value.parse::().map_err(|_| { + ScimError::BadRequest(format!("member value must be a UUID, got: {value}")) + }) +} + +/// Validate that a member user exists and has an SSO identity for this tenant's provider. +/// Returns the user_id if valid, or a SCIM error with a descriptive message. +async fn validate_member_exists( + user_id: uuid::Uuid, + ctx: &ScimContext, +) -> Result { + let exists = sqlx::query!( + r#" + SELECT u.id AS "id!: uuid::Uuid" + FROM auth.users u + JOIN auth.identities i + ON i.user_id = u.id + AND i.provider = 'sso:' || $1::uuid::text + WHERE u.id = $2 + "#, + ctx.sso_provider_id, + user_id, + ) + .fetch_optional(&ctx.pg_pool) + .await + .map_err(ScimError::internal)?; + + if exists.is_some() { + Ok(user_id) + } else { + Err(ScimError::NotFound) + } +} + +/// Parse SCIM member filter path like `members[value eq ""]`. +fn parse_member_filter(path: Option<&str>) -> Result { + let path = path.ok_or_else(|| { + ScimError::BadRequest("remove operation requires a path".to_string()) + })?; + + // Expected format: members[value eq ""] + let inner = path + .strip_prefix("members[") + .and_then(|s| s.strip_suffix(']')) + .ok_or_else(|| { + ScimError::BadRequest(format!("unsupported path format: {path}")) + })?; + + let parts: Vec<&str> = inner.splitn(3, ' ').collect(); + if parts.len() != 3 || parts[0] != "value" || !parts[1].eq_ignore_ascii_case("eq") { + return Err(ScimError::BadRequest(format!( + "unsupported filter in path: {path}" + ))); + } + + let id_str = parts[2] + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(parts[2]); + + parse_member_id(id_str) +} + +/// Extract member references from a SCIM operation value. +/// The value may be a single member object or an array of members. +fn extract_members_from_value(value: &serde_json::Value) -> Result, ScimError> { + match value { + serde_json::Value::Array(arr) => { + let members: Result, _> = arr + .iter() + .map(|v| serde_json::from_value(v.clone())) + .collect(); + members.map_err(|e| ScimError::BadRequest(format!("invalid member value: {e}"))) + } + serde_json::Value::Object(_) => { + let member: MemberRef = serde_json::from_value(value.clone()) + .map_err(|e| ScimError::BadRequest(format!("invalid member value: {e}")))?; + Ok(vec![member]) + } + _ => Err(ScimError::BadRequest( + "operation value must be a member object or array".to_string(), + )), + } +} + +/// Fetch current group members (users with matching grant). +async fn fetch_members( + ctx: &ScimContext, + prefix: &str, + capability: models::Capability, +) -> Result, ScimError> { + let rows = sqlx::query!( + r#" + SELECT + ug.user_id AS "user_id!: uuid::Uuid", + u.email AS "email: String" + FROM user_grants ug + LEFT JOIN auth.users u ON u.id = ug.user_id + WHERE ug.object_role = $1 + AND ug.capability = $2 + "#, + prefix, + capability as models::Capability, + ) + .fetch_all(&ctx.pg_pool) + .await + .map_err(ScimError::internal)?; + + Ok(rows + .iter() + .map(|r| { + serde_json::json!({ + "value": r.user_id.to_string(), + "display": r.email.as_deref().unwrap_or(""), + }) + }) + .collect()) +} + +/// Build a SCIM Group resource JSON object. +fn group_resource(display_name: &str, members: &[serde_json::Value]) -> serde_json::Value { + serde_json::json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": encode_group_id(display_name), + "displayName": display_name, + "members": members, + "meta": { + "resourceType": "Group", + }, + }) +} diff --git a/crates/control-plane-api/src/server/public/scim/mod.rs b/crates/control-plane-api/src/server/public/scim/mod.rs new file mode 100644 index 00000000000..19c981939f7 --- /dev/null +++ b/crates/control-plane-api/src/server/public/scim/mod.rs @@ -0,0 +1,187 @@ +//! SCIM 2.0 API for user provisioning/deprovisioning and group-based access. +//! +//! Authenticates via hashed bearer tokens in `internal.scim_tokens`, scoped to +//! a tenant. Supports user provisioning (via GoTrue admin API), deprovisioning, +//! and group management (groups map to catalog prefix + capability grants). + +mod discovery; +pub(crate) mod groups; +pub(crate) mod users; + +use std::sync::Arc; + +/// Middleware that logs the method, URI, and request body for SCIM requests. +async fn log_scim_request( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let method = request.method().clone(); + let uri = request.uri().clone(); + + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 64 * 1024) + .await + .unwrap_or_default(); + + let body_str = String::from_utf8_lossy(&bytes); + tracing::info!(%method, %uri, body = %body_str, "SCIM request"); + + let request = axum::extract::Request::from_parts(parts, axum::body::Body::from(bytes)); + next.run(request).await +} + +/// Build the SCIM v2 router, nested under `/api/v1/scim/v2/`. +/// +/// Discovery endpoints (ServiceProviderConfig, Schemas, ResourceTypes) are +/// unauthenticated per the SCIM spec — IdPs hit these during setup before +/// a token is configured. User and Group endpoints require a valid SCIM bearer token. +pub fn scim_router() -> axum::Router> { + axum::Router::new() + // Discovery endpoints — no authentication required. + .route( + "/api/v1/scim/v2/ServiceProviderConfig", + axum::routing::get(discovery::service_provider_config), + ) + .route( + "/api/v1/scim/v2/Schemas", + axum::routing::get(discovery::schemas), + ) + .route( + "/api/v1/scim/v2/ResourceTypes", + axum::routing::get(discovery::resource_types), + ) + // User endpoints — require SCIM bearer token (ScimContext extractor). + .route( + "/api/v1/scim/v2/Users", + axum::routing::get(users::list_users).post(users::create_user), + ) + .route( + "/api/v1/scim/v2/Users/{id}", + axum::routing::get(users::get_user).patch(users::patch_user), + ) + // Group endpoints — require SCIM bearer token (ScimContext extractor). + .route( + "/api/v1/scim/v2/Groups", + axum::routing::get(groups::list_groups).post(groups::create_group), + ) + .route( + "/api/v1/scim/v2/Groups/{id}", + axum::routing::get(groups::get_group) + .patch(groups::patch_group) + .put(groups::replace_group) + .delete(groups::delete_group), + ) + .layer(axum::middleware::from_fn(log_scim_request)) +} + +/// Context extracted from a SCIM bearer token. Authenticates the request and +/// identifies the tenant the SCIM client is acting on behalf of. +pub struct ScimContext { + /// The tenant prefix (e.g. "acmeCo/") that this SCIM token is scoped to. + pub tenant: String, + /// The tenant's SSO provider ID, used to scope user lookups by email domain. + pub sso_provider_id: uuid::Uuid, + /// Database connection pool. + pub pg_pool: sqlx::PgPool, + /// The full application state (for GoTrue API calls, etc.). + pub app: Arc, +} + +/// Rejection type for SCIM auth failures. +#[derive(Debug)] +pub enum ScimRejection { + /// Missing or malformed Authorization header. + MissingToken, + /// Token hash not found in `internal.scim_tokens`. + InvalidToken, + /// Tenant has no SSO provider configured. + NoSsoProvider, + /// Internal error during auth. + Internal(String), +} + +impl axum::response::IntoResponse for ScimRejection { + fn into_response(self) -> axum::response::Response { + use axum::http::StatusCode; + + let (status, detail) = match self { + ScimRejection::MissingToken => (StatusCode::UNAUTHORIZED, "missing bearer token"), + ScimRejection::InvalidToken => (StatusCode::UNAUTHORIZED, "invalid bearer token"), + ScimRejection::NoSsoProvider => ( + StatusCode::FORBIDDEN, + "tenant has no SSO provider configured", + ), + ScimRejection::Internal(ref msg) => { + tracing::error!(error = %msg, "SCIM auth internal error"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error") + } + }; + + let body = serde_json::json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": status.as_str(), + "detail": detail, + }); + + (status, axum::Json(body)).into_response() + } +} + +impl axum::extract::FromRequestParts> for ScimContext { + type Rejection = ScimRejection; + + fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &Arc, + ) -> impl std::future::Future> + Send { + async move { + // Extract bearer token from Authorization header. + use axum_extra::{ + TypedHeader, + headers::{Authorization, authorization::Bearer}, + }; + let TypedHeader(auth) = + TypedHeader::>::from_request_parts(parts, state) + .await + .map_err(|_| ScimRejection::MissingToken)?; + + // SHA-256 hash the token. + use sha2::Digest; + let hash = sha2::Sha256::digest(auth.token().as_bytes()); + let token_hash = hex::encode(hash); + + // Look up the token and join to tenants to get the tenant prefix and SSO provider. + let row = sqlx::query!( + r#" + SELECT + t.tenant AS "tenant!: String", + t.sso_provider_id AS "sso_provider_id: uuid::Uuid" + FROM internal.scim_tokens st + JOIN tenants t ON t.id = st.tenant_id + WHERE st.token_hash = $1 + "#, + token_hash, + ) + .fetch_optional(&state.pg_pool) + .await + .map_err(|e| ScimRejection::Internal(e.to_string()))?; + + let row = row.ok_or(ScimRejection::InvalidToken)?; + let sso_provider_id = row.sso_provider_id.ok_or(ScimRejection::NoSsoProvider)?; + + Ok(ScimContext { + tenant: row.tenant, + sso_provider_id, + pg_pool: state.pg_pool.clone(), + app: Arc::clone(state), + }) + } + } +} + +/// Helper to hash a plaintext SCIM token to its storage form. +pub fn hash_token(plaintext: &str) -> String { + use sha2::Digest; + let hash = sha2::Sha256::digest(plaintext.as_bytes()); + hex::encode(hash) +} diff --git a/crates/control-plane-api/src/server/public/scim/users.rs b/crates/control-plane-api/src/server/public/scim/users.rs new file mode 100644 index 00000000000..8e88d023b7b --- /dev/null +++ b/crates/control-plane-api/src/server/public/scim/users.rs @@ -0,0 +1,433 @@ +//! SCIM 2.0 Users endpoints. +//! +//! Users are scoped to the SCIM token's tenant by requiring a matching SSO +//! identity: only users who have an `auth.identities` row with `provider_id` +//! matching the tenant's `sso_provider_id` are visible. These accounts are +//! owned by the tenant's IdP. + +use super::ScimContext; +use axum::Json; + +/// POST /Users — provision a new user via GoTrue admin API. +pub async fn create_user( + ctx: ScimContext, + Json(body): Json, +) -> Result<(axum::http::StatusCode, Json), ScimError> { + let email = &body.user_name; + + // Call GoTrue admin API to create the user account. + let gotrue_response = ctx + .app + .http_client + .post(format!("{}/admin/users", ctx.app.gotrue_url)) + .header("apikey", &ctx.app.gotrue_service_role_key) + .bearer_auth(&ctx.app.gotrue_service_role_key) + .json(&serde_json::json!({ + "email": email, + "email_confirm": true, + })) + .send() + .await + .map_err(|e| ScimError::Internal(format!("GoTrue request failed: {e}")))?; + + if !gotrue_response.status().is_success() { + let status = gotrue_response.status(); + let body = gotrue_response + .text() + .await + .unwrap_or_else(|_| "unknown".to_string()); + + // If the user already exists, look them up and return 409 Conflict per SCIM spec. + // We intentionally don't attach an SSO identity here — the existing user may + // be a social-auth account, and GoTrue doesn't support multiple provider + // identities on one user. The on_sso_identity_insert trigger will handle + // merging when the user eventually logs in via SSO. + if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY + && body.contains("already been registered") + { + let existing = sqlx::query!( + r#" + SELECT + u.id AS "id!: uuid::Uuid", + u.email AS "email!: String", + u.raw_user_meta_data->>'full_name' AS "display_name: String" + FROM auth.users u + WHERE u.email = $1 + "#, + email.as_str(), + ) + .fetch_optional(&ctx.pg_pool) + .await + .map_err(ScimError::internal)? + .ok_or_else(|| { + ScimError::Internal("user exists in GoTrue but not found by email".to_string()) + })?; + + return Ok(( + axum::http::StatusCode::CONFLICT, + Json(user_resource( + &existing.id, + &existing.email, + existing.display_name.as_deref(), + true, + )), + )); + } + + return Err(ScimError::Internal(format!( + "GoTrue returned {status}: {body}" + ))); + } + + let gotrue_user: serde_json::Value = gotrue_response + .json() + .await + .map_err(|e| ScimError::Internal(format!("GoTrue response parse failed: {e}")))?; + + let user_id = gotrue_user["id"] + .as_str() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| ScimError::Internal("GoTrue response missing user id".to_string()))?; + + // Convert the GoTrue-created email user into an SSO user: + // 1. Delete the auto-created `email` provider identity (GoTrue always creates one). + // 2. Insert an SSO identity so subsequent SCIM queries (which JOIN on + // auth.identities) can see this user, and so GoTrue matches this identity + // when the user logs in via SAML — reusing the account instead of creating + // a duplicate. + // 3. Mark is_sso_user = true. + // The provider_id is the email, which must match the IdP's SAML NameID. + let sso_provider = format!("sso:{}", ctx.sso_provider_id); + + sqlx::query!( + r#" + WITH remove_email_identity AS ( + DELETE FROM auth.identities + WHERE user_id = $1 AND provider = 'email' + ), + mark_sso AS ( + UPDATE auth.users SET is_sso_user = true WHERE id = $1 + ) + INSERT INTO auth.identities (id, user_id, provider, provider_id, identity_data, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), $1, $2, $3, '{}'::jsonb, now(), now(), now()) + "#, + user_id, + sso_provider, + email.as_str(), + ) + .execute(&ctx.pg_pool) + .await + .map_err(|e| ScimError::Internal(format!("failed to create SSO identity: {e}")))?; + + tracing::info!( + %user_id, + %email, + tenant = %ctx.tenant, + "SCIM provisioned new SSO user" + ); + + Ok(( + axum::http::StatusCode::CREATED, + Json(user_resource( + &user_id, + email, + body.display_name.as_deref(), + true, + )), + )) +} + +/// GET /Users — list users, optionally filtered by `userName eq "..."`. +pub async fn list_users( + ctx: ScimContext, + axum::extract::Query(params): axum::extract::Query, +) -> Result, ScimError> { + let email_filter = parse_optional_username_filter(¶ms.filter)?; + + let users = sqlx::query!( + r#" + SELECT + u.id AS "id!: uuid::Uuid", + u.email AS "email!: String", + u.raw_user_meta_data->>'full_name' AS "display_name: String" + FROM auth.users u + JOIN auth.identities i + ON i.user_id = u.id + AND i.provider = 'sso:' || $1::uuid::text + WHERE ($2::text IS NULL OR u.email = $2) + "#, + ctx.sso_provider_id, + email_filter as Option<&str>, + ) + .fetch_all(&ctx.pg_pool) + .await + .map_err(ScimError::internal)?; + + let resources: Vec = users + .iter() + .map(|u| user_resource(&u.id, &u.email, u.display_name.as_deref(), true)) + .collect(); + + Ok(Json(serde_json::json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": resources.len(), + "Resources": resources, + }))) +} + +/// GET /Users/{id} — lookup a single user by UUID. +pub async fn get_user( + ctx: ScimContext, + axum::extract::Path(user_id): axum::extract::Path, +) -> Result, ScimError> { + let user = sqlx::query!( + r#" + SELECT + u.id AS "id!: uuid::Uuid", + u.email AS "email!: String", + u.raw_user_meta_data->>'full_name' AS "display_name: String" + FROM auth.users u + JOIN auth.identities i + ON i.user_id = u.id + AND i.provider = 'sso:' || $1::uuid::text + WHERE u.id = $2 + "#, + ctx.sso_provider_id, + user_id, + ) + .fetch_optional(&ctx.pg_pool) + .await + .map_err(ScimError::internal)? + .ok_or(ScimError::NotFound)?; + + Ok(Json(user_resource( + &user.id, + &user.email, + user.display_name.as_deref(), + true, + ))) +} + +/// PATCH /Users/{id} — deprovisioning via `active: false`. +pub async fn patch_user( + ctx: ScimContext, + axum::extract::Path(user_id): axum::extract::Path, + Json(patch): Json, +) -> Result, ScimError> { + // Validate the patch operations: we only support setting active to false. + let mut deactivate = false; + for op in &patch.operations { + match (op.op.as_str(), op.path.as_deref(), &op.value) { + ("replace", Some("active"), serde_json::Value::Bool(false)) => { + deactivate = true; + } + ("replace", Some("active"), serde_json::Value::String(s)) + if s == "false" || s == "False" => + { + deactivate = true; + } + _ => { + return Err(ScimError::BadRequest(format!( + "unsupported SCIM operation: op={}, path={:?}", + op.op, op.path + ))); + } + } + } + + if !deactivate { + return Err(ScimError::BadRequest( + "patch must set active to false".to_string(), + )); + } + + // Verify the user has an SSO identity matching this tenant's provider. + let user = sqlx::query!( + r#" + SELECT + u.id AS "id!: uuid::Uuid", + u.email AS "email!: String", + u.raw_user_meta_data->>'full_name' AS "display_name: String" + FROM auth.users u + JOIN auth.identities i + ON i.user_id = u.id + AND i.provider = 'sso:' || $1::uuid::text + WHERE u.id = $2 + "#, + ctx.sso_provider_id, + user_id, + ) + .fetch_optional(&ctx.pg_pool) + .await + .map_err(ScimError::internal)? + .ok_or(ScimError::NotFound)?; + + // Deprovision in a transaction: revoke all grants, tokens, and sessions. + let mut txn = ctx.pg_pool.begin().await.map_err(ScimError::internal)?; + + // Delete all user grants (user accounts are owned by the tenant). + let grants_deleted = sqlx::query!("DELETE FROM user_grants WHERE user_id = $1", user_id,) + .execute(&mut *txn) + .await + .map_err(ScimError::internal)? + .rows_affected(); + + // Revoke Estuary refresh tokens (flowctl / API tokens). + sqlx::query!("DELETE FROM refresh_tokens WHERE user_id = $1", user_id,) + .execute(&mut *txn) + .await + .map_err(ScimError::internal)?; + + // Revoke GoTrue sessions (forces immediate re-auth). + sqlx::query!("DELETE FROM auth.sessions WHERE user_id = $1", user_id,) + .execute(&mut *txn) + .await + .map_err(ScimError::internal)?; + + txn.commit().await.map_err(ScimError::internal)?; + + tracing::info!( + %user_id, + email = %user.email, + tenant = %ctx.tenant, + %grants_deleted, + "SCIM deprovisioned user" + ); + + Ok(Json(user_resource( + &user.id, + &user.email, + user.display_name.as_deref(), + false, + ))) +} + +// --- Types --- + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateUserBody { + pub user_name: String, + pub display_name: Option, + #[allow(dead_code)] + #[serde(default)] + pub schemas: Vec, +} + +#[derive(serde::Deserialize)] +pub struct ListUsersParams { + #[serde(default)] + filter: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatchOp { + #[allow(dead_code)] + #[serde(default)] + pub schemas: Vec, + #[serde(alias = "Operations")] + pub operations: Vec, +} + +#[derive(serde::Deserialize)] +pub struct Operation { + pub op: String, + pub path: Option, + #[serde(default)] + pub value: serde_json::Value, +} + +// --- Helpers --- + +/// Build a SCIM User resource JSON object. +fn user_resource( + id: &uuid::Uuid, + email: &str, + display_name: Option<&str>, + active: bool, +) -> serde_json::Value { + serde_json::json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": id.to_string(), + "userName": email, + "displayName": display_name.unwrap_or(""), + "active": active, + "meta": { + "resourceType": "User", + }, + }) +} + +/// Parse an optional SCIM filter like `userName eq "user@example.com"`. +/// Returns `None` if the filter is empty (list all users). +/// Only `userName eq "..."` is supported when a filter is provided. +fn parse_optional_username_filter(filter: &str) -> Result, ScimError> { + let filter = filter.trim(); + if filter.is_empty() { + return Ok(None); + } + + // Split into parts: ["userName", "eq", "\"user@example.com\""] + let parts: Vec<&str> = filter.splitn(3, ' ').collect(); + if parts.len() != 3 { + return Err(ScimError::BadRequest(format!( + "invalid filter syntax: {filter}" + ))); + } + + if !parts[0].eq_ignore_ascii_case("userName") || !parts[1].eq_ignore_ascii_case("eq") { + return Err(ScimError::BadRequest(format!( + "only 'userName eq' filter is supported, got: {filter}" + ))); + } + + // Strip surrounding quotes. + let value = parts[2] + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .ok_or_else(|| ScimError::BadRequest(format!("filter value must be quoted: {filter}")))?; + + Ok(Some(value)) +} + +// --- Errors --- + +#[derive(Debug)] +pub enum ScimError { + NotFound, + BadRequest(String), + Internal(String), +} + +impl ScimError { + pub(crate) fn internal(e: impl std::fmt::Display) -> Self { + ScimError::Internal(e.to_string()) + } +} + +impl axum::response::IntoResponse for ScimError { + fn into_response(self) -> axum::response::Response { + use axum::http::StatusCode; + + let (status, detail) = match &self { + ScimError::NotFound => (StatusCode::NOT_FOUND, "resource not found".to_string()), + ScimError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + ScimError::Internal(msg) => { + tracing::error!(error = %msg, "SCIM internal error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".to_string(), + ) + } + }; + + let body = serde_json::json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": status.as_str(), + "detail": detail, + }); + + (status, Json(body)).into_response() + } +} diff --git a/crates/control-plane-api/src/test_server.rs b/crates/control-plane-api/src/test_server.rs index 7a89d4b6f98..c4c33a38c00 100644 --- a/crates/control-plane-api/src/test_server.rs +++ b/crates/control-plane-api/src/test_server.rs @@ -91,6 +91,8 @@ impl TestServer { pg_pool.clone(), publisher, snapshot, + "http://invalid-gotrue-url".to_string(), + String::new(), )); let encoding_key = app.control_plane_jwt_encode_key.clone(); diff --git a/mise/tasks/local/saml b/mise/tasks/local/saml new file mode 100755 index 00000000000..dc7fc3dd812 --- /dev/null +++ b/mise/tasks/local/saml @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +#MISE description="Enable SAML/SSO on a running local stack" +#USAGE flag "--mocksaml" help="Use MockSAML (mocksaml.com) as the identity provider" +#USAGE flag "--okta" help="Use Okta as the identity provider" +#USAGE flag "--okta-metadata-url " help="Okta app metadata URL (required with --okta)" +#USAGE flag "--saml-domain " help="Email domain for SSO provider" default="example.com" +#USAGE flag "--saml-tenant " help="Tenant to link to SSO provider (defaults to first non-ops tenant)" + +MOCKSAML="${usage_mocksaml:-false}" +OKTA="${usage_okta:-false}" +OKTA_METADATA_URL="${usage_okta_metadata_url:-}" +SAML_DOMAIN="${usage_saml_domain:-example.com}" +SAML_TENANT="${usage_saml_tenant:-}" + +if [[ "$MOCKSAML" == "true" && "$OKTA" == "true" ]]; then + echo "Error: --mocksaml and --okta are mutually exclusive." >&2 + exit 1 +fi + +if [[ "$MOCKSAML" != "true" && "$OKTA" != "true" ]]; then + echo "Error: specify --mocksaml or --okta." >&2 + exit 1 +fi + +if [[ "$OKTA" == "true" && -z "$OKTA_METADATA_URL" ]]; then + echo "Error: --okta requires --okta-metadata-url." >&2 + exit 1 +fi + +echo "--- Enabling SAML/SSO ---" + +PSQL="psql postgresql://postgres:postgres@localhost:5432/postgres" + +# Generate a SAML signing key (PKCS#1). +# GoTrue needs this to sign outgoing SAMLRequests. +SAML_KEY=$(openssl genrsa -traditional 2048 2>/dev/null \ + | grep -v "^-----" \ + | tr -d '\n') + +# Detect docker prefix (direct or via Lima VM) +if docker ps &>/dev/null; then + DOCKER="docker" +elif limactl shell tiger docker ps &>/dev/null 2>&1; then + DOCKER="limactl shell tiger docker" +else + echo "Error: cannot reach Docker daemon. Start Docker or your Lima VM first." >&2 + exit 1 +fi + +# Inject SAML env vars into auth container (skipped if already present) +if $DOCKER exec supabase_auth_flow env 2>/dev/null | grep -q "GOTRUE_SAML_ENABLED=true"; then + echo "SAML env vars already present — skipping container recreation." +else + echo "--- Injecting SAML env vars into auth container ---" + + IMAGE=$($DOCKER inspect supabase_auth_flow --format '{{.Config.Image}}') + NETWORK=$($DOCKER inspect supabase_auth_flow --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}') + + $DOCKER inspect supabase_auth_flow --format '{{range .Config.Env}}{{println .}}{{end}}' \ + | grep -v -E '^(PATH=|API_EXTERNAL_URL=)' \ + > /tmp/auth_env.txt + echo "GOTRUE_SAML_ENABLED=true" >> /tmp/auth_env.txt + echo "GOTRUE_SAML_PRIVATE_KEY=$SAML_KEY" >> /tmp/auth_env.txt + echo "API_EXTERNAL_URL=http://127.0.0.1:5431/auth/v1" >> /tmp/auth_env.txt + echo "GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED=true" >> /tmp/auth_env.txt + echo "GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/check_sso_requirement" >> /tmp/auth_env.txt + + if [[ "$DOCKER" == limactl* ]]; then + limactl copy /tmp/auth_env.txt tiger:/tmp/auth_env.txt + fi + + $DOCKER stop supabase_auth_flow && $DOCKER rm supabase_auth_flow + $DOCKER run -d \ + --name supabase_auth_flow \ + --network "$NETWORK" \ + --restart always \ + --env-file /tmp/auth_env.txt \ + "$IMAGE" auth + + sleep 3 + if $DOCKER logs supabase_auth_flow --tail 5 2>&1 | grep -q "GoTrue API started"; then + echo "GoTrue is running with SAML enabled." + else + echo "Warning: GoTrue may not have started cleanly. Check logs:" + $DOCKER logs supabase_auth_flow --tail 20 + exit 1 + fi +fi + +if [[ "$MOCKSAML" == "true" ]]; then + PROVIDER_LABEL="MockSAML" + METADATA_URL="https://mocksaml.com/api/saml/metadata" +else + PROVIDER_LABEL="Okta" + METADATA_URL="$OKTA_METADATA_URL" +fi + +SERVICE_ROLE_KEY=$($DOCKER exec supabase_kong_flow cat /home/kong/kong.yml \ + | grep -o "sb_secret_[A-Za-z0-9_-]*" | head -1 || true) + +if [[ -z "$SERVICE_ROLE_KEY" ]]; then + echo "Error: could not determine service role key from Kong config." >&2 + exit 1 +fi + +AUTH_HEADERS=(-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY") + +# Look for an existing provider that already owns this domain. +EXISTING=$(curl -s 'http://127.0.0.1:5431/auth/v1/admin/sso/providers' "${AUTH_HEADERS[@]}") +read -r EXISTING_ID EXISTING_METADATA < <(echo "$EXISTING" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for item in (data.get('items') or []): + domains = [d.get('domain','') for d in (item.get('domains') or [])] + if '$SAML_DOMAIN' in domains: + url = (item.get('saml') or {}).get('metadata_url', '') + print(item['id'], url) + sys.exit() +print('', '') +" 2>/dev/null || echo "" "") + +if [[ -n "$EXISTING_ID" && "$EXISTING_METADATA" == "$METADATA_URL" ]]; then + PROVIDER_ID="$EXISTING_ID" + echo "$PROVIDER_LABEL provider already registered for $SAML_DOMAIN (id: $PROVIDER_ID)." +else + # Remove the old provider for this domain if the metadata URL changed. + if [[ -n "$EXISTING_ID" ]]; then + echo "Replacing existing provider for $SAML_DOMAIN ($EXISTING_ID) with $PROVIDER_LABEL..." + $PSQL -c "UPDATE public.tenants SET sso_provider_id = NULL WHERE sso_provider_id = '$EXISTING_ID';" 2>/dev/null + curl -s -X DELETE "http://127.0.0.1:5431/auth/v1/admin/sso/providers/$EXISTING_ID" "${AUTH_HEADERS[@]}" > /dev/null + fi + + echo "--- Registering $PROVIDER_LABEL provider (domain: $SAML_DOMAIN) ---" + + RESPONSE=$(curl -s -X POST 'http://127.0.0.1:5431/auth/v1/admin/sso/providers' \ + "${AUTH_HEADERS[@]}" \ + -H 'Content-Type: application/json' \ + -d "{\"type\":\"saml\",\"metadata_url\":\"$METADATA_URL\",\"domains\":[\"$SAML_DOMAIN\"]}") + + PROVIDER_ID=$(echo "$RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" 2>/dev/null || true) + + if [[ -z "$PROVIDER_ID" ]]; then + echo "Error: failed to register provider. Response: $RESPONSE" >&2 + exit 1 + fi + + echo "$PROVIDER_LABEL provider registered (id: $PROVIDER_ID, domain: $SAML_DOMAIN)." +fi + +# Link provider to a tenant +LINKED_TENANT=$($PSQL -t -c "SELECT tenant FROM public.tenants WHERE sso_provider_id = '$PROVIDER_ID' LIMIT 1;" 2>/dev/null | tr -d ' \n') + +if [[ -n "$LINKED_TENANT" ]]; then + echo "Provider already linked to tenant: $LINKED_TENANT" +else + if [[ -n "$SAML_TENANT" ]]; then + TENANT="$SAML_TENANT" + else + TENANT=$($PSQL -t -c "SELECT tenant FROM public.tenants WHERE tenant NOT LIKE 'ops.%' ORDER BY tenant LIMIT 1;" 2>/dev/null | tr -d ' \n') + if [[ -z "$TENANT" ]]; then + echo "Error: no tenants found. Create a tenant first or pass --saml-tenant." >&2 + exit 1 + fi + echo "No --saml-tenant specified, using first available: $TENANT" + fi + + # Normalise: ensure trailing slash + TENANT="${TENANT%/}/" + + # Create the tenant if it doesn't exist yet. + $PSQL -c "INSERT INTO public.tenants (tenant) VALUES ('$TENANT') ON CONFLICT (tenant) DO NOTHING;" + + $PSQL -c "UPDATE public.tenants SET sso_provider_id = '$PROVIDER_ID' WHERE tenant = '$TENANT';" + echo "Linked provider to tenant: $TENANT" +fi + +echo "" +echo "--- SAML setup complete ---" +echo " Provider : $PROVIDER_LABEL" +echo " Provider ID : $PROVIDER_ID" +echo " SSO login : curl -s -X POST 'http://127.0.0.1:5431/auth/v1/sso' -H 'Content-Type: application/json' -d '{\"provider_id\":\"$PROVIDER_ID\"}'" diff --git a/mise/tasks/local/stack b/mise/tasks/local/stack index dce4de701ff..f415f3d9a4d 100755 --- a/mise/tasks/local/stack +++ b/mise/tasks/local/stack @@ -3,14 +3,6 @@ set -euo pipefail #MISE description="Start a local stack (control plane and 'local-cluster' data plane)" #MISE depends=["build:connector-init", "build:rocksdb", "local:control-plane"] -#USAGE flag "--saml" help="Enable SAML/SSO via MockSAML after stack starts" -#USAGE flag "--saml-domain " help="Email domain for MockSAML provider" default="example.com" -#USAGE flag "--saml-tenant " help="Tenant to link to MockSAML provider (defaults to first non-ops tenant)" - -SAML="${usage_saml:-false}" -SAML_DOMAIN="${usage_saml_domain:-example.com}" -SAML_TENANT="${usage_saml_tenant:-}" - mise run local:data-plane local-cluster 8000 --num-brokers 4 --num-reactors 1 --link --dekaf # Publish the bundled local-view ops catalog to the control plane. @@ -48,130 +40,3 @@ end \$\$ language plpgsql; commit; EOF - -# ── Optional: Enable SAML/SSO via MockSAML ────────────────────────────────── - -if [[ "$SAML" != "true" ]]; then - exit 0 -fi - -echo "" -echo "--- Enabling SAML/SSO ---" - -PSQL="psql postgresql://postgres:postgres@localhost:5432/postgres" - -# Generate a SAML signing key (PKCS#1). -# GoTrue needs this to sign outgoing SAMLRequests. -SAML_KEY=$(openssl genrsa -traditional 2048 2>/dev/null \ - | grep -v "^-----" \ - | tr -d '\n') - -# Detect docker prefix (direct or via Lima VM) -if docker ps &>/dev/null; then - DOCKER="docker" -elif limactl shell tiger docker ps &>/dev/null 2>&1; then - DOCKER="limactl shell tiger docker" -else - echo "Error: cannot reach Docker daemon. Start Docker or your Lima VM first." >&2 - exit 1 -fi - -# Inject SAML env vars into auth container (skipped if already present) -if $DOCKER exec supabase_auth_flow env 2>/dev/null | grep -q "GOTRUE_SAML_ENABLED=true"; then - echo "SAML env vars already present — skipping container recreation." -else - echo "--- Injecting SAML env vars into auth container ---" - - IMAGE=$($DOCKER inspect supabase_auth_flow --format '{{.Config.Image}}') - NETWORK=$($DOCKER inspect supabase_auth_flow --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}') - - $DOCKER inspect supabase_auth_flow --format '{{range .Config.Env}}{{println .}}{{end}}' \ - | grep -v -E '^(PATH=|API_EXTERNAL_URL=)' \ - > /tmp/auth_env.txt - echo "GOTRUE_SAML_ENABLED=true" >> /tmp/auth_env.txt - echo "GOTRUE_SAML_PRIVATE_KEY=$SAML_KEY" >> /tmp/auth_env.txt - echo "API_EXTERNAL_URL=http://127.0.0.1:5431/auth/v1" >> /tmp/auth_env.txt - echo "GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED=true" >> /tmp/auth_env.txt - echo "GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/check_sso_requirement" >> /tmp/auth_env.txt - - if [[ "$DOCKER" == limactl* ]]; then - limactl copy /tmp/auth_env.txt tiger:/tmp/auth_env.txt - fi - - $DOCKER stop supabase_auth_flow && $DOCKER rm supabase_auth_flow - $DOCKER run -d \ - --name supabase_auth_flow \ - --network "$NETWORK" \ - --restart always \ - --env-file /tmp/auth_env.txt \ - "$IMAGE" auth - - sleep 3 - if $DOCKER logs supabase_auth_flow --tail 5 2>&1 | grep -q "GoTrue API started"; then - echo "GoTrue is running with SAML enabled." - else - echo "Warning: GoTrue may not have started cleanly. Check logs:" - $DOCKER logs supabase_auth_flow --tail 20 - exit 1 - fi -fi - -# Ensure MockSAML provider is registered -PROVIDER_ID=$($PSQL -t -c "SELECT id FROM auth.sso_providers LIMIT 1;" 2>/dev/null | tr -d ' \n') - -if [[ -n "$PROVIDER_ID" ]]; then - echo "MockSAML provider already registered (id: $PROVIDER_ID)." -else - echo "--- Registering MockSAML provider ---" - - SERVICE_ROLE_KEY=$($DOCKER exec supabase_kong_flow cat /home/kong/kong.yml \ - | grep -o "sb_secret_[A-Za-z0-9_-]*" | head -1 || true) - - if [[ -z "$SERVICE_ROLE_KEY" ]]; then - echo "Error: could not determine service role key from Kong config." >&2 - exit 1 - fi - - RESPONSE=$(curl -s -X POST 'http://127.0.0.1:5431/auth/v1/admin/sso/providers' \ - -H "apikey: $SERVICE_ROLE_KEY" \ - -H 'Content-Type: application/json' \ - -d "{\"type\":\"saml\",\"metadata_url\":\"https://mocksaml.com/api/saml/metadata\",\"domains\":[\"$SAML_DOMAIN\"]}") - - PROVIDER_ID=$(echo "$RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" 2>/dev/null || true) - - if [[ -z "$PROVIDER_ID" ]]; then - echo "Error: failed to register provider. Response: $RESPONSE" >&2 - exit 1 - fi - - echo "MockSAML provider registered (id: $PROVIDER_ID, domain: $SAML_DOMAIN)." -fi - -# Link provider to a tenant -LINKED_TENANT=$($PSQL -t -c "SELECT tenant FROM public.tenants WHERE sso_provider_id = '$PROVIDER_ID' LIMIT 1;" 2>/dev/null | tr -d ' \n') - -if [[ -n "$LINKED_TENANT" ]]; then - echo "Provider already linked to tenant: $LINKED_TENANT" -else - if [[ -n "$SAML_TENANT" ]]; then - TENANT="$SAML_TENANT" - else - TENANT=$($PSQL -t -c "SELECT tenant FROM public.tenants WHERE tenant NOT LIKE 'ops.%' ORDER BY tenant LIMIT 1;" 2>/dev/null | tr -d ' \n') - if [[ -z "$TENANT" ]]; then - echo "Error: no tenants found. Create a tenant first or pass --saml-tenant." >&2 - exit 1 - fi - echo "No --saml-tenant specified, using first available: $TENANT" - fi - - # Normalise: ensure trailing slash - TENANT="${TENANT%/}/" - - $PSQL -c "UPDATE public.tenants SET sso_provider_id = '$PROVIDER_ID' WHERE tenant = '$TENANT';" - echo "Linked provider to tenant: $TENANT" -fi - -echo "" -echo "--- SAML setup complete ---" -echo " Provider ID : $PROVIDER_ID" -echo " SSO login : curl -s -X POST 'http://127.0.0.1:5431/auth/v1/sso' -H 'Content-Type: application/json' -d '{\"provider_id\":\"$PROVIDER_ID\"}'" diff --git a/supabase/migrations/00_polyfill.sql b/supabase/migrations/00_polyfill.sql index 523dcfb2edd..68f0d167fc8 100644 --- a/supabase/migrations/00_polyfill.sql +++ b/supabase/migrations/00_polyfill.sql @@ -46,10 +46,14 @@ BEGIN domain text not null ); create table auth.identities ( + id uuid primary key default gen_random_uuid(), user_id uuid references auth.users(id), provider text, provider_id text, - identity_data jsonb + identity_data jsonb, + last_sign_in_at timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() ); create table auth.sessions ( id uuid primary key default gen_random_uuid(), diff --git a/supabase/migrations/20260330120000_scim_tokens.sql b/supabase/migrations/20260330120000_scim_tokens.sql new file mode 100644 index 00000000000..c7d386d6f33 --- /dev/null +++ b/supabase/migrations/20260330120000_scim_tokens.sql @@ -0,0 +1,26 @@ +-- Partial SCIM deprovisioning: token table for authenticating SCIM requests. +-- +-- Each row maps a hashed bearer token to a tenant. The control-plane-api +-- hashes the incoming Authorization header and looks up the row to identify +-- which tenant the SCIM client is acting on behalf of. +-- +-- Multiple tokens per tenant are supported for zero-downtime key rotation: +-- create new token → configure IdP → delete old token. + +begin; + +create table internal.scim_tokens ( + id uuid primary key default gen_random_uuid(), + tenant_id flowid not null references tenants(id), + token_hash text not null, -- SHA-256 hex digest of plaintext bearer token + label text, -- optional human-readable label (e.g. "Okta prod") + created_at timestamptz not null default now() +); + +-- Fast lookup by hash on every SCIM request. +create unique index on internal.scim_tokens (token_hash); + +comment on table internal.scim_tokens is + 'Bearer tokens for SCIM API authentication, hashed with SHA-256. Each token is scoped to a tenant.'; + +commit;