From 83e6251617cb4324eeb98eb09432a25b3b3bd622 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 15 Nov 2026 18:37:30 +0530 Subject: [PATCH] Give the bootstrap provisioning key the scopes integrators need The team-provisioning key registered at bootstrap now also has teams:manage, teams:keys and usage:read, so an integrator can update, archive and re-key the teams it provisions and read their usage. Bootstrap reconciles the stored scopes of the key it registered under the same name, so existing installs pick up the new scopes on restart instead of failing. Any other scope mismatch still fails closed. --- apps/api/README.md | 3 +- apps/api/docs/headless-provisioning.md | 2 +- apps/api/docs/organizations.md | 2 +- apps/api/src/apikey/queries.test.ts | 104 ++++++++++++++++++ apps/api/src/apikey/queries.ts | 40 +++++-- apps/api/src/bootstrap.test.ts | 11 +- apps/api/src/bootstrap.ts | 11 +- .../docs/developers/headless-provisioning.mdx | 2 +- .../content/docs/developers/provisioning.mdx | 2 +- .../content/docs/workspace/organizations.mdx | 2 +- apps/docs/content/docs/workspace/teams.mdx | 2 +- .../integrations/headless-provisioning.md | 2 +- 12 files changed, 166 insertions(+), 17 deletions(-) diff --git a/apps/api/README.md b/apps/api/README.md index 3da9661e..c6e04e51 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -184,7 +184,8 @@ revoked, expired, belongs to another organization, or has different scopes. Use separate keys: the short-lived delivery setup key has `organization:read`, `esps:read`, `esps:manage`, `delivery:read`, and `delivery:manage`; the runtime team provisioning key has only -`organization:read`, `teams:provision`, and `teams:read`. `delivery:manage` +`organization:read`, `teams:provision`, `teams:read`, +`teams:manage`, `teams:keys`, and `usage:read`. `delivery:manage` also authorizes quota and team-delivery policy fields, so revoke it after one-time setup if it is no longer needed. Compose passes both keys only to `init` and clears them from the long-running API and web containers. Configure diff --git a/apps/api/docs/headless-provisioning.md b/apps/api/docs/headless-provisioning.md index c6253770..333ebb86 100644 --- a/apps/api/docs/headless-provisioning.md +++ b/apps/api/docs/headless-provisioning.md @@ -21,7 +21,7 @@ Bootstrap should find or create the initial owner’s organization using `BOOTST | Secret setting | Registered key name | Use | Scopes | Where it should be available | | ------------------------------------- | ------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | | `BOOTSTRAP_DELIVERY_SETUP_API_KEY` | `[Auto-generated] Delivery Setup` | Configure shared delivery once | `organization:read`, `esps:manage`, `delivery:read`, `delivery:manage`; include `esps:read` if the initializer must list ESPs to reuse one | SendLit bootstrap and one-shot setup client | -| `BOOTSTRAP_TEAM_PROVISIONING_API_KEY` | `[Auto-generated] Team Provisioning` | CourseLit’s ongoing team provisioning | `organization:read`, `teams:provision`, `teams:read` | SendLit’s one-shot bootstrap and CourseLit’s runtime | +| `BOOTSTRAP_TEAM_PROVISIONING_API_KEY` | `[Auto-generated] Team Provisioning` | CourseLit’s ongoing team provisioning | `organization:read`, `teams:provision`, `teams:read`, `teams:manage`, `teams:keys`, `usage:read` | SendLit’s one-shot bootstrap and CourseLit’s runtime | Set both keys directly in the deployment `.env`. Bootstrap hashes them into organization API-key records; it never stores or logs the plaintext. It verifies an existing key’s organization, scopes, and revocation state on reruns. A mismatched or revoked key fails closed. Rotation means registering a new key, switching the integration, then explicitly revoking the old one. diff --git a/apps/api/docs/organizations.md b/apps/api/docs/organizations.md index 15dc744e..2336cbba 100644 --- a/apps/api/docs/organizations.md +++ b/apps/api/docs/organizations.md @@ -743,7 +743,7 @@ Supported Phase 1 scopes: - `usage:read` The CourseLit runtime key should use only `organization:read`, -`teams:provision`, and `teams:read`. A one-time delivery setup key may use +`teams:provision`, `teams:read`, `teams:manage`, `teams:keys`, and `usage:read`. A one-time delivery setup key may use `organization:read`, `esps:read`, `esps:manage`, `delivery:read`, and `delivery:manage`. The last scope authorizes every field in the delivery policy schema, including quota and team-delivery controls, so revoke that key diff --git a/apps/api/src/apikey/queries.test.ts b/apps/api/src/apikey/queries.test.ts index 499fa7ba..b4265651 100644 --- a/apps/api/src/apikey/queries.test.ts +++ b/apps/api/src/apikey/queries.test.ts @@ -151,6 +151,50 @@ describe("api key queries (integration)", () => { expect(await getOrganizationApiKeyBySecret(secret)).toBeNull(); }); + it("reconciles an existing configured key's scopes to the configured list", async () => { + const { organization } = await seedTeamAndContact(tdb); + const secret = generateOrganizationApiKeySecret(); + const oldScopes = ["organization:read", "teams:provision", "teams:read"]; + const newScopes = [...oldScopes, "teams:manage", "teams:keys", "usage:read"]; + const created = await registerConfiguredOrganizationApiKey( + organization.id, + "Team Provisioning", + oldScopes, + secret, + ); + + const upgraded = await registerConfiguredOrganizationApiKey( + organization.id, + "Team Provisioning", + newScopes, + secret, + undefined, + { reconcileScopes: true }, + ); + expect(upgraded.created).toBe(false); + expect(upgraded.apiKey.id).toBe(created.apiKey.id); + expect([...upgraded.apiKey.scopes].sort()).toEqual([...newScopes].sort()); + const [stored] = await tdb + .select() + .from(organizationApiKeys) + .where(eq(organizationApiKeys.id, created.apiKey.id)); + expect([...stored.scopes].sort()).toEqual([...newScopes].sort()); + expect((await getOrganizationApiKeyBySecret(secret))?.scopes.sort()).toEqual( + [...newScopes].sort(), + ); + + // Narrowing the configured list also reconciles. + const narrowed = await registerConfiguredOrganizationApiKey( + organization.id, + "Team Provisioning", + oldScopes, + secret, + undefined, + { reconcileScopes: true }, + ); + expect([...narrowed.apiKey.scopes].sort()).toEqual([...oldScopes].sort()); + }); + it("does not allow a generated key with a different role to impersonate a configured key", async () => { const { organization } = await seedTeamAndContact(tdb); const { secret } = await createOrganizationApiKey(organization.id, "Existing key", [ @@ -166,6 +210,66 @@ describe("api key queries (integration)", () => { secret, ), ).rejects.toThrow("configured_organization_api_key_scope_mismatch"); + // Reconciling is only for the key bootstrap registered under that name. + await expect( + registerConfiguredOrganizationApiKey( + organization.id, + "[Auto-generated] Team Provisioning", + ["organization:read", "teams:provision", "teams:read"], + secret, + undefined, + { reconcileScopes: true }, + ), + ).rejects.toThrow("configured_organization_api_key_scope_mismatch"); + }); + + it("keeps failing closed on a scope change unless the caller reconciles", async () => { + const { organization } = await seedTeamAndContact(tdb); + const secret = generateOrganizationApiKeySecret(); + await registerConfiguredOrganizationApiKey( + organization.id, + "Team Provisioning", + ["organization:read"], + secret, + ); + await expect( + registerConfiguredOrganizationApiKey( + organization.id, + "Team Provisioning", + ["organization:read", "teams:manage"], + secret, + ), + ).rejects.toThrow("configured_organization_api_key_scope_mismatch"); + }); + + it("does not reconcile scopes of a revoked configured key", async () => { + const { organization } = await seedTeamAndContact(tdb); + const secret = generateOrganizationApiKeySecret(); + const created = await registerConfiguredOrganizationApiKey( + organization.id, + "Team Provisioning", + ["organization:read"], + secret, + ); + await revokeOrganizationApiKey( + organization.id, + created.apiKey.organizationApiKeyId, + ); + await expect( + registerConfiguredOrganizationApiKey( + organization.id, + "Team Provisioning", + ["organization:read", "teams:manage"], + secret, + undefined, + { reconcileScopes: true }, + ), + ).rejects.toThrow("configured_organization_api_key_revoked"); + const [stored] = await tdb + .select() + .from(organizationApiKeys) + .where(eq(organizationApiKeys.id, created.apiKey.id)); + expect(stored.scopes).toEqual(["organization:read"]); }); it("fails closed when a configured key is expired", async () => { diff --git a/apps/api/src/apikey/queries.ts b/apps/api/src/apikey/queries.ts index 2298a7dd..d9dc7f75 100644 --- a/apps/api/src/apikey/queries.ts +++ b/apps/api/src/apikey/queries.ts @@ -1,6 +1,7 @@ import { and, eq, gt, isNull, or } from "drizzle-orm"; import { db } from "../db/client"; import { organizationApiKeys, teamApiKeys } from "../db/schema"; +import logger from "../services/log"; import { displayPrefix, generateApiKeySecret, @@ -96,14 +97,19 @@ export async function createOrganizationApiKey( /** Register an operator-supplied organization key without ever returning or * logging its plaintext. Re-running bootstrap with the same secret and role - * is a no-op; a revoked, expired, cross-organization, or differently scoped - * key fails closed instead of being silently repaired. */ + * is a no-op; a revoked, expired, or cross-organization key fails closed + * instead of being silently repaired. A key whose stored scopes differ also + * fails closed, unless the caller passes `reconcileScopes` and the key carries + * the same `name`: bootstrap owns the scopes of the keys it registers, so a + * newer release can widen them on restart. A key created for another role, or + * by hand, whose secret ends up in that setting still fails closed. */ export async function registerConfiguredOrganizationApiKey( organizationId: string, name: string, scopes: string[], secret: string, createdByUserId?: string, + options: { reconcileScopes?: boolean } = {}, ): Promise<{ apiKey: OrganizationApiKey; created: boolean }> { if (!isOrganizationApiKeySecret(secret)) { throw new Error("configured_organization_api_key_invalid"); @@ -111,9 +117,9 @@ export async function registerConfiguredOrganizationApiKey( const keyHash = hashApiKeySecret(secret); const expectedScopes = [...new Set(scopes)].sort(); - const validateExisting = ( + const validateExisting = async ( apiKey: OrganizationApiKey, - ): { apiKey: OrganizationApiKey; created: false } => { + ): Promise<{ apiKey: OrganizationApiKey; created: false }> => { if (apiKey.organizationId !== organizationId) { throw new Error("configured_organization_api_key_org_mismatch"); } @@ -125,7 +131,27 @@ export async function registerConfiguredOrganizationApiKey( } const actualScopes = [...new Set(apiKey.scopes)].sort(); if (JSON.stringify(actualScopes) !== JSON.stringify(expectedScopes)) { - throw new Error("configured_organization_api_key_scope_mismatch"); + if (!options.reconcileScopes || apiKey.name !== name) { + throw new Error("configured_organization_api_key_scope_mismatch"); + } + const [updated] = await db + .update(organizationApiKeys) + .set({ scopes: expectedScopes }) + .where(eq(organizationApiKeys.id, apiKey.id)) + .returning(); + logger.info( + { + organizationId, + keyName: apiKey.name, + previousScopes: actualScopes, + scopes: expectedScopes, + }, + "Reconciled configured organization API key scopes", + ); + return { + apiKey: updated ?? { ...apiKey, scopes: expectedScopes }, + created: false, + }; } return { apiKey, created: false }; }; @@ -135,7 +161,7 @@ export async function registerConfiguredOrganizationApiKey( .from(organizationApiKeys) .where(eq(organizationApiKeys.keyHash, keyHash)) .limit(1); - if (existing) return validateExisting(existing); + if (existing) return await validateExisting(existing); const [created] = await db .insert(organizationApiKeys) @@ -160,7 +186,7 @@ export async function registerConfiguredOrganizationApiKey( if (!raced) { throw new Error("configured_organization_api_key_registration_conflict"); } - return validateExisting(raced); + return await validateExisting(raced); } export async function getOrganizationApiKeys( diff --git a/apps/api/src/bootstrap.test.ts b/apps/api/src/bootstrap.test.ts index 473f4a89..beb598a1 100644 --- a/apps/api/src/bootstrap.test.ts +++ b/apps/api/src/bootstrap.test.ts @@ -105,14 +105,23 @@ describe("initial organization owner bootstrap", () => { ], setupSecret, "existing", + { reconcileScopes: true }, ); expect(bootstrapMocks.registerConfiguredOrganizationApiKey).toHaveBeenNthCalledWith( 2, "org-internal", "[Auto-generated] Team Provisioning", - ["organization:read", "teams:provision", "teams:read"], + [ + "organization:read", + "teams:provision", + "teams:read", + "teams:manage", + "teams:keys", + "usage:read", + ], runtimeSecret, "existing", + { reconcileScopes: true }, ); const logged = JSON.stringify(bootstrapMocks.info.mock.calls); expect(logged).not.toContain(setupSecret); diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index f8cb43d6..8e338869 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -37,7 +37,14 @@ export async function createInitialOrganizationOwnerIfMissing(): Promise { { secret: process.env.BOOTSTRAP_TEAM_PROVISIONING_API_KEY?.trim(), name: "[Auto-generated] Team Provisioning", - scopes: ["organization:read", "teams:provision", "teams:read"], + scopes: [ + "organization:read", + "teams:provision", + "teams:read", + "teams:manage", + "teams:keys", + "usage:read", + ], }, ].flatMap((key) => { const secret = key.secret?.trim(); @@ -87,6 +94,8 @@ export async function createInitialOrganizationOwnerIfMissing(): Promise { key.scopes, key.secret, identity.id, + // Bootstrap owns these keys' scopes, so a release can widen them. + { reconcileScopes: true }, ); registeredKeys.push({ name: key.name, diff --git a/apps/docs/content/docs/developers/headless-provisioning.mdx b/apps/docs/content/docs/developers/headless-provisioning.mdx index 39214b43..927155e5 100644 --- a/apps/docs/content/docs/developers/headless-provisioning.mdx +++ b/apps/docs/content/docs/developers/headless-provisioning.mdx @@ -21,7 +21,7 @@ mobile code. | Key | Bootstrap display name | Scopes | Use | | ----------------- | ----------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------- | | Delivery setup | `[Auto-generated] Delivery Setup` | `organization:read`, `esps:read`, `esps:manage`, `delivery:read`, `delivery:manage` | One-time ESP and delivery-policy configuration | -| Team provisioning | `[Auto-generated] Team Provisioning` | `organization:read`, `teams:provision`, `teams:read` | Ongoing server-to-server team provisioning | +| Team provisioning | `[Auto-generated] Team Provisioning` | `organization:read`, `teams:provision`, `teams:read`, `teams:manage`, `teams:keys`, `usage:read` | Ongoing server-to-server team provisioning | `delivery:manage` authorizes every field in the delivery-policy update schema, including shared quotas and team-delivery controls—not only the default ESP diff --git a/apps/docs/content/docs/developers/provisioning.mdx b/apps/docs/content/docs/developers/provisioning.mdx index d92743e0..5091fb6d 100644 --- a/apps/docs/content/docs/developers/provisioning.mdx +++ b/apps/docs/content/docs/developers/provisioning.mdx @@ -52,7 +52,7 @@ one team's contacts and email APIs. Keep these credentials separate. configure its own provider later using its team key or the dashboard. 3. Create an organization API key and store its plaintext value in your backend secret manager. It is displayed only once. For a team-provisioning - integration, use `organization:read`, `teams:provision`, and `teams:read`; + integration, use `organization:read`, `teams:provision`, `teams:read`, `teams:manage`, `teams:keys`, and `usage:read`; do not give its runtime key delivery administration scopes. Use the minimum scopes your integration needs: diff --git a/apps/docs/content/docs/workspace/organizations.mdx b/apps/docs/content/docs/workspace/organizations.mdx index 380f3a79..0e664b43 100644 --- a/apps/docs/content/docs/workspace/organizations.mdx +++ b/apps/docs/content/docs/workspace/organizations.mdx @@ -164,7 +164,7 @@ guide: 2. Add, test, and activate a shared mailbox. 3. Set the default-delivery policy and any quotas. 4. Create a runtime organization API key with `organization:read`, - `teams:provision`, and `teams:read`. Store it in your platform’s server-side + `teams:provision`, `teams:read`, `teams:manage`, `teams:keys`, and `usage:read`. Store it in your platform’s server-side secret manager and do not include delivery-administration scopes. 5. On each customer signup, your backend calls `POST /provisioning/teams` with `Authorization: Bearer `, a stable `externalId`, and the diff --git a/apps/docs/content/docs/workspace/teams.mdx b/apps/docs/content/docs/workspace/teams.mdx index d34c373a..0d6cceef 100644 --- a/apps/docs/content/docs/workspace/teams.mdx +++ b/apps/docs/content/docs/workspace/teams.mdx @@ -33,7 +33,7 @@ provisioning. Manage keys from the Teams page or through the Teams API. Keys are team-scoped and shown in full only once when created. Store the secret immediately; if it is lost or exposed, revoke it and create a replacement. For multi-tenant provisioning, use an organization API key with -`organization:read`, `teams:provision`, and `teams:read`. It is sent as a +`organization:read`, `teams:provision`, `teams:read`, `teams:manage`, `teams:keys`, and `usage:read`. It is sent as a Bearer token to the provisioning API; the old shared provisioning secret is not used. See [Headless organization setup](/developers/headless-provisioning) for the complete setup and key handoff. diff --git a/apps/docs/integrations/headless-provisioning.md b/apps/docs/integrations/headless-provisioning.md index f5fc6b7a..10633bc6 100644 --- a/apps/docs/integrations/headless-provisioning.md +++ b/apps/docs/integrations/headless-provisioning.md @@ -18,7 +18,7 @@ browser or mobile code. | Key | Bootstrap display name | Scopes | Use | | ----------------- | ------------------------------------ | ----------------------------------------------------------------------------------- | ----------------------------------------------------- | | Delivery setup | `[Auto-generated] Delivery Setup` | `organization:read`, `esps:read`, `esps:manage`, `delivery:read`, `delivery:manage` | One-time shared ESP and delivery-policy configuration | -| Team provisioning | `[Auto-generated] Team Provisioning` | `organization:read`, `teams:provision`, `teams:read` | Ongoing server-to-server team provisioning | +| Team provisioning | `[Auto-generated] Team Provisioning` | `organization:read`, `teams:provision`, `teams:read`, `teams:manage`, `teams:keys`, `usage:read` | Ongoing server-to-server team provisioning | `delivery:manage` authorizes every field in the delivery-policy update schema, including shared quotas and team delivery controls—not only the default ESP