From 28ca625c55495a8a15ccddd9f2267b53e6d7301e Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 18 Jun 2026 17:39:33 -0400 Subject: [PATCH 1/2] Service accounts v2 --- ...64124914c7c9d9203213837fbf2a62796fe9b.json | 24 + ...bc23cde331e451da6a7aaf01be99c9f6e39e0.json | 22 + ...507e2fcd07527118fa01e1f8c6af923ac6506.json | 54 + ...0e9b0ca37f7615c5dc86ae33aaf06f7c777ee.json | 22 + ...5b702e3cdd8438c28c362b65a75e0adf6d0e.json} | 4 +- ...db40c9aaf84d6a313a0d653c839c2d2696477.json | 14 + ...d431240effe02185b95683342cda765506f4b.json | 31 + ...c92c86eb49dd18354c1ac11566ce29510489c.json | 15 + ...8f467ad8a7c4aed2cfae74a065cb2ecc5cb20.json | 63 + ...cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json | 58 + ...cf3e2d32a6ee387d69af18ea7b06047335376.json | 16 + crates/billing-integrations/src/publish.rs | 5 + crates/control-plane-api/src/grants.rs | 29 + .../src/server/public/graphql/mod.rs | 3 + .../server/public/graphql/service_accounts.rs | 1457 +++++++++++++++++ crates/flow-client/control-plane-api.graphql | 133 ++ crates/models/src/authz.rs | 3 +- .../20260615120000_service_accounts.sql | 30 + 18 files changed, 1980 insertions(+), 3 deletions(-) create mode 100644 .sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json create mode 100644 .sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json create mode 100644 .sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.json create mode 100644 .sqlx/query-9496328d2bec1ffe02bafd913580e9b0ca37f7615c5dc86ae33aaf06f7c777ee.json rename .sqlx/{query-e3419ecd55893e5f4381f58a928f051bded17d2eeddd7bd199889549fb874076.json => query-a050d16b44c646e4f3aeea6e11345b702e3cdd8438c28c362b65a75e0adf6d0e.json} (56%) create mode 100644 .sqlx/query-a4712e4ff27607868fbd69c657edb40c9aaf84d6a313a0d653c839c2d2696477.json create mode 100644 .sqlx/query-b53236eb4a09b985887de632ed0d431240effe02185b95683342cda765506f4b.json create mode 100644 .sqlx/query-bdc6b63dae3c20d0dcf342f6353c92c86eb49dd18354c1ac11566ce29510489c.json create mode 100644 .sqlx/query-c645b91f5ae48d54373f9954d188f467ad8a7c4aed2cfae74a065cb2ecc5cb20.json create mode 100644 .sqlx/query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json create mode 100644 .sqlx/query-f7562d1965e61fe0ad91b6daf0ccf3e2d32a6ee387d69af18ea7b06047335376.json create mode 100644 crates/control-plane-api/src/server/public/graphql/service_accounts.rs create mode 100644 supabase/migrations/20260615120000_service_accounts.sql diff --git a/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json b/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json new file mode 100644 index 00000000000..6ef5686e67e --- /dev/null +++ b/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "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": [ + { + "ordinal": 0, + "name": "created_at!: chrono::DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b" +} diff --git a/.sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json b/.sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json new file mode 100644 index 00000000000..2c16edf501f --- /dev/null +++ b/.sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT sa.catalog_name AS \"catalog_name!: String\"\n FROM public.refresh_tokens rt\n JOIN internal.service_accounts sa ON sa.user_id = rt.user_id\n WHERE rt.id = $1 AND rt.valid_for <> interval '0'\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "catalog_name!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Macaddr8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0" +} diff --git a/.sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.json b/.sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.json new file mode 100644 index 00000000000..b878e8da61e --- /dev/null +++ b/.sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.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 updated_at across\n -- its tokens that have actually been exchanged (uses > 0;\n -- revoked included). Each exchange bumps the token's\n -- updated_at, so the tokens are the single source of truth.\n (\n SELECT max(rt.updated_at)\n FROM public.refresh_tokens rt\n WHERE rt.user_id = sa.user_id\n AND rt.uses > 0\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": "88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506" +} 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-e3419ecd55893e5f4381f58a928f051bded17d2eeddd7bd199889549fb874076.json b/.sqlx/query-a050d16b44c646e4f3aeea6e11345b702e3cdd8438c28c362b65a75e0adf6d0e.json similarity index 56% rename from .sqlx/query-e3419ecd55893e5f4381f58a928f051bded17d2eeddd7bd199889549fb874076.json rename to .sqlx/query-a050d16b44c646e4f3aeea6e11345b702e3cdd8438c28c362b65a75e0adf6d0e.json index 0ac87290b28..31476b2d54b 100644 --- a/.sqlx/query-e3419ecd55893e5f4381f58a928f051bded17d2eeddd7bd199889549fb874076.json +++ b/.sqlx/query-a050d16b44c646e4f3aeea6e11345b702e3cdd8438c28c362b65a75e0adf6d0e.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 addresses must\n -- 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": "e3419ecd55893e5f4381f58a928f051bded17d2eeddd7bd199889549fb874076" + "hash": "a050d16b44c646e4f3aeea6e11345b702e3cdd8438c28c362b65a75e0adf6d0e" } diff --git a/.sqlx/query-a4712e4ff27607868fbd69c657edb40c9aaf84d6a313a0d653c839c2d2696477.json b/.sqlx/query-a4712e4ff27607868fbd69c657edb40c9aaf84d6a313a0d653c839c2d2696477.json new file mode 100644 index 00000000000..4d63dc1c131 --- /dev/null +++ b/.sqlx/query-a4712e4ff27607868fbd69c657edb40c9aaf84d6a313a0d653c839c2d2696477.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE public.refresh_tokens SET valid_for = interval '0' WHERE id = $1 AND valid_for <> interval '0'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Macaddr8" + ] + }, + "nullable": [] + }, + "hash": "a4712e4ff27607868fbd69c657edb40c9aaf84d6a313a0d653c839c2d2696477" +} diff --git a/.sqlx/query-b53236eb4a09b985887de632ed0d431240effe02185b95683342cda765506f4b.json b/.sqlx/query-b53236eb4a09b985887de632ed0d431240effe02185b95683342cda765506f4b.json new file mode 100644 index 00000000000..b54ca3c81fc --- /dev/null +++ b/.sqlx/query-b53236eb4a09b985887de632ed0d431240effe02185b95683342cda765506f4b.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH new_token AS (\n SELECT gen_random_uuid()::text AS secret\n )\n INSERT INTO public.refresh_tokens\n (user_id, multi_use, valid_for, hash, detail, created_by)\n SELECT\n $1,\n true,\n v.valid_for,\n crypt(nt.secret, gen_salt('bf')),\n $3,\n $4\n FROM new_token nt, (SELECT $2::text::interval AS valid_for) v\n WHERE v.valid_for > interval '0' AND v.valid_for <= interval '366 days'\n RETURNING\n id AS \"id!: models::Id\",\n (SELECT secret FROM new_token) 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": "b53236eb4a09b985887de632ed0d431240effe02185b95683342cda765506f4b" +} 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-c645b91f5ae48d54373f9954d188f467ad8a7c4aed2cfae74a065cb2ecc5cb20.json b/.sqlx/query-c645b91f5ae48d54373f9954d188f467ad8a7c4aed2cfae74a065cb2ecc5cb20.json new file mode 100644 index 00000000000..8857bb98b55 --- /dev/null +++ b/.sqlx/query-c645b91f5ae48d54373f9954d188f467ad8a7c4aed2cfae74a065cb2ecc5cb20.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into user_grants (user_id, object_role, capability, detail)\n values ($1, $2, $3, $4)\n on conflict (user_id, object_role) do update set\n capability = $3,\n updated_at = now(),\n detail = $4\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "catalog_prefix", + "kind": { + "Domain": "Text" + } + } + }, + { + "Custom": { + "name": "grant_capability", + "kind": { + "Enum": [ + "none", + "x_01", + "x_02", + "x_03", + "x_04", + "x_05", + "x_06", + "x_07", + "x_08", + "x_09", + "read", + "x_11", + "x_12", + "x_13", + "x_14", + "x_15", + "x_16", + "x_17", + "x_18", + "x_19", + "write", + "x_21", + "x_22", + "x_23", + "x_24", + "x_25", + "x_26", + "x_27", + "x_28", + "x_29", + "admin" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "c645b91f5ae48d54373f9954d188f467ad8a7c4aed2cfae74a065cb2ecc5cb20" +} diff --git a/.sqlx/query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json b/.sqlx/query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json new file mode 100644 index 00000000000..232232a1edb --- /dev/null +++ b/.sqlx/query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n rt.id AS \"id!: models::Id\",\n rt.user_id,\n rt.detail,\n rt.created_by AS \"created_by!: uuid::Uuid\",\n rt.created_at AS \"created_at!: chrono::DateTime\",\n (rt.updated_at + rt.valid_for) AS \"expires_at!: chrono::DateTime\",\n CASE WHEN rt.uses > 0 THEN rt.updated_at END AS \"last_used_at: chrono::DateTime\"\n FROM public.refresh_tokens rt\n WHERE rt.user_id = ANY($1)\n AND rt.valid_for <> interval '0'\n ORDER BY rt.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: models::Id", + "type_info": "Macaddr8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "detail", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_by!: uuid::Uuid", + "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, + true, + true, + false, + null, + null + ] + }, + "hash": "edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a" +} 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/crates/billing-integrations/src/publish.rs b/crates/billing-integrations/src/publish.rs index 5cc6de3f453..1ba7dd7fe7e 100644 --- a/crates/billing-integrations/src/publish.rs +++ b/crates/billing-integrations/src/publish.rs @@ -864,6 +864,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 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/grants.rs b/crates/control-plane-api/src/grants.rs index d41d3996cc1..c9291dda7f4 100644 --- a/crates/control-plane-api/src/grants.rs +++ b/crates/control-plane-api/src/grants.rs @@ -26,3 +26,32 @@ pub async fn upsert_user_grant( Ok(()) } + +/// Upsert a user grant, unconditionally replacing the capability and detail of +/// any existing grant. Unlike [`upsert_user_grant`], this does not guard against +/// downgrades: the supplied `capability` always wins. +pub async fn overwrite_user_grant( + user: Uuid, + prefix: &str, + capability: models::Capability, + detail: Option, + txn: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> sqlx::Result<()> { + sqlx::query!( + r#"insert into user_grants (user_id, object_role, capability, detail) + values ($1, $2, $3, $4) + on conflict (user_id, object_role) do update set + capability = $3, + updated_at = now(), + detail = $4 + "#, + user, + prefix as &str, + capability as models::Capability, + detail as Option, + ) + .execute(&mut **txn) + .await?; + + Ok(()) +} 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/service_accounts.rs b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs new file mode 100644 index 00000000000..f45bea99cb8 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/service_accounts.rs @@ -0,0 +1,1457 @@ +use super::TimestampCursor; +use async_graphql::{Context, types::connection}; + +#[derive(Debug, Clone, async_graphql::SimpleObject)] +pub struct ServiceAccount { + 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 tokens: 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, +} + +/// A service-account credential: a multi-use refresh token owned by the account +/// and minted by an administrator. The secret itself is returned only once at +/// creation (see [`CreateServiceAccountTokenResult`]). +#[derive(Debug, Clone, async_graphql::SimpleObject)] +pub struct ServiceAccountTokenInfo { + pub id: models::Id, + pub detail: Option, + 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 CreateServiceAccountTokenResult { + pub id: models::Id, + /// The bearer credential, returned exactly once. Present it as an + /// `Authorization: Bearer` token or exchange it at `POST /api/v1/auth/token`. + 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 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::ManageServiceAccount, + 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.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", + -- The account's "last used" is the max updated_at across + -- its tokens that have actually been exchanged (uses > 0; + -- revoked included). Each exchange bumps the token's + -- updated_at, so the tokens are the single source of truth. + ( + SELECT max(rt.updated_at) + FROM public.refresh_tokens rt + WHERE rt.user_id = sa.user_id + AND rt.uses > 0 + ) 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(); + + // Tokens 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 `tokens`. + let token_rows = if user_ids.is_empty() { + vec![] + } else { + sqlx::query!( + r#" + SELECT + rt.id AS "id!: models::Id", + rt.user_id, + rt.detail, + rt.created_by AS "created_by!: uuid::Uuid", + rt.created_at AS "created_at!: chrono::DateTime", + (rt.updated_at + rt.valid_for) AS "expires_at!: chrono::DateTime", + CASE WHEN rt.uses > 0 THEN rt.updated_at END AS "last_used_at: chrono::DateTime" + FROM public.refresh_tokens rt + WHERE rt.user_id = ANY($1) + AND rt.valid_for <> interval '0' + ORDER BY rt.created_at DESC + "#, + &user_ids, + ) + .fetch_all(&env.pg_pool) + .await? + }; + + let mut tokens_by_sa: std::collections::HashMap< + uuid::Uuid, + Vec, + > = std::collections::HashMap::new(); + for tr in token_rows { + tokens_by_sa + .entry(tr.user_id) + .or_default() + .push(ServiceAccountTokenInfo { + id: tr.id, + detail: tr.detail, + created_by: tr.created_by, + created_at: tr.created_at, + expires_at: tr.expires_at, + last_used_at: tr.last_used_at, + }); + } + + let edges: Vec<_> = sa_rows + .into_iter() + .take(limit) + .map(|r| { + let tokens = tokens_by_sa.remove(&r.user_id).unwrap_or_default(); + connection::Edge::new( + TimestampCursor(r.created_at), + ServiceAccount { + 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, + tokens, + }, + ) + }) + .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 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. + async fn create_service_account( + &self, + ctx: &Context<'_>, + catalog_name: models::Name, + 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}" + ))); + } + // 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!( + "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", + )); + } + } + + // 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::authz::Capability::CreateGrant, + ) + .await?; + } + + let mut txn = env.pg_pool.begin().await?; + + 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!("{}@service_accounts.estuary.dev", catalog_name.as_str()), + serde_json::json!({ + "full_name": catalog_name.as_str(), + }), + ) + .execute(&mut *txn) + .await + .map_err(duplicate_err)?; + + let now = sqlx::query_scalar!( + r#" + 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(), + claims.sub, + ) + .fetch_one(&mut *txn) + .await + .map_err(duplicate_err)?; + + for grant in &grants { + crate::grants::overwrite_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 { + catalog_name, + created_by: claims.sub, + created_at: now, + updated_at: now, + last_used_at: None, + tokens: vec![], + }) + } + + /// Add a user_grant to a service account. + /// + /// 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 + /// PostgREST; when it migrates to GraphQL it should gate on this same + /// CreateGrant capability.) + async fn add_service_account_grant( + &self, + ctx: &Context<'_>, + catalog_name: models::Name, + prefix: models::Prefix, + capability: models::Capability, + ) -> async_graphql::Result { + 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}", + 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", + )); + } + + 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, + 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 (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 + /// prefixes they don't themselves administer. + async fn remove_service_account_grant( + &self, + ctx: &Context<'_>, + catalog_name: models::Name, + prefix: models::Prefix, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + super::verify_authorization( + env, + catalog_name.as_str(), + models::authz::Capability::ManageServiceAccount, + ) + .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, + 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) + } + + /// Mint a credential for a service account. + /// + /// The credential is a multi-use refresh token owned by the account: its + /// secret never rotates and its validity window of `valid_for` slides with + /// use, like any refresh token. Returns the token id and the bearer secret, + /// which is returned exactly once and cannot be retrieved again. Present it + /// as an `Authorization: Bearer` credential or exchange it for a 1-hour + /// access token via `POST /api/v1/auth/token`. + /// + /// The caller must have ManageServiceAccount on the account's catalog name. + async fn create_service_account_token( + &self, + ctx: &Context<'_>, + catalog_name: models::Name, + detail: String, + #[graphql(desc = "ISO 8601 duration for token validity (e.g. P90D, P1Y)")] + valid_for: String, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + super::verify_authorization( + env, + catalog_name.as_str(), + models::authz::Capability::ManageServiceAccount, + ) + .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"), + // 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", + )); + } + + // Mint the credential as a multi_use refresh token owned by the service + // account. The lifetime is bounded to at most one year and required to + // be positive; Postgres does the calendar-aware interval math (the WHERE + // clause yields zero rows when out of bounds). The secret is a random + // UUID bcrypt-hashed at rest, matching create_refresh_token. + let row = sqlx::query!( + r#" + WITH new_token AS ( + SELECT gen_random_uuid()::text AS secret + ) + INSERT INTO public.refresh_tokens + (user_id, multi_use, valid_for, hash, detail, created_by) + SELECT + $1, + true, + v.valid_for, + crypt(nt.secret, gen_salt('bf')), + $3, + $4 + FROM new_token nt, (SELECT $2::text::interval AS valid_for) v + WHERE v.valid_for > interval '0' AND v.valid_for <= interval '366 days' + RETURNING + id AS "id!: models::Id", + (SELECT secret FROM new_token) AS "secret!: String" + "#, + user_id, + valid_for, + detail, + claims.sub, + ) + .fetch_optional(&env.pg_pool) + .await + .map_err(|err| { + // 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. + let code = err.as_database_error().and_then(|e| e.code()); + if matches!(code.as_deref(), Some("22007" | "22008" | "22015")) { + async_graphql::Error::new( + "invalid valid_for: expected an ISO 8601 duration, e.g. P90D or P1Y", + ) + } else { + tracing::error!(?err, "failed to create service account token"); + async_graphql::Error::new("failed to create service account token") + } + })? + .ok_or_else(|| { + async_graphql::Error::new( + "valid_for must be a positive duration no greater than 1 year", + ) + })?; + + // The bearer form accepted by the Envelope extractor and the + // token-exchange endpoint: standard base64 of `{"id": ..., "secret": ...}`. + use base64::Engine; + let secret = base64::engine::general_purpose::STANDARD.encode( + serde_json::json!({ "id": row.id.to_string(), "secret": row.secret }).to_string(), + ); + + tracing::info!( + token_id = %row.id, + %user_id, + %detail, + %claims.sub, + "created service account token" + ); + + Ok(CreateServiceAccountTokenResult { id: row.id, secret }) + } + + /// Revoke a service-account token. + /// + /// The caller must have ManageServiceAccount capability on the owning service + /// account's catalog name. + /// + /// Rather than deleting the row, we zero its `valid_for` interval, which + /// makes the token inert (it fails the exchange's expiry check and is + /// excluded from listings) while preserving the audit trail. Already-revoked + /// tokens are treated as not found. + async fn revoke_service_account_token( + &self, + ctx: &Context<'_>, + id: models::Id, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + let catalog_name = sqlx::query_scalar!( + r#" + SELECT sa.catalog_name AS "catalog_name!: String" + FROM public.refresh_tokens rt + JOIN internal.service_accounts sa ON sa.user_id = rt.user_id + WHERE rt.id = $1 AND rt.valid_for <> interval '0' + "#, + id as models::Id, + ) + .fetch_optional(&env.pg_pool) + .await?; + + let catalog_name = match catalog_name { + Some(name) => name, + None => return Err(async_graphql::Error::new("service account token not found")), + }; + + super::verify_authorization( + env, + &catalog_name, + models::authz::Capability::ManageServiceAccount, + ) + .await?; + + sqlx::query!( + "UPDATE public.refresh_tokens SET valid_for = interval '0' \ + WHERE id = $1 AND valid_for <> interval '0'", + id as models::Id + ) + .execute(&env.pg_pool) + .await?; + + tracing::info!( + token_id = %id, + service_account = %catalog_name, + %claims.sub, + "revoked service account token" + ); + + Ok(true) + } +} + +/// 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, + catalog_name: &str, +) -> async_graphql::Result { + let row = sqlx::query_scalar!( + r#" + SELECT user_id + FROM internal.service_accounts + WHERE catalog_name = $1::text::catalog_name + "#, + catalog_name, + ) + .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!, $grants: [ServiceAccountGrantInput!]!) { + createServiceAccount( + catalogName: $catalogName + grants: $grants + ) { + catalogName + createdBy + createdAt + updatedAt + lastUsedAt + tokens { id } + } + }"#, + "variables": { + "catalogName": "aliceCo/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"]; + // 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["tokens"].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 }] + ) { 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")))), + "duplicate catalog name should be rejected: {dup}" + ); + 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" + grants: [{ prefix: "aliceCo/", capability: read }] + ) { catalogName } + }"# + }), + 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", grants: []) { catalogName } + }"# + }), + 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" + grants: [{ prefix: "aliceCo/", capability: none }] + ) { catalogName } + }"# + }), + 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" + grants: [ + { prefix: "aliceCo/", capability: read }, + { prefix: "bobCo/", capability: read } + ] + ) { catalogName } + }"# + }), + Some(&alice_token), + ) + .await; + assert!( + foreign_grant["errors"].is_array(), + "a grant to an unadministered prefix should be rejected: {foreign_grant}" + ); + + // === Mint a service-account token === + let create_token: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($detail: String!, $validFor: String!) { + createServiceAccountToken( + catalogName: "aliceCo/ci-deploy-bot" + detail: $detail + validFor: $validFor + ) { + id + secret + } + }"#, + "variables": { + "detail": "GitHub Actions", + "validFor": "P90D" + } + }), + Some(&alice_token), + ) + .await; + + assert!( + create_token["errors"].is_null(), + "create token should succeed: {create_token}" + ); + let token_data = &create_token["data"]["createServiceAccountToken"]; + let token_id = token_data["id"] + .as_str() + .expect("should have id") + .to_string(); + let secret = token_data["secret"] + .as_str() + .expect("should have secret") + .to_string(); + // The credential is the unified refresh-token bearer form (base64 JSON), + // not the retired flow_sa_ key format. + assert!( + !secret.starts_with("flow_sa_"), + "credential should be the refresh-token bearer form: {secret}" + ); + + // === valid_for validation === + // Each case must be rejected, and the error message identifies the + // 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 + ("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( + &serde_json::json!({ + "query": r#" + mutation($detail: String!, $validFor: String!) { + createServiceAccountToken(catalogName: "aliceCo/ci-deploy-bot", detail: $detail, validFor: $validFor) { id } + }"#, + "variables": { + "detail": "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 happy-path exchange (generate_access_token actually signing a JWT) + // is intentionally not exercised here: signing reads app.jwt_secret from + // vault.decrypted_secrets and calls pgjwt's sign(), neither of which + // exists in the sqlx::test DB. That path is covered by the pgTAP + // refresh-token tests. The assertions below all resolve *before* signing + // (a bad secret, or a revoked/expired token), so they're deterministic + // without that setup. + + // Simulate a successful exchange so the read-side last_used_at derivation + // is observable: generate_access_token bumps uses and updated_at, which + // the listing surfaces. We can't run the real exchange (it would sign), + // so stamp the row directly. + let parsed_token_id: models::Id = token_id.parse().unwrap(); + sqlx::query!( + "UPDATE public.refresh_tokens SET uses = 1, updated_at = now() WHERE id = $1", + parsed_token_id as models::Id, + ) + .execute(&pool) + .await + .unwrap(); + + // === List service accounts === + let list: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + serviceAccounts { + edges { + node { + catalogName + lastUsedAt + tokens { + id + detail + 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"]["catalogName"], "aliceCo/ci-deploy-bot"); + let listed_token = &edges[0]["node"]["tokens"][0]; + assert_eq!(edges[0]["node"]["tokens"].as_array().unwrap().len(), 1); + assert_eq!(listed_token["detail"], "GitHub Actions"); + assert_eq!( + listed_token["createdBy"], "11111111-1111-1111-1111-111111111111", + "token createdBy should be the calling admin: {list}" + ); + assert!( + listed_token["createdAt"].is_string() && listed_token["expiresAt"].is_string(), + "token createdAt/expiresAt should be set: {list}" + ); + // We stamped a use above, so the derived last_used_at is populated. + assert!( + listed_token["lastUsedAt"].is_string(), + "lastUsedAt should be derived from a used token's updated_at: {list}" + ); + // The account's lastUsedAt is derived as the max across its tokens. + assert!( + edges[0]["node"]["lastUsedAt"].is_string(), + "account lastUsedAt should be derived from its tokens' use: {list}" + ); + + // Bob sees no service accounts. + let bob_list: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + query { + serviceAccounts { edges { node { catalogName } } } + }"# + }), + Some(&bob_token), + ) + .await; + + let bob_edges = bob_list["data"]["serviceAccounts"]["edges"] + .as_array() + .expect("should have edges"); + assert_eq!(bob_edges.len(), 0); + + // === A bad secret is rejected statefully === + // generate_access_token checks the secret before signing, so this 401s + // deterministically even without the signing setup. Build a bearer form + // for the real token id but a wrong secret. + use base64::Engine; + let bad_bearer = base64::engine::general_purpose::STANDARD + .encode(serde_json::json!({ "id": token_id, "secret": "wrong-secret" }).to_string()); + let bad = server + .rest_client() + .post( + "/api/graphql", + &serde_json::json!({ "query": "query { __typename }" }), + Some(&bad_bearer), + ) + .send() + .await + .unwrap(); + assert_eq!( + bad.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a wrong secret must be rejected with 401" + ); + + // === Revoke the token === + let revoke: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($id: Id!) { + revokeServiceAccountToken(id: $id) + }"#, + "variables": { "id": token_id } + }), + Some(&alice_token), + ) + .await; + + assert!( + revoke["errors"].is_null(), + "revoke should succeed: {revoke}" + ); + + // The row is preserved with valid_for zeroed — revocation is a soft + // delete for audit purposes. The listing assertion below can't observe + // this distinction, so check the table directly. + let zeroed = sqlx::query_scalar!( + r#"SELECT valid_for = interval '0' AS "zeroed!" FROM public.refresh_tokens WHERE id = $1"#, + parsed_token_id as models::Id, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(zeroed, "revocation must zero valid_for, not delete the row"); + + // Revoking again fails: already-revoked tokens are treated as not found. + let revoke_again: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($id: Id!) { + revokeServiceAccountToken(id: $id) + }"#, + "variables": { "id": token_id } + }), + Some(&alice_token), + ) + .await; + assert!( + revoke_again["errors"][0]["message"] + .as_str() + .unwrap_or_default() + .contains("service account token not found"), + "re-revoking should report not found: {revoke_again}" + ); + + // The revoked token no longer authenticates — immediately, since every + // request re-verifies the credential against the database. A zeroed + // valid_for fails generate_access_token's expiry check (before signing), + // yielding the same 401 as an unknown token. + let rejected = server + .rest_client() + .post( + "/api/graphql", + &serde_json::json!({ "query": "query { __typename }" }), + Some(&secret), + ) + .send() + .await + .unwrap(); + let status = rejected.status(); + let body = rejected.text().await.unwrap(); + assert_eq!( + status, + reqwest::StatusCode::UNAUTHORIZED, + "revoked token should be rejected with 401: {body}" + ); + assert!( + body.contains("invalid, expired, or unknown refresh token"), + "revoked token rejection body: {body}" + ); + + // The revoked token 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 { tokens { id } } } } + }"# + }), + Some(&alice_token), + ) + .await; + assert_eq!( + list_after_revoke["data"]["serviceAccounts"]["edges"][0]["node"]["tokens"] + .as_array() + .unwrap() + .len(), + 0, + "revoked tokens 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() + } + + // 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. + let add_unmanaged: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/", capability: read) + }"# + }), + 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 { + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/", capability: read) + }"# + }), + 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 { + 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); + + // 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 { + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") + }"# + }), + 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 { + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") + }"# + }), + 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 { + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/data/") + }"# + }), + Some(&bob_token), + ) + .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 `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( + 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 — 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. + 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 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 + .graphql( + &serde_json::json!({ + "query": r#" + mutation($grants: [ServiceAccountGrantInput!]!) { + createServiceAccount( + catalogName: "aliceCo/team-bot" + grants: $grants + ) { catalogName 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}" + ); + assert_eq!( + create["data"]["createServiceAccount"]["createdBy"], + "33333333-3333-3333-3333-333333333333", + "createdBy should be the calling team admin: {create}" + ); + + // The anchor-only mutation createServiceAccountToken also accepts ManageServiceAccount. + let token: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + createServiceAccountToken(catalogName: "aliceCo/team-bot", detail: "ci", validFor: "P30D") { id } + }"# + }), + Some(&carol_token), + ) + .await; + assert!( + token["errors"].is_null(), + "a TeamAdmin should mint a service account token: {token}" + ); + + // 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 { + addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "aliceCo/ops/", capability: write) + }"# + }), + 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 { + addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "bobCo/", capability: read) + }"# + }), + 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" + grants: [{ prefix: "bobCo/", capability: read }] + ) { catalogName } + }"# + }), + 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/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 7016e736d14..1afca2e106c 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -398,6 +398,7 @@ enum CapabilityBit { ModifyDataPlanePrivateNetworking ViewBilling EditBilling + ManageServiceAccount Delegate Assume } @@ -663,6 +664,15 @@ type CreateBillingSetupIntentPayload { clientSecret: String! } +type CreateServiceAccountTokenResult { + id: Id! + """ + The bearer credential, returned exactly once. Present it as an + `Authorization: Bearer` token or exchange it at `POST /api/v1/auth/token`. + """ + secret: String! +} + """ Result of creating a storage mapping. """ @@ -1313,6 +1323,72 @@ 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 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. + """ + createServiceAccount(catalogName: Name!, grants: [ServiceAccountGrantInput!]!): ServiceAccount! + """ + Add a user_grant to a service account. + + 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 + PostgREST; when it migrates to GraphQL it should gate on this same + CreateGrant capability.) + """ + addServiceAccountGrant(catalogName: Name!, prefix: Prefix!, capability: Capability!): Boolean! + """ + Remove a user_grant from a service account. + + 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 + prefixes they don't themselves administer. + """ + removeServiceAccountGrant(catalogName: Name!, prefix: Prefix!): Boolean! + """ + Mint a credential for a service account. + + The credential is a multi-use refresh token owned by the account: its + secret never rotates and its validity window of `valid_for` slides with + use, like any refresh token. Returns the token id and the bearer secret, + which is returned exactly once and cannot be retrieved again. Present it + as an `Authorization: Bearer` credential or exchange it for a 1-hour + access token via `POST /api/v1/auth/token`. + + The caller must have ManageServiceAccount on the account's catalog name. + """ + createServiceAccountToken( catalogName: Name!, detail: String!, + """ + ISO 8601 duration for token validity (e.g. P90D, P1Y) + """ + validFor: String! + ): CreateServiceAccountTokenResult! + """ + Revoke a service-account token. + + The caller must have ManageServiceAccount capability on the owning service + account's catalog name. + + Rather than deleting the row, we zero its `valid_for` interval, which + makes the token inert (it fails the exchange's expiry check and is + excluded from listings) while preserving the audit trail. Already-revoked + tokens are treated as not found. + """ + revokeServiceAccountToken(id: Id!): Boolean! } """ @@ -1628,6 +1704,7 @@ type QueryRoot { List refresh tokens owned by the authenticated user. """ refreshTokens(after: String, first: Int): RefreshTokenInfoConnection! + serviceAccounts(after: String, first: Int): ServiceAccountConnection! } """ @@ -1706,6 +1783,62 @@ type RepublishRequested { lastBuildId: Id! } +type ServiceAccount { + catalogName: Name! + createdBy: UUID! + createdAt: DateTime! + updatedAt: DateTime! + lastUsedAt: DateTime + tokens: [ServiceAccountTokenInfo!]! +} + +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! +} + +""" +A service-account credential: a multi-use refresh token owned by the account +and minted by an administrator. The secret itself is returned only once at +creation (see [`CreateServiceAccountTokenResult`]). +""" +type ServiceAccountTokenInfo { + id: Id! + detail: String + createdBy: UUID! + createdAt: DateTime! + expiresAt: DateTime! + lastUsedAt: DateTime +} + type SetBillingContactPayload { contact: BillingContact! } diff --git a/crates/models/src/authz.rs b/crates/models/src/authz.rs index f65ac707366..cd387466a4a 100644 --- a/crates/models/src/authz.rs +++ b/crates/models/src/authz.rs @@ -31,6 +31,7 @@ pub enum Capability { ViewBilling, // `EditBilling` permits mutating a tenant's billing contact EditBilling, + ManageServiceAccount, Delegate, Assume, } @@ -113,7 +114,7 @@ impl CapabilityBundle { | Self::ManageDataPlane.capabilities() } Self::Billing => ViewBilling | EditBilling, - Self::TeamAdmin => CreateGrant | DeleteGrant | CreateInviteLink, + Self::TeamAdmin => CreateGrant | DeleteGrant | CreateInviteLink | ManageServiceAccount, Self::ManageDataPlane => { ViewDataPlanePrivateNetworking | ModifyDataPlanePrivateNetworking } diff --git a/supabase/migrations/20260615120000_service_accounts.sql b/supabase/migrations/20260615120000_service_accounts.sql new file mode 100644 index 00000000000..7c92edf002e --- /dev/null +++ b/supabase/migrations/20260615120000_service_accounts.sql @@ -0,0 +1,30 @@ +begin; + +-- Service accounts: non-login identities 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 public.catalog_name not null, + created_by uuid not null references auth.users (id), + 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 refresh tokens and are authorized through user_grants.'; + +create unique index service_accounts_catalog_name_key on internal.service_accounts + (catalog_name); + +create index service_accounts_catalog_name_spgist on internal.service_accounts + using spgist ((catalog_name::text)); + +alter table public.refresh_tokens + add column created_by uuid references auth.users (id); + +comment on column public.refresh_tokens.created_by is + 'User who minted the token on another identity''s behalf (service-account ' + 'credentials). Null for self-minted human tokens.'; + +commit; From 03fa4dbb33766dc25f254bef16fa47f3abe32ac1 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Thu, 2 Jul 2026 17:47:41 -0400 Subject: [PATCH 2/2] service accounts --- .github/workflows/platform-test.yaml | 12 +- ...71b673186747d84edf6b114df5b81b6531f28.json | 46 + ...7d6beb7bb1771d44e7d4afc541aa3aa2c7cda.json | 14 + ...eaa09398ba6305a273a1c67f877421dffcfa.json} | 8 +- ...64124914c7c9d9203213837fbf2a62796fe9b.json | 24 - ...bc23cde331e451da6a7aaf01be99c9f6e39e0.json | 22 - ...099ebeff434f8fde46145f9c12ebafb372bb4.json | 28 + ...fd8949e59fac6d86a6fd41fc694c65085c636.json | 14 + ...a352c80a47c5ea4cc48b0c1c4e8f6d3801605.json | 91 ++ ...507e2fcd07527118fa01e1f8c6af923ac6506.json | 54 -- ...3ca6c93f9b01e44608bb0bc6b72379e04152b.json | 16 + ...972de8f41f33e600a2fabe77add09621350cb.json | 54 ++ crates/control-plane-api/src/server/mod.rs | 28 +- .../public/graphql/billing/mutations.rs | 6 +- .../src/server/public/graphql/mod.rs | 3 + .../server/public/graphql/refresh_tokens.rs | 6 +- .../src/server/public/graphql/scalars.rs | 32 + .../server/public/graphql/service_accounts.rs | 784 +++++++++++++++--- .../src/server/public/token_exchange.rs | 71 +- crates/flow-client/control-plane-api.graphql | 87 +- 20 files changed, 1101 insertions(+), 299 deletions(-) create mode 100644 .sqlx/query-0e45df40ea87b3061b4919d36c071b673186747d84edf6b114df5b81b6531f28.json create mode 100644 .sqlx/query-1197b10e75effb22aafe3e2487d7d6beb7bb1771d44e7d4afc541aa3aa2c7cda.json rename .sqlx/{query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json => query-331a2739381aed586b31c1d264beeaa09398ba6305a273a1c67f877421dffcfa.json} (50%) delete mode 100644 .sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json delete mode 100644 .sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json create mode 100644 .sqlx/query-3b904e126ee14610dc18dfa4239099ebeff434f8fde46145f9c12ebafb372bb4.json create mode 100644 .sqlx/query-51177ad92eabdb931a92bde6ac8fd8949e59fac6d86a6fd41fc694c65085c636.json create mode 100644 .sqlx/query-814ee1a843b34fa540364dbc2b8a352c80a47c5ea4cc48b0c1c4e8f6d3801605.json delete mode 100644 .sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.json create mode 100644 .sqlx/query-98b945899f3acebf8253f917c9b3ca6c93f9b01e44608bb0bc6b72379e04152b.json create mode 100644 .sqlx/query-d118150d28fb7d20ed82ed5bc7f972de8f41f33e600a2fabe77add09621350cb.json create mode 100644 crates/control-plane-api/src/server/public/graphql/scalars.rs diff --git a/.github/workflows/platform-test.yaml b/.github/workflows/platform-test.yaml index 3af70c61171..cc8bae49786 100644 --- a/.github/workflows/platform-test.yaml +++ b/.github/workflows/platform-test.yaml @@ -131,13 +131,15 @@ jobs: - run: mise run local:bigtable - run: mise run ci:nextest-run + - run: mise run ci:doctest + - run: mise run ci:gotest + - run: mise run ci:catalog-test + - run: mise run build:flow-schema + + # Run last so its flakiness can't skip the test steps above. It still gates + # the job on failure, surfacing genuine Stripe regressions. - name: Stripe integration test env: STRIPE_API_KEY: ${{ secrets.STRIPE_TESTMODE_API_KEY }} if: env.STRIPE_API_KEY != '' run: cargo nextest run --frozen --run-ignored ignored-only -E 'test(graphql_billing_live_stripe)' - - - run: mise run ci:doctest - - run: mise run ci:gotest - - run: mise run ci:catalog-test - - run: mise run build:flow-schema diff --git a/.sqlx/query-0e45df40ea87b3061b4919d36c071b673186747d84edf6b114df5b81b6531f28.json b/.sqlx/query-0e45df40ea87b3061b4919d36c071b673186747d84edf6b114df5b81b6531f28.json new file mode 100644 index 00000000000..5617df95a69 --- /dev/null +++ b/.sqlx/query-0e45df40ea87b3061b4919d36c071b673186747d84edf6b114df5b81b6531f28.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n sa.catalog_name AS \"catalog_name!: String\",\n creator.email AS \"created_by_email: String\",\n sa.created_at AS \"created_at!: chrono::DateTime\",\n sa.updated_at AS \"updated_at!: chrono::DateTime\",\n -- See the listing query: \"last used\" is the max updated_at across\n -- the account's exchanged tokens (uses > 0; revoked included).\n (\n SELECT max(rt.updated_at)\n FROM public.refresh_tokens rt\n WHERE rt.user_id = sa.user_id\n AND rt.uses > 0\n ) AS \"last_used_at: chrono::DateTime\"\n FROM internal.service_accounts sa\n LEFT JOIN auth.users creator ON creator.id = sa.created_by\n WHERE sa.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "catalog_name!: String", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "created_by_email: String", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "created_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at: chrono::DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + false, + null + ] + }, + "hash": "0e45df40ea87b3061b4919d36c071b673186747d84edf6b114df5b81b6531f28" +} diff --git a/.sqlx/query-1197b10e75effb22aafe3e2487d7d6beb7bb1771d44e7d4afc541aa3aa2c7cda.json b/.sqlx/query-1197b10e75effb22aafe3e2487d7d6beb7bb1771d44e7d4afc541aa3aa2c7cda.json new file mode 100644 index 00000000000..24692eeeccf --- /dev/null +++ b/.sqlx/query-1197b10e75effb22aafe3e2487d7d6beb7bb1771d44e7d4afc541aa3aa2c7cda.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM public.user_grants WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1197b10e75effb22aafe3e2487d7d6beb7bb1771d44e7d4afc541aa3aa2c7cda" +} diff --git a/.sqlx/query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json b/.sqlx/query-331a2739381aed586b31c1d264beeaa09398ba6305a273a1c67f877421dffcfa.json similarity index 50% rename from .sqlx/query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json rename to .sqlx/query-331a2739381aed586b31c1d264beeaa09398ba6305a273a1c67f877421dffcfa.json index 232232a1edb..231e12d6158 100644 --- a/.sqlx/query-edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a.json +++ b/.sqlx/query-331a2739381aed586b31c1d264beeaa09398ba6305a273a1c67f877421dffcfa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n rt.id AS \"id!: models::Id\",\n rt.user_id,\n rt.detail,\n rt.created_by AS \"created_by!: uuid::Uuid\",\n rt.created_at AS \"created_at!: chrono::DateTime\",\n (rt.updated_at + rt.valid_for) AS \"expires_at!: chrono::DateTime\",\n CASE WHEN rt.uses > 0 THEN rt.updated_at END AS \"last_used_at: chrono::DateTime\"\n FROM public.refresh_tokens rt\n WHERE rt.user_id = ANY($1)\n AND rt.valid_for <> interval '0'\n ORDER BY rt.created_at DESC\n ", + "query": "\n SELECT\n rt.id AS \"id!: models::Id\",\n rt.user_id,\n rt.detail,\n creator.email AS \"created_by_email: String\",\n rt.created_at AS \"created_at!: chrono::DateTime\",\n (rt.updated_at + rt.valid_for) AS \"expires_at!: chrono::DateTime\",\n CASE WHEN rt.uses > 0 THEN rt.updated_at END AS \"last_used_at: chrono::DateTime\"\n FROM public.refresh_tokens rt\n LEFT JOIN auth.users creator ON creator.id = rt.created_by\n WHERE rt.user_id = ANY($1)\n AND rt.valid_for <> interval '0'\n ORDER BY rt.created_at DESC\n ", "describe": { "columns": [ { @@ -20,8 +20,8 @@ }, { "ordinal": 3, - "name": "created_by!: uuid::Uuid", - "type_info": "Uuid" + "name": "created_by_email: String", + "type_info": "Varchar" }, { "ordinal": 4, @@ -54,5 +54,5 @@ null ] }, - "hash": "edd03e38c9450f6dd49202f66d3cce662fbfb4b44c3b0c0818d5b9518cce0f1a" + "hash": "331a2739381aed586b31c1d264beeaa09398ba6305a273a1c67f877421dffcfa" } diff --git a/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json b/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json deleted file mode 100644 index 6ef5686e67e..00000000000 --- a/.sqlx/query-3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "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": [ - { - "ordinal": 0, - "name": "created_at!: chrono::DateTime", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "3577a825b849ccd125309ed485c64124914c7c9d9203213837fbf2a62796fe9b" -} diff --git a/.sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json b/.sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json deleted file mode 100644 index 2c16edf501f..00000000000 --- a/.sqlx/query-3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT sa.catalog_name AS \"catalog_name!: String\"\n FROM public.refresh_tokens rt\n JOIN internal.service_accounts sa ON sa.user_id = rt.user_id\n WHERE rt.id = $1 AND rt.valid_for <> interval '0'\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "catalog_name!: String", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Macaddr8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "3a653a108488d0351464fc9d193bc23cde331e451da6a7aaf01be99c9f6e39e0" -} diff --git a/.sqlx/query-3b904e126ee14610dc18dfa4239099ebeff434f8fde46145f9c12ebafb372bb4.json b/.sqlx/query-3b904e126ee14610dc18dfa4239099ebeff434f8fde46145f9c12ebafb372bb4.json new file mode 100644 index 00000000000..4c2e9cd5a21 --- /dev/null +++ b/.sqlx/query-3b904e126ee14610dc18dfa4239099ebeff434f8fde46145f9c12ebafb372bb4.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n rt.user_id,\n sa.catalog_name AS \"catalog_name!: String\"\n FROM public.refresh_tokens rt\n JOIN internal.service_accounts sa ON sa.user_id = rt.user_id\n WHERE rt.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "catalog_name!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Macaddr8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "3b904e126ee14610dc18dfa4239099ebeff434f8fde46145f9c12ebafb372bb4" +} diff --git a/.sqlx/query-51177ad92eabdb931a92bde6ac8fd8949e59fac6d86a6fd41fc694c65085c636.json b/.sqlx/query-51177ad92eabdb931a92bde6ac8fd8949e59fac6d86a6fd41fc694c65085c636.json new file mode 100644 index 00000000000..c4f41e597a5 --- /dev/null +++ b/.sqlx/query-51177ad92eabdb931a92bde6ac8fd8949e59fac6d86a6fd41fc694c65085c636.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE public.refresh_tokens SET valid_for = interval '0' WHERE user_id = $1 AND valid_for <> interval '0'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "51177ad92eabdb931a92bde6ac8fd8949e59fac6d86a6fd41fc694c65085c636" +} diff --git a/.sqlx/query-814ee1a843b34fa540364dbc2b8a352c80a47c5ea4cc48b0c1c4e8f6d3801605.json b/.sqlx/query-814ee1a843b34fa540364dbc2b8a352c80a47c5ea4cc48b0c1c4e8f6d3801605.json new file mode 100644 index 00000000000..1cc8963d40a --- /dev/null +++ b/.sqlx/query-814ee1a843b34fa540364dbc2b8a352c80a47c5ea4cc48b0c1c4e8f6d3801605.json @@ -0,0 +1,91 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n g.user_id,\n g.object_role AS \"prefix!: models::Prefix\",\n g.capability AS \"capability!: models::Capability\",\n g.detail,\n g.created_at AS \"created_at!: chrono::DateTime\",\n g.updated_at AS \"updated_at!: chrono::DateTime\"\n FROM public.user_grants g\n WHERE g.user_id = ANY($1)\n ORDER BY g.object_role\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "prefix!: models::Prefix", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "capability!: models::Capability", + "type_info": { + "Custom": { + "name": "grant_capability", + "kind": { + "Enum": [ + "none", + "x_01", + "x_02", + "x_03", + "x_04", + "x_05", + "x_06", + "x_07", + "x_08", + "x_09", + "read", + "x_11", + "x_12", + "x_13", + "x_14", + "x_15", + "x_16", + "x_17", + "x_18", + "x_19", + "write", + "x_21", + "x_22", + "x_23", + "x_24", + "x_25", + "x_26", + "x_27", + "x_28", + "x_29", + "admin" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "detail", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!: chrono::DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!: chrono::DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false + ] + }, + "hash": "814ee1a843b34fa540364dbc2b8a352c80a47c5ea4cc48b0c1c4e8f6d3801605" +} diff --git a/.sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.json b/.sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.json deleted file mode 100644 index b878e8da61e..00000000000 --- a/.sqlx/query-88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "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 updated_at across\n -- its tokens that have actually been exchanged (uses > 0;\n -- revoked included). Each exchange bumps the token's\n -- updated_at, so the tokens are the single source of truth.\n (\n SELECT max(rt.updated_at)\n FROM public.refresh_tokens rt\n WHERE rt.user_id = sa.user_id\n AND rt.uses > 0\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": "88a0091ddd578a63076aa1b1a1f507e2fcd07527118fa01e1f8c6af923ac6506" -} diff --git a/.sqlx/query-98b945899f3acebf8253f917c9b3ca6c93f9b01e44608bb0bc6b72379e04152b.json b/.sqlx/query-98b945899f3acebf8253f917c9b3ca6c93f9b01e44608bb0bc6b72379e04152b.json new file mode 100644 index 00000000000..fa8185fca70 --- /dev/null +++ b/.sqlx/query-98b945899f3acebf8253f917c9b3ca6c93f9b01e44608bb0bc6b72379e04152b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO internal.service_accounts (user_id, catalog_name, created_by)\n VALUES ($1, $2::text::catalog_name, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "98b945899f3acebf8253f917c9b3ca6c93f9b01e44608bb0bc6b72379e04152b" +} diff --git a/.sqlx/query-d118150d28fb7d20ed82ed5bc7f972de8f41f33e600a2fabe77add09621350cb.json b/.sqlx/query-d118150d28fb7d20ed82ed5bc7f972de8f41f33e600a2fabe77add09621350cb.json new file mode 100644 index 00000000000..e2036034cd1 --- /dev/null +++ b/.sqlx/query-d118150d28fb7d20ed82ed5bc7f972de8f41f33e600a2fabe77add09621350cb.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n sa.user_id,\n sa.catalog_name AS \"catalog_name!: String\",\n creator.email AS \"created_by_email: String\",\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 updated_at across\n -- its tokens that have actually been exchanged (uses > 0;\n -- revoked included). Each exchange bumps the token's\n -- updated_at, so the tokens are the single source of truth.\n (\n SELECT max(rt.updated_at)\n FROM public.refresh_tokens rt\n WHERE rt.user_id = sa.user_id\n AND rt.uses > 0\n ) AS \"last_used_at: chrono::DateTime\"\n FROM internal.service_accounts sa\n LEFT JOIN auth.users creator ON creator.id = sa.created_by\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_email: String", + "type_info": "Varchar" + }, + { + "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, + true, + false, + false, + null + ] + }, + "hash": "d118150d28fb7d20ed82ed5bc7f972de8f41f33e600a2fabe77add09621350cb" +} diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index c8247a163f5..3230f0e5ffb 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -253,32 +253,20 @@ pub async fn exchange_refresh_token( id: models::Id, secret: String, } - #[derive(Debug, serde::Deserialize)] - struct GenerateTokenResponse { - access_token: String, - } let bearer = tokens::jwt::parse_base64(refresh_token)?; let bearer: RefreshToken = serde_json::from_slice(&bearer) .map_err(|err| tonic::Status::invalid_argument(format!("invalid bearer token: {err}")))?; - let response = sqlx::query!( - "select generate_access_token($1, $2) as token", - bearer.id as models::Id, - bearer.secret, - ) - .fetch_one(pg_pool) - .await - .map_err(|err| { - tonic::Status::unauthenticated(format!("failed to exchange refresh token: {err}")) - })?; - - 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}")) - })?; + // Reuse the same exchange-and-sanitize path as the POST /api/v1/auth/token + // endpoint. This path only needs a freshly minted access token to verify + // in-process; any refresh-token rotation in the response is irrelevant + // here, since single-use tokens are exchanged via that endpoint rather than + // presented as bearer credentials. + let response = + public::token_exchange::generate_access_token(pg_pool, bearer.id, &bearer.secret).await?; - Ok(access_token) + Ok(response.access_token) } /// Parse a data-plane claims token without verifying its signature. diff --git a/crates/control-plane-api/src/server/public/graphql/billing/mutations.rs b/crates/control-plane-api/src/server/public/graphql/billing/mutations.rs index a4833ef5dc4..4d6a189326f 100644 --- a/crates/control-plane-api/src/server/public/graphql/billing/mutations.rs +++ b/crates/control-plane-api/src/server/public/graphql/billing/mutations.rs @@ -82,7 +82,9 @@ impl BillingMutation { .client_secret .context("stripe setup intent response was missing client_secret")?; - Ok(CreateBillingSetupIntentPayload { client_secret }) + Ok(CreateBillingSetupIntentPayload { + client_secret: super::super::Sensitive::new(client_secret), + }) } async fn set_billing_payment_method( @@ -216,7 +218,7 @@ impl BillingMutation { #[derive(Debug, Clone, SimpleObject)] pub struct CreateBillingSetupIntentPayload { - client_secret: String, + client_secret: super::super::Sensitive, } #[derive(Debug, Clone, SimpleObject)] 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 545c1ee8729..dfc8d4a4885 100644 --- a/crates/control-plane-api/src/server/public/graphql/mod.rs +++ b/crates/control-plane-api/src/server/public/graphql/mod.rs @@ -38,11 +38,14 @@ mod live_specs; mod prefixes; mod publication_history; mod refresh_tokens; +mod scalars; mod service_accounts; pub mod status; mod storage_mappings; mod tenant; +pub(crate) use scalars::Sensitive; + /// Whether the current user holds `capability` on `name`, as a pure check /// against the request's authorization Snapshot. /// 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..fedea1ce274 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 @@ -1,10 +1,10 @@ -use super::TimestampCursor; +use super::{Sensitive, TimestampCursor}; use async_graphql::{Context, types::connection}; #[derive(Debug, Clone, async_graphql::SimpleObject)] pub struct RefreshTokenResult { pub id: models::Id, - pub secret: String, + pub secret: Sensitive, } #[derive(Debug, Clone, async_graphql::SimpleObject)] @@ -186,7 +186,7 @@ impl RefreshTokensMutation { Ok(RefreshTokenResult { id: row.id, - secret: row.secret, + secret: Sensitive::new(row.secret), }) } diff --git a/crates/control-plane-api/src/server/public/graphql/scalars.rs b/crates/control-plane-api/src/server/public/graphql/scalars.rs new file mode 100644 index 00000000000..44552253408 --- /dev/null +++ b/crates/control-plane-api/src/server/public/graphql/scalars.rs @@ -0,0 +1,32 @@ +//! Custom GraphQL scalars shared across the API. + +/// A secret the API returns to the caller — a bearer credential or similar +/// one-time secret. It serializes as a plain string so the caller receives the +/// real value, but is a distinct scalar in the schema so client tooling can +/// recognize and redact it: in logs and UIs, and above all before any value is +/// handed to a language model. +/// +/// Its `Debug` impl never prints the secret, so wrapping a field in `Sensitive` +/// also keeps the value out of server logs, traces, and error messages. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct Sensitive(pub String); + +impl Sensitive { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl std::fmt::Debug for Sensitive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Sensitive()") + } +} + +async_graphql::scalar!( + Sensitive, + "Sensitive", + "A secret returned by the API, such as a bearer credential. The value is \ + serialized as a string, but clients must treat it as sensitive: redact it \ + from logs and UIs, and never pass it to a language model." +); 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 f45bea99cb8..211fb10f4fe 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 @@ -1,13 +1,16 @@ -use super::TimestampCursor; +use super::{Sensitive, TimestampCursor}; use async_graphql::{Context, types::connection}; #[derive(Debug, Clone, async_graphql::SimpleObject)] pub struct ServiceAccount { pub catalog_name: models::Name, - pub created_by: uuid::Uuid, + /// Email of the user who created the account. Null if that user has since + /// been deleted or has no email on file. + pub created_by_email: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub last_used_at: Option>, + pub grants: Vec, pub tokens: Vec, } @@ -18,6 +21,18 @@ pub struct ServiceAccountGrantInput { pub capability: models::Capability, } +/// A user_grant held by a service account: the prefix it may act on and the +/// capability it holds there. An account's access is the union of its grants, +/// which may span multiple prefixes independent of its catalog_name anchor. +#[derive(Debug, Clone, async_graphql::SimpleObject)] +pub struct ServiceAccountGrant { + pub prefix: models::Prefix, + pub capability: models::Capability, + pub detail: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + /// A service-account credential: a multi-use refresh token owned by the account /// and minted by an administrator. The secret itself is returned only once at /// creation (see [`CreateServiceAccountTokenResult`]). @@ -25,7 +40,9 @@ pub struct ServiceAccountGrantInput { pub struct ServiceAccountTokenInfo { pub id: models::Id, pub detail: Option, - pub created_by: uuid::Uuid, + /// Email of the user who minted the token. Null if that user has since + /// been deleted or has no email on file. + pub created_by_email: Option, pub created_at: chrono::DateTime, pub expires_at: chrono::DateTime, pub last_used_at: Option>, @@ -36,7 +53,10 @@ pub struct CreateServiceAccountTokenResult { pub id: models::Id, /// The bearer credential, returned exactly once. Present it as an /// `Authorization: Bearer` token or exchange it at `POST /api/v1/auth/token`. - pub secret: String, + pub secret: Sensitive, + /// The owning account in its post-mint state, so the new token merges into + /// client caches without a follow-up query. + pub service_account: ServiceAccount, } pub type PaginatedServiceAccounts = connection::Connection< @@ -98,7 +118,7 @@ impl ServiceAccountsQuery { SELECT sa.user_id, sa.catalog_name AS "catalog_name!: String", - sa.created_by, + creator.email AS "created_by_email: String", sa.created_at AS "created_at!: chrono::DateTime", sa.updated_at AS "updated_at!: chrono::DateTime", -- The account's "last used" is the max updated_at across @@ -112,6 +132,7 @@ impl ServiceAccountsQuery { AND rt.uses > 0 ) AS "last_used_at: chrono::DateTime" FROM internal.service_accounts sa + LEFT JOIN auth.users creator ON creator.id = sa.created_by WHERE sa.catalog_name::text ^@ ANY($1) AND ($2::timestamptz IS NULL OR sa.created_at < $2) ORDER BY sa.created_at DESC @@ -129,64 +150,27 @@ impl ServiceAccountsQuery { let user_ids: Vec = sa_rows.iter().take(limit).map(|r| r.user_id).collect(); - // Tokens 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 `tokens`. - let token_rows = if user_ids.is_empty() { - vec![] - } else { - sqlx::query!( - r#" - SELECT - rt.id AS "id!: models::Id", - rt.user_id, - rt.detail, - rt.created_by AS "created_by!: uuid::Uuid", - rt.created_at AS "created_at!: chrono::DateTime", - (rt.updated_at + rt.valid_for) AS "expires_at!: chrono::DateTime", - CASE WHEN rt.uses > 0 THEN rt.updated_at END AS "last_used_at: chrono::DateTime" - FROM public.refresh_tokens rt - WHERE rt.user_id = ANY($1) - AND rt.valid_for <> interval '0' - ORDER BY rt.created_at DESC - "#, - &user_ids, - ) - .fetch_all(&env.pg_pool) - .await? - }; - - let mut tokens_by_sa: std::collections::HashMap< - uuid::Uuid, - Vec, - > = std::collections::HashMap::new(); - for tr in token_rows { - tokens_by_sa - .entry(tr.user_id) - .or_default() - .push(ServiceAccountTokenInfo { - id: tr.id, - detail: tr.detail, - created_by: tr.created_by, - created_at: tr.created_at, - expires_at: tr.expires_at, - last_used_at: tr.last_used_at, - }); - } + // Tokens and grants are batch-loaded for the whole page in one + // query each (no N+1). The tradeoff is that they load even when + // the caller selected neither field. + let mut tokens_by_sa = load_tokens_by_user(&env.pg_pool, &user_ids).await?; + let mut grants_by_sa = load_grants_by_user(&env.pg_pool, &user_ids).await?; let edges: Vec<_> = sa_rows .into_iter() .take(limit) .map(|r| { let tokens = tokens_by_sa.remove(&r.user_id).unwrap_or_default(); + let grants = grants_by_sa.remove(&r.user_id).unwrap_or_default(); connection::Edge::new( TimestampCursor(r.created_at), ServiceAccount { catalog_name: models::Name::new(&r.catalog_name), - created_by: r.created_by, + created_by_email: r.created_by_email, created_at: r.created_at, updated_at: r.updated_at, last_used_at: r.last_used_at, + grants, tokens, }, ) @@ -310,17 +294,16 @@ impl ServiceAccountsMutation { .await .map_err(duplicate_err)?; - let now = sqlx::query_scalar!( + sqlx::query!( r#" 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(), claims.sub, ) - .fetch_one(&mut *txn) + .execute(&mut *txn) .await .map_err(duplicate_err)?; @@ -345,14 +328,11 @@ impl ServiceAccountsMutation { "created service account" ); - Ok(ServiceAccount { - catalog_name, - created_by: claims.sub, - created_at: now, - updated_at: now, - last_used_at: None, - tokens: vec![], - }) + // Read the account back through the shared loader so the create + // response is byte-for-byte what a subsequent query returns — + // including createdByEmail resolved from auth.users rather than the + // caller's token claims. + load_service_account(&env.pg_pool, sa_user_id).await } /// Add a user_grant to a service account. @@ -369,7 +349,7 @@ impl ServiceAccountsMutation { catalog_name: models::Name, prefix: models::Prefix, capability: models::Capability, - ) -> async_graphql::Result { + ) -> async_graphql::Result { let env = ctx.data::()?; let claims = env.claims()?; @@ -399,8 +379,12 @@ impl ServiceAccountsMutation { let user_id = resolve_service_account(&env.pg_pool, catalog_name.as_str()).await?; + // Overwrite rather than upsert: addServiceAccountGrant replaces the + // grant's capability outright, so a manager can narrow an existing + // grant (e.g. admin -> read) in a single call. `upsert_user_grant` + // would only ever raise the capability, silently ignoring a downgrade. let mut txn = env.pg_pool.begin().await?; - crate::grants::upsert_user_grant( + crate::grants::overwrite_user_grant( user_id, prefix.as_str(), capability, @@ -419,22 +403,26 @@ impl ServiceAccountsMutation { "added service account grant" ); - Ok(true) + load_service_account(&env.pg_pool, user_id).await } - /// Remove a user_grant from a service account. + /// Remove a user_grant from a service account, returning the account in its + /// post-removal state. /// /// 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 /// prefixes they don't themselves administer. + /// + /// Removal is idempotent: removing a grant the account doesn't hold is a + /// no-op that returns the unchanged account rather than an error. async fn remove_service_account_grant( &self, ctx: &Context<'_>, catalog_name: models::Name, prefix: models::Prefix, - ) -> async_graphql::Result { + ) -> async_graphql::Result { let env = ctx.data::()?; let claims = env.claims()?; @@ -455,19 +443,56 @@ impl ServiceAccountsMutation { .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, + removed = deleted.rows_affected(), %claims.sub, "removed service account grant" ); - Ok(true) + load_service_account(&env.pg_pool, user_id).await + } + + /// Remove ALL user_grants from a service account, stripping its access in + /// one call and returning the account with `grants: []`. + /// + /// The caller must manage the service account (ManageServiceAccount on its + /// catalog name). As with removeServiceAccountGrant, no capability on the + /// grants' prefixes is required: removal only narrows access, so a manager + /// may clear grants to prefixes they don't themselves administer. Clearing + /// an account that already has no grants is an idempotent no-op. + async fn remove_all_service_account_grants( + &self, + ctx: &Context<'_>, + catalog_name: models::Name, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + super::verify_authorization( + env, + catalog_name.as_str(), + models::authz::Capability::ManageServiceAccount, + ) + .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", user_id,) + .execute(&env.pg_pool) + .await?; + + tracing::info!( + %user_id, + %catalog_name, + removed = deleted.rows_affected(), + %claims.sub, + "removed all service account grants" + ); + + load_service_account(&env.pg_pool, user_id).await } /// Mint a credential for a service account. @@ -581,46 +606,59 @@ impl ServiceAccountsMutation { "created service account token" ); - Ok(CreateServiceAccountTokenResult { id: row.id, secret }) + let service_account = load_service_account(&env.pg_pool, user_id).await?; + + Ok(CreateServiceAccountTokenResult { + id: row.id, + secret: Sensitive::new(secret), + service_account, + }) } - /// Revoke a service-account token. + /// Revoke a service-account token, returning the owning account in its + /// post-revocation state. /// /// The caller must have ManageServiceAccount capability on the owning service - /// account's catalog name. + /// account's catalog name. The account is resolved from the token id. /// /// Rather than deleting the row, we zero its `valid_for` interval, which /// makes the token inert (it fails the exchange's expiry check and is - /// excluded from listings) while preserving the audit trail. Already-revoked - /// tokens are treated as not found. + /// excluded from listings) while preserving the audit trail. Revocation is + /// idempotent: revoking an already-inert token is a no-op that still returns + /// the account. Only an id that maps to no service-account token errors. async fn revoke_service_account_token( &self, ctx: &Context<'_>, id: models::Id, - ) -> async_graphql::Result { + ) -> async_graphql::Result { let env = ctx.data::()?; let claims = env.claims()?; - let catalog_name = sqlx::query_scalar!( + // Resolve the owning account regardless of the token's current validity, + // so revoking an already-inert token still finds it and is a no-op (the + // UPDATE below only touches still-active tokens). + let owner = sqlx::query!( r#" - SELECT sa.catalog_name AS "catalog_name!: String" + SELECT + rt.user_id, + sa.catalog_name AS "catalog_name!: String" FROM public.refresh_tokens rt JOIN internal.service_accounts sa ON sa.user_id = rt.user_id - WHERE rt.id = $1 AND rt.valid_for <> interval '0' + WHERE rt.id = $1 "#, id as models::Id, ) .fetch_optional(&env.pg_pool) .await?; - let catalog_name = match catalog_name { - Some(name) => name, + let owner = match owner { + Some(owner) => owner, None => return Err(async_graphql::Error::new("service account token not found")), }; super::verify_authorization( env, - &catalog_name, + &owner.catalog_name, models::authz::Capability::ManageServiceAccount, ) .await?; @@ -635,12 +673,57 @@ impl ServiceAccountsMutation { tracing::info!( token_id = %id, - service_account = %catalog_name, + service_account = %owner.catalog_name, %claims.sub, "revoked service account token" ); - Ok(true) + load_service_account(&env.pg_pool, owner.user_id).await + } + + /// Revoke ALL of a service account's tokens at once — the credential kill + /// switch — returning the account with no active tokens. + /// + /// The caller must have ManageServiceAccount on the account's catalog name. + /// Like revokeServiceAccountToken, each token is made inert by zeroing its + /// `valid_for` interval (preserving the audit trail) rather than deleted; + /// already-revoked tokens are skipped. A service account's user_id only ever + /// owns its own minted credentials, so this targets exactly those. An + /// account with no active tokens is an idempotent no-op. + async fn revoke_all_service_account_tokens( + &self, + ctx: &Context<'_>, + catalog_name: models::Name, + ) -> async_graphql::Result { + let env = ctx.data::()?; + let claims = env.claims()?; + + super::verify_authorization( + env, + catalog_name.as_str(), + models::authz::Capability::ManageServiceAccount, + ) + .await?; + + let user_id = resolve_service_account(&env.pg_pool, catalog_name.as_str()).await?; + + let revoked = sqlx::query!( + "UPDATE public.refresh_tokens SET valid_for = interval '0' \ + WHERE user_id = $1 AND valid_for <> interval '0'", + user_id, + ) + .execute(&env.pg_pool) + .await?; + + tracing::info!( + %user_id, + %catalog_name, + revoked = revoked.rows_affected(), + %claims.sub, + "revoked all service account tokens" + ); + + load_service_account(&env.pg_pool, user_id).await } } @@ -667,6 +750,152 @@ async fn resolve_service_account( row.ok_or_else(|| async_graphql::Error::new("service account not found")) } +/// Load a single service account in its current state — its profile fields +/// plus its grants and active tokens. Mutations return this so clients +/// reconcile by catalogName without a follow-up query. Errors if no account +/// is homed at `user_id` (e.g. it was concurrently deleted). +async fn load_service_account( + pg_pool: &sqlx::PgPool, + user_id: uuid::Uuid, +) -> async_graphql::Result { + let row = sqlx::query!( + r#" + SELECT + sa.catalog_name AS "catalog_name!: String", + creator.email AS "created_by_email: String", + sa.created_at AS "created_at!: chrono::DateTime", + sa.updated_at AS "updated_at!: chrono::DateTime", + -- See the listing query: "last used" is the max updated_at across + -- the account's exchanged tokens (uses > 0; revoked included). + ( + SELECT max(rt.updated_at) + FROM public.refresh_tokens rt + WHERE rt.user_id = sa.user_id + AND rt.uses > 0 + ) AS "last_used_at: chrono::DateTime" + FROM internal.service_accounts sa + LEFT JOIN auth.users creator ON creator.id = sa.created_by + WHERE sa.user_id = $1 + "#, + user_id, + ) + .fetch_optional(pg_pool) + .await? + .ok_or_else(|| async_graphql::Error::new("service account not found"))?; + + // A single account is the degenerate one-element case of the batch loaders. + let mut grants_by_sa = load_grants_by_user(pg_pool, &[user_id]).await?; + let mut tokens_by_sa = load_tokens_by_user(pg_pool, &[user_id]).await?; + + Ok(ServiceAccount { + catalog_name: models::Name::new(&row.catalog_name), + created_by_email: row.created_by_email, + created_at: row.created_at, + updated_at: row.updated_at, + last_used_at: row.last_used_at, + grants: grants_by_sa.remove(&user_id).unwrap_or_default(), + tokens: tokens_by_sa.remove(&user_id).unwrap_or_default(), + }) +} + +/// Batch-load the grants of a set of service accounts, keyed by user_id and +/// ordered by prefix within each account. An account's reach is the union of +/// its grants, so they're a list rather than a single capability. +async fn load_grants_by_user( + pg_pool: &sqlx::PgPool, + user_ids: &[uuid::Uuid], +) -> sqlx::Result>> { + let mut by_user: std::collections::HashMap> = + std::collections::HashMap::new(); + if user_ids.is_empty() { + return Ok(by_user); + } + + let rows = sqlx::query!( + r#" + SELECT + g.user_id, + g.object_role AS "prefix!: models::Prefix", + g.capability AS "capability!: models::Capability", + g.detail, + g.created_at AS "created_at!: chrono::DateTime", + g.updated_at AS "updated_at!: chrono::DateTime" + FROM public.user_grants g + WHERE g.user_id = ANY($1) + ORDER BY g.object_role + "#, + user_ids, + ) + .fetch_all(pg_pool) + .await?; + + for gr in rows { + by_user + .entry(gr.user_id) + .or_default() + .push(ServiceAccountGrant { + prefix: gr.prefix, + capability: gr.capability, + detail: gr.detail, + created_at: gr.created_at, + updated_at: gr.updated_at, + }); + } + + Ok(by_user) +} + +/// Batch-load the active (non-revoked) tokens of a set of service accounts, +/// keyed by user_id and newest first. Revoked tokens (valid_for zeroed) are +/// excluded, matching the listing surface. +async fn load_tokens_by_user( + pg_pool: &sqlx::PgPool, + user_ids: &[uuid::Uuid], +) -> sqlx::Result>> { + let mut by_user: std::collections::HashMap> = + std::collections::HashMap::new(); + if user_ids.is_empty() { + return Ok(by_user); + } + + let rows = sqlx::query!( + r#" + SELECT + rt.id AS "id!: models::Id", + rt.user_id, + rt.detail, + creator.email AS "created_by_email: String", + rt.created_at AS "created_at!: chrono::DateTime", + (rt.updated_at + rt.valid_for) AS "expires_at!: chrono::DateTime", + CASE WHEN rt.uses > 0 THEN rt.updated_at END AS "last_used_at: chrono::DateTime" + FROM public.refresh_tokens rt + LEFT JOIN auth.users creator ON creator.id = rt.created_by + WHERE rt.user_id = ANY($1) + AND rt.valid_for <> interval '0' + ORDER BY rt.created_at DESC + "#, + user_ids, + ) + .fetch_all(pg_pool) + .await?; + + for tr in rows { + by_user + .entry(tr.user_id) + .or_default() + .push(ServiceAccountTokenInfo { + id: tr.id, + detail: tr.detail, + created_by_email: tr.created_by_email, + created_at: tr.created_at, + expires_at: tr.expires_at, + last_used_at: tr.last_used_at, + }); + } + + Ok(by_user) +} + #[cfg(test)] mod test { use crate::test_server; @@ -709,10 +938,11 @@ mod test { grants: $grants ) { catalogName - createdBy + createdByEmail createdAt updatedAt lastUsedAt + grants { prefix capability detail createdAt updatedAt } tokens { id } } }"#, @@ -739,6 +969,19 @@ mod test { assert_eq!(sa["catalogName"], "aliceCo/ci-deploy-bot"); assert_eq!(sa["tokens"].as_array().unwrap().len(), 0); + // The create response echoes the seeded grants in request order, each + // carrying the capability, the standard "service account grant" detail, + // and creation timestamps. + let created_grants = sa["grants"].as_array().unwrap(); + assert_eq!(created_grants.len(), 2, "both seeded grants returned: {sa}"); + assert_eq!(created_grants[0]["prefix"], "aliceCo/"); + assert_eq!(created_grants[0]["capability"], "admin"); + assert_eq!(created_grants[0]["detail"], "service account grant"); + assert!(created_grants[0]["createdAt"].is_string()); + assert!(created_grants[0]["updatedAt"].is_string()); + assert_eq!(created_grants[1]["prefix"], "aliceCo/data/"); + assert_eq!(created_grants[1]["capability"], "read"); + // === A catalog name is unique to one service account === // A second account cannot claim the same handle, even for an authorized // caller. @@ -769,12 +1012,13 @@ mod test { 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. + // Provenance and timestamp fields are populated on creation: + // createdByEmail is the creator's email resolved from auth.users (alice, + // per the fixture), 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}" + sa["createdByEmail"], "alice@example.com", + "createdByEmail should be the creator's email from auth.users: {create_response}" ); assert!(sa["createdAt"].is_string(), "createdAt should be set: {sa}"); assert!(sa["updatedAt"].is_string(), "updatedAt should be set: {sa}"); @@ -885,6 +1129,10 @@ mod test { ) { id secret + serviceAccount { + catalogName + tokens { id detail createdByEmail } + } } }"#, "variables": { @@ -915,6 +1163,23 @@ mod test { !secret.starts_with("flow_sa_"), "credential should be the refresh-token bearer form: {secret}" ); + // The result embeds the owning account in its post-mint state, so a + // client can merge the new token into its cache without a refetch: the + // just-minted token appears among its tokens. + let minted_account = &token_data["serviceAccount"]; + assert_eq!(minted_account["catalogName"], "aliceCo/ci-deploy-bot"); + let minted_tokens = minted_account["tokens"].as_array().unwrap(); + assert_eq!( + minted_tokens.len(), + 1, + "the new token is present: {create_token}" + ); + assert_eq!(minted_tokens[0]["id"], token_id); + assert_eq!(minted_tokens[0]["detail"], "GitHub Actions"); + assert_eq!( + minted_tokens[0]["createdByEmail"], "alice@example.com", + "token createdByEmail should be the minting admin's email: {create_token}" + ); // === valid_for validation === // Each case must be rejected, and the error message identifies the @@ -982,11 +1247,13 @@ mod test { edges { node { catalogName + createdByEmail lastUsedAt + grants { prefix capability detail } tokens { id detail - createdBy + createdByEmail createdAt expiresAt lastUsedAt @@ -1005,12 +1272,17 @@ mod test { .expect("should have edges"); assert_eq!(edges.len(), 1); assert_eq!(edges[0]["node"]["catalogName"], "aliceCo/ci-deploy-bot"); + // The listing resolves the creator's email via the auth.users join. + assert_eq!( + edges[0]["node"]["createdByEmail"], "alice@example.com", + "listing should resolve createdByEmail from auth.users: {list}" + ); let listed_token = &edges[0]["node"]["tokens"][0]; assert_eq!(edges[0]["node"]["tokens"].as_array().unwrap().len(), 1); assert_eq!(listed_token["detail"], "GitHub Actions"); assert_eq!( - listed_token["createdBy"], "11111111-1111-1111-1111-111111111111", - "token createdBy should be the calling admin: {list}" + listed_token["createdByEmail"], "alice@example.com", + "token createdByEmail should be the minting admin's email: {list}" ); assert!( listed_token["createdAt"].is_string() && listed_token["expiresAt"].is_string(), @@ -1026,6 +1298,16 @@ mod test { edges[0]["node"]["lastUsedAt"].is_string(), "account lastUsedAt should be derived from its tokens' use: {list}" ); + // The read-side grants resolver batch-loads from user_grants, ordered + // by prefix: the two seeded grants come back with their persisted + // capability and detail. + let listed_grants = edges[0]["node"]["grants"].as_array().unwrap(); + assert_eq!(listed_grants.len(), 2, "both grants listed: {list}"); + assert_eq!(listed_grants[0]["prefix"], "aliceCo/"); + assert_eq!(listed_grants[0]["capability"], "admin"); + assert_eq!(listed_grants[0]["detail"], "service account grant"); + assert_eq!(listed_grants[1]["prefix"], "aliceCo/data/"); + assert_eq!(listed_grants[1]["capability"], "read"); // Bob sees no service accounts. let bob_list: serde_json::Value = server @@ -1069,12 +1351,17 @@ mod test { ); // === Revoke the token === + // The mutation returns the owning account in its post-revocation state: + // the revoked token drops out of its active tokens, leaving none. let revoke: serde_json::Value = server .graphql( &serde_json::json!({ "query": r#" mutation($id: Id!) { - revokeServiceAccountToken(id: $id) + revokeServiceAccountToken(id: $id) { + catalogName + tokens { id } + } }"#, "variables": { "id": token_id } }), @@ -1086,6 +1373,18 @@ mod test { revoke["errors"].is_null(), "revoke should succeed: {revoke}" ); + assert_eq!( + revoke["data"]["revokeServiceAccountToken"]["catalogName"], + "aliceCo/ci-deploy-bot" + ); + assert_eq!( + revoke["data"]["revokeServiceAccountToken"]["tokens"] + .as_array() + .unwrap() + .len(), + 0, + "the revoked token should no longer be active: {revoke}" + ); // The row is preserved with valid_for zeroed — revocation is a soft // delete for audit purposes. The listing assertion below can't observe @@ -1099,13 +1398,18 @@ mod test { .unwrap(); assert!(zeroed, "revocation must zero valid_for, not delete the row"); - // Revoking again fails: already-revoked tokens are treated as not found. + // Revoking again is an idempotent no-op: the token still exists (just + // inert), so it resolves the account and returns it unchanged rather + // than erroring. Only an id mapping to no service-account token errors. let revoke_again: serde_json::Value = server .graphql( &serde_json::json!({ "query": r#" mutation($id: Id!) { - revokeServiceAccountToken(id: $id) + revokeServiceAccountToken(id: $id) { + catalogName + tokens { id } + } }"#, "variables": { "id": token_id } }), @@ -1113,11 +1417,37 @@ mod test { ) .await; assert!( - revoke_again["errors"][0]["message"] + revoke_again["errors"].is_null(), + "re-revoking an inert token should be an idempotent no-op: {revoke_again}" + ); + assert_eq!( + revoke_again["data"]["revokeServiceAccountToken"]["tokens"] + .as_array() + .unwrap() + .len(), + 0, + "no active tokens remain after re-revoking: {revoke_again}" + ); + + // An unknown token id still errors: there's no account to resolve. + let revoke_unknown: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($id: Id!) { + revokeServiceAccountToken(id: $id) { catalogName } + }"#, + "variables": { "id": "00:00:00:00:00:00:00:01" } + }), + Some(&alice_token), + ) + .await; + assert!( + revoke_unknown["errors"][0]["message"] .as_str() .unwrap_or_default() .contains("service account token not found"), - "re-revoking should report not found: {revoke_again}" + "an unknown token id should report not found: {revoke_unknown}" ); // The revoked token no longer authenticates — immediately, since every @@ -1202,7 +1532,7 @@ mod test { &serde_json::json!({ "query": r#" mutation { - addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/", capability: read) + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/", capability: read) { catalogName } }"# }), Some(&bob_token), @@ -1220,7 +1550,7 @@ mod test { &serde_json::json!({ "query": r#" mutation { - addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/", capability: read) + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/", capability: read) { catalogName } }"# }), Some(&alice_token), @@ -1231,13 +1561,17 @@ mod test { "a grant to an unadministered prefix must be rejected: {add_foreign}" ); - // Happy path: alice manages the account and admins aliceCo/ops/. + // Happy path: alice manages the account and admins aliceCo/ops/. The + // mutation returns the account in its post-add state, so the new grant + // is present in the returned grants (ordered by prefix). let add: serde_json::Value = server .graphql( &serde_json::json!({ "query": r#" mutation { - addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/ops/", capability: write) + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/ops/", capability: write) { + grants { prefix capability } + } }"# }), Some(&alice_token), @@ -1245,6 +1579,64 @@ mod test { .await; assert!(add["errors"].is_null(), "add grant should succeed: {add}"); assert_eq!(grant_count(&pool, &sa_user_id).await, 3); + let added_grants = add["data"]["addServiceAccountGrant"]["grants"] + .as_array() + .unwrap(); + assert_eq!(added_grants.len(), 3, "the new grant is returned: {add}"); + assert!( + added_grants + .iter() + .any(|g| g["prefix"] == "aliceCo/ops/" && g["capability"] == "write"), + "the added aliceCo/ops/ write grant should be present: {add}" + ); + + // aliceCo/ops/ now holds `write`. Adding a LOWER capability overwrites + // the existing grant in place: addServiceAccountGrant replaces the + // capability rather than only ever raising it, so a manager can narrow + // a grant in one call without removing it first. + let downgrade: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation { + addServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/ops/", capability: read) { + grants { prefix capability } + } + }"# + }), + Some(&alice_token), + ) + .await; + assert!( + downgrade["errors"].is_null(), + "narrowing a grant should succeed by overwriting it: {downgrade}" + ); + // The returned account reflects the narrowed capability in place. + let downgraded_grants = downgrade["data"]["addServiceAccountGrant"]["grants"] + .as_array() + .unwrap(); + assert!( + downgraded_grants + .iter() + .any(|g| g["prefix"] == "aliceCo/ops/" && g["capability"] == "read"), + "aliceCo/ops/ should now be read in the returned account: {downgrade}" + ); + // The grant is overwritten in place: capability lowered to read, with + // no additional row. + let ops_capability = sqlx::query_scalar!( + r#"SELECT capability AS "capability!: models::Capability" + FROM public.user_grants WHERE user_id = $1 AND object_role = 'aliceCo/ops/'"#, + uuid::Uuid::parse_str(&sa_user_id).unwrap(), + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + ops_capability, + models::Capability::Read, + "the grant should be overwritten to the requested lower capability" + ); + 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 @@ -1261,7 +1653,9 @@ mod test { &serde_json::json!({ "query": r#" mutation { - removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") { + grants { prefix } + } }"# }), Some(&alice_token), @@ -1272,25 +1666,39 @@ mod test { "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); + // The returned account no longer carries the bobCo/ grant. + assert!( + remove_foreign["data"]["removeServiceAccountGrant"]["grants"] + .as_array() + .unwrap() + .iter() + .all(|g| g["prefix"] != "bobCo/"), + "bobCo/ should be gone from the returned account: {remove_foreign}" + ); - // Removing an absent grant reports not found. + // Removing an absent grant is an idempotent no-op: it returns the + // unchanged account rather than erroring. let remove_again: serde_json::Value = server .graphql( &serde_json::json!({ "query": r#" mutation { - removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "bobCo/") { + grants { prefix } + } }"# }), 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}" + remove_again["errors"].is_null(), + "re-removing an absent grant should be an idempotent no-op: {remove_again}" + ); + assert_eq!( + grant_count(&pool, &sa_user_id).await, + 3, + "a no-op removal must not change the grant set" ); // Bob cannot remove grants of an account he doesn't manage. @@ -1299,13 +1707,154 @@ mod test { &serde_json::json!({ "query": r#" mutation { - removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/data/") + removeServiceAccountGrant(catalogName: "aliceCo/ci-deploy-bot", prefix: "aliceCo/data/") { catalogName } }"# }), Some(&bob_token), ) .await; assert!(remove_unmanaged["errors"].is_array()); + + // === Kill switches: revoke all tokens, remove all grants === + + // Mint two fresh credentials so revokeAllServiceAccountTokens has + // something to act on (the token minted earlier was already revoked). + for detail in ["ci-one", "ci-two"] { + let minted: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#" + mutation($detail: String!) { + createServiceAccountToken(catalogName: "aliceCo/ci-deploy-bot", detail: $detail, validFor: "P30D") { id } + }"#, + "variables": { "detail": detail } + }), + Some(&alice_token), + ) + .await; + assert!(minted["errors"].is_null(), "mint should succeed: {minted}"); + } + + // A non-manager cannot trip the kill switch. + let bob_revoke_all: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { revokeAllServiceAccountTokens(catalogName: "aliceCo/ci-deploy-bot") { catalogName } }"# + }), + Some(&bob_token), + ) + .await; + assert!( + bob_revoke_all["errors"].is_array(), + "a non-manager must not revoke all tokens: {bob_revoke_all}" + ); + + // The manager revokes both active tokens in one call; the returned + // account has no active tokens left. + let revoke_all: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { revokeAllServiceAccountTokens(catalogName: "aliceCo/ci-deploy-bot") { tokens { id } } }"# + }), + Some(&alice_token), + ) + .await; + assert!( + revoke_all["errors"].is_null(), + "revoke all should succeed: {revoke_all}" + ); + assert_eq!( + revoke_all["data"]["revokeAllServiceAccountTokens"]["tokens"] + .as_array() + .unwrap() + .len(), + 0, + "no active tokens should remain after the kill switch: {revoke_all}" + ); + + // A second call is an idempotent no-op (not an error), and the account + // still reports no active tokens — proving the first call persisted. + let revoke_all_again: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { revokeAllServiceAccountTokens(catalogName: "aliceCo/ci-deploy-bot") { tokens { id } } }"# + }), + Some(&alice_token), + ) + .await; + assert!( + revoke_all_again["errors"].is_null(), + "revoking all again should be an idempotent no-op: {revoke_all_again}" + ); + assert_eq!( + revoke_all_again["data"]["revokeAllServiceAccountTokens"]["tokens"] + .as_array() + .unwrap() + .len(), + 0, + "still no active tokens on the second call: {revoke_all_again}" + ); + + // A non-manager cannot strip an account's grants either. + let bob_remove_all: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { removeAllServiceAccountGrants(catalogName: "aliceCo/ci-deploy-bot") { catalogName } }"# + }), + Some(&bob_token), + ) + .await; + assert!( + bob_remove_all["errors"].is_array(), + "a non-manager must not remove all grants: {bob_remove_all}" + ); + + // The manager strips every grant in one call; the returned account has + // an empty grant set. It currently holds three: aliceCo/, aliceCo/data/, + // and aliceCo/ops/. + let remove_all: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { removeAllServiceAccountGrants(catalogName: "aliceCo/ci-deploy-bot") { grants { prefix } } }"# + }), + Some(&alice_token), + ) + .await; + assert!( + remove_all["errors"].is_null(), + "remove all should succeed: {remove_all}" + ); + assert_eq!( + remove_all["data"]["removeAllServiceAccountGrants"]["grants"] + .as_array() + .unwrap() + .len(), + 0, + "the returned account should have no grants: {remove_all}" + ); + assert_eq!(grant_count(&pool, &sa_user_id).await, 0); + + // A second call is an idempotent no-op. + let remove_all_again: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { removeAllServiceAccountGrants(catalogName: "aliceCo/ci-deploy-bot") { grants { prefix } } }"# + }), + Some(&alice_token), + ) + .await; + assert!( + remove_all_again["errors"].is_null(), + "removing all again should be an idempotent no-op: {remove_all_again}" + ); + assert_eq!( + remove_all_again["data"]["removeAllServiceAccountGrants"]["grants"] + .as_array() + .unwrap() + .len(), + 0, + "still no grants on the second call: {remove_all_again}" + ); } /// The management gates accept the fine-grained capabilities the feature @@ -1360,7 +1909,7 @@ mod test { createServiceAccount( catalogName: "aliceCo/team-bot" grants: $grants - ) { catalogName createdBy } + ) { catalogName createdByEmail } }"#, "variables": { "grants": [ { "prefix": "aliceCo/data/", "capability": "read" } ] @@ -1374,9 +1923,8 @@ mod test { "a TeamAdmin without full Admin should create a service account: {create}" ); assert_eq!( - create["data"]["createServiceAccount"]["createdBy"], - "33333333-3333-3333-3333-333333333333", - "createdBy should be the calling team admin: {create}" + create["data"]["createServiceAccount"]["createdByEmail"], "carol@example.test", + "createdByEmail should be the calling team admin's email: {create}" ); // The anchor-only mutation createServiceAccountToken also accepts ManageServiceAccount. @@ -1403,7 +1951,7 @@ mod test { &serde_json::json!({ "query": r#" mutation { - addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "aliceCo/ops/", capability: write) + addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "aliceCo/ops/", capability: write) { catalogName } }"# }), Some(&carol_token), @@ -1423,7 +1971,7 @@ mod test { &serde_json::json!({ "query": r#" mutation { - addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "bobCo/", capability: read) + addServiceAccountGrant(catalogName: "aliceCo/team-bot", prefix: "bobCo/", capability: read) { catalogName } }"# }), Some(&carol_token), 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..b14534410cf 100644 --- a/crates/control-plane-api/src/server/public/token_exchange.rs +++ b/crates/control-plane-api/src/server/public/token_exchange.rs @@ -33,58 +33,61 @@ pub async fn handle_post_token( TokenRequest::RefreshToken { refresh_token_id, secret, - } => exchange_refresh_token(&app, refresh_token_id, &secret).await, + } => { + let response = generate_access_token(&app.pg_pool, refresh_token_id, &secret).await?; + Ok(axum::Json(response)) + } } } -// Exchange a refresh token for an access token. +// Exchange a refresh token for an access token by calling the SQL +// `generate_access_token` function and returning its parsed response. +// +// Shared by the `POST /api/v1/auth/token` endpoint (above) and the +// bearer-credential authentication path +// (`crate::server::exchange_refresh_token`), so the credential-error +// sanitization below lives in exactly one place rather than being duplicated — +// and kept in sync — across both. // -// This delegates to the SQL `generate_access_token` function transitionally: -// existing clients (flowctl via flow-client) still authenticate against the -// PostgREST `/rpc/generate_access_token` surface, so the function must keep -// working unchanged. The plan is to migrate those callers onto this endpoint -// and then retire the SQL function, folding refresh-token minting into an -// application-layer path. New clients should target this endpoint rather -// than PostgREST. -async fn exchange_refresh_token( - app: &crate::App, +// The SQL delegation is transitional: existing clients (flowctl via +// flow-client) still authenticate against the PostgREST +// `/rpc/generate_access_token` surface, so the function must keep working +// unchanged. The plan is to migrate those callers onto this endpoint and then +// retire the SQL function, folding refresh-token minting into an +// application-layer path. New clients should target this endpoint rather than +// PostgREST. +pub(crate) async fn generate_access_token( + pg_pool: &sqlx::PgPool, refresh_token_id: models::Id, secret: &str, -) -> Result, crate::ApiError> { +) -> tonic::Result { let response = sqlx::query!( "select generate_access_token($1, $2) as token", refresh_token_id as models::Id, secret, ) - .fetch_one(&app.pg_pool) + .fetch_one(pg_pool) .await .map_err(|err| { // `generate_access_token` signals an unusable credential (unknown id, - // bad secret, or expired token) by `raise`-ing, which surfaces as - // SQLSTATE P0001. Those are the only legitimate 401s, and we collapse - // them into a single generic message so the response doesn't reveal - // which check failed. Any other error is an internal fault: log the - // detail and return 500. - // - // This will change again when we retire generate_access_token and implement the logic in the application. + // bad secret, or expired/revoked token) by `raise`-ing, which surfaces + // as SQLSTATE P0001. Those are the only legitimate 401s, and we collapse + // them into a single generic message so the response neither reveals + // which check failed nor leaks the raw DB error. Any other error is an + // internal fault: log the detail and return 500. if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("P0001") { - crate::ApiError::Status(tonic::Status::unauthenticated( - "invalid, expired, or unknown refresh token", - )) + tonic::Status::unauthenticated("invalid, expired, or unknown refresh token") } else { tracing::error!(?err, "failed to exchange refresh token"); - crate::ApiError::Status(tonic::Status::internal("failed to exchange refresh token")) + tonic::Status::internal("failed to exchange refresh token") } })?; - let parsed: TokenResponse = serde_json::from_value(response.token.unwrap_or_default()) - .map_err(|err| { - tracing::error!( - ?err, - "generate_access_token returned an unparseable response" - ); - crate::ApiError::Status(tonic::Status::internal("invalid token response")) - })?; - - Ok(axum::Json(parsed)) + serde_json::from_value(response.token.unwrap_or_default()).map_err(|err| { + tracing::error!( + ?err, + "generate_access_token returned an unparseable response" + ); + tonic::Status::internal("invalid token response") + }) } diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 1afca2e106c..baa6ad42194 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -661,7 +661,7 @@ type Controller { } type CreateBillingSetupIntentPayload { - clientSecret: String! + clientSecret: Sensitive! } type CreateServiceAccountTokenResult { @@ -670,7 +670,12 @@ type CreateServiceAccountTokenResult { The bearer credential, returned exactly once. Present it as an `Authorization: Bearer` token or exchange it at `POST /api/v1/auth/token`. """ - secret: String! + secret: Sensitive! + """ + The owning account in its post-mint state, so the new token merges into + client caches without a follow-up query. + """ + serviceAccount: ServiceAccount! } """ @@ -1348,17 +1353,32 @@ type MutationRoot { PostgREST; when it migrates to GraphQL it should gate on this same CreateGrant capability.) """ - addServiceAccountGrant(catalogName: Name!, prefix: Prefix!, capability: Capability!): Boolean! + addServiceAccountGrant(catalogName: Name!, prefix: Prefix!, capability: Capability!): ServiceAccount! """ - Remove a user_grant from a service account. + Remove a user_grant from a service account, returning the account in its + post-removal state. 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 prefixes they don't themselves administer. + + Removal is idempotent: removing a grant the account doesn't hold is a + no-op that returns the unchanged account rather than an error. + """ + removeServiceAccountGrant(catalogName: Name!, prefix: Prefix!): ServiceAccount! + """ + Remove ALL user_grants from a service account, stripping its access in + one call and returning the account with `grants: []`. + + The caller must manage the service account (ManageServiceAccount on its + catalog name). As with removeServiceAccountGrant, no capability on the + grants' prefixes is required: removal only narrows access, so a manager + may clear grants to prefixes they don't themselves administer. Clearing + an account that already has no grants is an idempotent no-op. """ - removeServiceAccountGrant(catalogName: Name!, prefix: Prefix!): Boolean! + removeAllServiceAccountGrants(catalogName: Name!): ServiceAccount! """ Mint a credential for a service account. @@ -1378,17 +1398,31 @@ type MutationRoot { validFor: String! ): CreateServiceAccountTokenResult! """ - Revoke a service-account token. + Revoke a service-account token, returning the owning account in its + post-revocation state. The caller must have ManageServiceAccount capability on the owning service - account's catalog name. + account's catalog name. The account is resolved from the token id. Rather than deleting the row, we zero its `valid_for` interval, which makes the token inert (it fails the exchange's expiry check and is - excluded from listings) while preserving the audit trail. Already-revoked - tokens are treated as not found. + excluded from listings) while preserving the audit trail. Revocation is + idempotent: revoking an already-inert token is a no-op that still returns + the account. Only an id that maps to no service-account token errors. """ - revokeServiceAccountToken(id: Id!): Boolean! + revokeServiceAccountToken(id: Id!): ServiceAccount! + """ + Revoke ALL of a service account's tokens at once — the credential kill + switch — returning the account with no active tokens. + + The caller must have ManageServiceAccount on the account's catalog name. + Like revokeServiceAccountToken, each token is made inert by zeroing its + `valid_for` interval (preserving the audit trail) rather than deleted; + already-revoked tokens are skipped. A service account's user_id only ever + owns its own minted credentials, so this targets exactly those. An + account with no active tokens is an idempotent no-op. + """ + revokeAllServiceAccountTokens(catalogName: Name!): ServiceAccount! } """ @@ -1762,7 +1796,7 @@ type RefreshTokenInfoEdge { type RefreshTokenResult { id: Id! - secret: String! + secret: Sensitive! } type RepublishRequested { @@ -1783,12 +1817,22 @@ type RepublishRequested { lastBuildId: Id! } +""" +A secret returned by the API, such as a bearer credential. The value is serialized as a string, but clients must treat it as sensitive: redact it from logs and UIs, and never pass it to a language model. +""" +scalar Sensitive + type ServiceAccount { catalogName: Name! - createdBy: UUID! + """ + Email of the user who created the account. Null if that user has since + been deleted or has no email on file. + """ + createdByEmail: String createdAt: DateTime! updatedAt: DateTime! lastUsedAt: DateTime + grants: [ServiceAccountGrant!]! tokens: [ServiceAccountTokenInfo!]! } @@ -1817,6 +1861,19 @@ type ServiceAccountEdge { cursor: String! } +""" +A user_grant held by a service account: the prefix it may act on and the +capability it holds there. An account's access is the union of its grants, +which may span multiple prefixes independent of its catalog_name anchor. +""" +type ServiceAccountGrant { + prefix: Prefix! + capability: Capability! + detail: String + createdAt: DateTime! + updatedAt: DateTime! +} + """ A user_grant to seed a service account with at creation time. """ @@ -1833,7 +1890,11 @@ creation (see [`CreateServiceAccountTokenResult`]). type ServiceAccountTokenInfo { id: Id! detail: String - createdBy: UUID! + """ + Email of the user who minted the token. Null if that user has since + been deleted or has no email on file. + """ + createdByEmail: String createdAt: DateTime! expiresAt: DateTime! lastUsedAt: DateTime