Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/docs/headless-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion apps/api/docs/organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions apps/api/src/apikey/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
Expand All @@ -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 () => {
Expand Down
40 changes: 33 additions & 7 deletions apps/api/src/apikey/queries.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -96,24 +97,29 @@ 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");
}

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");
}
Expand All @@ -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 };
};
Expand All @@ -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)
Expand All @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ export async function createInitialOrganizationOwnerIfMissing(): Promise<void> {
{
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();
Expand Down Expand Up @@ -87,6 +94,8 @@ export async function createInitialOrganizationOwnerIfMissing(): Promise<void> {
key.scopes,
key.secret,
identity.id,
// Bootstrap owns these keys' scopes, so a release can widen them.
{ reconcileScopes: true },
);
registeredKeys.push({
name: key.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/developers/provisioning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/workspace/organizations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <organization key>`, a stable `externalId`, and the
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/workspace/teams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion apps/docs/integrations/headless-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading