feat(backend): add admin CRUD for SAML connections

Three endpoints under /api/v1/saml-connections — admin-only thin REST
wrappers around the JSON-config storage so the dashboard SSO pages
don't compose key paths manually:

- GET    /saml-connections           list all (omits cert)
- POST   /saml-connections           upsert by id
- DELETE /saml-connections           delete by id
- GET    /saml-connections/[id]      full detail (includes cert)

User accounts linked via a deleted connection remain in the DB; they
just become unable to sign in until a connection with the same id is
recreated. (Dashboard delete UX should warn on this.)

Underlying storage is the same overrideEnvironmentConfigOverride /
resetEnvironmentConfigOverrideKeys flow used by the seed script and
the e2e tests, so behavior is identical across all surfaces.
This commit is contained in:
Bilal Godil 2026-04-29 15:59:49 -07:00
parent 191ad700bd
commit b4bc68750e
2 changed files with 241 additions and 0 deletions

View File

@ -0,0 +1,64 @@
/**
* Admin GET for a single SAML connection returns the full config
* including idp_certificate. Use this for the dashboard's detail page;
* the list endpoint omits the cert.
*/
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
export const GET = createSmartRouteHandler({
metadata: {
summary: "Get a SAML connection",
description: "Admin: full connection config including the IdP certificate.",
tags: ["Saml"],
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
params: yupObject({
connection_id: yupString().defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
id: yupString().defined(),
display_name: yupString().defined(),
allow_sign_in: yupBoolean().defined(),
domain: yupString().nullable().defined(),
idp_entity_id: yupString().nullable().defined(),
idp_sso_url: yupString().nullable().defined(),
idp_certificate: yupString().nullable().defined(),
attribute_mapping: yupObject({
email: yupString().optional(),
display_name: yupString().optional(),
}).nullable().defined(),
}).defined(),
}),
async handler({ auth, params }) {
if (!(params.connection_id in auth.tenancy.config.auth.saml.connections)) {
throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} not found`);
}
const conn = auth.tenancy.config.auth.saml.connections[params.connection_id];
return {
statusCode: 200,
bodyType: "json",
body: {
id: params.connection_id,
display_name: conn.displayName,
allow_sign_in: conn.allowSignIn,
domain: conn.domain ?? null,
idp_entity_id: conn.idpEntityId ?? null,
idp_sso_url: conn.idpSsoUrl ?? null,
idp_certificate: conn.idpCertificate ?? null,
attribute_mapping: conn.attributeMapping
? { email: conn.attributeMapping.email, display_name: conn.attributeMapping.displayName }
: null,
},
};
},
});

View File

@ -0,0 +1,177 @@
/**
* Admin endpoints for managing SAML connections on a project.
*
* Connection config lives in tenancy.config.auth.saml.connections (JSON).
* These endpoints are thin REST wrappers around the same config-override
* mechanism the dashboard would otherwise call directly they exist so
* the dashboard's SSO list/detail pages don't have to compose key paths
* manually.
*
* Admin access only. Per-project; the project is identified by the admin
* auth context (no project_id in the URL).
*/
import { overrideEnvironmentConfigOverride, resetEnvironmentConfigOverrideKeys } from "@/lib/config";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
const samlConnectionResponseShape = yupObject({
id: yupString().defined(),
display_name: yupString().defined(),
allow_sign_in: yupBoolean().defined(),
domain: yupString().nullable().defined(),
idp_entity_id: yupString().nullable().defined(),
idp_sso_url: yupString().nullable().defined(),
// idp_certificate intentionally truncated in list responses (it's long
// and rarely needed); full cert is returned only by GET /[id].
has_idp_certificate: yupBoolean().defined(),
});
export const GET = createSmartRouteHandler({
metadata: {
summary: "List SAML connections",
description: "Admin: list every SAML connection configured on the project.",
tags: ["Saml"],
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
items: yupArray(samlConnectionResponseShape).defined(),
}).defined(),
}),
async handler({ auth }) {
const connections = auth.tenancy.config.auth.saml.connections;
type Conn = (typeof auth.tenancy.config.auth.saml.connections)[string];
return {
statusCode: 200,
bodyType: "json",
body: {
items: (Object.entries(connections) as Array<[string, Conn]>).map(([id, c]) => ({
id,
display_name: c.displayName,
allow_sign_in: c.allowSignIn,
domain: c.domain ?? null,
idp_entity_id: c.idpEntityId ?? null,
idp_sso_url: c.idpSsoUrl ?? null,
has_idp_certificate: !!c.idpCertificate,
})),
},
};
},
});
export const POST = createSmartRouteHandler({
metadata: {
summary: "Create or update a SAML connection",
description: "Admin: idempotent upsert by connection_id.",
tags: ["Saml"],
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
body: yupObject({
id: yupString().matches(/^[a-z0-9_-]+$/i).defined(),
display_name: yupString().defined(),
allow_sign_in: yupBoolean().defined(),
domain: yupString().nullable().optional(),
idp_entity_id: yupString().defined(),
idp_sso_url: yupString().defined(),
idp_certificate: yupString().defined(),
attribute_mapping: yupObject({
email: yupString().optional(),
display_name: yupString().optional(),
}).optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: samlConnectionResponseShape,
}),
async handler({ auth, body }) {
const overlay: Record<string, unknown> = {
[`auth.saml.connections.${body.id}.displayName`]: body.display_name,
[`auth.saml.connections.${body.id}.allowSignIn`]: body.allow_sign_in,
[`auth.saml.connections.${body.id}.idpEntityId`]: body.idp_entity_id,
[`auth.saml.connections.${body.id}.idpSsoUrl`]: body.idp_sso_url,
[`auth.saml.connections.${body.id}.idpCertificate`]: body.idp_certificate,
};
if (body.domain !== undefined) {
overlay[`auth.saml.connections.${body.id}.domain`] = body.domain;
}
if (body.attribute_mapping) {
overlay[`auth.saml.connections.${body.id}.attributeMapping`] = {
email: body.attribute_mapping.email,
displayName: body.attribute_mapping.display_name,
};
}
await overrideEnvironmentConfigOverride({
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
environmentConfigOverrideOverride: overlay as Parameters<typeof overrideEnvironmentConfigOverride>[0]["environmentConfigOverrideOverride"],
});
return {
statusCode: 200,
bodyType: "json",
body: {
id: body.id,
display_name: body.display_name,
allow_sign_in: body.allow_sign_in,
domain: body.domain ?? null,
idp_entity_id: body.idp_entity_id,
idp_sso_url: body.idp_sso_url,
has_idp_certificate: true,
},
};
},
});
export const DELETE = createSmartRouteHandler({
metadata: {
summary: "Delete a SAML connection",
description: "Admin: remove the connection. Existing user accounts linked via this connection remain in the database (they just become unable to sign in until a connection with the same id is recreated).",
tags: ["Saml"],
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
body: yupObject({
id: yupString().defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
success: yupBoolean().defined(),
}).defined(),
}),
async handler({ auth, body }) {
if (!(body.id in auth.tenancy.config.auth.saml.connections)) {
throw new StatusError(StatusError.NotFound, `SAML connection ${body.id} not found`);
}
await resetEnvironmentConfigOverrideKeys({
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
keysToReset: [`auth.saml.connections.${body.id}`],
});
return {
statusCode: 200,
bodyType: "json",
body: { success: true },
};
},
});