From 9d63fbbd4ecebd16165ee1a92ae570b571066346 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 11 Jun 2026 15:36:42 -0400 Subject: [PATCH 01/13] control plane: service accounts and API keys Restacked onto greg/refresh-token-exchange, which carries the refresh-token GraphQL operations, the /api/v1/auth/token endpoint, and stateful bearer authentication that this branch previously bundled. This commit squashes the prior branch history (PR feedback, tenant filtering, revocation semantics, SHA-256 key hashing) into the remaining service-account delta: - internal.service_accounts and internal.api_keys tables. A service account is a non-login auth.users identity whose access is determined solely by its user_grants; its prefix is a management anchor determining who may manage it. - GraphQL operations: serviceAccounts query (tenant-filterable, paginated, with batch-loaded API keys), createServiceAccount, revokeServiceAccountGrants (the kill switch), createApiKey, revokeApiKey. - api_key grant on POST /api/v1/auth/token: verifies flow_sa_ keys against SHA-256 hashes and mints access tokens in the application layer. - ManageServiceAccounts orthogonal capability, bundled into TeamAdmin; authorized_prefixes gains a tenant filter that narrows (never widens) the caller's authorized set. - Service-account principals are denied refresh tokens, and SA synthetic emails are excluded from Stripe billing-contact selection. --- ...d9c6994ea7ab034268e289897d848da5f1d29.json | 14 + ...48bb80a3818f6435d85cfa87fdcf43eca1e3d.json | 60 + ...eac5f15d5b8c1e5b01d10874e84710b8768af.json | 22 + ...52d412f18eebcb69c32932267d8efe31b50df.json | 22 + ...4b0bf4f9a0c8da1830dcd304f5c4157ba2441.json | 58 + ...b6438ee7bb6cb9005397703d413f93b6a4c59.json | 14 + ...f6d13534650d8365dca164c03df68b462d154.json | 25 + ...1fbe471070810a12f453b3e26b4f1cd70fcc.json} | 4 +- ...2928b9fe23e87b813aabc3b6e0a834f7f06fb.json | 22 + ...c92c86eb49dd18354c1ac11566ce29510489c.json | 15 + ...f7b967c364bfdb01f4d7d30ad7a62a1b42131.json | 22 + ...7860a5f60c60b27f4717aa9c1cd6f910889ab.json | 31 + ...cf3e2d32a6ee387d69af18ea7b06047335376.json | 16 + ...f184bf6b81e497732955eac3ecadfcdf5554d.json | 23 + crates/billing-integrations/src/publish.rs | 5 + crates/control-plane-api/src/envelope.rs | 21 +- crates/control-plane-api/src/server/mod.rs | 78 + .../src/server/public/graphql/mod.rs | 3 + .../server/public/graphql/refresh_tokens.rs | 59 +- .../server/public/graphql/service_accounts.rs | 1385 +++++++++++++++++ crates/flow-client/control-plane-api.graphql | 251 +-- crates/models/src/authz.rs | 3 +- supabase/migrations/00_polyfill.sql | 4 +- .../20260528120000_service_accounts.sql | 61 + 24 files changed, 2105 insertions(+), 113 deletions(-) create mode 100644 .sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json create mode 100644 .sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json create mode 100644 .sqlx/query-3ca07bc1e85a922a7b12de7a6dfeac5f15d5b8c1e5b01d10874e84710b8768af.json create mode 100644 .sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json create mode 100644 .sqlx/query-45e004513d51bbe95e3b6e6ceff4b0bf4f9a0c8da1830dcd304f5c4157ba2441.json create mode 100644 .sqlx/query-629063373c60c75e6f7f1cb17b7b6438ee7bb6cb9005397703d413f93b6a4c59.json create mode 100644 .sqlx/query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json rename .sqlx/{query-3284a4b8b8368d36bb286e8aaad6a12642ebfe7815281a62705bd3d7a32e2eb0.json => query-7903341c9acaf216eafd315680b31fbe471070810a12f453b3e26b4f1cd70fcc.json} (51%) create mode 100644 .sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json create mode 100644 .sqlx/query-bdc6b63dae3c20d0dcf342f6353c92c86eb49dd18354c1ac11566ce29510489c.json create mode 100644 .sqlx/query-cb2678905898cd566802b5f77cff7b967c364bfdb01f4d7d30ad7a62a1b42131.json create mode 100644 .sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json create mode 100644 .sqlx/query-f7562d1965e61fe0ad91b6daf0ccf3e2d32a6ee387d69af18ea7b06047335376.json create mode 100644 .sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json create mode 100644 crates/control-plane-api/src/server/public/graphql/service_accounts.rs create mode 100644 supabase/migrations/20260528120000_service_accounts.sql diff --git a/.sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json b/.sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json new file mode 100644 index 00000000000..343f27c2ff1 --- /dev/null +++ b/.sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE internal.api_keys\n SET revoked_at = now()\n WHERE service_account_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29" +} diff --git a/.sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json b/.sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json new file mode 100644 index 00000000000..ed356120641 --- /dev/null +++ b/.sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n sa.user_id,\n sa.display_name,\n sa.catalog_name AS \"catalog_name!: String\",\n sa.created_by,\n sa.created_at AS \"created_at!: chrono::DateTime\",\n sa.updated_at AS \"updated_at!: chrono::DateTime\",\n sa.last_used_at AS \"last_used_at: chrono::DateTime\"\n FROM internal.service_accounts sa\n WHERE sa.catalog_name::text ^@ ANY($1)\n AND ($2::timestamptz IS NULL OR sa.created_at < $2)\n ORDER BY sa.created_at DESC\n LIMIT $3 + 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "catalog_name!: String", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "last_used_at: chrono::DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Timestamptz", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d" +} diff --git a/.sqlx/query-3ca07bc1e85a922a7b12de7a6dfeac5f15d5b8c1e5b01d10874e84710b8768af.json b/.sqlx/query-3ca07bc1e85a922a7b12de7a6dfeac5f15d5b8c1e5b01d10874e84710b8768af.json new file mode 100644 index 00000000000..01c08e645ce --- /dev/null +++ b/.sqlx/query-3ca07bc1e85a922a7b12de7a6dfeac5f15d5b8c1e5b01d10874e84710b8768af.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM internal.service_accounts WHERE user_id = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3ca07bc1e85a922a7b12de7a6dfeac5f15d5b8c1e5b01d10874e84710b8768af" +} diff --git a/.sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json b/.sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json new file mode 100644 index 00000000000..516deb99c14 --- /dev/null +++ b/.sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT catalog_name AS \"catalog_name!: String\"\n FROM internal.service_accounts\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "catalog_name!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df" +} diff --git a/.sqlx/query-45e004513d51bbe95e3b6e6ceff4b0bf4f9a0c8da1830dcd304f5c4157ba2441.json b/.sqlx/query-45e004513d51bbe95e3b6e6ceff4b0bf4f9a0c8da1830dcd304f5c4157ba2441.json new file mode 100644 index 00000000000..6de68677589 --- /dev/null +++ b/.sqlx/query-45e004513d51bbe95e3b6e6ceff4b0bf4f9a0c8da1830dcd304f5c4157ba2441.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ak.id AS \"id!: models::Id\",\n ak.service_account_id,\n ak.label,\n ak.created_by,\n ak.created_at AS \"created_at!: chrono::DateTime\",\n ak.expires_at AS \"expires_at!: chrono::DateTime\",\n ak.last_used_at AS \"last_used_at: chrono::DateTime\"\n FROM internal.api_keys ak\n WHERE ak.service_account_id = ANY($1)\n AND ak.revoked_at IS NULL\n ORDER BY ak.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: models::Id", + "type_info": "Macaddr8" + }, + { + "ordinal": 1, + "name": "service_account_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "label", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "last_used_at: chrono::DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "45e004513d51bbe95e3b6e6ceff4b0bf4f9a0c8da1830dcd304f5c4157ba2441" +} diff --git a/.sqlx/query-629063373c60c75e6f7f1cb17b7b6438ee7bb6cb9005397703d413f93b6a4c59.json b/.sqlx/query-629063373c60c75e6f7f1cb17b7b6438ee7bb6cb9005397703d413f93b6a4c59.json new file mode 100644 index 00000000000..49fffcd968d --- /dev/null +++ b/.sqlx/query-629063373c60c75e6f7f1cb17b7b6438ee7bb6cb9005397703d413f93b6a4c59.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE internal.api_keys SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Macaddr8" + ] + }, + "nullable": [] + }, + "hash": "629063373c60c75e6f7f1cb17b7b6438ee7bb6cb9005397703d413f93b6a4c59" +} diff --git a/.sqlx/query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json b/.sqlx/query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json new file mode 100644 index 00000000000..8c98f310cf9 --- /dev/null +++ b/.sqlx/query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO internal.service_accounts (user_id, catalog_name, display_name, created_by)\n VALUES ($1, $2::text::catalog_name, $3, $4)\n RETURNING created_at AS \"created_at!: chrono::DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at!: chrono::DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154" +} diff --git a/.sqlx/query-3284a4b8b8368d36bb286e8aaad6a12642ebfe7815281a62705bd3d7a32e2eb0.json b/.sqlx/query-7903341c9acaf216eafd315680b31fbe471070810a12f453b3e26b4f1cd70fcc.json similarity index 51% rename from .sqlx/query-3284a4b8b8368d36bb286e8aaad6a12642ebfe7815281a62705bd3d7a32e2eb0.json rename to .sqlx/query-7903341c9acaf216eafd315680b31fbe471070810a12f453b3e26b4f1cd70fcc.json index dbaada791a7..3a12243cf30 100644 --- a/.sqlx/query-3284a4b8b8368d36bb286e8aaad6a12642ebfe7815281a62705bd3d7a32e2eb0.json +++ b/.sqlx/query-7903341c9acaf216eafd315680b31fbe471070810a12f453b3e26b4f1cd70fcc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n select users.email as email\n from user_grants\n join auth.users as users on user_grants.user_id = users.id\n where users.email is not null and user_grants.object_role = $1\n and user_grants.capability = 'admin'\n order by users.created_at asc\n ", + "query": "\n select users.email as email\n from user_grants\n join auth.users as users on user_grants.user_id = users.id\n where users.email is not null and user_grants.object_role = $1\n and user_grants.capability = 'admin'\n -- Exclude service accounts: their synthetic sa+ addresses\n -- must never be chosen as a tenant's Stripe billing contact.\n and not exists (\n select 1 from internal.service_accounts sa where sa.user_id = users.id\n )\n order by users.created_at asc\n ", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "3284a4b8b8368d36bb286e8aaad6a12642ebfe7815281a62705bd3d7a32e2eb0" + "hash": "7903341c9acaf216eafd315680b31fbe471070810a12f453b3e26b4f1cd70fcc" } diff --git a/.sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json b/.sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json new file mode 100644 index 00000000000..06c0683bedc --- /dev/null +++ b/.sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ak.service_account_id\n FROM internal.api_keys ak\n WHERE ak.id = $1 AND ak.revoked_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "service_account_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Macaddr8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb" +} diff --git a/.sqlx/query-bdc6b63dae3c20d0dcf342f6353c92c86eb49dd18354c1ac11566ce29510489c.json b/.sqlx/query-bdc6b63dae3c20d0dcf342f6353c92c86eb49dd18354c1ac11566ce29510489c.json new file mode 100644 index 00000000000..541a0452b0c --- /dev/null +++ b/.sqlx/query-bdc6b63dae3c20d0dcf342f6353c92c86eb49dd18354c1ac11566ce29510489c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM public.user_grants WHERE user_id = $1 AND object_role = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bdc6b63dae3c20d0dcf342f6353c92c86eb49dd18354c1ac11566ce29510489c" +} diff --git a/.sqlx/query-cb2678905898cd566802b5f77cff7b967c364bfdb01f4d7d30ad7a62a1b42131.json b/.sqlx/query-cb2678905898cd566802b5f77cff7b967c364bfdb01f4d7d30ad7a62a1b42131.json new file mode 100644 index 00000000000..8bb93fbf4a9 --- /dev/null +++ b/.sqlx/query-cb2678905898cd566802b5f77cff7b967c364bfdb01f4d7d30ad7a62a1b42131.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT $1::text::interval > interval '0'\n AND $1::text::interval <= interval '1 year' AS \"ok!: bool\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "ok!: bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "cb2678905898cd566802b5f77cff7b967c364bfdb01f4d7d30ad7a62a1b42131" +} diff --git a/.sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json b/.sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json new file mode 100644 index 00000000000..e4fbc8720c6 --- /dev/null +++ b/.sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH new_key AS (\n SELECT\n internal.id_generator() AS id,\n -- 256 bits from pgcrypto's CSPRNG; the SHA-256 hashing\n -- below rests on secrets being high-entropy.\n encode(gen_random_bytes(32), 'hex') AS secret\n )\n INSERT INTO internal.api_keys (id, service_account_id, secret_hash, label, expires_at, created_by)\n SELECT\n nk.id,\n $1,\n -- SHA-256 rather than bcrypt: API keys are only ever\n -- verified statefully -- every bearer presentation re-checks\n -- this hash, and a key is never exchanged for a signed JWT --\n -- placing verification squarely in the per-request hot path,\n -- where a fast hash matters. A slow hash would buy nothing\n -- anyway: the secret is high-entropy random, and offline\n -- brute-force resistance only matters for low-entropy\n -- passwords. This matches how GitHub stores PATs.\n -- Refresh-token secrets will migrate to the same scheme once\n -- the legacy postgREST token functions are retired.\n encode(digest(nk.secret, 'sha256'), 'hex'),\n $2,\n now() + $3::text::interval,\n $4\n FROM new_key nk\n RETURNING\n id AS \"id!: models::Id\",\n (SELECT secret FROM new_key) AS \"secret!: String\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: models::Id", + "type_info": "Macaddr8" + }, + { + "ordinal": 1, + "name": "secret!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab" +} diff --git a/.sqlx/query-f7562d1965e61fe0ad91b6daf0ccf3e2d32a6ee387d69af18ea7b06047335376.json b/.sqlx/query-f7562d1965e61fe0ad91b6daf0ccf3e2d32a6ee387d69af18ea7b06047335376.json new file mode 100644 index 00000000000..d59830a69f5 --- /dev/null +++ b/.sqlx/query-f7562d1965e61fe0ad91b6daf0ccf3e2d32a6ee387d69af18ea7b06047335376.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO auth.users (id, email, raw_user_meta_data)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "f7562d1965e61fe0ad91b6daf0ccf3e2d32a6ee387d69af18ea7b06047335376" +} diff --git a/.sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json b/.sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json new file mode 100644 index 00000000000..773adb2b14a --- /dev/null +++ b/.sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH verified AS (\n UPDATE internal.api_keys\n SET last_used_at = now()\n WHERE id = $1\n AND secret_hash = encode(digest($2, 'sha256'), 'hex')\n AND expires_at > now()\n AND revoked_at IS NULL\n RETURNING service_account_id\n )\n UPDATE internal.service_accounts sa\n SET last_used_at = now()\n FROM verified v\n WHERE sa.user_id = v.service_account_id\n RETURNING sa.user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Macaddr8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d" +} diff --git a/crates/billing-integrations/src/publish.rs b/crates/billing-integrations/src/publish.rs index ebe31955887..ddba8d31959 100644 --- a/crates/billing-integrations/src/publish.rs +++ b/crates/billing-integrations/src/publish.rs @@ -825,6 +825,11 @@ async fn get_or_create_customer_for_tenant( join auth.users as users on user_grants.user_id = users.id where users.email is not null and user_grants.object_role = $1 and user_grants.capability = 'admin' + -- Exclude service accounts: their synthetic sa+ addresses + -- must never be chosen as a tenant's Stripe billing contact. + and not exists ( + select 1 from internal.service_accounts sa where sa.user_id = users.id + ) order by users.created_at asc "#, tenant diff --git a/crates/control-plane-api/src/envelope.rs b/crates/control-plane-api/src/envelope.rs index 3ca7d720771..3565327996d 100644 --- a/crates/control-plane-api/src/envelope.rs +++ b/crates/control-plane-api/src/envelope.rs @@ -237,8 +237,25 @@ impl axum::extract::FromRequestParts> for Envelope { let mut token = auth.token(); let exchanged_token: Option; - // Is this is a refresh token? If so, first exchange for an access token. - if !token.contains(".") { + // A service-account API key (flow_sa_-prefixed) or a + // refresh token (base64 JSON, which never contains '.') may + // be presented in lieu of a signed JWT. We exchange either + // for a short-lived signed access token and then verify it + // exactly like a JWT presented directly; a JWT is verified + // by its signature. The exchange's stateful database check + // is the source of trust — the signature just lets the + // minted token flow through the normal verify path. + if token.starts_with("flow_sa_") { + exchanged_token = Some( + crate::server::exchange_api_key( + &state.pg_pool, + &state.control_plane_jwt_encode_key, + token, + ) + .await?, + ); + token = exchanged_token.as_ref().unwrap(); + } else if !token.contains('.') { exchanged_token = Some( crate::server::exchange_refresh_token(&state.pg_pool, token).await?, ); diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index c8247a163f5..af53f4da232 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -281,6 +281,84 @@ pub async fn exchange_refresh_token( Ok(access_token) } +/// Authenticate a service-account API key presented as a bearer credential, +/// returning the claims it proves. +/// +/// API keys are evaluated *statefully only*: every presentation is verified +/// against the database, and a key is never exchanged for a signed JWT. This +/// is what makes key revocation immediate — there are no outstanding minted +/// tokens to wait out — and it's why key secrets are hashed with SHA-256 +/// rather than bcrypt: the secret is high-entropy random (so a slow hash adds +/// no brute-force protection) and this verification is in the per-request hot +/// path, where a fast hash matters. +pub async fn authenticate_api_key( + pg_pool: &sqlx::PgPool, + api_key: &str, +) -> tonic::Result> { + let raw = api_key + .strip_prefix("flow_sa_") + .expect("caller dispatches on the flow_sa_ prefix"); + + use base64::Engine; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(raw) + .map_err(|_| tonic::Status::invalid_argument("malformed api key: invalid base64"))?; + let decoded = String::from_utf8(decoded) + .map_err(|_| tonic::Status::invalid_argument("malformed api key: invalid UTF-8"))?; + + let (id_str, secret) = decoded + .split_once(':') + .ok_or_else(|| tonic::Status::invalid_argument("malformed api key payload"))?; + let key_id: models::Id = id_str + .parse() + .map_err(|_| tonic::Status::invalid_argument("malformed api key: invalid key id"))?; + + // Validate the secret, expiry, and revocation in one query, stamping + // last_used_at on both the key and its service account as part of the + // same verification round-trip. + let row = sqlx::query!( + r#" + WITH verified AS ( + UPDATE internal.api_keys + SET last_used_at = now() + WHERE id = $1 + AND secret_hash = encode(digest($2, 'sha256'), 'hex') + AND expires_at > now() + AND revoked_at IS NULL + RETURNING service_account_id + ) + UPDATE internal.service_accounts sa + SET last_used_at = now() + FROM verified v + WHERE sa.user_id = v.service_account_id + RETURNING sa.user_id + "#, + key_id as models::Id, + secret, + ) + .fetch_optional(pg_pool) + .await + .map_err(|err| tonic::Status::internal(format!("failed to authenticate api key: {err}")))? + .ok_or_else(|| tonic::Status::unauthenticated("invalid, expired, or revoked api key"))?; + + // As with refresh-token bearer authentication: verification re-runs on + // every presentation, making revocation near-immediate, and the small + // expiry only bounds any future caching of this authentication. + let now = tokens::now(); + let exp = now + chrono::Duration::minutes(5); + + let claims = crate::ControlClaims { + iat: now.timestamp() as u64, + exp: exp.timestamp() as u64, + sub: row.user_id, + role: "authenticated".to_string(), + aud: "authenticated".to_string(), + email: None, + }; + + Ok(tokens::Verified::assert_authenticity(claims, exp)) +} + /// Parse a data-plane claims token without verifying its signature. /// Returns an `Unverified` wrapper to make clear the claims have not been verified. fn parse_untrusted_data_plane_claims( 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 9b4222d35d8..545c1ee8729 100644 --- a/crates/control-plane-api/src/server/public/graphql/mod.rs +++ b/crates/control-plane-api/src/server/public/graphql/mod.rs @@ -38,6 +38,7 @@ mod live_specs; mod prefixes; mod publication_history; mod refresh_tokens; +mod service_accounts; pub mod status; mod storage_mappings; mod tenant; @@ -120,6 +121,7 @@ pub struct QueryRoot( connectors::ConnectorsQuery, tenant::TenantQuery, refresh_tokens::RefreshTokensQuery, + service_accounts::ServiceAccountsQuery, ); // Represents the portion of the GraphQL schema that deals with mutations. @@ -132,6 +134,7 @@ pub struct MutationRoot( invite_links::InviteLinksMutation, data_planes::DataPlanesMutation, refresh_tokens::RefreshTokensMutation, + service_accounts::ServiceAccountsMutation, ); pub fn create_schema(alert_config_defaults: models::AlertConfig) -> GraphQLSchema { diff --git a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs index 29821b8dd17..d57c633de27 100644 --- a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs +++ b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs @@ -137,6 +137,15 @@ impl RefreshTokensMutation { )); } + // Service accounts authenticate exclusively via API keys, which are + // expiring and revocable. A refresh token bypasses both, so deny + // issuance to SA principals. + if super::service_accounts::is_service_account(&env.pg_pool, claims.sub).await? { + return Err(async_graphql::Error::new( + "service accounts cannot create refresh tokens; authenticate with an API key instead", + )); + } + let row = sqlx::query!( r#" WITH new_token AS ( @@ -240,9 +249,10 @@ mod test { /// Covers the refresh-token GraphQL surface (create → list → revoke, plus /// the `validFor` validation and not-found idempotency guards), the - /// `/api/v1/auth/token` refresh-token dispatch, and rejection of a refresh + /// `/api/v1/auth/token` refresh-token dispatch, rejection of a refresh /// token presented as a bearer credential when its secret is bad or it has - /// been revoked. + /// been revoked, and the guard denying refresh tokens to service-account + /// principals. /// /// The happy-path *exchange* — `generate_access_token` actually signing a /// JWT — is intentionally not exercised here: it reads `app.jwt_secret` from @@ -472,5 +482,50 @@ mod test { ) .await; assert!(revoke_again["errors"].is_array()); + + // === Service accounts cannot create refresh tokens === + // They authenticate via API keys, which are expiring and revocable; a + // refresh token would bypass both, so issuance to an SA principal must + // be denied. + let create_sa: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccount( + catalogName: "aliceCo/refresh-token-bot" + displayName: "refresh-token bot" + grants: [{ prefix: "aliceCo/", capability: admin }] + ) { id } + }"# + }), + Some(&alice_token), + ) + .await; + assert!( + create_sa["errors"].is_null(), + "create SA should succeed: {create_sa}" + ); + let sa_user_id = create_sa["data"]["createServiceAccount"]["id"] + .as_str() + .expect("should have id"); + + // Mint an access token whose `sub` is the service account, mirroring + // the claims that stateful API-key bearer authentication constructs + // (no email for an SA principal). + let sa_token = server.make_access_token(uuid::Uuid::parse_str(sa_user_id).unwrap(), None); + + let sa_create_rt: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { createRefreshToken(validFor: "P30D") { id secret } }"# + }), + Some(&sa_token), + ) + .await; + assert!( + sa_create_rt["errors"].is_array(), + "service account should be denied a refresh token: {sa_create_rt}" + ); } } diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs new file mode 100644 index 00000000000..054a24c272e --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -0,0 +1,1385 @@ +use super::TimestampCursor; +use async_graphql::{Context, types::connection}; + +#[derive(Debug, Clone, async_graphql::SimpleObject)] +pub struct ServiceAccount { + // Exposed as `id`: a service account's identifier happens to be its + // backing auth.users id, but that's an implementation detail we don't + // surface in the public schema. + #[graphql(name = "id")] + pub user_id: uuid::Uuid, + pub display_name: String, + pub catalog_name: models::Name, + pub created_by: uuid::Uuid, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub last_used_at: Option>, + pub api_keys: Vec, +} + +/// A user_grant to seed a service account with at creation time. +#[derive(Debug, Clone, async_graphql::InputObject)] +pub struct ServiceAccountGrantInput { + pub prefix: models::Prefix, + pub capability: models::Capability, +} + +#[derive(Debug, Clone, async_graphql::SimpleObject)] +pub struct ApiKeyInfo { + #[graphql(name = "id")] + pub key_id: models::Id, + pub label: String, + pub created_by: uuid::Uuid, + pub created_at: chrono::DateTime, + pub expires_at: chrono::DateTime, + pub last_used_at: Option>, +} + +#[derive(Debug, Clone, async_graphql::SimpleObject)] +pub struct CreateApiKeyResult { + #[graphql(name = "id")] + pub key_id: models::Id, + pub secret: String, +} + +pub type PaginatedServiceAccounts = connection::Connection< + TimestampCursor, + ServiceAccount, + connection::EmptyFields, + connection::EmptyFields, + connection::DefaultConnectionName, + connection::DefaultEdgeName, + connection::DisableNodesField, +>; + +#[derive(Debug, Default)] +pub struct ServiceAccountsQuery; + +const DEFAULT_PAGE_SIZE: usize = 25; +const MAX_PREFIXES: usize = 20; + +#[async_graphql::Object] +impl ServiceAccountsQuery { + async fn service_accounts( + &self, + ctx: &Context<'_>, + after: Option, + first: Option, + ) -> async_graphql::Result { + let env = ctx.data::()?; + + let snapshot = env.snapshot(); + // Service accounts are visible to callers who can manage them: those + // holding ManageServiceAccounts on a prefix covering the account's + // catalog_name. + let user_accessible_prefixes = super::authorized_prefixes::authorized_prefixes( + &snapshot.role_grants, + &snapshot.user_grants, + env.claims()?.sub, + models::authz::Capability::ManageServiceAccounts, + None, + ); + + if user_accessible_prefixes.is_empty() { + return Ok(PaginatedServiceAccounts::new(false, false)); + } + if user_accessible_prefixes.len() > MAX_PREFIXES { + return Err(async_graphql::Error::new("Too many accessible prefixes")); + } + + connection::query_with::( + after, + None, + first, + None, + |after, _, first, _| async move { + let after_created_at = after.map(|c| c.0); + let limit = first.unwrap_or(DEFAULT_PAGE_SIZE); + + let sa_rows = sqlx::query!( + r#" + SELECT + sa.user_id, + sa.display_name, + sa.catalog_name AS "catalog_name!: String", + sa.created_by, + sa.created_at AS "created_at!: chrono::DateTime", + sa.updated_at AS "updated_at!: chrono::DateTime", + sa.last_used_at AS "last_used_at: chrono::DateTime" + FROM internal.service_accounts sa + WHERE sa.catalog_name::text ^@ ANY($1) + AND ($2::timestamptz IS NULL OR sa.created_at < $2) + ORDER BY sa.created_at DESC + LIMIT $3 + 1 + "#, + &user_accessible_prefixes, + after_created_at, + limit as i64, + ) + .fetch_all(&env.pg_pool) + .await?; + + let has_next = sa_rows.len() > limit; + + let user_ids: Vec = + sa_rows.iter().take(limit).map(|r| r.user_id).collect(); + + // Keys are batch-loaded for the whole page in one query (no + // N+1). The tradeoff is that this runs even when the caller + // didn't select `apiKeys`. That's fine for a low-frequency admin + // listing against an indexed column. Revisit with a `DataLoader` + // (a `#[ComplexObject]` api_keys resolver backed by a batching + // loader keyed on service_account_id) if either changes: callers + // commonly list service accounts WITHOUT `apiKeys` (making this + // fetch mostly wasted), or more lazily-resolved per-account child + // collections get added — at which point one batching mechanism + // beats several conditional eager fetches. + let key_rows = if user_ids.is_empty() { + vec![] + } else { + sqlx::query!( + r#" + SELECT + ak.id AS "id!: models::Id", + ak.service_account_id, + ak.label, + ak.created_by, + ak.created_at AS "created_at!: chrono::DateTime", + ak.expires_at AS "expires_at!: chrono::DateTime", + ak.last_used_at AS "last_used_at: chrono::DateTime" + FROM internal.api_keys ak + WHERE ak.service_account_id = ANY($1) + AND ak.revoked_at IS NULL + ORDER BY ak.created_at DESC + "#, + &user_ids, + ) + .fetch_all(&env.pg_pool) + .await? + }; + + let mut keys_by_sa: std::collections::HashMap> = + std::collections::HashMap::new(); + for kr in key_rows { + keys_by_sa + .entry(kr.service_account_id) + .or_default() + .push(ApiKeyInfo { + key_id: kr.id, + label: kr.label, + created_by: kr.created_by, + created_at: kr.created_at, + expires_at: kr.expires_at, + last_used_at: kr.last_used_at, + }); + } + + let edges: Vec<_> = sa_rows + .into_iter() + .take(limit) + .map(|r| { + let api_keys = keys_by_sa.remove(&r.user_id).unwrap_or_default(); + connection::Edge::new( + TimestampCursor(r.created_at), + ServiceAccount { + user_id: r.user_id, + display_name: r.display_name, + catalog_name: models::Name::new(&r.catalog_name), + created_by: r.created_by, + created_at: r.created_at, + updated_at: r.updated_at, + last_used_at: r.last_used_at, + api_keys, + }, + ) + }) + .collect(); + + let mut conn = connection::Connection::new(after_created_at.is_some(), has_next); + conn.edges = edges; + Ok(conn) + }, + ) + .await + } +} + +#[derive(Debug, Default)] +pub struct ServiceAccountsMutation; + +#[async_graphql::Object] +impl ServiceAccountsMutation { + /// Create a service account homed at the specified catalog name, seeded + /// with the given user_grants. + /// + /// `catalogName` is a management anchor: admins of a prefix covering it + /// may manage the account. It determines who may manage the account, not + /// what the account may access. Access is determined solely by the + /// account's user_grants, which may span multiple prefixes. + /// + /// The caller must have admin capability on the catalog name AND on each + /// granted prefix. Creates an auth.users row, an + /// internal.service_accounts row, and a user_grants row per requested + /// grant. + async fn create_service_account( + &self, + ctx: &Context<'_>, + catalog_name: models::Name, + display_name: String, + grants: Vec, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + if let Err(err) = validator::Validate::validate(&catalog_name) { + return Err(async_graphql::Error::new(format!( + "invalid catalog name: {err}" + ))); + } + for grant in &grants { + if let Err(err) = validator::Validate::validate(&grant.prefix) { + return Err(async_graphql::Error::new(format!( + "invalid grant prefix {}: {err}", + grant.prefix.as_str(), + ))); + } + // `none` confers no access until bundles are wired, so reject it + // rather than mint a no-op grant. + if grant.capability == models::Capability::None { + return Err(async_graphql::Error::new( + "grant capability must be one of: read, write, admin", + )); + } + } + + super::verify_authorization(env, catalog_name.as_str(), models::Capability::Admin).await?; + // Each grant requires the same authorization as granting a human user: + // admin capability on the granted prefix. This prevents a caller from + // provisioning an account with access they don't themselves administer. + for grant in &grants { + super::verify_authorization(env, grant.prefix.as_str(), models::Capability::Admin) + .await?; + } + + let mut txn = env.pg_pool.begin().await?; + + let sa_user_id = uuid::Uuid::new_v4(); + + sqlx::query!( + r#" + INSERT INTO auth.users (id, email, raw_user_meta_data) + VALUES ($1, $2, $3) + "#, + sa_user_id, + format!("sa+{}@service.estuary.dev", sa_user_id), + serde_json::json!({ + "full_name": display_name, + }), + ) + .execute(&mut *txn) + .await?; + + let now = sqlx::query_scalar!( + r#" + INSERT INTO internal.service_accounts (user_id, catalog_name, display_name, created_by) + VALUES ($1, $2::text::catalog_name, $3, $4) + RETURNING created_at AS "created_at!: chrono::DateTime" + "#, + sa_user_id, + catalog_name.as_str(), + display_name, + claims.sub, + ) + .fetch_one(&mut *txn) + .await?; + + for grant in &grants { + crate::grants::upsert_user_grant( + sa_user_id, + grant.prefix.as_str(), + grant.capability, + Some("service account grant".to_string()), + &mut txn, + ) + .await?; + } + + txn.commit().await?; + + tracing::info!( + %catalog_name, + ?grants, + %claims.sub, + %sa_user_id, + "created service account" + ); + + Ok(ServiceAccount { + user_id: sa_user_id, + display_name, + catalog_name, + created_by: claims.sub, + created_at: now, + updated_at: now, + last_used_at: None, + api_keys: vec![], + }) + } + + /// Add a user_grant to a service account. + /// + /// The caller must manage the service account (admin capability on its + /// catalog name) AND have admin capability on the granted prefix — the + /// same authorization as granting a human user. The second requirement + /// prevents a caller from extending an account's access beyond what they + /// themselves administer. + async fn add_service_account_grant( + &self, + ctx: &Context<'_>, + #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, + prefix: models::Prefix, + capability: models::Capability, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + if let Err(err) = validator::Validate::validate(&prefix) { + return Err(async_graphql::Error::new(format!( + "invalid grant prefix {}: {err}", + prefix.as_str(), + ))); + } + // `none` confers no access until bundles are wired, so reject it + // rather than mint a no-op grant. + if capability == models::Capability::None { + return Err(async_graphql::Error::new( + "grant capability must be one of: read, write, admin", + )); + } + + let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; + super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + super::verify_authorization(env, prefix.as_str(), models::Capability::Admin).await?; + + let mut txn = env.pg_pool.begin().await?; + crate::grants::upsert_user_grant( + user_id, + prefix.as_str(), + capability, + Some("service account grant".to_string()), + &mut txn, + ) + .await?; + txn.commit().await?; + + tracing::info!( + %user_id, + %catalog_name, + %prefix, + ?capability, + %claims.sub, + "added service account grant" + ); + + Ok(true) + } + + /// Remove a user_grant from a service account. + /// + /// The caller must manage the service account (admin capability on its + /// catalog name). Unlike addServiceAccountGrant, no capability on the + /// grant's prefix is required: removal only ever narrows the account's + /// access, so managers may remove ANY grant — including grants to + /// prefixes they don't themselves administer. + async fn remove_service_account_grant( + &self, + ctx: &Context<'_>, + #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, + prefix: models::Prefix, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; + super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + + let deleted = sqlx::query!( + "DELETE FROM public.user_grants WHERE user_id = $1 AND object_role = $2", + user_id, + prefix.as_str(), + ) + .execute(&env.pg_pool) + .await?; + + if deleted.rows_affected() == 0 { + return Err(async_graphql::Error::new("grant not found")); + } + + tracing::info!( + %user_id, + %catalog_name, + %prefix, + %claims.sub, + "removed service account grant" + ); + + Ok(true) + } + + /// Create an API key for a service account. + /// + /// Returns the key_id and the plaintext secret (flow_sa_...). + /// The secret is returned exactly once and cannot be retrieved again. + /// + /// The key is presented directly as an `Authorization: Bearer` credential. + /// It is verified statefully against the database on every request and is + /// never exchanged for an access token, so revoking it cuts off access + /// immediately. + async fn create_api_key( + &self, + ctx: &Context<'_>, + #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, + label: String, + #[graphql(desc = "ISO 8601 duration for key validity (e.g. P90D, P1Y)")] valid_for: String, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; + super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + + // valid_for is documented as an ISO 8601 duration (e.g. P90D, P1Y). + // Reject anything that isn't ISO 8601 up front: the `::interval` cast + // below would otherwise also accept Postgres's own syntax ("90 days"), + // silently widening the contract and contradicting the field's docs and + // error messages. ISO 8601 durations always start with 'P'; no Postgres + // traditional unit does, so this prefix check cleanly distinguishes them. + if !valid_for.trim_start().starts_with('P') { + return Err(async_graphql::Error::new( + "valid_for must be an ISO 8601 duration, e.g. P90D or P1Y", + )); + } + + // Bound the lifetime so a key can't become an effectively-permanent + // credential, and require it to be positive. Postgres does the interval + // math, which is calendar-aware for the P1Y / P3M cases. + let within_bounds = sqlx::query_scalar!( + r#" + SELECT $1::text::interval > interval '0' + AND $1::text::interval <= interval '1 year' AS "ok!: bool" + "#, + valid_for, + ) + .fetch_one(&env.pg_pool) + .await; + + let within_bounds = match within_bounds { + Ok(ok) => ok, + // A 'P'-prefixed but malformed duration still fails the `::interval` + // cast (SQLSTATE 22007/22008); surface it as a client error, not a 500. + Err(sqlx::Error::Database(db)) + if matches!(db.code().as_deref(), Some("22007") | Some("22008")) => + { + return Err(async_graphql::Error::new( + "invalid valid_for: expected an ISO 8601 duration, e.g. P90D or P1Y", + )); + } + Err(err) => return Err(err.into()), + }; + + if !within_bounds { + return Err(async_graphql::Error::new( + "valid_for must be a positive duration no greater than 1 year", + )); + } + + let row = sqlx::query!( + r#" + WITH new_key AS ( + SELECT + internal.id_generator() AS id, + -- 256 bits from pgcrypto's CSPRNG; the SHA-256 hashing + -- below rests on secrets being high-entropy. + encode(gen_random_bytes(32), 'hex') AS secret + ) + INSERT INTO internal.api_keys (id, service_account_id, secret_hash, label, expires_at, created_by) + SELECT + nk.id, + $1, + -- SHA-256 rather than bcrypt: API keys are only ever + -- verified statefully -- every bearer presentation re-checks + -- this hash, and a key is never exchanged for a signed JWT -- + -- placing verification squarely in the per-request hot path, + -- where a fast hash matters. A slow hash would buy nothing + -- anyway: the secret is high-entropy random, and offline + -- brute-force resistance only matters for low-entropy + -- passwords. This matches how GitHub stores PATs. + -- Refresh-token secrets will migrate to the same scheme once + -- the legacy postgREST token functions are retired. + encode(digest(nk.secret, 'sha256'), 'hex'), + $2, + now() + $3::text::interval, + $4 + FROM new_key nk + RETURNING + id AS "id!: models::Id", + (SELECT secret FROM new_key) AS "secret!: String" + "#, + user_id, + label, + valid_for, + claims.sub, + ) + .fetch_one(&env.pg_pool) + .await?; + + use base64::Engine; + let payload = format!("{}:{}", row.id, row.secret); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.as_bytes()); + let full_secret = format!("flow_sa_{encoded}"); + + tracing::info!( + key_id = %row.id, + %user_id, + %label, + %claims.sub, + "created api key for service account" + ); + + Ok(CreateApiKeyResult { + key_id: row.id, + secret: full_secret, + }) + } + + /// Revoke an API key. + /// + /// The caller must have admin capability on the owning service account's + /// catalog name. + /// + /// Rather than deleting the row, we stamp `revoked_at`, which makes the key + /// inert (excluded from bearer authentication and listings) while + /// preserving the audit trail. Already-revoked keys are treated as not + /// found. + /// + /// Revocation is immediate: keys are verified statefully on every request + /// and never exchanged for access tokens, so there are no outstanding + /// minted credentials to wait out. The next request presenting this key + /// fails authentication. + async fn revoke_api_key( + &self, + ctx: &Context<'_>, + #[graphql(name = "id")] key_id: models::Id, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + let key_owner = sqlx::query!( + r#" + SELECT ak.service_account_id + FROM internal.api_keys ak + WHERE ak.id = $1 AND ak.revoked_at IS NULL + "#, + key_id as models::Id, + ) + .fetch_optional(&env.pg_pool) + .await?; + + let key_owner = match key_owner { + Some(row) => row.service_account_id, + None => return Err(async_graphql::Error::new("API key not found")), + }; + + let catalog_name = lookup_service_account(&env.pg_pool, key_owner).await?; + super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + + sqlx::query!( + "UPDATE internal.api_keys SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", + key_id as models::Id + ) + .execute(&env.pg_pool) + .await?; + + tracing::info!( + %key_id, + service_account = %key_owner, + %claims.sub, + "revoked api key" + ); + + Ok(true) + } + + /// Revoke ALL of a service account's API keys. + /// + /// The caller must manage the service account (admin capability on its + /// catalog name). + /// + /// This is the kill switch for a compromised or retired account: API keys + /// are its only means of authentication and are verified statefully on + /// every request, so revoking them all cuts off access immediately — the + /// account's next request fails authentication. Its user_grants are + /// untouched and may be cleaned up separately via + /// removeServiceAccountGrant. + /// + /// Idempotent: returns the number of keys revoked, zero if none remained. + async fn revoke_api_keys( + &self, + ctx: &Context<'_>, + #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; + super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + + let revoked = sqlx::query!( + r#" + UPDATE internal.api_keys + SET revoked_at = now() + WHERE service_account_id = $1 AND revoked_at IS NULL + "#, + user_id, + ) + .execute(&env.pg_pool) + .await?; + + tracing::info!( + %user_id, + %catalog_name, + revoked = revoked.rows_affected(), + %claims.sub, + "revoked all service account api keys" + ); + + Ok(revoked.rows_affected() as i32) + } +} + +/// Returns whether `user_id` is backed by a service account. Used to deny +/// SA principals operations reserved for human users (e.g. refresh tokens). +pub(super) async fn is_service_account( + pg_pool: &sqlx::PgPool, + user_id: uuid::Uuid, +) -> async_graphql::Result { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM internal.service_accounts WHERE user_id = $1) AS "exists!""#, + user_id, + ) + .fetch_one(pg_pool) + .await?; + + Ok(exists) +} + +/// Look up a service account's management anchor: admins of a prefix covering +/// this catalog name may manage the account, independent of whatever its +/// user_grants authorize. +async fn lookup_service_account( + pg_pool: &sqlx::PgPool, + user_id: uuid::Uuid, +) -> async_graphql::Result { + let row = sqlx::query_scalar!( + r#" + SELECT catalog_name AS "catalog_name!: String" + FROM internal.service_accounts + WHERE user_id = $1 + "#, + user_id, + ) + .fetch_optional(pg_pool) + .await?; + + row.ok_or_else(|| async_graphql::Error::new("service account not found")) +} + +#[cfg(test)] +mod test { + use crate::test_server; + + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_service_account_lifecycle(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@example.test"), + ); + + // Create a bob user who does NOT have admin on aliceCo/. + sqlx::query("INSERT INTO auth.users (id, email) VALUES ('22222222-2222-2222-2222-222222222222', 'bob@example.test')") + .execute(&pool) + .await + .unwrap(); + + let bob_token = + server.make_access_token(uuid::Uuid::from_bytes([0x22; 16]), Some("bob@example.test")); + + // === Create a service account with multiple seeded grants === + let create_response: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($catalogName: Name!, $name: String!, $grants: [ServiceAccountGrantInput!]!) { + createServiceAccount( + catalogName: $catalogName + displayName: $name + grants: $grants + ) { + id + displayName + catalogName + createdBy + createdAt + updatedAt + lastUsedAt + apiKeys { id } + } + }"#, + "variables": { + "catalogName": "aliceCo/ci-deploy-bot", + "name": "CI Deploy Bot", + "grants": [ + { "prefix": "aliceCo/", "capability": "admin" }, + { "prefix": "aliceCo/data/", "capability": "read" } + ] + } + }), + Some(&alice_token), + ) + .await; + + assert!( + create_response["errors"].is_null(), + "create should succeed: {create_response}" + ); + let sa = &create_response["data"]["createServiceAccount"]; + let sa_user_id = sa["id"].as_str().expect("should have id"); + assert_eq!(sa["displayName"], "CI Deploy Bot"); + assert_eq!(sa["catalogName"], "aliceCo/ci-deploy-bot"); + assert_eq!(sa["apiKeys"].as_array().unwrap().len(), 0); + assert_eq!( + grant_count(&pool, sa_user_id).await, + 2, + "each requested grant should mint a user_grants row" + ); + // Provenance and timestamp fields are populated on creation: createdBy + // is the calling admin (alice), the timestamps are set, and a freshly + // created account has never been used. + assert_eq!( + sa["createdBy"], "11111111-1111-1111-1111-111111111111", + "createdBy should be the calling admin: {create_response}" + ); + assert!(sa["createdAt"].is_string(), "createdAt should be set: {sa}"); + assert!(sa["updatedAt"].is_string(), "updatedAt should be set: {sa}"); + assert!( + sa["lastUsedAt"].is_null(), + "a never-used account should have null lastUsedAt: {sa}" + ); + + // === Bob cannot create a service account for aliceCo/ === + let unauthorized: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccount( + catalogName: "aliceCo/hacker-bot" + displayName: "hacker bot" + grants: [{ prefix: "aliceCo/", capability: read }] + ) { id } + }"# + }), + Some(&bob_token), + ) + .await; + + assert!(unauthorized["errors"].is_array()); + + // === create_service_account input validation === + // An invalid catalog name is rejected (before authorization), even + // for an admin caller. + let bad_name: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccount(catalogName: "Not A Name", displayName: "x", grants: []) { id } + }"# + }), + Some(&alice_token), + ) + .await; + assert!( + bad_name["errors"][0]["message"] + .as_str() + .unwrap_or_default() + .contains("invalid catalog name"), + "invalid catalog name should be rejected: {bad_name}" + ); + + // capability `none` confers no access until bundles are wired, so it is + // rejected rather than minting a no-op grant. + let none_capability: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccount( + catalogName: "aliceCo/no-op-bot" + displayName: "x" + grants: [{ prefix: "aliceCo/", capability: none }] + ) { id } + }"# + }), + Some(&alice_token), + ) + .await; + assert!( + none_capability["errors"][0]["message"] + .as_str() + .unwrap_or_default() + .contains("capability must be one of"), + "Capability::None should be rejected: {none_capability}" + ); + + // Every requested grant is independently authorized: alice admins + // aliceCo/ but not bobCo/, so seeding a bobCo/ grant must fail even + // though the account itself is homed under her prefix. + let foreign_grant: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccount( + catalogName: "aliceCo/overreach-bot" + displayName: "overreach bot" + grants: [ + { prefix: "aliceCo/", capability: read }, + { prefix: "bobCo/", capability: read } + ] + ) { id } + }"# + }), + Some(&alice_token), + ) + .await; + assert!( + foreign_grant["errors"].is_array(), + "a grant to an unadministered prefix should be rejected: {foreign_grant}" + ); + + // === Create an API key === + let create_key: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!, $label: String!, $validFor: String!) { + createApiKey( + serviceAccountId: $userId + label: $label + validFor: $validFor + ) { + id + secret + } + }"#, + "variables": { + "userId": sa_user_id, + "label": "GitHub Actions", + "validFor": "P90D" + } + }), + Some(&alice_token), + ) + .await; + + assert!( + create_key["errors"].is_null(), + "create key should succeed: {create_key}" + ); + let key_data = &create_key["data"]["createApiKey"]; + let key_id = key_data["id"].as_str().expect("should have id"); + let secret = key_data["secret"].as_str().expect("should have secret"); + assert!(secret.starts_with("flow_sa_")); + + // === valid_for validation === + // Each case must be rejected, and the error message identifies the + // specific branch: non-ISO syntax, malformed ISO, non-positive, and + // over the one-year cap. + for (valid_for, want) in [ + ("90 days", "ISO 8601"), // Postgres syntax, not ISO 8601 + ("Pfoo", "invalid valid_for"), // 'P'-prefixed but unparseable + ("P0D", "positive"), // zero duration + ("P2Y", "no greater than 1 year"), // exceeds the cap + ] { + let rejected: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!, $label: String!, $validFor: String!) { + createApiKey(serviceAccountId: $userId, label: $label, validFor: $validFor) { id } + }"#, + "variables": { + "userId": sa_user_id, + "label": "bad valid_for", + "validFor": valid_for, + } + }), + Some(&alice_token), + ) + .await; + assert!( + rejected["errors"][0]["message"] + .as_str() + .unwrap_or_default() + .contains(want), + "valid_for {valid_for:?} should be rejected mentioning {want:?}: {rejected}" + ); + } + + // === The API key authenticates directly as a bearer credential === + // The Envelope verifies it against the database and constructs claims + // for the service account without minting (or verifying) a JWT. The + // refreshTokens listing is empty (the account owns none), but a data + // response proves authentication resolved to the account's identity. + let via_bearer: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"query { refreshTokens { edges { node { id } } } }"# + }), + Some(secret), + ) + .await; + assert!( + via_bearer["data"]["refreshTokens"]["edges"].is_array(), + "bearer-authenticated request should succeed: {via_bearer}" + ); + + // === List service accounts === + let list: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + serviceAccounts { + edges { + node { + id + displayName + catalogName + apiKeys { + id + label + createdBy + createdAt + expiresAt + lastUsedAt + } + } + } + } + }"# + }), + Some(&alice_token), + ) + .await; + + let edges = list["data"]["serviceAccounts"]["edges"] + .as_array() + .expect("should have edges"); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0]["node"]["displayName"], "CI Deploy Bot"); + let listed_key = &edges[0]["node"]["apiKeys"][0]; + assert_eq!(edges[0]["node"]["apiKeys"].as_array().unwrap().len(), 1); + assert_eq!(listed_key["label"], "GitHub Actions"); + assert_eq!( + listed_key["createdBy"], "11111111-1111-1111-1111-111111111111", + "key createdBy should be the calling admin: {list}" + ); + assert!( + listed_key["createdAt"].is_string() && listed_key["expiresAt"].is_string(), + "key createdAt/expiresAt should be set: {list}" + ); + // The key was presented as a bearer credential above, so its + // last_used_at is now populated — the stamp is fused into the bearer + // verification query. + assert!( + listed_key["lastUsedAt"].is_string(), + "lastUsedAt should be set after a successful bearer use: {list}" + ); + + // Bob sees no service accounts. + let bob_list: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + serviceAccounts { edges { node { id } } } + }"# + }), + Some(&bob_token), + ) + .await; + + let bob_edges = bob_list["data"]["serviceAccounts"]["edges"] + .as_array() + .expect("should have edges"); + assert_eq!(bob_edges.len(), 0); + + // === Revoke the API key === + let revoke: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($keyId: Id!) { + revokeApiKey(id: $keyId) + }"#, + "variables": { "keyId": key_id } + }), + Some(&alice_token), + ) + .await; + + assert!( + revoke["errors"].is_null(), + "revoke should succeed: {revoke}" + ); + + // The row is preserved with revoked_at stamped — revocation is a soft + // delete for audit purposes. The exchange and listing assertions below + // can't observe this distinction, so check the table directly. + let parsed_key_id: models::Id = key_id.parse().unwrap(); + let revoked_row = sqlx::query!( + r#"SELECT revoked_at FROM internal.api_keys WHERE id = $1"#, + parsed_key_id as models::Id, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + revoked_row.revoked_at.is_some(), + "revocation must stamp revoked_at, not delete the row" + ); + + // Revoking again fails: already-revoked keys are treated as not found. + let revoke_again: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($keyId: Id!) { + revokeApiKey(id: $keyId) + }"#, + "variables": { "keyId": key_id } + }), + Some(&alice_token), + ) + .await; + assert!( + revoke_again["errors"][0]["message"] + .as_str() + .unwrap_or_default() + .contains("API key not found"), + "re-revoking should report not found: {revoke_again}" + ); + + // The revoked key no longer authenticates — immediately, since every + // request re-verifies the credential against the database. Revoked + // keys are excluded from the verification query, so this falls + // through to the same 401 rejection as a nonexistent key. + let rejected = server + .rest_client() + .post( + "/api/graphql", + &serde_json::json!({ "query": "query { refreshTokens { edges { node { id } } } }" }), + Some(secret), + ) + .send() + .await + .unwrap(); + let status = rejected.status(); + let body = rejected.text().await.unwrap(); + assert_eq!( + status, + reqwest::StatusCode::UNAUTHORIZED, + "revoked key should be rejected with 401: {body}" + ); + assert!( + body.contains("invalid, expired, or revoked api key"), + "revoked key rejection body: {body}" + ); + + // The revoked key is excluded from listings, even though its row remains. + let list_after_revoke: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + serviceAccounts { edges { node { apiKeys { id } } } } + }"# + }), + Some(&alice_token), + ) + .await; + assert_eq!( + list_after_revoke["data"]["serviceAccounts"]["edges"][0]["node"]["apiKeys"] + .as_array() + .unwrap() + .len(), + 0, + "revoked keys must not appear in listings: {list_after_revoke}" + ); + + // Count the service account's user_grants rows directly: the + // grant-management assertions below observe access changes through + // this, since bearer authentication succeeds whether or not grants + // remain (access is wholly determined by user_grants). + async fn grant_count(pool: &sqlx::PgPool, user_id: &str) -> i64 { + sqlx::query_scalar!( + r#"SELECT count(*) AS "count!" FROM public.user_grants WHERE user_id = $1"#, + uuid::Uuid::parse_str(user_id).unwrap(), + ) + .fetch_one(pool) + .await + .unwrap() + } + + // === Grant management === + // Adding a grant requires BOTH managing the account and admin on the + // granted prefix. Bob has neither, so he can't add a grant. + let add_unmanaged: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + addServiceAccountGrant(serviceAccountId: $userId, prefix: "aliceCo/", capability: read) + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&bob_token), + ) + .await; + assert!( + add_unmanaged["errors"].is_array(), + "a non-manager must not add grants: {add_unmanaged}" + ); + + // Alice manages the account but doesn't admin bobCo/: extending the + // account's access beyond what she administers is denied. + let add_foreign: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + addServiceAccountGrant(serviceAccountId: $userId, prefix: "bobCo/", capability: read) + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&alice_token), + ) + .await; + assert!( + add_foreign["errors"].is_array(), + "a grant to an unadministered prefix must be rejected: {add_foreign}" + ); + + // Happy path: alice manages the account and admins aliceCo/ops/. + let add: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + addServiceAccountGrant(serviceAccountId: $userId, prefix: "aliceCo/ops/", capability: write) + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&alice_token), + ) + .await; + assert!(add["errors"].is_null(), "add grant should succeed: {add}"); + assert_eq!(grant_count(&pool, sa_user_id).await, 3); + + // Removal requires only account management — no capability on the + // grant's prefix. Seed a grant to bobCo/ directly (adding one via the + // API requires admin on it), then alice removes it despite having no + // bobCo/ access of her own. + sqlx::query("INSERT INTO user_grants (user_id, object_role, capability) VALUES ($1, 'bobCo/', 'read')") + .bind(uuid::Uuid::parse_str(sa_user_id).unwrap()) + .execute(&pool) + .await + .unwrap(); + + let remove_foreign: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + removeServiceAccountGrant(serviceAccountId: $userId, prefix: "bobCo/") + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&alice_token), + ) + .await; + assert!( + remove_foreign["errors"].is_null(), + "a manager may remove ANY grant, including one to a prefix they don't administer: {remove_foreign}" + ); + assert_eq!(grant_count(&pool, sa_user_id).await, 3); + + // Removing an absent grant reports not found. + let remove_again: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + removeServiceAccountGrant(serviceAccountId: $userId, prefix: "bobCo/") + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&alice_token), + ) + .await; + assert!( + remove_again["errors"][0]["message"] + .as_str() + .unwrap_or_default() + .contains("grant not found"), + "re-removing should report not found: {remove_again}" + ); + + // Bob cannot remove grants of an account he doesn't manage. + let remove_unmanaged: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + removeServiceAccountGrant(serviceAccountId: $userId, prefix: "aliceCo/data/") + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&bob_token), + ) + .await; + assert!(remove_unmanaged["errors"].is_array()); + + // === Bulk key revocation is the kill switch === + // Mint a fresh key (the first was revoked above) and prove it works. + let create_key2: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!, $label: String!, $validFor: String!) { + createApiKey(serviceAccountId: $userId, label: $label, validFor: $validFor) { + id + secret + } + }"#, + "variables": { + "userId": sa_user_id, + "label": "temp key", + "validFor": "P30D" + } + }), + Some(&alice_token), + ) + .await; + let secret2 = create_key2["data"]["createApiKey"]["secret"] + .as_str() + .unwrap(); + + let live: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"query { refreshTokens { edges { node { id } } } }"# + }), + Some(secret2), + ) + .await; + assert!( + live["data"]["refreshTokens"]["edges"].is_array(), + "fresh key should authenticate: {live}" + ); + + // Bob cannot bulk-revoke keys of an account he doesn't manage. + let bulk_unmanaged: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + revokeApiKeys(serviceAccountId: $userId) + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&bob_token), + ) + .await; + assert!(bulk_unmanaged["errors"].is_array()); + + // Revoking every key disables the account: keys are its only means + // of authentication, and they're re-verified on every request, so the + // cutoff is immediate. + let bulk: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + revokeApiKeys(serviceAccountId: $userId) + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&alice_token), + ) + .await; + assert!( + bulk["errors"].is_null(), + "bulk revoke should succeed: {bulk}" + ); + assert_eq!( + bulk["data"]["revokeApiKeys"].as_i64(), + Some(1), + "the one live key is revoked: {bulk}" + ); + + let rejected = server + .rest_client() + .post( + "/api/graphql", + &serde_json::json!({ "query": "query { refreshTokens { edges { node { id } } } }" }), + Some(secret2), + ) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED); + + // Revoking again is an idempotent no-op reporting zero keys revoked. + let bulk_again: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($userId: UUID!) { + revokeApiKeys(serviceAccountId: $userId) + }"#, + "variables": { "userId": sa_user_id } + }), + Some(&alice_token), + ) + .await; + assert_eq!( + bulk_again["data"]["revokeApiKeys"].as_i64(), + Some(0), + "repeat bulk revoke is a no-op: {bulk_again}" + ); + } +} diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 353b01be592..ae7515f1872 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1,17 +1,3 @@ -type AWSPrivateLink { - region: String! - azIds: [String!]! - serviceName: String! - serviceRegion: String -} - -input AWSPrivateLinkInput { - region: String! - azIds: [String!]! - serviceName: String! - serviceRegion: String -} - """ Status of the abandonment evaluation for a task. """ @@ -256,6 +242,15 @@ input AlertsBy { active: Boolean } +type ApiKeyInfo { + id: Id! + label: String! + createdBy: UUID! + createdAt: DateTime! + expiresAt: DateTime! + lastUsedAt: DateTime +} + type AutoDiscoverFailure { """ The number of consecutive failures that have been observed. @@ -326,20 +321,6 @@ type AutoDiscoverStatus { failure: AutoDiscoverFailure } -type AzurePrivateLink { - serviceName: String! - location: String! - dnsName: String - resourceType: String -} - -input AzurePrivateLinkInput { - serviceName: String! - location: String! - dnsName: String - resourceType: String -} - type BillingPaymentMethodPayload { paymentMethods: [PaymentMethod!]! primaryPaymentMethod: PaymentMethod @@ -370,8 +351,7 @@ enum CapabilityBit { CreateGrant DeleteGrant CreateInviteLink - ViewDataPlanePrivateNetworking - ModifyDataPlanePrivateNetworking + ManageServiceAccounts Delegate Assume } @@ -633,6 +613,11 @@ type Controller { updatedAt: DateTime! } +type CreateApiKeyResult { + id: Id! + secret: String! +} + type CreateBillingSetupIntentPayload { clientSecret: String! } @@ -704,30 +689,6 @@ type DataPlane { Azure application client ID for this data-plane. """ azureApplicationClientId: String - """ - Configured private link endpoints for this data-plane. Replacing this - list (via `updateDataPlanePrivateLinks`) triggers reconvergence by the - data-plane controller on its next poll. Returns an empty list to - callers that lack the `ViewDataPlanePrivateNetworking` capability on - this data plane. - """ - privateLinks: [PrivateLink!]! - """ - AWS PrivateLink endpoint provisioning results, opaque JSON exported by - the data-plane controller. Empty when no AWS endpoints are provisioned, - or when the caller lacks `ViewDataPlanePrivateNetworking`. - """ - awsLinkEndpoints: [JSON!]! - """ - Azure Private Link endpoint provisioning results, opaque JSON. Empty when - the caller lacks `ViewDataPlanePrivateNetworking`. - """ - azureLinkEndpoints: [JSON!]! - """ - GCP Private Service Connect endpoint provisioning results, opaque JSON. - Empty when the caller lacks `ViewDataPlanePrivateNetworking`. - """ - gcpPscEndpoints: [JSON!]! } """ @@ -814,22 +775,6 @@ type FieldProvenance { source: String } -type GCPPrivateServiceConnect { - serviceAttachment: String! - region: String! - dnsZoneName: String! - dnsRecordNames: [String!]! - allPorts: Boolean! -} - -input GCPPrivateServiceConnectInput { - serviceAttachment: String! - region: String! - dnsZoneName: String! - dnsRecordNames: [String!]! - allPorts: Boolean! = false -} - scalar Id """ @@ -1257,24 +1202,11 @@ type MutationRoot { """ deleteInviteLink(token: UUID!): Boolean! """ - Replaces the configured private link endpoints on a private data plane. - - The provided list overwrites the entire `private_links` column; partial - updates are intentionally not supported. The data-plane controller - converges to the new configuration on its next poll. Returns the desired - private links state. The `*LinkEndpoints` provisioning results are not echoed here: - they lag this write until the controller converges, so callers needing them re-query `dataPlanes`. - - Requires the `ModifyDataPlanePrivateNetworking` capability on the - private data-plane name. - """ - updateDataPlanePrivateLinks(dataPlaneName: String!, privateLinks: [PrivateLinkInput!]!): [PrivateLink!]! - """ Create a refresh token for the authenticated user. """ createRefreshToken( """ - ISO 8601 duration for token validity (e.g. P90D); must be greater than zero and at most one year + ISO 8601 duration for token validity (e.g. P90D) """ validFor: String! = "P90D", multiUse: Boolean! = true, detail: String = null ): RefreshTokenResult! @@ -1286,6 +1218,91 @@ type MutationRoot { Already-zeroed (revoked) tokens are treated as not found. """ revokeRefreshToken(id: Id!): Boolean! + """ + Create a service account homed at the specified catalog name, seeded + with the given user_grants. + + `catalogName` is a management anchor: admins of a prefix covering it + may manage the account. It determines who may manage the account, not + what the account may access. Access is determined solely by the + account's user_grants, which may span multiple prefixes. + + The caller must have admin capability on the catalog name AND on each + granted prefix. Creates an auth.users row, an + internal.service_accounts row, and a user_grants row per requested + grant. + """ + createServiceAccount(catalogName: Name!, displayName: String!, grants: [ServiceAccountGrantInput!]!): ServiceAccount! + """ + Add a user_grant to a service account. + + The caller must manage the service account (admin capability on its + catalog name) AND have admin capability on the granted prefix — the + same authorization as granting a human user. The second requirement + prevents a caller from extending an account's access beyond what they + themselves administer. + """ + addServiceAccountGrant(serviceAccountId: UUID!, prefix: Prefix!, capability: Capability!): Boolean! + """ + Remove a user_grant from a service account. + + The caller must manage the service account (admin capability on its + catalog name). Unlike addServiceAccountGrant, no capability on the + grant's prefix is required: removal only ever narrows the account's + access, so managers may remove ANY grant — including grants to + prefixes they don't themselves administer. + """ + removeServiceAccountGrant(serviceAccountId: UUID!, prefix: Prefix!): Boolean! + """ + Create an API key for a service account. + + Returns the key_id and the plaintext secret (flow_sa_...). + The secret is returned exactly once and cannot be retrieved again. + + The key is presented directly as an `Authorization: Bearer` credential. + It is verified statefully against the database on every request and is + never exchanged for an access token, so revoking it cuts off access + immediately. + """ + createApiKey( serviceAccountId: UUID!, label: String!, + """ + ISO 8601 duration for key validity (e.g. P90D, P1Y) + """ + validFor: String! + ): CreateApiKeyResult! + """ + Revoke an API key. + + The caller must have admin capability on the owning service account's + catalog name. + + Rather than deleting the row, we stamp `revoked_at`, which makes the key + inert (excluded from bearer authentication and listings) while + preserving the audit trail. Already-revoked keys are treated as not + found. + + Revocation is immediate: keys are verified statefully on every request + and never exchanged for access tokens, so there are no outstanding + minted credentials to wait out. The next request presenting this key + fails authentication. + """ + revokeApiKey(id: Id!): Boolean! + """ + Revoke ALL of a service account's API keys. + + The caller must manage the service account (admin capability on its + catalog name). + + This is the kill switch for a compromised or retired account: API keys + are its only means of authentication and are verified statefully on + every request, so revoking them all cuts off access immediately — the + account's next request fails authentication. Its user_grants are + untouched and may be cleaned up separately via + removeServiceAccountGrant. + + Idempotent: returns the number of keys revoked, zero if none remained. + """ + revokeApiKeys(serviceAccountId: UUID!): Int! } """ @@ -1411,22 +1428,6 @@ input PrefixesBy { minCapability: Capability! } -""" -Private link configuration for a customer-owned data plane: AWS -PrivateLink, Azure Private Link, or GCP Private Service Connect. -""" -union PrivateLink = AWSPrivateLink | AzurePrivateLink | GCPPrivateServiceConnect - -""" -Private link configuration for a customer-owned data plane: AWS -PrivateLink, Azure Private Link, or GCP Private Service Connect. -""" -input PrivateLinkInput @oneOf { - aws: AWSPrivateLinkInput - azure: AzurePrivateLinkInput - gcp: GCPPrivateServiceConnectInput -} - """ Filter connectors by their protocol (capture or materialization). """ @@ -1601,6 +1602,7 @@ type QueryRoot { List refresh tokens owned by the authenticated user. """ refreshTokens(after: String, first: Int): RefreshTokenInfoConnection! + serviceAccounts(after: String, first: Int): ServiceAccountConnection! } """ @@ -1623,6 +1625,7 @@ type RefreshTokenInfo { createdAt: DateTime! updatedAt: DateTime! multiUse: Boolean! + validFor: String! uses: Int! """ True once the token's validity window has elapsed @@ -1679,6 +1682,50 @@ type RepublishRequested { lastBuildId: Id! } +type ServiceAccount { + id: UUID! + displayName: String! + catalogName: Name! + createdBy: UUID! + createdAt: DateTime! + updatedAt: DateTime! + lastUsedAt: DateTime + apiKeys: [ApiKeyInfo!]! +} + +type ServiceAccountConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [ServiceAccountEdge!]! +} + +""" +An edge in a connection. +""" +type ServiceAccountEdge { + """ + The item at the end of the edge + """ + node: ServiceAccount! + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A user_grant to seed a service account with at creation time. +""" +input ServiceAccountGrantInput { + prefix: Prefix! + capability: Capability! +} + """ The shape of a connector status, which matches that of an ops::Log. """ @@ -2087,10 +2134,6 @@ Directs the executor to include this field or fragment only when the `if` argume """ directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """ -Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its field be provided) -""" -directive @oneOf on INPUT_OBJECT -""" Directs the executor to skip this field or fragment when the `if` argument is true. """ directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/crates/models/src/authz.rs b/crates/models/src/authz.rs index 0cc9e48a54d..0e38d5bbff2 100644 --- a/crates/models/src/authz.rs +++ b/crates/models/src/authz.rs @@ -26,6 +26,7 @@ pub enum Capability { // `ModifyDataPlanePrivateNetworking` permits mutating that same // configuration; the data-plane controller converges to it. ModifyDataPlanePrivateNetworking, + ManageServiceAccounts, Delegate, Assume, } @@ -108,7 +109,7 @@ impl CapabilityBundle { | Self::ManageDataPlane.capabilities() } Self::Billing => EnumSet::empty(), - Self::TeamAdmin => CreateGrant | DeleteGrant | CreateInviteLink, + Self::TeamAdmin => CreateGrant | DeleteGrant | CreateInviteLink | ManageServiceAccounts, Self::ManageDataPlane => { ViewDataPlanePrivateNetworking | ModifyDataPlanePrivateNetworking } diff --git a/supabase/migrations/00_polyfill.sql b/supabase/migrations/00_polyfill.sql index b31ebc82fb8..b691d574783 100644 --- a/supabase/migrations/00_polyfill.sql +++ b/supabase/migrations/00_polyfill.sql @@ -27,8 +27,8 @@ BEGIN -- Roles are cluster-wide and already exist from migrations applied to the -- primary `postgres` database. We only need to stub Supabase schemas. - -- pgcrypto provides crypt()/gen_salt(), used for refresh-token - -- secret hashing. Real Supabase pre-installs it in the + -- pgcrypto provides crypt()/gen_salt(), used by refresh-token and + -- API-key secret hashing. Real Supabase pre-installs it in the -- `extensions` schema; here we install into `public` (on the default -- search_path) so the migrations' unqualified calls resolve. create extension if not exists pgcrypto with schema public; diff --git a/supabase/migrations/20260528120000_service_accounts.sql b/supabase/migrations/20260528120000_service_accounts.sql new file mode 100644 index 00000000000..d030f40c229 --- /dev/null +++ b/supabase/migrations/20260528120000_service_accounts.sql @@ -0,0 +1,61 @@ +begin; + +-- Service accounts: non-login identities that authenticate via API keys. +-- Used for programmatic access (CI/CD, automation) and scoped, time-limited +-- access grants. + +create table internal.service_accounts ( + user_id uuid primary key references auth.users (id), + -- `catalog_name` is a management anchor only: admins of a prefix covering + -- this name may manage the service account (mint keys, revoke grants) and + -- see it in listings. The account's *access* is determined solely by its + -- user_grants rows, which are managed like any other user's and may span + -- multiple prefixes. + catalog_name public.catalog_name not null, + display_name text not null, + created_by uuid not null references auth.users (id), + last_used_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table internal.service_accounts is + 'Non-login identities that authenticate via API keys and are authorized through user_grants.'; + +-- The serviceAccounts query scopes results to a caller's admin prefixes with +-- `catalog_name::text ^@ ANY($1)`. SP-GiST natively supports the `^@` +-- (starts-with) operator; a btree index would not be used by it. +create index service_accounts_catalog_name_spgist on internal.service_accounts + using spgist ((catalog_name::text)); + +-- API keys: long-lived credentials for service accounts, presented directly +-- as Authorization: Bearer credentials. Keys are evaluated statefully ONLY: +-- every request re-verifies the key against this table, and a key is never +-- exchanged for a signed JWT — which is what makes revocation immediate. + +create table internal.api_keys ( + id public.flowid primary key not null default internal.id_generator(), + service_account_id uuid not null references internal.service_accounts (user_id), + -- Hex-encoded SHA-256 of the key secret. A fast hash (not bcrypt) is the + -- right choice because stateful-only evaluation places verification in the + -- per-request hot path, and a slow hash would buy nothing: secrets are + -- high-entropy random values, so offline brute-force resistance is moot. + -- See the minting site in graphql/service_accounts.rs. + secret_hash text not null, + label text not null, + expires_at timestamptz not null, + created_by uuid not null references auth.users (id), + last_used_at timestamptz, + created_at timestamptz not null default now(), + -- Revocation stamps this rather than deleting the row, preserving the + -- audit trail. Revoked keys are inert: excluded from bearer authentication + -- and from listings. + revoked_at timestamptz +); + +create index api_keys_service_account_id on internal.api_keys (service_account_id); + +comment on table internal.api_keys is + 'Long-lived credentials for service accounts, verified statefully as bearer credentials on every request (never exchanged for JWTs).'; + +commit; From ca979c09bf694a78670d0f93631a884e599d62d3 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 13:58:15 -0400 Subject: [PATCH 02/13] control plane: exchange service-account API keys for access tokens Add an api_key grant to POST /api/v1/auth/token so a service-account key can be traded for a short-lived signed access token, alongside the existing refresh_token grant. This suits clients that prefer the standard OAuth shape: present the long-lived key once, then carry a JWT. Factor the stateful key verification out of authenticate_api_key into server::verify_api_key, which returns the verified user_id (secret hash + expiry + revocation check, plus the last_used_at stamp). Both the bearer path and the exchange endpoint call it and then build their own claims: the bearer path wraps them in a request-scoped Verified, while the exchange signs a JWT. The minted token gets a 1-hour expiry matching the refresh-token exchange, so both grant types yield equivalent access tokens; the bearer path keeps its 5-minute expiry, which is right for per-request re-verification. A signed token can't be revoked before it expires, so the 1-hour lifetime bounds how long an exchanged token outlives a key revocation; direct bearer use still cuts off immediately. Revoked or expired keys can't be exchanged, since the exchange routes through the same verification. Also drop the bulk revokeApiKeys mutation and its test coverage; keys are revoked individually via revokeApiKey. --- ...7860a5f60c60b27f4717aa9c1cd6f910889ab.json | 31 --- ...fa86639f6437aa902769a4fd4f17e16f53a86.json | 31 +++ crates/control-plane-api/src/server/mod.rs | 43 +-- .../server/public/graphql/service_accounts.rs | 254 ++++++------------ .../src/server/public/token_exchange.rs | 57 ++++ 5 files changed, 196 insertions(+), 220 deletions(-) delete mode 100644 .sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json create mode 100644 .sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json diff --git a/.sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json b/.sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json deleted file mode 100644 index e4fbc8720c6..00000000000 --- a/.sqlx/query-edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH new_key AS (\n SELECT\n internal.id_generator() AS id,\n -- 256 bits from pgcrypto's CSPRNG; the SHA-256 hashing\n -- below rests on secrets being high-entropy.\n encode(gen_random_bytes(32), 'hex') AS secret\n )\n INSERT INTO internal.api_keys (id, service_account_id, secret_hash, label, expires_at, created_by)\n SELECT\n nk.id,\n $1,\n -- SHA-256 rather than bcrypt: API keys are only ever\n -- verified statefully -- every bearer presentation re-checks\n -- this hash, and a key is never exchanged for a signed JWT --\n -- placing verification squarely in the per-request hot path,\n -- where a fast hash matters. A slow hash would buy nothing\n -- anyway: the secret is high-entropy random, and offline\n -- brute-force resistance only matters for low-entropy\n -- passwords. This matches how GitHub stores PATs.\n -- Refresh-token secrets will migrate to the same scheme once\n -- the legacy postgREST token functions are retired.\n encode(digest(nk.secret, 'sha256'), 'hex'),\n $2,\n now() + $3::text::interval,\n $4\n FROM new_key nk\n RETURNING\n id AS \"id!: models::Id\",\n (SELECT secret FROM new_key) AS \"secret!: String\"\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!: models::Id", - "type_info": "Macaddr8" - }, - { - "ordinal": 1, - "name": "secret!: String", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Uuid" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "edea1ef230b2cbe296b321a73dd7860a5f60c60b27f4717aa9c1cd6f910889ab" -} diff --git a/.sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json b/.sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json new file mode 100644 index 00000000000..68599d8df5d --- /dev/null +++ b/.sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH new_key AS (\n SELECT\n internal.id_generator() AS id,\n -- 256 bits from pgcrypto's CSPRNG; the SHA-256 hashing\n -- below rests on secrets being high-entropy.\n encode(gen_random_bytes(32), 'hex') AS secret\n )\n INSERT INTO internal.api_keys (id, service_account_id, secret_hash, label, expires_at, created_by)\n SELECT\n nk.id,\n $1,\n -- SHA-256 rather than bcrypt: this hash is re-checked on\n -- every bearer presentation (and on each access-token\n -- exchange), placing verification squarely in the per-request\n -- hot path, where a fast hash matters. A slow hash would buy nothing\n -- anyway: the secret is high-entropy random, and offline\n -- brute-force resistance only matters for low-entropy\n -- passwords. This matches how GitHub stores PATs.\n -- Refresh-token secrets will migrate to the same scheme once\n -- the legacy postgREST token functions are retired.\n encode(digest(nk.secret, 'sha256'), 'hex'),\n $2,\n now() + $3::text::interval,\n $4\n FROM new_key nk\n RETURNING\n id AS \"id!: models::Id\",\n (SELECT secret FROM new_key) AS \"secret!: String\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: models::Id", + "type_info": "Macaddr8" + }, + { + "ordinal": 1, + "name": "secret!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86" +} diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index af53f4da232..bf4c84d7006 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -281,20 +281,21 @@ pub async fn exchange_refresh_token( Ok(access_token) } -/// Authenticate a service-account API key presented as a bearer credential, -/// returning the claims it proves. +/// Statefully verify a service-account API key against the database, returning +/// the verified service account's `user_id`. /// -/// API keys are evaluated *statefully only*: every presentation is verified -/// against the database, and a key is never exchanged for a signed JWT. This -/// is what makes key revocation immediate — there are no outstanding minted -/// tokens to wait out — and it's why key secrets are hashed with SHA-256 -/// rather than bcrypt: the secret is high-entropy random (so a slow hash adds -/// no brute-force protection) and this verification is in the per-request hot -/// path, where a fast hash matters. -pub async fn authenticate_api_key( - pg_pool: &sqlx::PgPool, - api_key: &str, -) -> tonic::Result> { +/// This is the sole check of an API key's validity — secret hash, expiry, and +/// revocation — and it stamps `last_used_at` on both the key and its service +/// account in the same round-trip. Both the bearer path +/// (`authenticate_api_key`) and the token-exchange endpoint +/// (`token_exchange::exchange_api_key`) build a credential atop the verified +/// identity; they differ only in the lifetime of what they then issue. +/// +/// Secrets are hashed with SHA-256 rather than bcrypt: the secret is +/// high-entropy random (so a slow hash adds no brute-force protection) and +/// this verification sits in the per-request hot path, where a fast hash +/// matters. +pub async fn verify_api_key(pg_pool: &sqlx::PgPool, api_key: &str) -> tonic::Result { let raw = api_key .strip_prefix("flow_sa_") .expect("caller dispatches on the flow_sa_ prefix"); @@ -341,16 +342,24 @@ pub async fn authenticate_api_key( .map_err(|err| tonic::Status::internal(format!("failed to authenticate api key: {err}")))? .ok_or_else(|| tonic::Status::unauthenticated("invalid, expired, or revoked api key"))?; - // As with refresh-token bearer authentication: verification re-runs on - // every presentation, making revocation near-immediate, and the small - // expiry only bounds any future caching of this authentication. + Ok(row.user_id) +} + +/// Authenticate a service-account API key presented as a bearer credential, +/// returning the claims it proves. +pub async fn authenticate_api_key( + pg_pool: &sqlx::PgPool, + api_key: &str, +) -> tonic::Result> { + let user_id = verify_api_key(pg_pool, api_key).await?; + let now = tokens::now(); let exp = now + chrono::Duration::minutes(5); let claims = crate::ControlClaims { iat: now.timestamp() as u64, exp: exp.timestamp() as u64, - sub: row.user_id, + sub: user_id, role: "authenticated".to_string(), aud: "authenticated".to_string(), email: None, diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index 054a24c272e..d99cd71ffca 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -431,10 +431,8 @@ impl ServiceAccountsMutation { /// Returns the key_id and the plaintext secret (flow_sa_...). /// The secret is returned exactly once and cannot be retrieved again. /// - /// The key is presented directly as an `Authorization: Bearer` credential. - /// It is verified statefully against the database on every request and is - /// never exchanged for an access token, so revoking it cuts off access - /// immediately. + /// The API key can be exchanged for an 1-hr access token via `POST /api/v1/auth/token` + /// or used directly as an `Authorization: Bearer` credential async fn create_api_key( &self, ctx: &Context<'_>, @@ -506,14 +504,8 @@ impl ServiceAccountsMutation { SELECT nk.id, $1, - -- SHA-256 rather than bcrypt: API keys are only ever - -- verified statefully -- every bearer presentation re-checks - -- this hash, and a key is never exchanged for a signed JWT -- - -- placing verification squarely in the per-request hot path, - -- where a fast hash matters. A slow hash would buy nothing - -- anyway: the secret is high-entropy random, and offline - -- brute-force resistance only matters for low-entropy - -- passwords. This matches how GitHub stores PATs. + -- SHA-256 rather than bcrypt: the secret is high-entropy random, + -- so bcrypt isn't necessary here. -- Refresh-token secrets will migrate to the same scheme once -- the legacy postgREST token functions are retired. encode(digest(nk.secret, 'sha256'), 'hex'), @@ -561,11 +553,6 @@ impl ServiceAccountsMutation { /// inert (excluded from bearer authentication and listings) while /// preserving the audit trail. Already-revoked keys are treated as not /// found. - /// - /// Revocation is immediate: keys are verified statefully on every request - /// and never exchanged for access tokens, so there are no outstanding - /// minted credentials to wait out. The next request presenting this key - /// fails authentication. async fn revoke_api_key( &self, ctx: &Context<'_>, @@ -609,52 +596,6 @@ impl ServiceAccountsMutation { Ok(true) } - - /// Revoke ALL of a service account's API keys. - /// - /// The caller must manage the service account (admin capability on its - /// catalog name). - /// - /// This is the kill switch for a compromised or retired account: API keys - /// are its only means of authentication and are verified statefully on - /// every request, so revoking them all cuts off access immediately — the - /// account's next request fails authentication. Its user_grants are - /// untouched and may be cleaned up separately via - /// removeServiceAccountGrant. - /// - /// Idempotent: returns the number of keys revoked, zero if none remained. - async fn revoke_api_keys( - &self, - ctx: &Context<'_>, - #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, - ) -> async_graphql::Result { - let env = ctx.data::()?; - let claims = env.claims()?; - - let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; - super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; - - let revoked = sqlx::query!( - r#" - UPDATE internal.api_keys - SET revoked_at = now() - WHERE service_account_id = $1 AND revoked_at IS NULL - "#, - user_id, - ) - .execute(&env.pg_pool) - .await?; - - tracing::info!( - %user_id, - %catalog_name, - revoked = revoked.rows_affected(), - %claims.sub, - "revoked all service account api keys" - ); - - Ok(revoked.rows_affected() as i32) - } } /// Returns whether `user_id` is backed by a service account. Used to deny @@ -966,6 +907,66 @@ mod test { "bearer-authenticated request should succeed: {via_bearer}" ); + // === The API key can also be exchanged for an access token === + // POST /api/v1/auth/token with an `api_key` grant statefully verifies + // the key and returns a signed JWT (no refresh token — the key is the + // durable credential). The minted token then authenticates a request + // by its signature, resolving to the same service-account identity. + let exchanged = server + .rest_client() + .post( + "/api/v1/auth/token", + &serde_json::json!({ "grant_type": "api_key", "api_key": secret }), + None, + ) + .send() + .await + .unwrap(); + assert_eq!( + exchanged.status(), + reqwest::StatusCode::OK, + "api_key exchange should succeed" + ); + let exchanged: serde_json::Value = exchanged.json().await.unwrap(); + let access_token = exchanged["access_token"] + .as_str() + .expect("exchange returns an access_token"); + assert!( + exchanged["refresh_token"].is_null(), + "api_key exchange returns no refresh token: {exchanged}" + ); + + // The exchanged token is valid for one hour — the exchange window, not + // the 5-minute expiry the bearer path stamps on its claims. + use base64::Engine; + let payload = access_token.split('.').nth(1).expect("jwt has a payload"); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .expect("jwt payload is base64url"); + let payload: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + let (iat, exp) = ( + payload["iat"].as_u64().unwrap(), + payload["exp"].as_u64().unwrap(), + ); + assert_eq!( + exp - iat, + 3600, + "exchanged token should be valid for one hour: {payload}" + ); + + let via_jwt: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"query { refreshTokens { edges { node { id } } } }"# + }), + Some(access_token), + ) + .await; + assert!( + via_jwt["data"]["refreshTokens"]["edges"].is_array(), + "exchanged access token should authenticate: {via_jwt}" + ); + // === List service accounts === let list: serde_json::Value = server .graphql( @@ -1119,6 +1120,24 @@ mod test { "revoked key rejection body: {body}" ); + // Exchange routes through the same stateful verification, so a revoked + // key can't be traded for an access token either. + let exchange_revoked = server + .rest_client() + .post( + "/api/v1/auth/token", + &serde_json::json!({ "grant_type": "api_key", "api_key": secret }), + None, + ) + .send() + .await + .unwrap(); + assert_eq!( + exchange_revoked.status(), + reqwest::StatusCode::UNAUTHORIZED, + "revoked key must not be exchangeable for an access token" + ); + // The revoked key is excluded from listings, even though its row remains. let list_after_revoke: serde_json::Value = server .graphql( @@ -1272,114 +1291,5 @@ mod test { ) .await; assert!(remove_unmanaged["errors"].is_array()); - - // === Bulk key revocation is the kill switch === - // Mint a fresh key (the first was revoked above) and prove it works. - let create_key2: serde_json::Value = server - .graphql( - &serde_json::json!({ - "query": r#" - mutation($userId: UUID!, $label: String!, $validFor: String!) { - createApiKey(serviceAccountId: $userId, label: $label, validFor: $validFor) { - id - secret - } - }"#, - "variables": { - "userId": sa_user_id, - "label": "temp key", - "validFor": "P30D" - } - }), - Some(&alice_token), - ) - .await; - let secret2 = create_key2["data"]["createApiKey"]["secret"] - .as_str() - .unwrap(); - - let live: serde_json::Value = server - .graphql( - &serde_json::json!({ - "query": r#"query { refreshTokens { edges { node { id } } } }"# - }), - Some(secret2), - ) - .await; - assert!( - live["data"]["refreshTokens"]["edges"].is_array(), - "fresh key should authenticate: {live}" - ); - - // Bob cannot bulk-revoke keys of an account he doesn't manage. - let bulk_unmanaged: serde_json::Value = server - .graphql( - &serde_json::json!({ - "query": r#" - mutation($userId: UUID!) { - revokeApiKeys(serviceAccountId: $userId) - }"#, - "variables": { "userId": sa_user_id } - }), - Some(&bob_token), - ) - .await; - assert!(bulk_unmanaged["errors"].is_array()); - - // Revoking every key disables the account: keys are its only means - // of authentication, and they're re-verified on every request, so the - // cutoff is immediate. - let bulk: serde_json::Value = server - .graphql( - &serde_json::json!({ - "query": r#" - mutation($userId: UUID!) { - revokeApiKeys(serviceAccountId: $userId) - }"#, - "variables": { "userId": sa_user_id } - }), - Some(&alice_token), - ) - .await; - assert!( - bulk["errors"].is_null(), - "bulk revoke should succeed: {bulk}" - ); - assert_eq!( - bulk["data"]["revokeApiKeys"].as_i64(), - Some(1), - "the one live key is revoked: {bulk}" - ); - - let rejected = server - .rest_client() - .post( - "/api/graphql", - &serde_json::json!({ "query": "query { refreshTokens { edges { node { id } } } }" }), - Some(secret2), - ) - .send() - .await - .unwrap(); - assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED); - - // Revoking again is an idempotent no-op reporting zero keys revoked. - let bulk_again: serde_json::Value = server - .graphql( - &serde_json::json!({ - "query": r#" - mutation($userId: UUID!) { - revokeApiKeys(serviceAccountId: $userId) - }"#, - "variables": { "userId": sa_user_id } - }), - Some(&alice_token), - ) - .await; - assert_eq!( - bulk_again["data"]["revokeApiKeys"].as_i64(), - Some(0), - "repeat bulk revoke is a no-op: {bulk_again}" - ); } } diff --git a/crates/control-plane-api/src/server/public/token_exchange.rs b/crates/control-plane-api/src/server/public/token_exchange.rs index e3f4ed0fd87..43eef7c40c8 100644 --- a/crates/control-plane-api/src/server/public/token_exchange.rs +++ b/crates/control-plane-api/src/server/public/token_exchange.rs @@ -8,6 +8,11 @@ pub enum TokenRequest { refresh_token_id: models::Id, secret: String, }, + #[serde(rename = "api_key")] + ApiKey { + // The full `flow_sa_...` service-account API key. + api_key: String, + }, } #[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] @@ -34,9 +39,61 @@ pub async fn handle_post_token( refresh_token_id, secret, } => exchange_refresh_token(&app, refresh_token_id, &secret).await, + TokenRequest::ApiKey { api_key } => exchange_api_key(&app, &api_key).await, } } +// Lifetime of an access token minted by exchanging an API key. Matches the +// refresh-token exchange (SQL `generate_access_token`), so both grant types +// yield equivalent access tokens. +const ACCESS_TOKEN_TTL: chrono::Duration = chrono::Duration::hours(1); + +// Exchange a service-account API key for an access token. +// +// An API key can already be presented directly as an `Authorization: Bearer` +// credential, verified statefully against the database on every request. This +// exchange path is a convenience for clients that prefer the standard OAuth +// shape: present the long-lived key once, then carry a signed access token. +// +// No refresh token is returned: the API key itself is the durable credential, +// and the client simply re-exchanges it when the access token expires. +async fn exchange_api_key( + app: &crate::App, + api_key: &str, +) -> Result, crate::ApiError> { + // `verify_api_key` dispatches on this prefix via `expect`; reject a + // non-key here so a malformed request is a 400 rather than a panic. + if !api_key.starts_with("flow_sa_") { + return Err(crate::ApiError::Status(tonic::Status::invalid_argument( + "api_key must be a flow_sa_ service-account key", + ))); + } + + // The same stateful verification the bearer path performs, including the + // last_used_at stamp — but here we mint a signed token for the verified + // identity rather than asserting request-scoped claims. The DB check, not + // the signature, is the source of trust; signing just lets the client + // reuse the result without a per-request round-trip. + let user_id = crate::server::verify_api_key(&app.pg_pool, api_key).await?; + + let now = tokens::now(); + let claims = crate::ControlClaims { + iat: now.timestamp() as u64, + exp: (now + ACCESS_TOKEN_TTL).timestamp() as u64, + sub: user_id, + role: "authenticated".to_string(), + aud: "authenticated".to_string(), + email: None, + }; + + let access_token = tokens::jwt::sign(&claims, &app.control_plane_jwt_encode_key)?; + + Ok(axum::Json(TokenResponse { + access_token, + refresh_token: None, + })) +} + // Exchange a refresh token for an access token. // // This delegates to the SQL `generate_access_token` function transitionally: From c0453bd0cb782d67150806330e534b8ec3463c6a Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 14:27:21 -0400 Subject: [PATCH 03/13] control plane: bump service_accounts migration timestamp so it sorts after merged migrations The service_accounts migration was dated 20260528, earlier than the 20260601 (grant_bundles_select) and 20260605 (anon_connector_grants) migrations that have since landed on master. Once this branch merges, a migration with a timestamp earlier than already-applied upstream migrations is out of order. Rename it to 20260615 so it sorts last and applies cleanly everywhere. Content is unchanged. --- ...0_service_accounts.sql => 20260615120000_service_accounts.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260528120000_service_accounts.sql => 20260615120000_service_accounts.sql} (100%) diff --git a/supabase/migrations/20260528120000_service_accounts.sql b/supabase/migrations/20260615120000_service_accounts.sql similarity index 100% rename from supabase/migrations/20260528120000_service_accounts.sql rename to supabase/migrations/20260615120000_service_accounts.sql From 0037905a230e61662ba4a1d5de49c80772ed8b69 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 23:57:38 -0400 Subject: [PATCH 04/13] control plane: gate service-account management on fine-grained capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service-account management surface was asymmetric: listing authorized on the fine-grained `ManageServiceAccounts` capability while every mutation required the full `Admin` bundle. That produced a class of principal (a `TeamAdmin` without `Admin`) who could see service accounts but manage none of them, and it meant the named capability the feature defines was never the actual gate for any write. Make the surface track the capabilities it defines: - Anchor checks (create/add-grant/remove-grant/create-key/revoke-key, all on the account's `catalog_name`) now require `ManageServiceAccounts`, matching the listing query. A `TeamAdmin` can fully manage accounts and their keys without holding `Admin`. - The per-grant prefix check now requires `CreateGrant` on each granted prefix rather than `Admin`. This is the anti-escalation guard — a caller still can't hand a service account reach they couldn't grant anyone — but it keys off the capability that authorizes granting, not the whole Admin bundle. Human-user grant creation still lives in PostgREST; when it migrates to GraphQL it should adopt this same `CreateGrant` gate. To express fine-grained capabilities at these call sites, generalize `evaluate_names_authorization` and `verify_authorization` to accept anything that converts into a `CapabilitySet` (legacy `models::Capability`, a single `models::authz::Capability` bit, or an explicit set). The BFS primitive already operated on `CapabilitySet`, so this only lifts the wrapper signatures; existing legacy-capability callers are unaffected. Add a `Display` impl for `models::authz::Capability` so denial messages render the required capability. These three changes mirror the same generalization in #2944 so the two branches converge cleanly on whichever merges second. Add `test_team_admin_manages_without_full_admin`, which seeds a caller holding only the `team_admin` bundle (no `Admin`) and asserts both the positive path (manage accounts, mint keys, grant prefixes within reach) and the anti-escalation boundary (cannot grant a prefix they lack `CreateGrant` on). Also correct the migration comments, which claimed API keys are never exchanged for a JWT — the token-exchange endpoint mints a short-lived access token from a key. --- .../server/public/graphql/service_accounts.rs | 231 ++++++++++++++++-- .../20260615120000_service_accounts.sql | 8 +- 2 files changed, 215 insertions(+), 24 deletions(-) diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index d99cd71ffca..68adcf007b8 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -217,8 +217,8 @@ impl ServiceAccountsMutation { /// what the account may access. Access is determined solely by the /// account's user_grants, which may span multiple prefixes. /// - /// The caller must have admin capability on the catalog name AND on each - /// granted prefix. Creates an auth.users row, an + /// The caller must have ManageServiceAccounts on the catalog name AND + /// CreateGrant on each granted prefix. Creates an auth.users row, an /// internal.service_accounts row, and a user_grants row per requested /// grant. async fn create_service_account( @@ -252,13 +252,29 @@ impl ServiceAccountsMutation { } } - super::verify_authorization(env, catalog_name.as_str(), models::Capability::Admin).await?; - // Each grant requires the same authorization as granting a human user: - // admin capability on the granted prefix. This prevents a caller from - // provisioning an account with access they don't themselves administer. + // Managing the account (here, creating it under this anchor) requires + // ManageServiceAccounts on the catalog name — the same capability that + // gates listing in ServiceAccountsQuery, so the read and write surfaces + // agree. This is deliberately narrower than full Admin. + super::verify_authorization( + env, + catalog_name.as_str(), + models::authz::Capability::ManageServiceAccounts, + ) + .await?; + // Granting the account access to a prefix requires CreateGrant on that + // prefix — the anti-escalation guard, distinct from managing the + // account: a caller can't hand a service account reach they couldn't + // grant anyone. (Human-user grant creation still lives in PostgREST; + // when it migrates to GraphQL it should gate on this same CreateGrant + // capability.) for grant in &grants { - super::verify_authorization(env, grant.prefix.as_str(), models::Capability::Admin) - .await?; + super::verify_authorization( + env, + grant.prefix.as_str(), + models::authz::Capability::CreateGrant, + ) + .await?; } let mut txn = env.pg_pool.begin().await?; @@ -328,11 +344,12 @@ impl ServiceAccountsMutation { /// Add a user_grant to a service account. /// - /// The caller must manage the service account (admin capability on its - /// catalog name) AND have admin capability on the granted prefix — the - /// same authorization as granting a human user. The second requirement - /// prevents a caller from extending an account's access beyond what they - /// themselves administer. + /// The caller must manage the service account (ManageServiceAccounts on its + /// catalog name) AND have CreateGrant on the granted prefix. The second + /// requirement prevents a caller from extending an account's access beyond + /// what they could grant anyone. (Human-user grant creation still lives in + /// PostgREST; when it migrates to GraphQL it should gate on this same + /// CreateGrant capability.) async fn add_service_account_grant( &self, ctx: &Context<'_>, @@ -358,8 +375,14 @@ impl ServiceAccountsMutation { } let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; - super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; - super::verify_authorization(env, prefix.as_str(), models::Capability::Admin).await?; + super::verify_authorization( + env, + &catalog_name, + models::authz::Capability::ManageServiceAccounts, + ) + .await?; + super::verify_authorization(env, prefix.as_str(), models::authz::Capability::CreateGrant) + .await?; let mut txn = env.pg_pool.begin().await?; crate::grants::upsert_user_grant( @@ -386,7 +409,7 @@ impl ServiceAccountsMutation { /// Remove a user_grant from a service account. /// - /// The caller must manage the service account (admin capability on its + /// The caller must manage the service account (ManageServiceAccounts on its /// catalog name). Unlike addServiceAccountGrant, no capability on the /// grant's prefix is required: removal only ever narrows the account's /// access, so managers may remove ANY grant — including grants to @@ -401,7 +424,12 @@ impl ServiceAccountsMutation { let claims = env.claims()?; let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; - super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + super::verify_authorization( + env, + &catalog_name, + models::authz::Capability::ManageServiceAccounts, + ) + .await?; let deleted = sqlx::query!( "DELETE FROM public.user_grants WHERE user_id = $1 AND object_role = $2", @@ -444,7 +472,12 @@ impl ServiceAccountsMutation { let claims = env.claims()?; let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; - super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + super::verify_authorization( + env, + &catalog_name, + models::authz::Capability::ManageServiceAccounts, + ) + .await?; // valid_for is documented as an ISO 8601 duration (e.g. P90D, P1Y). // Reject anything that isn't ISO 8601 up front: the `::interval` cast @@ -578,7 +611,12 @@ impl ServiceAccountsMutation { }; let catalog_name = lookup_service_account(&env.pg_pool, key_owner).await?; - super::verify_authorization(env, &catalog_name, models::Capability::Admin).await?; + super::verify_authorization( + env, + &catalog_name, + models::authz::Capability::ManageServiceAccounts, + ) + .await?; sqlx::query!( "UPDATE internal.api_keys SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", @@ -1292,4 +1330,159 @@ mod test { .await; assert!(remove_unmanaged["errors"].is_array()); } + + /// The management gates accept the fine-grained capabilities the feature + /// defines, not only the full `Admin` bundle: a caller holding `TeamAdmin` + /// (which confers `ManageServiceAccounts` + `CreateGrant`) but NOT `Admin` + /// can manage service accounts, while the per-grant `CreateGrant` check + /// still bounds how far they can extend an account's reach. + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_team_admin_manages_without_full_admin(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + // Carol holds the TeamAdmin bundle on aliceCo/ and nothing else: her + // grant carries no legacy capability ('none'), so her bits come solely + // from the bundle — ManageServiceAccounts and CreateGrant, but none of + // the wider Admin-bundle bits. This is the caller class the gates were + // narrowed to admit. Seeded before the snapshot so authorization + // observes it. + let carol_uid = uuid::Uuid::from_bytes([0x33; 16]); + sqlx::query("INSERT INTO auth.users (id, email) VALUES ($1, 'carol@example.test')") + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO public.user_grants (user_id, object_role, capability, bundles) + VALUES ($1, 'aliceCo/', 'none', ARRAY['team_admin']::capability_bundle[])", + ) + .bind(carol_uid) + .execute(&pool) + .await + .unwrap(); + + let server = test_server::TestServer::start( + pool.clone(), + test_server::snapshot(pool.clone(), true).await, + ) + .await; + + let carol_token = server.make_access_token(carol_uid, Some("carol@example.test")); + + // Create succeeds: the anchor gate accepts ManageServiceAccounts, and + // the per-grant gate accepts CreateGrant on aliceCo/data/ (covered by + // Carol's aliceCo/ bundle) — all without her holding full Admin. + let create: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($grants: [ServiceAccountGrantInput!]!) { + createServiceAccount( + catalogName: "aliceCo/team-bot" + displayName: "Team Bot" + grants: $grants + ) { id createdBy } + }"#, + "variables": { + "grants": [ { "prefix": "aliceCo/data/", "capability": "read" } ] + } + }), + Some(&carol_token), + ) + .await; + assert!( + create["errors"].is_null(), + "a TeamAdmin without full Admin should create a service account: {create}" + ); + let sa_user_id = create["data"]["createServiceAccount"]["id"] + .as_str() + .expect("should have id"); + assert_eq!( + create["data"]["createServiceAccount"]["createdBy"], + "33333333-3333-3333-3333-333333333333", + "createdBy should be the calling team admin: {create}" + ); + + // The anchor-only mutation createApiKey also accepts ManageServiceAccounts. + let key: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($id: UUID!) { + createApiKey(serviceAccountId: $id, label: "ci", validFor: "P30D") { id } + }"#, + "variables": { "id": sa_user_id } + }), + Some(&carol_token), + ) + .await; + assert!( + key["errors"].is_null(), + "a TeamAdmin should mint an API key: {key}" + ); + + // addServiceAccountGrant to a prefix Carol can confer (she holds + // CreateGrant across aliceCo/) succeeds. + let add_ok: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($id: UUID!) { + addServiceAccountGrant(serviceAccountId: $id, prefix: "aliceCo/ops/", capability: write) + }"#, + "variables": { "id": sa_user_id } + }), + Some(&carol_token), + ) + .await; + assert!( + add_ok["errors"].is_null(), + "granting a prefix the team admin can confer should succeed: {add_ok}" + ); + + // Anti-escalation: Carol lacks CreateGrant on bobCo/, so she cannot + // extend the account there — managing an account does not let her widen + // its reach beyond what she could grant. This is the boundary now + // sitting at CreateGrant rather than Admin. + let add_escalation: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($id: UUID!) { + addServiceAccountGrant(serviceAccountId: $id, prefix: "bobCo/", capability: read) + }"#, + "variables": { "id": sa_user_id } + }), + Some(&carol_token), + ) + .await; + assert!( + add_escalation["errors"].is_array(), + "a team admin must not grant a prefix she lacks CreateGrant on: {add_escalation}" + ); + + // The same boundary binds at creation time: seeding a foreign grant fails. + let create_escalation: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccount( + catalogName: "aliceCo/overreach-bot" + displayName: "overreach" + grants: [{ prefix: "bobCo/", capability: read }] + ) { id } + }"# + }), + Some(&carol_token), + ) + .await; + assert!( + create_escalation["errors"].is_array(), + "seeding a grant beyond the team admin's CreateGrant must fail: {create_escalation}" + ); + } } diff --git a/supabase/migrations/20260615120000_service_accounts.sql b/supabase/migrations/20260615120000_service_accounts.sql index d030f40c229..0ee33dd8b3f 100644 --- a/supabase/migrations/20260615120000_service_accounts.sql +++ b/supabase/migrations/20260615120000_service_accounts.sql @@ -28,10 +28,8 @@ comment on table internal.service_accounts is create index service_accounts_catalog_name_spgist on internal.service_accounts using spgist ((catalog_name::text)); --- API keys: long-lived credentials for service accounts, presented directly --- as Authorization: Bearer credentials. Keys are evaluated statefully ONLY: --- every request re-verifies the key against this table, and a key is never --- exchanged for a signed JWT — which is what makes revocation immediate. +-- API keys: long-lived credentials for service accounts. Presented directly +-- as Authorization: Bearer credentials or exchanged for a short-lived (1-hour) access token. create table internal.api_keys ( id public.flowid primary key not null default internal.id_generator(), @@ -56,6 +54,6 @@ create table internal.api_keys ( create index api_keys_service_account_id on internal.api_keys (service_account_id); comment on table internal.api_keys is - 'Long-lived credentials for service accounts, verified statefully as bearer credentials on every request (never exchanged for JWTs).'; + 'Long-lived service-account credentials, verified statefully on every request as bearer credentials, or optionally exchanged for a short-lived access token.'; commit; From 60dc63a7199818fffd1316690162d3eb563a1b2d Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 00:10:46 -0400 Subject: [PATCH 05/13] edit comments --- crates/control-plane-api/src/server/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index bf4c84d7006..d217ad4fa5c 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -289,12 +289,10 @@ pub async fn exchange_refresh_token( /// account in the same round-trip. Both the bearer path /// (`authenticate_api_key`) and the token-exchange endpoint /// (`token_exchange::exchange_api_key`) build a credential atop the verified -/// identity; they differ only in the lifetime of what they then issue. +/// identity. /// /// Secrets are hashed with SHA-256 rather than bcrypt: the secret is -/// high-entropy random (so a slow hash adds no brute-force protection) and -/// this verification sits in the per-request hot path, where a fast hash -/// matters. +/// high-entropy random (so the slow bcrypt hash adds no brute-force protection). pub async fn verify_api_key(pg_pool: &sqlx::PgPool, api_key: &str) -> tonic::Result { let raw = api_key .strip_prefix("flow_sa_") From 29983f74fd7c60573cf8e6b690336e758f4614c8 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 00:21:13 -0400 Subject: [PATCH 06/13] control plane: register the API-key authentication entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service accounts add a third caller of `Verified::assert_authenticity` — `authenticate_api_key`, which mints an authentication proof from a verified API-key secret. This is the new authentication entry point this branch introduces over its refresh-token base. Account for it where the choke point is documented and guarded: add the API-key caller to the SECURITY doc's enumerated set, bump the `authenticity_census` count for server/mod.rs from one to two, and annotate the call site. Without this, the census inherited from the base branch fails the build — which is the intended signal that a new way to authenticate a request was added and needs review. --- crates/control-plane-api/src/server/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index d217ad4fa5c..f39ced09099 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -363,6 +363,10 @@ pub async fn authenticate_api_key( email: None, }; + // assert_authenticity mints the authentication proof — an authentication + // entry point. Like authenticate_refresh_token and unlike jwt::verify, no + // signature is checked; the proof rests on verify_api_key's stateful + // database check above. See the SECURITY notes on assert_authenticity. Ok(tokens::Verified::assert_authenticity(claims, exp)) } From a92dac355eb968f27353a008acd55a274ac7a71e Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 00:32:55 -0400 Subject: [PATCH 07/13] remove unnecessary service account display name --- .../server/public/graphql/service_accounts.rs | 28 ++++--------------- crates/flow-client/control-plane-api.graphql | 3 +- .../20260615120000_service_accounts.sql | 1 - 3 files changed, 7 insertions(+), 25 deletions(-) diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index 68adcf007b8..7cc83852d0a 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -8,7 +8,6 @@ pub struct ServiceAccount { // surface in the public schema. #[graphql(name = "id")] pub user_id: uuid::Uuid, - pub display_name: String, pub catalog_name: models::Name, pub created_by: uuid::Uuid, pub created_at: chrono::DateTime, @@ -100,7 +99,6 @@ impl ServiceAccountsQuery { r#" SELECT sa.user_id, - sa.display_name, sa.catalog_name AS "catalog_name!: String", sa.created_by, sa.created_at AS "created_at!: chrono::DateTime", @@ -183,7 +181,6 @@ impl ServiceAccountsQuery { TimestampCursor(r.created_at), ServiceAccount { user_id: r.user_id, - display_name: r.display_name, catalog_name: models::Name::new(&r.catalog_name), created_by: r.created_by, created_at: r.created_at, @@ -225,7 +222,6 @@ impl ServiceAccountsMutation { &self, ctx: &Context<'_>, catalog_name: models::Name, - display_name: String, grants: Vec, ) -> async_graphql::Result { let env = ctx.data::()?; @@ -289,7 +285,7 @@ impl ServiceAccountsMutation { sa_user_id, format!("sa+{}@service.estuary.dev", sa_user_id), serde_json::json!({ - "full_name": display_name, + "full_name": catalog_name.as_str(), }), ) .execute(&mut *txn) @@ -297,13 +293,12 @@ impl ServiceAccountsMutation { let now = sqlx::query_scalar!( r#" - INSERT INTO internal.service_accounts (user_id, catalog_name, display_name, created_by) - VALUES ($1, $2::text::catalog_name, $3, $4) + INSERT INTO internal.service_accounts (user_id, catalog_name, created_by) + VALUES ($1, $2::text::catalog_name, $3) RETURNING created_at AS "created_at!: chrono::DateTime" "#, sa_user_id, catalog_name.as_str(), - display_name, claims.sub, ) .fetch_one(&mut *txn) @@ -332,7 +327,6 @@ impl ServiceAccountsMutation { Ok(ServiceAccount { user_id: sa_user_id, - display_name, catalog_name, created_by: claims.sub, created_at: now, @@ -709,14 +703,12 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($catalogName: Name!, $name: String!, $grants: [ServiceAccountGrantInput!]!) { + mutation($catalogName: Name!, $grants: [ServiceAccountGrantInput!]!) { createServiceAccount( catalogName: $catalogName - displayName: $name grants: $grants ) { id - displayName catalogName createdBy createdAt @@ -727,7 +719,6 @@ mod test { }"#, "variables": { "catalogName": "aliceCo/ci-deploy-bot", - "name": "CI Deploy Bot", "grants": [ { "prefix": "aliceCo/", "capability": "admin" }, { "prefix": "aliceCo/data/", "capability": "read" } @@ -744,7 +735,6 @@ mod test { ); let sa = &create_response["data"]["createServiceAccount"]; let sa_user_id = sa["id"].as_str().expect("should have id"); - assert_eq!(sa["displayName"], "CI Deploy Bot"); assert_eq!(sa["catalogName"], "aliceCo/ci-deploy-bot"); assert_eq!(sa["apiKeys"].as_array().unwrap().len(), 0); assert_eq!( @@ -774,7 +764,6 @@ mod test { mutation { createServiceAccount( catalogName: "aliceCo/hacker-bot" - displayName: "hacker bot" grants: [{ prefix: "aliceCo/", capability: read }] ) { id } }"# @@ -793,7 +782,7 @@ mod test { &serde_json::json!({ "query": r#" mutation { - createServiceAccount(catalogName: "Not A Name", displayName: "x", grants: []) { id } + createServiceAccount(catalogName: "Not A Name", grants: []) { id } }"# }), Some(&alice_token), @@ -816,7 +805,6 @@ mod test { mutation { createServiceAccount( catalogName: "aliceCo/no-op-bot" - displayName: "x" grants: [{ prefix: "aliceCo/", capability: none }] ) { id } }"# @@ -842,7 +830,6 @@ mod test { mutation { createServiceAccount( catalogName: "aliceCo/overreach-bot" - displayName: "overreach bot" grants: [ { prefix: "aliceCo/", capability: read }, { prefix: "bobCo/", capability: read } @@ -1015,7 +1002,6 @@ mod test { edges { node { id - displayName catalogName apiKeys { id @@ -1038,7 +1024,7 @@ mod test { .as_array() .expect("should have edges"); assert_eq!(edges.len(), 1); - assert_eq!(edges[0]["node"]["displayName"], "CI Deploy Bot"); + assert_eq!(edges[0]["node"]["catalogName"], "aliceCo/ci-deploy-bot"); let listed_key = &edges[0]["node"]["apiKeys"][0]; assert_eq!(edges[0]["node"]["apiKeys"].as_array().unwrap().len(), 1); assert_eq!(listed_key["label"], "GitHub Actions"); @@ -1382,7 +1368,6 @@ mod test { mutation($grants: [ServiceAccountGrantInput!]!) { createServiceAccount( catalogName: "aliceCo/team-bot" - displayName: "Team Bot" grants: $grants ) { id createdBy } }"#, @@ -1472,7 +1457,6 @@ mod test { mutation { createServiceAccount( catalogName: "aliceCo/overreach-bot" - displayName: "overreach" grants: [{ prefix: "bobCo/", capability: read }] ) { id } }"# diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index ae7515f1872..6df4b85ca4e 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1232,7 +1232,7 @@ type MutationRoot { internal.service_accounts row, and a user_grants row per requested grant. """ - createServiceAccount(catalogName: Name!, displayName: String!, grants: [ServiceAccountGrantInput!]!): ServiceAccount! + createServiceAccount(catalogName: Name!, grants: [ServiceAccountGrantInput!]!): ServiceAccount! """ Add a user_grant to a service account. @@ -1684,7 +1684,6 @@ type RepublishRequested { type ServiceAccount { id: UUID! - displayName: String! catalogName: Name! createdBy: UUID! createdAt: DateTime! diff --git a/supabase/migrations/20260615120000_service_accounts.sql b/supabase/migrations/20260615120000_service_accounts.sql index 0ee33dd8b3f..528cfaa556e 100644 --- a/supabase/migrations/20260615120000_service_accounts.sql +++ b/supabase/migrations/20260615120000_service_accounts.sql @@ -12,7 +12,6 @@ create table internal.service_accounts ( -- user_grants rows, which are managed like any other user's and may span -- multiple prefixes. catalog_name public.catalog_name not null, - display_name text not null, created_by uuid not null references auth.users (id), last_used_at timestamptz, created_at timestamptz not null default now(), From 04d4b75f5c37a02565de796ecff9e44c99a3d22f Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 00:51:14 -0400 Subject: [PATCH 08/13] remove redundant service-account last_used_at column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service_accounts.last_used_at column was a denormalized cache of max(api_keys.last_used_at): verify_api_key stamped both the key and its owning account with the same now() in one round-trip, so the account column never held information not already present on api_keys. Drop the column and derive ServiceAccount.lastUsedAt at read time as the max over the account's keys (revoked included, preserving the old column's semantics). This collapses verify_api_key — on the per-request auth hot path — from two row updates to one, shifting the negligible aggregate cost to the low-frequency admin listing. The GraphQL field and SDL are unchanged. --- crates/control-plane-api/src/server/mod.rs | 33 ++++++++----------- .../server/public/graphql/service_accounts.rs | 16 ++++++++- .../20260615120000_service_accounts.sql | 1 - 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index f39ced09099..0744afccb9a 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -285,9 +285,8 @@ pub async fn exchange_refresh_token( /// the verified service account's `user_id`. /// /// This is the sole check of an API key's validity — secret hash, expiry, and -/// revocation — and it stamps `last_used_at` on both the key and its service -/// account in the same round-trip. Both the bearer path -/// (`authenticate_api_key`) and the token-exchange endpoint +/// revocation — and it stamps the key's `last_used_at` in the same round-trip. +/// Both the bearer path (`authenticate_api_key`) and the token-exchange endpoint /// (`token_exchange::exchange_api_key`) build a credential atop the verified /// identity. /// @@ -312,25 +311,19 @@ pub async fn verify_api_key(pg_pool: &sqlx::PgPool, api_key: &str) -> tonic::Res .parse() .map_err(|_| tonic::Status::invalid_argument("malformed api key: invalid key id"))?; - // Validate the secret, expiry, and revocation in one query, stamping - // last_used_at on both the key and its service account as part of the - // same verification round-trip. + // Validate the secret, expiry, and revocation in one query, stamping the + // key's last_used_at as part of the same verification round-trip. The owning + // account's "last used" is derived from its keys at read time, so there's no + // second row to update here. let row = sqlx::query!( r#" - WITH verified AS ( - UPDATE internal.api_keys - SET last_used_at = now() - WHERE id = $1 - AND secret_hash = encode(digest($2, 'sha256'), 'hex') - AND expires_at > now() - AND revoked_at IS NULL - RETURNING service_account_id - ) - UPDATE internal.service_accounts sa + UPDATE internal.api_keys SET last_used_at = now() - FROM verified v - WHERE sa.user_id = v.service_account_id - RETURNING sa.user_id + WHERE id = $1 + AND secret_hash = encode(digest($2, 'sha256'), 'hex') + AND expires_at > now() + AND revoked_at IS NULL + RETURNING service_account_id "#, key_id as models::Id, secret, @@ -340,7 +333,7 @@ pub async fn verify_api_key(pg_pool: &sqlx::PgPool, api_key: &str) -> tonic::Res .map_err(|err| tonic::Status::internal(format!("failed to authenticate api key: {err}")))? .ok_or_else(|| tonic::Status::unauthenticated("invalid, expired, or revoked api key"))?; - Ok(row.user_id) + Ok(row.service_account_id) } /// Authenticate a service-account API key presented as a bearer credential, diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index 7cc83852d0a..17a4de1d8db 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -103,7 +103,15 @@ impl ServiceAccountsQuery { sa.created_by, sa.created_at AS "created_at!: chrono::DateTime", sa.updated_at AS "updated_at!: chrono::DateTime", - sa.last_used_at AS "last_used_at: chrono::DateTime" + -- The account's "last used" is the max across all its + -- keys (revoked included): verify_api_key only ever + -- stamps the key it authenticated, so api_keys is the + -- single source of truth. + ( + SELECT max(ak.last_used_at) + FROM internal.api_keys ak + WHERE ak.service_account_id = sa.user_id + ) AS "last_used_at: chrono::DateTime" FROM internal.service_accounts sa WHERE sa.catalog_name::text ^@ ANY($1) AND ($2::timestamptz IS NULL OR sa.created_at < $2) @@ -1003,6 +1011,7 @@ mod test { node { id catalogName + lastUsedAt apiKeys { id label @@ -1043,6 +1052,11 @@ mod test { listed_key["lastUsedAt"].is_string(), "lastUsedAt should be set after a successful bearer use: {list}" ); + // The account's lastUsedAt is derived as the max across its keys' last_used_at. + assert!( + edges[0]["node"]["lastUsedAt"].is_string(), + "account lastUsedAt should be derived from its keys' use: {list}" + ); // Bob sees no service accounts. let bob_list: serde_json::Value = server diff --git a/supabase/migrations/20260615120000_service_accounts.sql b/supabase/migrations/20260615120000_service_accounts.sql index 528cfaa556e..db2deeb5b16 100644 --- a/supabase/migrations/20260615120000_service_accounts.sql +++ b/supabase/migrations/20260615120000_service_accounts.sql @@ -13,7 +13,6 @@ create table internal.service_accounts ( -- multiple prefixes. catalog_name public.catalog_name not null, created_by uuid not null references auth.users (id), - last_used_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); From cb6c25704efbaa9343bd7b325227740756aee3ac Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 00:53:44 -0400 Subject: [PATCH 09/13] regenerate sqlx cache --- ...d9c6994ea7ab034268e289897d848da5f1d29.json | 14 ----- ...48bb80a3818f6435d85cfa87fdcf43eca1e3d.json | 60 ------------------- ...4124914c7c9d9203213837fbf2a62796fe9b.json} | 5 +- ...df3c93ab0e7def33b016e24b42a561e27c11d.json | 31 ++++++++++ ...7fe80d08329efd2ea90dc56c42c722cb35f22.json | 54 +++++++++++++++++ ...47400cd78f0106597944391d04faaa133fcd7.json | 23 +++++++ ...fa86639f6437aa902769a4fd4f17e16f53a86.json | 31 ---------- ...f184bf6b81e497732955eac3ecadfcdf5554d.json | 23 ------- 8 files changed, 110 insertions(+), 131 deletions(-) delete mode 100644 .sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json delete mode 100644 .sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json rename .sqlx/{query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json => query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json} (57%) create mode 100644 .sqlx/query-8397efbae3f2744e7ef47aa5f9adf3c93ab0e7def33b016e24b42a561e27c11d.json create mode 100644 .sqlx/query-87c3c08fe92a4e2ecdc866ca6837fe80d08329efd2ea90dc56c42c722cb35f22.json create mode 100644 .sqlx/query-cbe2f613660b912b54a98dac61847400cd78f0106597944391d04faaa133fcd7.json delete mode 100644 .sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json delete mode 100644 .sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json diff --git a/.sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json b/.sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json deleted file mode 100644 index 343f27c2ff1..00000000000 --- a/.sqlx/query-334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE internal.api_keys\n SET revoked_at = now()\n WHERE service_account_id = $1 AND revoked_at IS NULL\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "334f10ddf20d95f7d7b46f59753d9c6994ea7ab034268e289897d848da5f1d29" -} diff --git a/.sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json b/.sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json deleted file mode 100644 index ed356120641..00000000000 --- a/.sqlx/query-3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n sa.user_id,\n sa.display_name,\n sa.catalog_name AS \"catalog_name!: String\",\n sa.created_by,\n sa.created_at AS \"created_at!: chrono::DateTime\",\n sa.updated_at AS \"updated_at!: chrono::DateTime\",\n sa.last_used_at AS \"last_used_at: chrono::DateTime\"\n FROM internal.service_accounts sa\n WHERE sa.catalog_name::text ^@ ANY($1)\n AND ($2::timestamptz IS NULL OR sa.created_at < $2)\n ORDER BY sa.created_at DESC\n LIMIT $3 + 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "catalog_name!: String", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "created_by", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "created_at!: chrono::DateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "updated_at!: chrono::DateTime", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "last_used_at: chrono::DateTime", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "TextArray", - "Timestamptz", - "Int4" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - true - ] - }, - "hash": "3437b25138d928e75abd668d3f748bb80a3818f6435d85cfa87fdcf43eca1e3d" -} diff --git a/.sqlx/query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json b/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json similarity index 57% rename from .sqlx/query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json rename to .sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json index 8c98f310cf9..6ef5686e67e 100644 --- a/.sqlx/query-741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154.json +++ b/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO internal.service_accounts (user_id, catalog_name, display_name, created_by)\n VALUES ($1, $2::text::catalog_name, $3, $4)\n RETURNING created_at AS \"created_at!: chrono::DateTime\"\n ", + "query": "\n INSERT INTO internal.service_accounts (user_id, catalog_name, created_by)\n VALUES ($1, $2::text::catalog_name, $3)\n RETURNING created_at AS \"created_at!: chrono::DateTime\"\n ", "describe": { "columns": [ { @@ -13,7 +13,6 @@ "Left": [ "Uuid", "Text", - "Text", "Uuid" ] }, @@ -21,5 +20,5 @@ false ] }, - "hash": "741ae89c80d9b9cd1f06fbf3eabf6d13534650d8365dca164c03df68b462d154" + "hash": "3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b" } diff --git a/.sqlx/query-8397efbae3f2744e7ef47aa5f9adf3c93ab0e7def33b016e24b42a561e27c11d.json b/.sqlx/query-8397efbae3f2744e7ef47aa5f9adf3c93ab0e7def33b016e24b42a561e27c11d.json new file mode 100644 index 00000000000..01ec82ffd62 --- /dev/null +++ b/.sqlx/query-8397efbae3f2744e7ef47aa5f9adf3c93ab0e7def33b016e24b42a561e27c11d.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH new_key AS (\n SELECT\n internal.id_generator() AS id,\n -- 256 bits from pgcrypto's CSPRNG; the SHA-256 hashing\n -- below rests on secrets being high-entropy.\n encode(gen_random_bytes(32), 'hex') AS secret\n )\n INSERT INTO internal.api_keys (id, service_account_id, secret_hash, label, expires_at, created_by)\n SELECT\n nk.id,\n $1,\n -- SHA-256 rather than bcrypt: the secret is high-entropy random,\n -- so bcrypt isn't necessary here.\n -- Refresh-token secrets will migrate to the same scheme once\n -- the legacy postgREST token functions are retired.\n encode(digest(nk.secret, 'sha256'), 'hex'),\n $2,\n now() + $3::text::interval,\n $4\n FROM new_key nk\n RETURNING\n id AS \"id!: models::Id\",\n (SELECT secret FROM new_key) AS \"secret!: String\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: models::Id", + "type_info": "Macaddr8" + }, + { + "ordinal": 1, + "name": "secret!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "8397efbae3f2744e7ef47aa5f9adf3c93ab0e7def33b016e24b42a561e27c11d" +} diff --git a/.sqlx/query-87c3c08fe92a4e2ecdc866ca6837fe80d08329efd2ea90dc56c42c722cb35f22.json b/.sqlx/query-87c3c08fe92a4e2ecdc866ca6837fe80d08329efd2ea90dc56c42c722cb35f22.json new file mode 100644 index 00000000000..ea3dd12cbcd --- /dev/null +++ b/.sqlx/query-87c3c08fe92a4e2ecdc866ca6837fe80d08329efd2ea90dc56c42c722cb35f22.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n sa.user_id,\n sa.catalog_name AS \"catalog_name!: String\",\n sa.created_by,\n sa.created_at AS \"created_at!: chrono::DateTime\",\n sa.updated_at AS \"updated_at!: chrono::DateTime\",\n -- The account's \"last used\" is the max across all its\n -- keys (revoked included): verify_api_key only ever\n -- stamps the key it authenticated, so api_keys is the\n -- single source of truth.\n (\n SELECT max(ak.last_used_at)\n FROM internal.api_keys ak\n WHERE ak.service_account_id = sa.user_id\n ) AS \"last_used_at: chrono::DateTime\"\n FROM internal.service_accounts sa\n WHERE sa.catalog_name::text ^@ ANY($1)\n AND ($2::timestamptz IS NULL OR sa.created_at < $2)\n ORDER BY sa.created_at DESC\n LIMIT $3 + 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "catalog_name!: String", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_used_at: chrono::DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Timestamptz", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + null + ] + }, + "hash": "87c3c08fe92a4e2ecdc866ca6837fe80d08329efd2ea90dc56c42c722cb35f22" +} diff --git a/.sqlx/query-cbe2f613660b912b54a98dac61847400cd78f0106597944391d04faaa133fcd7.json b/.sqlx/query-cbe2f613660b912b54a98dac61847400cd78f0106597944391d04faaa133fcd7.json new file mode 100644 index 00000000000..228bb20d816 --- /dev/null +++ b/.sqlx/query-cbe2f613660b912b54a98dac61847400cd78f0106597944391d04faaa133fcd7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE internal.api_keys\n SET last_used_at = now()\n WHERE id = $1\n AND secret_hash = encode(digest($2, 'sha256'), 'hex')\n AND expires_at > now()\n AND revoked_at IS NULL\n RETURNING service_account_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "service_account_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Macaddr8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cbe2f613660b912b54a98dac61847400cd78f0106597944391d04faaa133fcd7" +} diff --git a/.sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json b/.sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json deleted file mode 100644 index 68599d8df5d..00000000000 --- a/.sqlx/query-f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH new_key AS (\n SELECT\n internal.id_generator() AS id,\n -- 256 bits from pgcrypto's CSPRNG; the SHA-256 hashing\n -- below rests on secrets being high-entropy.\n encode(gen_random_bytes(32), 'hex') AS secret\n )\n INSERT INTO internal.api_keys (id, service_account_id, secret_hash, label, expires_at, created_by)\n SELECT\n nk.id,\n $1,\n -- SHA-256 rather than bcrypt: this hash is re-checked on\n -- every bearer presentation (and on each access-token\n -- exchange), placing verification squarely in the per-request\n -- hot path, where a fast hash matters. A slow hash would buy nothing\n -- anyway: the secret is high-entropy random, and offline\n -- brute-force resistance only matters for low-entropy\n -- passwords. This matches how GitHub stores PATs.\n -- Refresh-token secrets will migrate to the same scheme once\n -- the legacy postgREST token functions are retired.\n encode(digest(nk.secret, 'sha256'), 'hex'),\n $2,\n now() + $3::text::interval,\n $4\n FROM new_key nk\n RETURNING\n id AS \"id!: models::Id\",\n (SELECT secret FROM new_key) AS \"secret!: String\"\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!: models::Id", - "type_info": "Macaddr8" - }, - { - "ordinal": 1, - "name": "secret!: String", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Uuid" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "f53885d3b3936727cfa8f8c01bffa86639f6437aa902769a4fd4f17e16f53a86" -} diff --git a/.sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json b/.sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json deleted file mode 100644 index 773adb2b14a..00000000000 --- a/.sqlx/query-fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH verified AS (\n UPDATE internal.api_keys\n SET last_used_at = now()\n WHERE id = $1\n AND secret_hash = encode(digest($2, 'sha256'), 'hex')\n AND expires_at > now()\n AND revoked_at IS NULL\n RETURNING service_account_id\n )\n UPDATE internal.service_accounts sa\n SET last_used_at = now()\n FROM verified v\n WHERE sa.user_id = v.service_account_id\n RETURNING sa.user_id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "user_id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Macaddr8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "fd2704f61da4296e95f90a3cd39f184bf6b81e497732955eac3ecadfcdf5554d" -} From aeacb6b20c6d4a44ba62387013b2c5db193c0897 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 20:29:33 -0400 Subject: [PATCH 10/13] control plane: mint a throw-away JWT for API-key bearer authentication Reworks how a service-account API key presented as an Authorization: Bearer credential is authenticated, to match how refresh tokens now work. Instead of verifying the key statefully and asserting request-scoped claims, the Envelope exchanges the key for a short-lived signed access token and verifies that token like any JWT. The stateful database check (verify_api_key: secret hash, expiry, revocation, last_used_at stamp) remains the source of trust; the signature just lets the minted token flow through the normal verify path. Replaces server::authenticate_api_key with server::exchange_api_key, which verifies the key and signs a 1-hour JWT for the verified identity. Both the bearer path (which mints, verifies, and discards the token within the request) and the /api/v1/auth/token exchange endpoint route through it, so the inline verify-and-sign in the endpoint collapses to a single call and a revoked or expired key can never yield a token. This also drops the assert_authenticity seam and its census, consistent with the refresh-token revert: API keys, like refresh tokens, no longer construct claims directly. Tests and doc comments are updated accordingly. --- crates/control-plane-api/src/server/mod.rs | 45 ++++-- .../server/public/graphql/refresh_tokens.rs | 6 +- .../server/public/graphql/service_accounts.rs | 9 +- .../src/server/public/token_exchange.rs | 41 ++--- crates/flow-client/control-plane-api.graphql | 150 ++++++++++++++---- .../20260615120000_service_accounts.sql | 11 +- 6 files changed, 165 insertions(+), 97 deletions(-) diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index 0744afccb9a..70c4c9414ec 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -286,8 +286,8 @@ pub async fn exchange_refresh_token( /// /// This is the sole check of an API key's validity — secret hash, expiry, and /// revocation — and it stamps the key's `last_used_at` in the same round-trip. -/// Both the bearer path (`authenticate_api_key`) and the token-exchange endpoint -/// (`token_exchange::exchange_api_key`) build a credential atop the verified +/// Both the bearer path and the token-exchange endpoint route through +/// [`exchange_api_key`], which mints a signed access token atop the verified /// identity. /// /// Secrets are hashed with SHA-256 rather than bcrypt: the secret is @@ -330,22 +330,45 @@ pub async fn verify_api_key(pg_pool: &sqlx::PgPool, api_key: &str) -> tonic::Res ) .fetch_optional(pg_pool) .await - .map_err(|err| tonic::Status::internal(format!("failed to authenticate api key: {err}")))? + .map_err(|err| { + // Log the database detail server-side; don't leak it to the caller. + tracing::error!(?err, "failed to verify service-account API key"); + tonic::Status::internal("failed to verify API key") + })? .ok_or_else(|| tonic::Status::unauthenticated("invalid, expired, or revoked api key"))?; Ok(row.service_account_id) } -/// Authenticate a service-account API key presented as a bearer credential, -/// returning the claims it proves. -pub async fn authenticate_api_key( +/// Lifetime of an access token minted in the application layer. A signed token +/// can't be revoked before it expires, so this bounds how long a minted token +/// outlives a credential revocation. Used by [`exchange_api_key`] today; the +/// refresh-token exchange will reuse it once that minting moves out of the SQL +/// `generate_access_token` function and into the application layer. +const ACCESS_TOKEN_TTL: chrono::Duration = chrono::Duration::hours(1); + +/// Exchange a service-account API key for a short-lived signed access token. +/// +/// Verifies the key statefully via [`verify_api_key`] and mints a JWT for the +/// verified service-account identity. This mirrors how a refresh token is +/// exchanged (see [`exchange_refresh_token`]): the bearer path mints a token, +/// verifies it, and discards it within the same request, while the +/// `/api/v1/auth/token` endpoint returns it to the caller. Because both route +/// through the same verification, a revoked or expired key can never yield a +/// token. +/// +/// On the bearer path the token's lifetime is moot — it's verified once and +/// thrown away — but for the exchange endpoint it bounds how long a token +/// outlives a key revocation. See [`ACCESS_TOKEN_TTL`]. +pub async fn exchange_api_key( pg_pool: &sqlx::PgPool, + jwt_encode_key: &tokens::jwt::EncodingKey, api_key: &str, -) -> tonic::Result> { +) -> tonic::Result { let user_id = verify_api_key(pg_pool, api_key).await?; let now = tokens::now(); - let exp = now + chrono::Duration::minutes(5); + let exp = now + ACCESS_TOKEN_TTL; let claims = crate::ControlClaims { iat: now.timestamp() as u64, @@ -356,11 +379,7 @@ pub async fn authenticate_api_key( email: None, }; - // assert_authenticity mints the authentication proof — an authentication - // entry point. Like authenticate_refresh_token and unlike jwt::verify, no - // signature is checked; the proof rests on verify_api_key's stateful - // database check above. See the SECURITY notes on assert_authenticity. - Ok(tokens::Verified::assert_authenticity(claims, exp)) + tokens::jwt::sign(&claims, jwt_encode_key) } /// Parse a data-plane claims token without verifying its signature. diff --git a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs index d57c633de27..b0e6a986fe4 100644 --- a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs +++ b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs @@ -494,7 +494,6 @@ mod test { mutation { createServiceAccount( catalogName: "aliceCo/refresh-token-bot" - displayName: "refresh-token bot" grants: [{ prefix: "aliceCo/", capability: admin }] ) { id } }"# @@ -510,9 +509,8 @@ mod test { .as_str() .expect("should have id"); - // Mint an access token whose `sub` is the service account, mirroring - // the claims that stateful API-key bearer authentication constructs - // (no email for an SA principal). + // Mint an access token whose `sub` is the service account (an SA + // principal has no email), standing in for an authenticated SA caller. let sa_token = server.make_access_token(uuid::Uuid::parse_str(sa_user_id).unwrap(), None); let sa_create_rt: serde_json::Value = server diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index 17a4de1d8db..fcb22969629 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -923,10 +923,10 @@ mod test { } // === The API key authenticates directly as a bearer credential === - // The Envelope verifies it against the database and constructs claims - // for the service account without minting (or verifying) a JWT. The + // The Envelope exchanges it for a short-lived signed access token and + // verifies that, resolving to the service account's identity. The // refreshTokens listing is empty (the account owns none), but a data - // response proves authentication resolved to the account's identity. + // response proves authentication succeeded. let via_bearer: serde_json::Value = server .graphql( &serde_json::json!({ @@ -969,8 +969,7 @@ mod test { "api_key exchange returns no refresh token: {exchanged}" ); - // The exchanged token is valid for one hour — the exchange window, not - // the 5-minute expiry the bearer path stamps on its claims. + // The exchanged token is valid for one hour. use base64::Engine; let payload = access_token.split('.').nth(1).expect("jwt has a payload"); let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD diff --git a/crates/control-plane-api/src/server/public/token_exchange.rs b/crates/control-plane-api/src/server/public/token_exchange.rs index 43eef7c40c8..aa8c431765b 100644 --- a/crates/control-plane-api/src/server/public/token_exchange.rs +++ b/crates/control-plane-api/src/server/public/token_exchange.rs @@ -43,20 +43,12 @@ pub async fn handle_post_token( } } -// Lifetime of an access token minted by exchanging an API key. Matches the -// refresh-token exchange (SQL `generate_access_token`), so both grant types -// yield equivalent access tokens. -const ACCESS_TOKEN_TTL: chrono::Duration = chrono::Duration::hours(1); - -// Exchange a service-account API key for an access token. -// -// An API key can already be presented directly as an `Authorization: Bearer` -// credential, verified statefully against the database on every request. This -// exchange path is a convenience for clients that prefer the standard OAuth -// shape: present the long-lived key once, then carry a signed access token. -// -// No refresh token is returned: the API key itself is the durable credential, -// and the client simply re-exchanges it when the access token expires. +// Exchange a service-account API key for a short-lived access token. This is +// the standard way to authenticate a service account: present the long-lived +// key once, then carry the signed token. Presenting the key directly as an +// `Authorization: Bearer` credential is also supported, as a convenience for +// one-off requests; that path mints the same token internally, so both route +// through `server::exchange_api_key`. async fn exchange_api_key( app: &crate::App, api_key: &str, @@ -69,24 +61,9 @@ async fn exchange_api_key( ))); } - // The same stateful verification the bearer path performs, including the - // last_used_at stamp — but here we mint a signed token for the verified - // identity rather than asserting request-scoped claims. The DB check, not - // the signature, is the source of trust; signing just lets the client - // reuse the result without a per-request round-trip. - let user_id = crate::server::verify_api_key(&app.pg_pool, api_key).await?; - - let now = tokens::now(); - let claims = crate::ControlClaims { - iat: now.timestamp() as u64, - exp: (now + ACCESS_TOKEN_TTL).timestamp() as u64, - sub: user_id, - role: "authenticated".to_string(), - aud: "authenticated".to_string(), - email: None, - }; - - let access_token = tokens::jwt::sign(&claims, &app.control_plane_jwt_encode_key)?; + let access_token = + crate::server::exchange_api_key(&app.pg_pool, &app.control_plane_jwt_encode_key, api_key) + .await?; Ok(axum::Json(TokenResponse { access_token, diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 6df4b85ca4e..b74f97c3a99 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1,3 +1,17 @@ +type AWSPrivateLink { + region: String! + azIds: [String!]! + serviceName: String! + serviceRegion: String +} + +input AWSPrivateLinkInput { + region: String! + azIds: [String!]! + serviceName: String! + serviceRegion: String +} + """ Status of the abandonment evaluation for a task. """ @@ -321,6 +335,20 @@ type AutoDiscoverStatus { failure: AutoDiscoverFailure } +type AzurePrivateLink { + serviceName: String! + location: String! + dnsName: String + resourceType: String +} + +input AzurePrivateLinkInput { + serviceName: String! + location: String! + dnsName: String + resourceType: String +} + type BillingPaymentMethodPayload { paymentMethods: [PaymentMethod!]! primaryPaymentMethod: PaymentMethod @@ -351,6 +379,8 @@ enum CapabilityBit { CreateGrant DeleteGrant CreateInviteLink + ViewDataPlanePrivateNetworking + ModifyDataPlanePrivateNetworking ManageServiceAccounts Delegate Assume @@ -689,6 +719,30 @@ type DataPlane { Azure application client ID for this data-plane. """ azureApplicationClientId: String + """ + Configured private link endpoints for this data-plane. Replacing this + list (via `updateDataPlanePrivateLinks`) triggers reconvergence by the + data-plane controller on its next poll. Returns an empty list to + callers that lack the `ViewDataPlanePrivateNetworking` capability on + this data plane. + """ + privateLinks: [PrivateLink!]! + """ + AWS PrivateLink endpoint provisioning results, opaque JSON exported by + the data-plane controller. Empty when no AWS endpoints are provisioned, + or when the caller lacks `ViewDataPlanePrivateNetworking`. + """ + awsLinkEndpoints: [JSON!]! + """ + Azure Private Link endpoint provisioning results, opaque JSON. Empty when + the caller lacks `ViewDataPlanePrivateNetworking`. + """ + azureLinkEndpoints: [JSON!]! + """ + GCP Private Service Connect endpoint provisioning results, opaque JSON. + Empty when the caller lacks `ViewDataPlanePrivateNetworking`. + """ + gcpPscEndpoints: [JSON!]! } """ @@ -775,6 +829,22 @@ type FieldProvenance { source: String } +type GCPPrivateServiceConnect { + serviceAttachment: String! + region: String! + dnsZoneName: String! + dnsRecordNames: [String!]! + allPorts: Boolean! +} + +input GCPPrivateServiceConnectInput { + serviceAttachment: String! + region: String! + dnsZoneName: String! + dnsRecordNames: [String!]! + allPorts: Boolean! = false +} + scalar Id """ @@ -1202,11 +1272,24 @@ type MutationRoot { """ deleteInviteLink(token: UUID!): Boolean! """ + Replaces the configured private link endpoints on a private data plane. + + The provided list overwrites the entire `private_links` column; partial + updates are intentionally not supported. The data-plane controller + converges to the new configuration on its next poll. Returns the desired + private links state. The `*LinkEndpoints` provisioning results are not echoed here: + they lag this write until the controller converges, so callers needing them re-query `dataPlanes`. + + Requires the `ModifyDataPlanePrivateNetworking` capability on the + private data-plane name. + """ + updateDataPlanePrivateLinks(dataPlaneName: String!, privateLinks: [PrivateLinkInput!]!): [PrivateLink!]! + """ Create a refresh token for the authenticated user. """ createRefreshToken( """ - ISO 8601 duration for token validity (e.g. P90D) + ISO 8601 duration for token validity (e.g. P90D); must be greater than zero and at most one year """ validFor: String! = "P90D", multiUse: Boolean! = true, detail: String = null ): RefreshTokenResult! @@ -1227,8 +1310,8 @@ type MutationRoot { what the account may access. Access is determined solely by the account's user_grants, which may span multiple prefixes. - The caller must have admin capability on the catalog name AND on each - granted prefix. Creates an auth.users row, an + The caller must have ManageServiceAccounts on the catalog name AND + CreateGrant on each granted prefix. Creates an auth.users row, an internal.service_accounts row, and a user_grants row per requested grant. """ @@ -1236,17 +1319,18 @@ type MutationRoot { """ Add a user_grant to a service account. - The caller must manage the service account (admin capability on its - catalog name) AND have admin capability on the granted prefix — the - same authorization as granting a human user. The second requirement - prevents a caller from extending an account's access beyond what they - themselves administer. + The caller must manage the service account (ManageServiceAccounts on its + catalog name) AND have CreateGrant on the granted prefix. The second + requirement prevents a caller from extending an account's access beyond + what they could grant anyone. (Human-user grant creation still lives in + PostgREST; when it migrates to GraphQL it should gate on this same + CreateGrant capability.) """ addServiceAccountGrant(serviceAccountId: UUID!, prefix: Prefix!, capability: Capability!): Boolean! """ Remove a user_grant from a service account. - The caller must manage the service account (admin capability on its + The caller must manage the service account (ManageServiceAccounts on its catalog name). Unlike addServiceAccountGrant, no capability on the grant's prefix is required: removal only ever narrows the account's access, so managers may remove ANY grant — including grants to @@ -1259,10 +1343,8 @@ type MutationRoot { Returns the key_id and the plaintext secret (flow_sa_...). The secret is returned exactly once and cannot be retrieved again. - The key is presented directly as an `Authorization: Bearer` credential. - It is verified statefully against the database on every request and is - never exchanged for an access token, so revoking it cuts off access - immediately. + The API key can be exchanged for an 1-hr access token via `POST /api/v1/auth/token` + or used directly as an `Authorization: Bearer` credential """ createApiKey( serviceAccountId: UUID!, label: String!, """ @@ -1280,29 +1362,8 @@ type MutationRoot { inert (excluded from bearer authentication and listings) while preserving the audit trail. Already-revoked keys are treated as not found. - - Revocation is immediate: keys are verified statefully on every request - and never exchanged for access tokens, so there are no outstanding - minted credentials to wait out. The next request presenting this key - fails authentication. """ revokeApiKey(id: Id!): Boolean! - """ - Revoke ALL of a service account's API keys. - - The caller must manage the service account (admin capability on its - catalog name). - - This is the kill switch for a compromised or retired account: API keys - are its only means of authentication and are verified statefully on - every request, so revoking them all cuts off access immediately — the - account's next request fails authentication. Its user_grants are - untouched and may be cleaned up separately via - removeServiceAccountGrant. - - Idempotent: returns the number of keys revoked, zero if none remained. - """ - revokeApiKeys(serviceAccountId: UUID!): Int! } """ @@ -1428,6 +1489,22 @@ input PrefixesBy { minCapability: Capability! } +""" +Private link configuration for a customer-owned data plane: AWS +PrivateLink, Azure Private Link, or GCP Private Service Connect. +""" +union PrivateLink = AWSPrivateLink | AzurePrivateLink | GCPPrivateServiceConnect + +""" +Private link configuration for a customer-owned data plane: AWS +PrivateLink, Azure Private Link, or GCP Private Service Connect. +""" +input PrivateLinkInput @oneOf { + aws: AWSPrivateLinkInput + azure: AzurePrivateLinkInput + gcp: GCPPrivateServiceConnectInput +} + """ Filter connectors by their protocol (capture or materialization). """ @@ -1625,7 +1702,6 @@ type RefreshTokenInfo { createdAt: DateTime! updatedAt: DateTime! multiUse: Boolean! - validFor: String! uses: Int! """ True once the token's validity window has elapsed @@ -2133,6 +2209,10 @@ Directs the executor to include this field or fragment only when the `if` argume """ directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """ +Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its field be provided) +""" +directive @oneOf on INPUT_OBJECT +""" Directs the executor to skip this field or fragment when the `if` argument is true. """ directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT diff --git a/supabase/migrations/20260615120000_service_accounts.sql b/supabase/migrations/20260615120000_service_accounts.sql index db2deeb5b16..6c42a5800d2 100644 --- a/supabase/migrations/20260615120000_service_accounts.sql +++ b/supabase/migrations/20260615120000_service_accounts.sql @@ -26,17 +26,12 @@ comment on table internal.service_accounts is create index service_accounts_catalog_name_spgist on internal.service_accounts using spgist ((catalog_name::text)); --- API keys: long-lived credentials for service accounts. Presented directly --- as Authorization: Bearer credentials or exchanged for a short-lived (1-hour) access token. +-- API keys: long-lived credentials for service accounts exchanged for a short-lived (1-hour) access token. create table internal.api_keys ( id public.flowid primary key not null default internal.id_generator(), service_account_id uuid not null references internal.service_accounts (user_id), - -- Hex-encoded SHA-256 of the key secret. A fast hash (not bcrypt) is the - -- right choice because stateful-only evaluation places verification in the - -- per-request hot path, and a slow hash would buy nothing: secrets are - -- high-entropy random values, so offline brute-force resistance is moot. - -- See the minting site in graphql/service_accounts.rs. + -- Hex-encoded SHA-256 of the key secret. secret_hash text not null, label text not null, expires_at timestamptz not null, @@ -52,6 +47,6 @@ create table internal.api_keys ( create index api_keys_service_account_id on internal.api_keys (service_account_id); comment on table internal.api_keys is - 'Long-lived service-account credentials, verified statefully on every request as bearer credentials, or optionally exchanged for a short-lived access token.'; + 'Long-lived service-account credentials, exchanged for a short-lived access token.'; commit; From b2bd00d878debde521a96f54fefba8a203bd0244 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 21:36:47 -0400 Subject: [PATCH 11/13] service accounts: enforce unique catalog_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catalog name is a service account's handle, so two accounts must not share one. Add a unique btree index on internal.service_accounts (catalog_name) — scoped to the table, so it does not collide with live_specs or other catalog entities, and case-sensitive per the catalog's no-lower()-folding convention. The existing SP-GiST index stays for the listing query's ^@ prefix scan, which the btree wouldn't serve. create_service_account maps the resulting unique-violation (SQLSTATE 23505) to a clean "a service account already exists for catalog name '...'" error rather than surfacing the raw database error. Covered by a duplicate-rejection assertion in test_service_account_lifecycle. --- .../server/public/graphql/service_accounts.rs | 38 ++++++++++++++++++- .../20260615120000_service_accounts.sql | 7 +++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index fcb22969629..3d875f2c6d9 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -310,7 +310,19 @@ impl ServiceAccountsMutation { claims.sub, ) .fetch_one(&mut *txn) - .await?; + .await + .map_err(|err| { + // SQLSTATE 23505 is the unique-violation on `catalog_name`: a + // service account already claims this handle. + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") { + async_graphql::Error::new(format!( + "a service account already exists for catalog name '{}'", + catalog_name.as_str(), + )) + } else { + err.into() + } + })?; for grant in &grants { crate::grants::upsert_user_grant( @@ -745,6 +757,30 @@ mod test { let sa_user_id = sa["id"].as_str().expect("should have id"); assert_eq!(sa["catalogName"], "aliceCo/ci-deploy-bot"); assert_eq!(sa["apiKeys"].as_array().unwrap().len(), 0); + + // === A catalog name is unique to one service account === + // A second account cannot claim the same handle, even for an authorized + // caller. + let dup: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccount( + catalogName: "aliceCo/ci-deploy-bot" + grants: [{ prefix: "aliceCo/", capability: admin }] + ) { id } + }"# + }), + Some(&alice_token), + ) + .await; + assert!( + dup["errors"].as_array().is_some_and(|errs| errs + .iter() + .any(|e| e["message"].as_str().is_some_and(|m| m.contains("already exists")))), + "duplicate catalog name should be rejected: {dup}" + ); assert_eq!( grant_count(&pool, sa_user_id).await, 2, diff --git a/supabase/migrations/20260615120000_service_accounts.sql b/supabase/migrations/20260615120000_service_accounts.sql index 6c42a5800d2..74519e507e5 100644 --- a/supabase/migrations/20260615120000_service_accounts.sql +++ b/supabase/migrations/20260615120000_service_accounts.sql @@ -20,9 +20,14 @@ create table internal.service_accounts ( comment on table internal.service_accounts is 'Non-login identities that authenticate via API keys and are authorized through user_grants.'; +-- A catalog name is the service account's handle: it identifies the account +-- within this table. +create unique index service_accounts_catalog_name_key on internal.service_accounts + (catalog_name); + -- The serviceAccounts query scopes results to a caller's admin prefixes with -- `catalog_name::text ^@ ANY($1)`. SP-GiST natively supports the `^@` --- (starts-with) operator; a btree index would not be used by it. +-- (starts-with) operator; the unique btree above would not be used by it. create index service_accounts_catalog_name_spgist on internal.service_accounts using spgist ((catalog_name::text)); From 28941ff32de7023b07199c80940cf1193d394468 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 22:17:52 -0400 Subject: [PATCH 12/13] service accounts: address by catalog_name, drop user_id from the public API Now that catalog_name is unique, it serves as a service account's stable public handle, so the backing auth.users id no longer needs to be exposed. This decouples the API from that storage detail; the id can be reintroduced later if a real need arises (e.g. correlating with grants or audit data). - Remove the id field from the ServiceAccount type. - addServiceAccountGrant, removeServiceAccountGrant, and createApiKey now take catalogName instead of serviceAccountId. They authorize ManageServiceAccounts directly on the supplied name (no user_id -> name round-trip) and resolve the backing user_id internally for the write. - Replace the forward lookup_service_account(user_id) -> name with the reverse resolve_service_account(name) -> user_id; revoke_api_key JOINs to recover the catalog name from a key id. Tests address accounts by catalog name and read the backing user_id from the DB where they assert at the row level. --- ...52d412f18eebcb69c32932267d8efe31b50df.json | 22 -- ...2928b9fe23e87b813aabc3b6e0a834f7f06fb.json | 22 -- ...0e9b0ca37f7615c5dc86ae33aaf06f7c777ee.json | 22 ++ ...6515aaabc3a9ea6bc6c40502c4b28607a7bc8.json | 22 ++ .../server/public/graphql/refresh_tokens.rs | 18 +- .../server/public/graphql/service_accounts.rs | 190 +++++++++--------- crates/flow-client/control-plane-api.graphql | 7 +- 7 files changed, 154 insertions(+), 149 deletions(-) delete mode 100644 .sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json delete mode 100644 .sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json create mode 100644 .sqlx/query-9496328d2bec1ffe02bafd913580e9b0ca37f7615c5dc86ae33aaf06f7c777ee.json create mode 100644 .sqlx/query-b75dee229313efb8195899acd566515aaabc3a9ea6bc6c40502c4b28607a7bc8.json diff --git a/.sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json b/.sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json deleted file mode 100644 index 516deb99c14..00000000000 --- a/.sqlx/query-424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT catalog_name AS \"catalog_name!: String\"\n FROM internal.service_accounts\n WHERE user_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "catalog_name!: String", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "424e1c5fc41d8408356ed9b36cc52d412f18eebcb69c32932267d8efe31b50df" -} diff --git a/.sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json b/.sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json deleted file mode 100644 index 06c0683bedc..00000000000 --- a/.sqlx/query-89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT ak.service_account_id\n FROM internal.api_keys ak\n WHERE ak.id = $1 AND ak.revoked_at IS NULL\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "service_account_id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Macaddr8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "89315611b850f3d56961abaf9a32928b9fe23e87b813aabc3b6e0a834f7f06fb" -} diff --git a/.sqlx/query-9496328d2bec1ffe02bafd913580e9b0ca37f7615c5dc86ae33aaf06f7c777ee.json b/.sqlx/query-9496328d2bec1ffe02bafd913580e9b0ca37f7615c5dc86ae33aaf06f7c777ee.json new file mode 100644 index 00000000000..7f296666bc5 --- /dev/null +++ b/.sqlx/query-9496328d2bec1ffe02bafd913580e9b0ca37f7615c5dc86ae33aaf06f7c777ee.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id\n FROM internal.service_accounts\n WHERE catalog_name = $1::text::catalog_name\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9496328d2bec1ffe02bafd913580e9b0ca37f7615c5dc86ae33aaf06f7c777ee" +} diff --git a/.sqlx/query-b75dee229313efb8195899acd566515aaabc3a9ea6bc6c40502c4b28607a7bc8.json b/.sqlx/query-b75dee229313efb8195899acd566515aaabc3a9ea6bc6c40502c4b28607a7bc8.json new file mode 100644 index 00000000000..7d2652ce4ec --- /dev/null +++ b/.sqlx/query-b75dee229313efb8195899acd566515aaabc3a9ea6bc6c40502c4b28607a7bc8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT sa.catalog_name AS \"catalog_name!: String\"\n FROM internal.api_keys ak\n JOIN internal.service_accounts sa ON sa.user_id = ak.service_account_id\n WHERE ak.id = $1 AND ak.revoked_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "catalog_name!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Macaddr8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b75dee229313efb8195899acd566515aaabc3a9ea6bc6c40502c4b28607a7bc8" +} diff --git a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs index b0e6a986fe4..6a5583b5480 100644 --- a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs +++ b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs @@ -495,7 +495,7 @@ mod test { createServiceAccount( catalogName: "aliceCo/refresh-token-bot" grants: [{ prefix: "aliceCo/", capability: admin }] - ) { id } + ) { catalogName } }"# }), Some(&alice_token), @@ -505,13 +505,17 @@ mod test { create_sa["errors"].is_null(), "create SA should succeed: {create_sa}" ); - let sa_user_id = create_sa["data"]["createServiceAccount"]["id"] - .as_str() - .expect("should have id"); - // Mint an access token whose `sub` is the service account (an SA - // principal has no email), standing in for an authenticated SA caller. - let sa_token = server.make_access_token(uuid::Uuid::parse_str(sa_user_id).unwrap(), None); + // The API doesn't expose the SA's backing user_id, so read it from the + // DB to mint a token whose `sub` is the service account (an SA principal + // has no email), standing in for an authenticated SA caller. + let sa_user_id: uuid::Uuid = sqlx::query_scalar( + "SELECT user_id FROM internal.service_accounts WHERE catalog_name = 'aliceCo/refresh-token-bot'", + ) + .fetch_one(&pool) + .await + .unwrap(); + let sa_token = server.make_access_token(sa_user_id, None); let sa_create_rt: serde_json::Value = server .graphql( diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index 3d875f2c6d9..6c65a6be0d4 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -3,11 +3,9 @@ use async_graphql::{Context, types::connection}; #[derive(Debug, Clone, async_graphql::SimpleObject)] pub struct ServiceAccount { - // Exposed as `id`: a service account's identifier happens to be its - // backing auth.users id, but that's an implementation detail we don't - // surface in the public schema. - #[graphql(name = "id")] - pub user_id: uuid::Uuid, + // A service account is addressed by its `catalog_name` handle. Its backing + // auth.users id is an implementation detail and is deliberately not exposed + // in the public schema; it can be added later if a need arises. pub catalog_name: models::Name, pub created_by: uuid::Uuid, pub created_at: chrono::DateTime, @@ -188,7 +186,6 @@ impl ServiceAccountsQuery { connection::Edge::new( TimestampCursor(r.created_at), ServiceAccount { - user_id: r.user_id, catalog_name: models::Name::new(&r.catalog_name), created_by: r.created_by, created_at: r.created_at, @@ -346,7 +343,6 @@ impl ServiceAccountsMutation { ); Ok(ServiceAccount { - user_id: sa_user_id, catalog_name, created_by: claims.sub, created_at: now, @@ -367,7 +363,7 @@ impl ServiceAccountsMutation { async fn add_service_account_grant( &self, ctx: &Context<'_>, - #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, + catalog_name: models::Name, prefix: models::Prefix, capability: models::Capability, ) -> async_graphql::Result { @@ -388,16 +384,17 @@ impl ServiceAccountsMutation { )); } - let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; super::verify_authorization( env, - &catalog_name, + catalog_name.as_str(), models::authz::Capability::ManageServiceAccounts, ) .await?; super::verify_authorization(env, prefix.as_str(), models::authz::Capability::CreateGrant) .await?; + let user_id = resolve_service_account(&env.pg_pool, catalog_name.as_str()).await?; + let mut txn = env.pg_pool.begin().await?; crate::grants::upsert_user_grant( user_id, @@ -431,20 +428,21 @@ impl ServiceAccountsMutation { async fn remove_service_account_grant( &self, ctx: &Context<'_>, - #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, + catalog_name: models::Name, prefix: models::Prefix, ) -> async_graphql::Result { let env = ctx.data::()?; let claims = env.claims()?; - let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; super::verify_authorization( env, - &catalog_name, + catalog_name.as_str(), models::authz::Capability::ManageServiceAccounts, ) .await?; + let user_id = resolve_service_account(&env.pg_pool, catalog_name.as_str()).await?; + let deleted = sqlx::query!( "DELETE FROM public.user_grants WHERE user_id = $1 AND object_role = $2", user_id, @@ -478,21 +476,22 @@ impl ServiceAccountsMutation { async fn create_api_key( &self, ctx: &Context<'_>, - #[graphql(name = "serviceAccountId")] user_id: uuid::Uuid, + catalog_name: models::Name, label: String, #[graphql(desc = "ISO 8601 duration for key validity (e.g. P90D, P1Y)")] valid_for: String, ) -> async_graphql::Result { let env = ctx.data::()?; let claims = env.claims()?; - let catalog_name = lookup_service_account(&env.pg_pool, user_id).await?; super::verify_authorization( env, - &catalog_name, + catalog_name.as_str(), models::authz::Capability::ManageServiceAccounts, ) .await?; + let user_id = resolve_service_account(&env.pg_pool, catalog_name.as_str()).await?; + // valid_for is documented as an ISO 8601 duration (e.g. P90D, P1Y). // Reject anything that isn't ISO 8601 up front: the `::interval` cast // below would otherwise also accept Postgres's own syntax ("90 days"), @@ -608,10 +607,11 @@ impl ServiceAccountsMutation { let env = ctx.data::()?; let claims = env.claims()?; - let key_owner = sqlx::query!( + let catalog_name = sqlx::query_scalar!( r#" - SELECT ak.service_account_id + SELECT sa.catalog_name AS "catalog_name!: String" FROM internal.api_keys ak + JOIN internal.service_accounts sa ON sa.user_id = ak.service_account_id WHERE ak.id = $1 AND ak.revoked_at IS NULL "#, key_id as models::Id, @@ -619,12 +619,11 @@ impl ServiceAccountsMutation { .fetch_optional(&env.pg_pool) .await?; - let key_owner = match key_owner { - Some(row) => row.service_account_id, + let catalog_name = match catalog_name { + Some(name) => name, None => return Err(async_graphql::Error::new("API key not found")), }; - let catalog_name = lookup_service_account(&env.pg_pool, key_owner).await?; super::verify_authorization( env, &catalog_name, @@ -641,7 +640,7 @@ impl ServiceAccountsMutation { tracing::info!( %key_id, - service_account = %key_owner, + service_account = %catalog_name, %claims.sub, "revoked api key" ); @@ -666,20 +665,22 @@ pub(super) async fn is_service_account( Ok(exists) } -/// Look up a service account's management anchor: admins of a prefix covering -/// this catalog name may manage the account, independent of whatever its -/// user_grants authorize. -async fn lookup_service_account( +/// Resolve a service account's backing `user_id` from its `catalog_name` handle. +/// +/// Service accounts are addressed publicly by catalog name; the writes still +/// need the backing auth.users id. Callers authorize against the catalog name +/// *before* resolving, so a "not found" here is for an authorized namespace. +async fn resolve_service_account( pg_pool: &sqlx::PgPool, - user_id: uuid::Uuid, -) -> async_graphql::Result { + catalog_name: &str, +) -> async_graphql::Result { let row = sqlx::query_scalar!( r#" - SELECT catalog_name AS "catalog_name!: String" + SELECT user_id FROM internal.service_accounts - WHERE user_id = $1 + WHERE catalog_name = $1::text::catalog_name "#, - user_id, + catalog_name, ) .fetch_optional(pg_pool) .await?; @@ -728,7 +729,6 @@ mod test { catalogName: $catalogName grants: $grants ) { - id catalogName createdBy createdAt @@ -754,7 +754,9 @@ mod test { "create should succeed: {create_response}" ); let sa = &create_response["data"]["createServiceAccount"]; - let sa_user_id = sa["id"].as_str().expect("should have id"); + // The public API doesn't expose the backing user_id; fetch it from the + // DB for the row-level assertions below. + let sa_user_id = service_account_user_id(&pool, "aliceCo/ci-deploy-bot").await; assert_eq!(sa["catalogName"], "aliceCo/ci-deploy-bot"); assert_eq!(sa["apiKeys"].as_array().unwrap().len(), 0); @@ -769,20 +771,22 @@ mod test { createServiceAccount( catalogName: "aliceCo/ci-deploy-bot" grants: [{ prefix: "aliceCo/", capability: admin }] - ) { id } + ) { catalogName } }"# }), Some(&alice_token), ) .await; assert!( - dup["errors"].as_array().is_some_and(|errs| errs - .iter() - .any(|e| e["message"].as_str().is_some_and(|m| m.contains("already exists")))), + dup["errors"] + .as_array() + .is_some_and(|errs| errs.iter().any(|e| e["message"] + .as_str() + .is_some_and(|m| m.contains("already exists")))), "duplicate catalog name should be rejected: {dup}" ); assert_eq!( - grant_count(&pool, sa_user_id).await, + grant_count(&pool, &sa_user_id).await, 2, "each requested grant should mint a user_grants row" ); @@ -809,7 +813,7 @@ mod test { createServiceAccount( catalogName: "aliceCo/hacker-bot" grants: [{ prefix: "aliceCo/", capability: read }] - ) { id } + ) { catalogName } }"# }), Some(&bob_token), @@ -826,7 +830,7 @@ mod test { &serde_json::json!({ "query": r#" mutation { - createServiceAccount(catalogName: "Not A Name", grants: []) { id } + createServiceAccount(catalogName: "Not A Name", grants: []) { catalogName } }"# }), Some(&alice_token), @@ -850,7 +854,7 @@ mod test { createServiceAccount( catalogName: "aliceCo/no-op-bot" grants: [{ prefix: "aliceCo/", capability: none }] - ) { id } + ) { catalogName } }"# }), Some(&alice_token), @@ -878,7 +882,7 @@ mod test { { prefix: "aliceCo/", capability: read }, { prefix: "bobCo/", capability: read } ] - ) { id } + ) { catalogName } }"# }), Some(&alice_token), @@ -894,9 +898,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!, $label: String!, $validFor: String!) { + mutation($label: String!, $validFor: String!) { createApiKey( - serviceAccountId: $userId + catalogName: "aliceCo/ci-deploy-bot" label: $label validFor: $validFor ) { @@ -905,7 +909,6 @@ mod test { } }"#, "variables": { - "userId": sa_user_id, "label": "GitHub Actions", "validFor": "P90D" } @@ -937,11 +940,10 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!, $label: String!, $validFor: String!) { - createApiKey(serviceAccountId: $userId, label: $label, validFor: $validFor) { id } + mutation($label: String!, $validFor: String!) { + createApiKey(catalogName: "aliceCo/ci-deploy-bot", label: $label, validFor: $validFor) { id } }"#, "variables": { - "userId": sa_user_id, "label": "bad valid_for", "validFor": valid_for, } @@ -1044,7 +1046,6 @@ mod test { serviceAccounts { edges { node { - id catalogName lastUsedAt apiKeys { @@ -1099,7 +1100,7 @@ mod test { &serde_json::json!({ "query": r#" query { - serviceAccounts { edges { node { id } } } + serviceAccounts { edges { node { catalogName } } } }"# }), Some(&bob_token), @@ -1246,6 +1247,19 @@ mod test { .unwrap() } + // The public API addresses service accounts by catalog name; tests that + // assert at the row level still need the backing user_id. + async fn service_account_user_id(pool: &sqlx::PgPool, catalog_name: &str) -> String { + sqlx::query_scalar!( + r#"SELECT user_id FROM internal.service_accounts WHERE catalog_name = $1::text::catalog_name"#, + catalog_name, + ) + .fetch_one(pool) + .await + .unwrap() + .to_string() + } + // === Grant management === // Adding a grant requires BOTH managing the account and admin on the // granted prefix. Bob has neither, so he can't add a grant. @@ -1253,10 +1267,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!) { - addServiceAccountGrant(serviceAccountId: $userId, prefix: "aliceCo/", capability: read) - }"#, - "variables": { "userId": sa_user_id } + mutation { + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/", capability: read) + }"# }), Some(&bob_token), ) @@ -1272,10 +1285,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!) { - addServiceAccountGrant(serviceAccountId: $userId, prefix: "bobCo/", capability: read) - }"#, - "variables": { "userId": sa_user_id } + mutation { + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/", capability: read) + }"# }), Some(&alice_token), ) @@ -1290,23 +1302,22 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!) { - addServiceAccountGrant(serviceAccountId: $userId, prefix: "aliceCo/ops/", capability: write) - }"#, - "variables": { "userId": sa_user_id } + mutation { + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/ops/", capability: write) + }"# }), Some(&alice_token), ) .await; assert!(add["errors"].is_null(), "add grant should succeed: {add}"); - assert_eq!(grant_count(&pool, sa_user_id).await, 3); + assert_eq!(grant_count(&pool, &sa_user_id).await, 3); // Removal requires only account management — no capability on the // grant's prefix. Seed a grant to bobCo/ directly (adding one via the // API requires admin on it), then alice removes it despite having no // bobCo/ access of her own. sqlx::query("INSERT INTO user_grants (user_id, object_role, capability) VALUES ($1, 'bobCo/', 'read')") - .bind(uuid::Uuid::parse_str(sa_user_id).unwrap()) + .bind(uuid::Uuid::parse_str(&sa_user_id).unwrap()) .execute(&pool) .await .unwrap(); @@ -1315,10 +1326,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!) { - removeServiceAccountGrant(serviceAccountId: $userId, prefix: "bobCo/") - }"#, - "variables": { "userId": sa_user_id } + mutation { + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") + }"# }), Some(&alice_token), ) @@ -1327,17 +1337,16 @@ mod test { remove_foreign["errors"].is_null(), "a manager may remove ANY grant, including one to a prefix they don't administer: {remove_foreign}" ); - assert_eq!(grant_count(&pool, sa_user_id).await, 3); + assert_eq!(grant_count(&pool, &sa_user_id).await, 3); // Removing an absent grant reports not found. let remove_again: serde_json::Value = server .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!) { - removeServiceAccountGrant(serviceAccountId: $userId, prefix: "bobCo/") - }"#, - "variables": { "userId": sa_user_id } + mutation { + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") + }"# }), Some(&alice_token), ) @@ -1355,10 +1364,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($userId: UUID!) { - removeServiceAccountGrant(serviceAccountId: $userId, prefix: "aliceCo/data/") - }"#, - "variables": { "userId": sa_user_id } + mutation { + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/data/") + }"# }), Some(&bob_token), ) @@ -1418,7 +1426,7 @@ mod test { createServiceAccount( catalogName: "aliceCo/team-bot" grants: $grants - ) { id createdBy } + ) { catalogName createdBy } }"#, "variables": { "grants": [ { "prefix": "aliceCo/data/", "capability": "read" } ] @@ -1431,9 +1439,6 @@ mod test { create["errors"].is_null(), "a TeamAdmin without full Admin should create a service account: {create}" ); - let sa_user_id = create["data"]["createServiceAccount"]["id"] - .as_str() - .expect("should have id"); assert_eq!( create["data"]["createServiceAccount"]["createdBy"], "33333333-3333-3333-3333-333333333333", @@ -1445,10 +1450,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($id: UUID!) { - createApiKey(serviceAccountId: $id, label: "ci", validFor: "P30D") { id } - }"#, - "variables": { "id": sa_user_id } + mutation { + createApiKey(catalogName: "aliceCo/team-bot", label: "ci", validFor: "P30D") { id } + }"# }), Some(&carol_token), ) @@ -1464,10 +1468,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($id: UUID!) { - addServiceAccountGrant(serviceAccountId: $id, prefix: "aliceCo/ops/", capability: write) - }"#, - "variables": { "id": sa_user_id } + mutation { + addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "aliceCo/ops/", capability: write) + }"# }), Some(&carol_token), ) @@ -1485,10 +1488,9 @@ mod test { .graphql( &serde_json::json!({ "query": r#" - mutation($id: UUID!) { - addServiceAccountGrant(serviceAccountId: $id, prefix: "bobCo/", capability: read) - }"#, - "variables": { "id": sa_user_id } + mutation { + addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "bobCo/", capability: read) + }"# }), Some(&carol_token), ) @@ -1507,7 +1509,7 @@ mod test { createServiceAccount( catalogName: "aliceCo/overreach-bot" grants: [{ prefix: "bobCo/", capability: read }] - ) { id } + ) { catalogName } }"# }), Some(&carol_token), diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index b74f97c3a99..2b1cce3aad5 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1326,7 +1326,7 @@ type MutationRoot { PostgREST; when it migrates to GraphQL it should gate on this same CreateGrant capability.) """ - addServiceAccountGrant(serviceAccountId: UUID!, prefix: Prefix!, capability: Capability!): Boolean! + addServiceAccountGrant(catalogName: Name!, prefix: Prefix!, capability: Capability!): Boolean! """ Remove a user_grant from a service account. @@ -1336,7 +1336,7 @@ type MutationRoot { access, so managers may remove ANY grant — including grants to prefixes they don't themselves administer. """ - removeServiceAccountGrant(serviceAccountId: UUID!, prefix: Prefix!): Boolean! + removeServiceAccountGrant(catalogName: Name!, prefix: Prefix!): Boolean! """ Create an API key for a service account. @@ -1346,7 +1346,7 @@ type MutationRoot { The API key can be exchanged for an 1-hr access token via `POST /api/v1/auth/token` or used directly as an `Authorization: Bearer` credential """ - createApiKey( serviceAccountId: UUID!, label: String!, + createApiKey( catalogName: Name!, label: String!, """ ISO 8601 duration for key validity (e.g. P90D, P1Y) """ @@ -1759,7 +1759,6 @@ type RepublishRequested { } type ServiceAccount { - id: UUID! catalogName: Name! createdBy: UUID! createdAt: DateTime! From 4c1627bbc7aa6fbe8a25f06d7eb92d5add483c48 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Wed, 17 Jun 2026 18:58:05 -0400 Subject: [PATCH 13/13] Cleaning up, anticipating PR feedback --- crates/billing-integrations/src/publish.rs | 4 +- crates/control-plane-api/src/server/mod.rs | 18 ++- .../server/public/graphql/service_accounts.rs | 125 ++++++++++-------- crates/flow-client/control-plane-api.graphql | 10 +- crates/models/src/authz.rs | 4 +- 5 files changed, 95 insertions(+), 66 deletions(-) diff --git a/crates/billing-integrations/src/publish.rs b/crates/billing-integrations/src/publish.rs index ddba8d31959..276a31c6958 100644 --- a/crates/billing-integrations/src/publish.rs +++ b/crates/billing-integrations/src/publish.rs @@ -825,8 +825,8 @@ async fn get_or_create_customer_for_tenant( join auth.users as users on user_grants.user_id = users.id where users.email is not null and user_grants.object_role = $1 and user_grants.capability = 'admin' - -- Exclude service accounts: their synthetic sa+ addresses - -- must never be chosen as a tenant's Stripe billing contact. + -- Exclude service accounts: their synthetic addresses must + -- never be chosen as a tenant's Stripe billing contact. and not exists ( select 1 from internal.service_accounts sa where sa.user_id = users.id ) diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index 70c4c9414ec..f30a4dc6d8d 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -270,12 +270,26 @@ pub async fn exchange_refresh_token( .fetch_one(pg_pool) .await .map_err(|err| { - tonic::Status::unauthenticated(format!("failed to exchange refresh token: {err}")) + // `generate_access_token` signals an unusable credential (unknown id, + // bad secret, or expired token) by `raise`-ing, which surfaces as + // SQLSTATE P0001 — the only legitimate 401, collapsed into one generic + // message so the response doesn't reveal which check failed. Any other + // error is an internal fault: log the detail and don't leak it. + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("P0001") { + tonic::Status::unauthenticated("invalid, expired, or unknown refresh token") + } else { + tracing::error!(?err, "failed to exchange refresh token"); + tonic::Status::internal("failed to exchange refresh token") + } })?; let GenerateTokenResponse { access_token } = serde_json::from_value(response.token.unwrap_or_default()).map_err(|err| { - tonic::Status::internal(format!("invalid access token generated: {err}")) + tracing::error!( + ?err, + "generate_access_token returned an unparseable response" + ); + tonic::Status::internal("invalid token response") })?; Ok(access_token) diff --git a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs index 6c65a6be0d4..7dffb699754 100644 --- a/crates/control-plane-api/src/server/public/graphql/service_accounts.rs +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -67,13 +67,13 @@ impl ServiceAccountsQuery { let snapshot = env.snapshot(); // Service accounts are visible to callers who can manage them: those - // holding ManageServiceAccounts on a prefix covering the account's + // holding ManageServiceAccount on a prefix covering the account's // catalog_name. let user_accessible_prefixes = super::authorized_prefixes::authorized_prefixes( &snapshot.role_grants, &snapshot.user_grants, env.claims()?.sub, - models::authz::Capability::ManageServiceAccounts, + models::authz::Capability::ManageServiceAccount, None, ); @@ -219,7 +219,7 @@ impl ServiceAccountsMutation { /// what the account may access. Access is determined solely by the /// account's user_grants, which may span multiple prefixes. /// - /// The caller must have ManageServiceAccounts on the catalog name AND + /// The caller must have ManageServiceAccount on the catalog name AND /// CreateGrant on each granted prefix. Creates an auth.users row, an /// internal.service_accounts row, and a user_grants row per requested /// grant. @@ -237,6 +237,17 @@ impl ServiceAccountsMutation { "invalid catalog name: {err}" ))); } + // Managing the account (here, creating it under this anchor) requires + // ManageServiceAccount on the catalog name — the same capability that + // gates listing in ServiceAccountsQuery, so the read and write surfaces + // agree. This is deliberately narrower than full Admin. + super::verify_authorization( + env, + catalog_name.as_str(), + models::authz::Capability::ManageServiceAccount, + ) + .await?; + for grant in &grants { if let Err(err) = validator::Validate::validate(&grant.prefix) { return Err(async_graphql::Error::new(format!( @@ -253,16 +264,6 @@ impl ServiceAccountsMutation { } } - // Managing the account (here, creating it under this anchor) requires - // ManageServiceAccounts on the catalog name — the same capability that - // gates listing in ServiceAccountsQuery, so the read and write surfaces - // agree. This is deliberately narrower than full Admin. - super::verify_authorization( - env, - catalog_name.as_str(), - models::authz::Capability::ManageServiceAccounts, - ) - .await?; // Granting the account access to a prefix requires CreateGrant on that // prefix — the anti-escalation guard, distinct from managing the // account: a caller can't hand a service account reach they couldn't @@ -282,19 +283,36 @@ impl ServiceAccountsMutation { let sa_user_id = uuid::Uuid::new_v4(); + // Both the synthetic email and the catalog_name are unique and derived + // from the same handle, so either insert can raise the duplicate + // (SQLSTATE 23505) — and which one fires first depends on the + // environment (real Supabase enforces a unique email; the local stub + // does not). Map either violation to one clear message. + let duplicate_err = |err: sqlx::Error| -> async_graphql::Error { + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") { + async_graphql::Error::new(format!( + "a service account already exists for catalog name '{}'", + catalog_name.as_str(), + )) + } else { + err.into() + } + }; + sqlx::query!( r#" INSERT INTO auth.users (id, email, raw_user_meta_data) VALUES ($1, $2, $3) "#, sa_user_id, - format!("sa+{}@service.estuary.dev", sa_user_id), + format!("{}@service_accounts.estuary.dev", catalog_name.as_str()), serde_json::json!({ "full_name": catalog_name.as_str(), }), ) .execute(&mut *txn) - .await?; + .await + .map_err(duplicate_err)?; let now = sqlx::query_scalar!( r#" @@ -308,18 +326,7 @@ impl ServiceAccountsMutation { ) .fetch_one(&mut *txn) .await - .map_err(|err| { - // SQLSTATE 23505 is the unique-violation on `catalog_name`: a - // service account already claims this handle. - if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") { - async_graphql::Error::new(format!( - "a service account already exists for catalog name '{}'", - catalog_name.as_str(), - )) - } else { - err.into() - } - })?; + .map_err(duplicate_err)?; for grant in &grants { crate::grants::upsert_user_grant( @@ -354,7 +361,7 @@ impl ServiceAccountsMutation { /// Add a user_grant to a service account. /// - /// The caller must manage the service account (ManageServiceAccounts on its + /// The caller must manage the service account (ManageServiceAccount on its /// catalog name) AND have CreateGrant on the granted prefix. The second /// requirement prevents a caller from extending an account's access beyond /// what they could grant anyone. (Human-user grant creation still lives in @@ -370,6 +377,13 @@ impl ServiceAccountsMutation { let env = ctx.data::()?; let claims = env.claims()?; + super::verify_authorization( + env, + catalog_name.as_str(), + models::authz::Capability::ManageServiceAccount, + ) + .await?; + if let Err(err) = validator::Validate::validate(&prefix) { return Err(async_graphql::Error::new(format!( "invalid grant prefix {}: {err}", @@ -384,12 +398,6 @@ impl ServiceAccountsMutation { )); } - super::verify_authorization( - env, - catalog_name.as_str(), - models::authz::Capability::ManageServiceAccounts, - ) - .await?; super::verify_authorization(env, prefix.as_str(), models::authz::Capability::CreateGrant) .await?; @@ -420,7 +428,7 @@ impl ServiceAccountsMutation { /// Remove a user_grant from a service account. /// - /// The caller must manage the service account (ManageServiceAccounts on its + /// The caller must manage the service account (ManageServiceAccount on its /// catalog name). Unlike addServiceAccountGrant, no capability on the /// grant's prefix is required: removal only ever narrows the account's /// access, so managers may remove ANY grant — including grants to @@ -437,7 +445,7 @@ impl ServiceAccountsMutation { super::verify_authorization( env, catalog_name.as_str(), - models::authz::Capability::ManageServiceAccounts, + models::authz::Capability::ManageServiceAccount, ) .await?; @@ -486,7 +494,7 @@ impl ServiceAccountsMutation { super::verify_authorization( env, catalog_name.as_str(), - models::authz::Capability::ManageServiceAccounts, + models::authz::Capability::ManageServiceAccount, ) .await?; @@ -519,16 +527,24 @@ impl ServiceAccountsMutation { let within_bounds = match within_bounds { Ok(ok) => ok, - // A 'P'-prefixed but malformed duration still fails the `::interval` - // cast (SQLSTATE 22007/22008); surface it as a client error, not a 500. + // A 'P'-prefixed value can still fail the `::interval` cast: Postgres + // raises SQLSTATE 22007 (invalid_datetime_format) / 22008 + // (datetime_field_overflow) for a malformed duration and 22015 + // (interval_field_overflow) for one too extreme to parse. All are + // client errors, not internal faults, so surface a sanitized message. Err(sqlx::Error::Database(db)) - if matches!(db.code().as_deref(), Some("22007") | Some("22008")) => + if matches!(db.code().as_deref(), Some("22007" | "22008" | "22015")) => { return Err(async_graphql::Error::new( "invalid valid_for: expected an ISO 8601 duration, e.g. P90D or P1Y", )); } - Err(err) => return Err(err.into()), + // Any other database error is an internal fault: log the detail + // server-side and don't leak it to the caller. + Err(err) => { + tracing::error!(?err, "failed to validate api key valid_for"); + return Err(async_graphql::Error::new("failed to create api key")); + } }; if !within_bounds { @@ -552,8 +568,6 @@ impl ServiceAccountsMutation { $1, -- SHA-256 rather than bcrypt: the secret is high-entropy random, -- so bcrypt isn't necessary here. - -- Refresh-token secrets will migrate to the same scheme once - -- the legacy postgREST token functions are retired. encode(digest(nk.secret, 'sha256'), 'hex'), $2, now() + $3::text::interval, @@ -592,7 +606,7 @@ impl ServiceAccountsMutation { /// Revoke an API key. /// - /// The caller must have admin capability on the owning service account's + /// The caller must have ManageServiceAccount capability on the owning service account's /// catalog name. /// /// Rather than deleting the row, we stamp `revoked_at`, which makes the key @@ -627,7 +641,7 @@ impl ServiceAccountsMutation { super::verify_authorization( env, &catalog_name, - models::authz::Capability::ManageServiceAccounts, + models::authz::Capability::ManageServiceAccount, ) .await?; @@ -928,13 +942,14 @@ mod test { // === valid_for validation === // Each case must be rejected, and the error message identifies the - // specific branch: non-ISO syntax, malformed ISO, non-positive, and - // over the one-year cap. + // specific branch: non-ISO syntax, malformed ISO, interval overflow, + // non-positive, and over the one-year cap. for (valid_for, want) in [ - ("90 days", "ISO 8601"), // Postgres syntax, not ISO 8601 - ("Pfoo", "invalid valid_for"), // 'P'-prefixed but unparseable - ("P0D", "positive"), // zero duration - ("P2Y", "no greater than 1 year"), // exceeds the cap + ("90 days", "ISO 8601"), // Postgres syntax, not ISO 8601 + ("Pfoo", "invalid valid_for"), // 'P'-prefixed but unparseable + ("P300000000000Y", "invalid valid_for"), // overflows interval parsing (SQLSTATE 22015) + ("P0D", "positive"), // zero duration + ("P2Y", "no greater than 1 year"), // exceeds the cap ] { let rejected: serde_json::Value = server .graphql( @@ -1376,7 +1391,7 @@ mod test { /// The management gates accept the fine-grained capabilities the feature /// defines, not only the full `Admin` bundle: a caller holding `TeamAdmin` - /// (which confers `ManageServiceAccounts` + `CreateGrant`) but NOT `Admin` + /// (which confers `ManageServiceAccount` + `CreateGrant`) but NOT `Admin` /// can manage service accounts, while the per-grant `CreateGrant` check /// still bounds how far they can extend an account's reach. #[sqlx::test( @@ -1388,7 +1403,7 @@ mod test { // Carol holds the TeamAdmin bundle on aliceCo/ and nothing else: her // grant carries no legacy capability ('none'), so her bits come solely - // from the bundle — ManageServiceAccounts and CreateGrant, but none of + // from the bundle — ManageServiceAccount and CreateGrant, but none of // the wider Admin-bundle bits. This is the caller class the gates were // narrowed to admit. Seeded before the snapshot so authorization // observes it. @@ -1415,7 +1430,7 @@ mod test { let carol_token = server.make_access_token(carol_uid, Some("carol@example.test")); - // Create succeeds: the anchor gate accepts ManageServiceAccounts, and + // Create succeeds: the anchor gate accepts ManageServiceAccount, and // the per-grant gate accepts CreateGrant on aliceCo/data/ (covered by // Carol's aliceCo/ bundle) — all without her holding full Admin. let create: serde_json::Value = server @@ -1445,7 +1460,7 @@ mod test { "createdBy should be the calling team admin: {create}" ); - // The anchor-only mutation createApiKey also accepts ManageServiceAccounts. + // The anchor-only mutation createApiKey also accepts ManageServiceAccount. let key: serde_json::Value = server .graphql( &serde_json::json!({ diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 2b1cce3aad5..eee0fbfeefe 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -381,7 +381,7 @@ enum CapabilityBit { CreateInviteLink ViewDataPlanePrivateNetworking ModifyDataPlanePrivateNetworking - ManageServiceAccounts + ManageServiceAccount Delegate Assume } @@ -1310,7 +1310,7 @@ type MutationRoot { what the account may access. Access is determined solely by the account's user_grants, which may span multiple prefixes. - The caller must have ManageServiceAccounts on the catalog name AND + The caller must have ManageServiceAccount on the catalog name AND CreateGrant on each granted prefix. Creates an auth.users row, an internal.service_accounts row, and a user_grants row per requested grant. @@ -1319,7 +1319,7 @@ type MutationRoot { """ Add a user_grant to a service account. - The caller must manage the service account (ManageServiceAccounts on its + The caller must manage the service account (ManageServiceAccount on its catalog name) AND have CreateGrant on the granted prefix. The second requirement prevents a caller from extending an account's access beyond what they could grant anyone. (Human-user grant creation still lives in @@ -1330,7 +1330,7 @@ type MutationRoot { """ Remove a user_grant from a service account. - The caller must manage the service account (ManageServiceAccounts on its + The caller must manage the service account (ManageServiceAccount on its catalog name). Unlike addServiceAccountGrant, no capability on the grant's prefix is required: removal only ever narrows the account's access, so managers may remove ANY grant — including grants to @@ -1355,7 +1355,7 @@ type MutationRoot { """ Revoke an API key. - The caller must have admin capability on the owning service account's + The caller must have ManageServiceAccount capability on the owning service account's catalog name. Rather than deleting the row, we stamp `revoked_at`, which makes the key diff --git a/crates/models/src/authz.rs b/crates/models/src/authz.rs index 0e38d5bbff2..f7df620d8be 100644 --- a/crates/models/src/authz.rs +++ b/crates/models/src/authz.rs @@ -26,7 +26,7 @@ pub enum Capability { // `ModifyDataPlanePrivateNetworking` permits mutating that same // configuration; the data-plane controller converges to it. ModifyDataPlanePrivateNetworking, - ManageServiceAccounts, + ManageServiceAccount, Delegate, Assume, } @@ -109,7 +109,7 @@ impl CapabilityBundle { | Self::ManageDataPlane.capabilities() } Self::Billing => EnumSet::empty(), - Self::TeamAdmin => CreateGrant | DeleteGrant | CreateInviteLink | ManageServiceAccounts, + Self::TeamAdmin => CreateGrant | DeleteGrant | CreateInviteLink | ManageServiceAccount, Self::ManageDataPlane => { ViewDataPlanePrivateNetworking | ModifyDataPlanePrivateNetworking }