From 236b066629a07e25811e5c040882813a817b2e63 Mon Sep 17 00:00:00 2001 From: src-opn Date: Fri, 24 Apr 2026 12:38:55 -0700 Subject: [PATCH 1/3] feat(den): add org-scoped SCIM support Wire Better Auth SCIM into Den, add org-admin management routes and UI entrypoints, add the SCIM provider schema, and document the compatibility-layer follow-up plan based on tester findings. --- ee/apps/den-api/README.md | 1 + ee/apps/den-api/package.json | 3 +- ee/apps/den-api/src/app.ts | 3 +- ee/apps/den-api/src/auth.ts | 18 + ee/apps/den-api/src/routes/auth/index.ts | 4 +- ee/apps/den-api/src/routes/auth/scim.ts | 175 + ee/apps/den-api/src/routes/org/README.md | 3 +- ee/apps/den-api/src/routes/org/index.ts | 2 + ee/apps/den-api/src/routes/org/scim.ts | 259 + ee/apps/den-api/src/routes/org/shared.ts | 28 + ee/apps/den-api/src/scim.ts | 131 + ee/apps/den-web/app/(den)/_lib/den-org.ts | 50 + .../den-web/app/(den)/dashboard/scim/page.tsx | 1 + .../_components/org-dashboard-shell.tsx | 12 + .../dashboard/_components/scim-screen.tsx | 334 ++ .../(den)/o/[orgSlug]/dashboard/scim/page.tsx | 5 + ee/apps/den-web/next-env.d.ts | 2 +- .../den-db/drizzle/0014_scim_provider.sql | 11 + .../den-db/drizzle/meta/0014_snapshot.json | 5056 +++++++++++++++++ ee/packages/den-db/drizzle/meta/_journal.json | 7 + ee/packages/den-db/src/schema/auth.ts | 21 +- ee/packages/utils/src/typeid.ts | 1 + pnpm-lock.yaml | 17 + prds/scim/den-api-scim-plan.md | 158 + prds/scim/scim-compatibility-layer-plan.md | 251 + 25 files changed, 6547 insertions(+), 6 deletions(-) create mode 100644 ee/apps/den-api/src/routes/auth/scim.ts create mode 100644 ee/apps/den-api/src/routes/org/scim.ts create mode 100644 ee/apps/den-api/src/scim.ts create mode 100644 ee/apps/den-web/app/(den)/dashboard/scim/page.tsx create mode 100644 ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/scim-screen.tsx create mode 100644 ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/scim/page.tsx create mode 100644 ee/packages/den-db/drizzle/0014_scim_provider.sql create mode 100644 ee/packages/den-db/drizzle/meta/0014_snapshot.json create mode 100644 prds/scim/den-api-scim-plan.md create mode 100644 prds/scim/scim-compatibility-layer-plan.md diff --git a/ee/apps/den-api/README.md b/ee/apps/den-api/README.md index 0412a7c68..35c81ec9e 100644 --- a/ee/apps/den-api/README.md +++ b/ee/apps/den-api/README.md @@ -20,6 +20,7 @@ pnpm --filter @openwork-ee/den-api dev:local - desktop handoff routes under `/v1/auth/*` - current user routes under `/v1/me*` - organization routes under `/v1/orgs*` +- active-organization SCIM management routes under `/v1/scim*` - admin routes under `/v1/admin*` - worker lifecycle and billing routes under `/v1/workers*` diff --git a/ee/apps/den-api/package.json b/ee/apps/den-api/package.json index 07da96ab1..11b25dc52 100644 --- a/ee/apps/den-api/package.json +++ b/ee/apps/den-api/package.json @@ -11,13 +11,14 @@ }, "dependencies": { "@better-auth/api-key": "^1.5.6", + "@better-auth/scim": "^1.5.6", "@daytonaio/sdk": "^0.150.0", "@hono/node-server": "^1.13.8", "@hono/standard-validator": "^0.2.2", "@hono/swagger-ui": "^0.6.1", - "@openwork/types": "workspace:*", "@openwork-ee/den-db": "workspace:*", "@openwork-ee/utils": "workspace:*", + "@openwork/types": "workspace:*", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@standard-schema/spec": "^1.1.0", diff --git a/ee/apps/den-api/src/app.ts b/ee/apps/den-api/src/app.ts index d1162c802..afbdc7c49 100644 --- a/ee/apps/den-api/src/app.ts +++ b/ee/apps/den-api/src/app.ts @@ -57,7 +57,7 @@ if (env.corsOrigins.length > 0) { origin: env.corsOrigins, credentials: true, allowHeaders: ["Content-Type", "Authorization", "X-Api-Key", "X-Request-Id"], - allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], exposeHeaders: ["Content-Length", "X-Request-Id"], maxAge: 600, }), @@ -147,6 +147,7 @@ app.get( { name: "Organizations", description: "Top-level organization creation and context routes." }, { name: "Invitations", description: "Invitation preview, acceptance, creation, and cancellation routes." }, { name: "API Keys", description: "Organization API key management routes." }, + { name: "SCIM", description: "Organization SCIM connector management routes." }, { name: "Members", description: "Organization member management routes." }, { name: "Roles", description: "Organization custom role management routes." }, { name: "Teams", description: "Organization team management routes." }, diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index c9cbf38b4..567d08547 100644 --- a/ee/apps/den-api/src/auth.ts +++ b/ee/apps/den-api/src/auth.ts @@ -19,6 +19,7 @@ import { seedDefaultOrganizationRoles } from "./orgs.js"; import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"; import * as schema from "@openwork-ee/den-db/schema"; import { apiKey } from "@better-auth/api-key"; +import { scim } from "@better-auth/scim"; import { APIError } from "better-call"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; @@ -127,6 +128,8 @@ export const auth = betterAuth({ return createDenTypeId("teamMember"); case "organizationRole": return createDenTypeId("organizationRole"); + case "scimProvider": + return createDenTypeId("scimProvider"); default: return false; } @@ -242,6 +245,21 @@ export const auth = betterAuth({ }, }, }), + scim({ + beforeSCIMTokenGenerated: async ({ member }) => { + if (!member?.organizationId) { + throw new APIError("FORBIDDEN", { + message: "SCIM connections must belong to an organization.", + }); + } + + if (!hasRole(member.role, "owner") && !hasRole(member.role, "admin")) { + throw new APIError("FORBIDDEN", { + message: "Only workspace owners and admins can manage SCIM.", + }); + } + }, + }), apiKey({ defaultPrefix: DEN_API_KEY_DEFAULT_PREFIX, enableMetadata: true, diff --git a/ee/apps/den-api/src/routes/auth/index.ts b/ee/apps/den-api/src/routes/auth/index.ts index 86668bb1c..41ee2fcae 100644 --- a/ee/apps/den-api/src/routes/auth/index.ts +++ b/ee/apps/den-api/src/routes/auth/index.ts @@ -4,10 +4,12 @@ import { auth } from "../../auth.js" import { emptyResponse } from "../../openapi.js" import type { AuthContextVariables } from "../../session.js" import { registerDesktopAuthRoutes } from "./desktop-handoff.js" +import { registerScimAuthRoutes } from "./scim.js" export function registerAuthRoutes(app: Hono) { + registerScimAuthRoutes(app) app.on( - ["GET", "POST"], + ["GET", "POST", "PUT", "PATCH", "DELETE"], "/api/auth/*", describeRoute({ hide: true, diff --git a/ee/apps/den-api/src/routes/auth/scim.ts b/ee/apps/den-api/src/routes/auth/scim.ts new file mode 100644 index 000000000..e60ccb3dc --- /dev/null +++ b/ee/apps/den-api/src/routes/auth/scim.ts @@ -0,0 +1,175 @@ +import { describeRoute } from "hono-openapi" +import type { Hono } from "hono" +import { resolver } from "hono-openapi" +import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" +import { z } from "zod" +import { deleteScimProvisionedAccess } from "../../scim.js" +import type { AuthContextVariables } from "../../session.js" + +const scimErrorSchema = z.object({ + detail: z.string(), +}).meta({ ref: "ScimAuthRouteError" }) + +const scimManagementForbiddenSchema = z.object({ + error: z.literal("forbidden"), + message: z.string(), +}).meta({ ref: "ScimManagementForbiddenError" }) + +function readBearerToken(headers: Headers) { + const header = headers.get("authorization")?.trim() ?? "" + const match = header.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() ?? null +} + +export function registerScimAuthRoutes(app: Hono) { + const rejectManagementRoute = (c: { + get: (key: "user") => AuthContextVariables["user"] + json: (object: unknown, status?: number | { status: number }) => Response + }) => { + const user = c.get("user") + if (!user?.id) { + return c.json({ error: "unauthorized" }, 401) + } + + return c.json({ + error: "forbidden", + message: "Use the organization SCIM endpoints instead of the raw Better Auth management routes.", + }, 403) + } + + app.post( + "/api/auth/scim/generate-token", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM token management", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.get( + "/api/auth/scim/list-provider-connections", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM provider listing", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.get( + "/api/auth/scim/get-provider-connection", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM provider lookup", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.post( + "/api/auth/scim/delete-provider-connection", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM provider deletion", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.delete( + "/api/auth/scim/v2/Users/:userId", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Delete SCIM provisioned org access", + description: "Removes the organization membership and SCIM provider account without deleting the global app user.", + responses: { + 204: { + description: "SCIM provisioned org access deleted.", + }, + 401: { + description: "Invalid SCIM token.", + content: { + "application/json": { + schema: resolver(scimErrorSchema), + }, + }, + }, + 404: { + description: "User not found.", + content: { + "application/json": { + schema: resolver(scimErrorSchema), + }, + }, + }, + }, + }), + async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return c.json({ detail: "SCIM token is required" }, 401) + } + + const deleted = await deleteScimProvisionedAccess({ + bearerToken, + userId: normalizeDenTypeId("user", c.req.param("userId")), + }) + + if (!deleted.ok) { + return c.json(deleted.body, { status: deleted.status as 401 | 404 }) + } + + return c.body(null, 204) + }, + ) +} diff --git a/ee/apps/den-api/src/routes/org/README.md b/ee/apps/den-api/src/routes/org/README.md index 9cd5504e6..3606cc2ff 100644 --- a/ee/apps/den-api/src/routes/org/README.md +++ b/ee/apps/den-api/src/routes/org/README.md @@ -9,6 +9,7 @@ This folder owns organization-facing Den API routes. - `invitations.ts`: invitation creation and cancellation - `members.ts`: member role updates and member removal - `roles.ts`: dynamic role CRUD +- `scim.ts`: active-organization SCIM connector metadata and token rotation - `templates.ts`: shared template CRUD - `shared.ts`: shared route-local helpers, param schemas, and guard helpers @@ -18,7 +19,7 @@ This folder owns organization-facing Den API routes. - New sessions should get an initial `activeOrganizationId` from Better Auth session creation hooks in `src/auth.ts`. - `GET /v1/org` returns the active organization from the current session, including a nested `organization.owner` object plus the current member and team context. - `POST /v1/org` creates a new organization and switches the session to it. `PATCH /v1/org` updates the active organization. -- Active-org scoped resources should prefer top-level routes like `/v1/templates`, `/v1/skills`, `/v1/teams`, `/v1/roles`, `/v1/api-keys`, `/v1/llm-providers`, and plugin-system `/v1/...` routes. They should not require `:orgId` or `:orgSlug` in the path. +- Active-org scoped resources should prefer top-level routes like `/v1/templates`, `/v1/skills`, `/v1/teams`, `/v1/roles`, `/v1/api-keys`, `/v1/llm-providers`, `/v1/scim`, and plugin-system `/v1/...` routes. They should not require `:orgId` or `:orgSlug` in the path. - Routes under `/v1/orgs/**` are reserved for cross-org flows that are not tied to the active workspace yet, such as invitation preview/accept. - If a client needs to change workspaces, it should call Better Auth set-active first, then use the active-org scoped `/v1/...` resource routes. diff --git a/ee/apps/den-api/src/routes/org/index.ts b/ee/apps/den-api/src/routes/org/index.ts index 8958483b8..109d00d29 100644 --- a/ee/apps/den-api/src/routes/org/index.ts +++ b/ee/apps/den-api/src/routes/org/index.ts @@ -8,6 +8,7 @@ import { registerOrgLlmProviderRoutes } from "./llm-providers.js" import { registerOrgMemberRoutes } from "./members.js" import { registerPluginArchRoutes } from "./plugin-system/routes.js" import { registerOrgRoleRoutes } from "./roles.js" +import { registerOrgScimRoutes } from "./scim.js" import { registerOrgSkillRoutes } from "./skills.js" import { registerOrgTeamRoutes } from "./teams.js" import { registerOrgTemplateRoutes } from "./templates.js" @@ -41,6 +42,7 @@ function extractLegacyOrgProxyTarget(pathname: string) { export function registerOrgRoutes(app: Hono) { registerOrgCoreRoutes(app) registerOrgApiKeyRoutes(app) + registerOrgScimRoutes(app) registerOrgInvitationRoutes(app) registerOrgLlmProviderRoutes(app) registerOrgMemberRoutes(app) diff --git a/ee/apps/den-api/src/routes/org/scim.ts b/ee/apps/den-api/src/routes/org/scim.ts new file mode 100644 index 000000000..349b44a71 --- /dev/null +++ b/ee/apps/den-api/src/routes/org/scim.ts @@ -0,0 +1,259 @@ +import type { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { z } from "zod" +import { deleteOrganizationScimConnection, getOrganizationScimConnection, getScimBaseUrl, rotateOrganizationScimToken } from "../../scim.js" +import { requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" +import type { OrgRouteVariables } from "./shared.js" +import { ensureScimManager } from "./shared.js" + +const invalidRequestSchema = z.object({ + error: z.literal("invalid_request"), + details: z.array(z.object({ + message: z.string(), + path: z.array(z.union([z.string(), z.number()])).optional(), + }).passthrough()), +}).meta({ ref: "ScimInvalidRequestError" }) + +const unauthorizedSchema = z.object({ + error: z.literal("unauthorized"), +}).meta({ ref: "ScimUnauthorizedError" }) + +const organizationNotFoundSchema = z.object({ + error: z.literal("organization_not_found"), +}).meta({ ref: "ScimOrganizationNotFoundError" }) + +const forbiddenSchema = z.object({ + error: z.literal("forbidden"), + message: z.string(), +}).meta({ ref: "ScimForbiddenError" }) + +const scimConnectionSchema = z.object({ + id: z.string(), + providerId: z.string(), + organizationId: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).meta({ ref: "OrganizationScimConnection" }) + +const scimConnectionResponseSchema = z.object({ + baseUrl: z.string().url(), + connection: scimConnectionSchema.nullable(), +}).meta({ ref: "OrganizationScimConnectionResponse" }) + +const rotateScimTokenResponseSchema = z.object({ + baseUrl: z.string().url(), + connection: scimConnectionSchema, + scimToken: z.string().min(1), +}).meta({ ref: "RotateOrganizationScimTokenResponse" }) + +function serializeConnection(connection: NonNullable>>) { + return { + id: connection.id, + providerId: connection.providerId, + organizationId: connection.organizationId, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + } +} + +export function registerOrgScimRoutes(app: Hono) { + app.get( + "/v1/scim", + describeRoute({ + tags: ["SCIM"], + summary: "Get organization SCIM connection", + description: "Returns the SCIM base URL and current connector metadata for the selected organization.", + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: "Organization SCIM configuration", + content: { + "application/json": { + schema: resolver(scimConnectionResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(invalidRequestSchema), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(unauthorizedSchema), + }, + }, + }, + 403: { + description: "Only workspace owners and admins can manage SCIM.", + content: { + "application/json": { + schema: resolver(forbiddenSchema), + }, + }, + }, + 404: { + description: "Organization not found", + content: { + "application/json": { + schema: resolver(organizationNotFoundSchema), + }, + }, + }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureScimManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationScimConnection(payload.organization.id) + + return c.json({ + baseUrl: getScimBaseUrl(), + connection: connection ? serializeConnection(connection) : null, + }) + }, + ) + + app.post( + "/v1/scim/token", + describeRoute({ + tags: ["SCIM"], + summary: "Create or rotate an organization SCIM token", + description: "Creates the organization SCIM connector if needed and returns a freshly rotated bearer token.", + hide: process.env.NODE_ENV === "production", + security: [{ bearerAuth: [] }], + responses: { + 201: { + description: "Organization SCIM token created", + content: { + "application/json": { + schema: resolver(rotateScimTokenResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(invalidRequestSchema), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(unauthorizedSchema), + }, + }, + }, + 403: { + description: "Only workspace owners and admins can manage SCIM.", + content: { + "application/json": { + schema: resolver(forbiddenSchema), + }, + }, + }, + 404: { + description: "Organization not found", + content: { + "application/json": { + schema: resolver(organizationNotFoundSchema), + }, + }, + }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureScimManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const rotated = await rotateOrganizationScimToken({ + organizationId: payload.organization.id, + headers: c.req.raw.headers, + }) + + return c.json({ + baseUrl: getScimBaseUrl(), + connection: serializeConnection(rotated.connection), + scimToken: rotated.scimToken, + }, 201) + }, + ) + + app.delete( + "/v1/scim", + describeRoute({ + tags: ["SCIM"], + summary: "Delete an organization SCIM connection", + description: "Deletes the organization SCIM connection and invalidates the current bearer token.", + security: [{ bearerAuth: [] }], + responses: { + 204: { + description: "Organization SCIM connection deleted", + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(invalidRequestSchema), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(unauthorizedSchema), + }, + }, + }, + 403: { + description: "Only workspace owners and admins can manage SCIM.", + content: { + "application/json": { + schema: resolver(forbiddenSchema), + }, + }, + }, + 404: { + description: "Organization not found", + content: { + "application/json": { + schema: resolver(organizationNotFoundSchema), + }, + }, + }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureScimManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + await deleteOrganizationScimConnection(payload.organization.id) + return c.body(null, 204) + }, + ) +} diff --git a/ee/apps/den-api/src/routes/org/shared.ts b/ee/apps/den-api/src/routes/org/shared.ts index c561d756a..e5929c959 100644 --- a/ee/apps/den-api/src/routes/org/shared.ts +++ b/ee/apps/den-api/src/routes/org/shared.ts @@ -11,6 +11,10 @@ export type OrgRouteVariables = & Partial & Partial +export const orgIdParamSchema = z.object({ + orgId: denTypeIdSchema("organization"), +}) + export function idParamSchema(key: K, typeName?: DenTypeIdName) { if (!typeName) { return z.object({ @@ -155,6 +159,30 @@ export function ensureApiKeyManager(c: { get: (key: "organizationContext") => Or } } +export function ensureScimManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) { + const payload = c.get("organizationContext") + if (!payload) { + return { + ok: false as const, + response: { + error: "organization_not_found", + }, + } + } + + if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) { + return { ok: true as const } + } + + return { + ok: false as const, + response: { + error: "forbidden", + message: "Only workspace owners and admins can manage SCIM.", + }, + } +} + export function createInvitationId() { return createDenTypeId("invitation") } diff --git a/ee/apps/den-api/src/scim.ts b/ee/apps/den-api/src/scim.ts new file mode 100644 index 000000000..625396bab --- /dev/null +++ b/ee/apps/den-api/src/scim.ts @@ -0,0 +1,131 @@ +import { Buffer } from "node:buffer" +import { and, eq } from "@openwork-ee/den-db/drizzle" +import { AuthAccountTable, MemberTable, ScimProviderTable } from "@openwork-ee/den-db/schema" +import { auth } from "./auth.js" +import { db } from "./db.js" +import { env } from "./env.js" +import { removeOrganizationMember } from "./orgs.js" + +type OrganizationId = typeof MemberTable.$inferSelect.organizationId +type UserId = typeof MemberTable.$inferSelect.userId + +function decodeBase64Url(value: string) { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)) + return Buffer.from(`${normalized}${padding}`, "base64").toString("utf8") +} + +export function buildOrganizationScimProviderId(organizationId: OrganizationId) { + return `openwork-scim-${organizationId}` +} + +export function getScimBaseUrl() { + return `${env.betterAuthUrl}/api/auth/scim/v2` +} + +export async function getOrganizationScimConnection(organizationId: OrganizationId) { + const rows = await db + .select() + .from(ScimProviderTable) + .where(eq(ScimProviderTable.organizationId, organizationId)) + .limit(1) + + return rows[0] ?? null +} + +export async function rotateOrganizationScimToken(input: { + organizationId: OrganizationId + headers: Headers +}) { + const existing = await getOrganizationScimConnection(input.organizationId) + const providerId = buildOrganizationScimProviderId(input.organizationId) + + if (existing && existing.providerId !== providerId) { + await db.delete(ScimProviderTable).where(eq(ScimProviderTable.id, existing.id)) + } + + const generated = await auth.api.generateSCIMToken({ + body: { + providerId, + organizationId: input.organizationId, + }, + headers: input.headers, + }) + + const connection = await getOrganizationScimConnection(input.organizationId) + if (!connection) { + throw new Error("SCIM connection was created, but could not be loaded.") + } + + return { + connection, + scimToken: generated.scimToken, + } +} + +export async function deleteOrganizationScimConnection(organizationId: OrganizationId) { + const connection = await getOrganizationScimConnection(organizationId) + if (!connection) { + return false + } + + await db.delete(ScimProviderTable).where(eq(ScimProviderTable.id, connection.id)) + return true +} + +export async function deleteScimProvisionedAccess(input: { + bearerToken: string + userId: UserId +}) { + let decoded: string + try { + decoded = decodeBase64Url(input.bearerToken) + } catch { + return { ok: false as const, status: 401, body: { detail: "Invalid SCIM token" } } + } + + const [rawToken, providerId, ...organizationParts] = decoded.split(":") + const organizationId = organizationParts.join(":") + + if (!rawToken || !providerId || !organizationId) { + return { ok: false as const, status: 401, body: { detail: "Invalid SCIM token" } } + } + + const providerRows = await db + .select() + .from(ScimProviderTable) + .where(and(eq(ScimProviderTable.providerId, providerId), eq(ScimProviderTable.organizationId, organizationId as OrganizationId))) + .limit(1) + + const provider = providerRows[0] ?? null + if (!provider || provider.scimToken !== rawToken) { + return { ok: false as const, status: 401, body: { detail: "Invalid SCIM token" } } + } + + const accountRows = await db + .select() + .from(AuthAccountTable) + .where(and(eq(AuthAccountTable.userId, input.userId), eq(AuthAccountTable.providerId, providerId))) + .limit(1) + + const memberRows = await db + .select() + .from(MemberTable) + .where(and(eq(MemberTable.userId, input.userId), eq(MemberTable.organizationId, organizationId as OrganizationId))) + .limit(1) + + const account = accountRows[0] ?? null + const member = memberRows[0] ?? null + if (!account || !member) { + return { ok: false as const, status: 404, body: { detail: "User not found" } } + } + + await removeOrganizationMember({ + organizationId: organizationId as OrganizationId, + memberId: member.id, + }) + + await db.delete(AuthAccountTable).where(eq(AuthAccountTable.id, account.id)) + + return { ok: true as const } +} diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts index 0eae106ba..24cee4a04 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-org.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -105,6 +105,14 @@ export type DenOrgApiKey = { }; }; +export type DenOrgScimConnection = { + id: string; + providerId: string; + organizationId: string; + createdAt: string | null; + updatedAt: string | null; +}; + export type DenOrgContext = { organization: { id: string; @@ -246,6 +254,7 @@ export function getOrgAccessFlags(roleValue: string, isOwner: boolean) { canManageRoles: isOwner, canManageTeams: isAdmin, canManageApiKeys: isAdmin, + canManageScim: isAdmin, }; } @@ -313,6 +322,10 @@ export function getApiKeysRoute(orgSlug?: string | null): string { return `${getOrgDashboardRoute(orgSlug)}/api-keys`; } +export function getScimRoute(orgSlug?: string | null): string { + return `${getOrgDashboardRoute(orgSlug)}/scim`; +} + export function getSkillHubsRoute(orgSlug?: string | null): string { return `${getOrgDashboardRoute(orgSlug)}/skill-hubs`; } @@ -712,3 +725,40 @@ export function parseOrgApiKeysPayload(payload: unknown): DenOrgApiKey[] { }) .filter((entry): entry is DenOrgApiKey => entry !== null); } + +export function parseOrgScimPayload(payload: unknown): { + baseUrl: string | null; + connection: DenOrgScimConnection | null; + scimToken: string | null; +} { + if (!isRecord(payload)) { + return { baseUrl: null, connection: null, scimToken: null }; + } + + const rawConnection = isRecord(payload.connection) ? payload.connection : null; + const connection = rawConnection + ? (() => { + const id = asString(rawConnection.id); + const providerId = asString(rawConnection.providerId); + const organizationId = asString(rawConnection.organizationId); + + if (!id || !providerId || !organizationId) { + return null; + } + + return { + id, + providerId, + organizationId, + createdAt: asIsoString(rawConnection.createdAt), + updatedAt: asIsoString(rawConnection.updatedAt), + } satisfies DenOrgScimConnection; + })() + : null; + + return { + baseUrl: asString(payload.baseUrl), + connection, + scimToken: asString(payload.scimToken), + }; +} diff --git a/ee/apps/den-web/app/(den)/dashboard/scim/page.tsx b/ee/apps/den-web/app/(den)/dashboard/scim/page.tsx new file mode 100644 index 000000000..7252d0c83 --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/scim/page.tsx @@ -0,0 +1 @@ +export { default } from "../../o/[orgSlug]/dashboard/scim/page"; diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx index 135e8cb65..77b09902b 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx @@ -16,6 +16,7 @@ import { MessageSquare, Puzzle, Share2, + Shield, SlidersHorizontal, Store, Users, @@ -34,6 +35,7 @@ import { getOrgSettingsRoute, getMarketplacesRoute, getPluginsRoute, + getScimRoute, getSharedSetupsRoute, getSkillHubsRoute, } from "../../../../_lib/den-org"; @@ -109,6 +111,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) { if (pathname.startsWith(getApiKeysRoute(orgSlug))) { return "API Keys"; } + if (pathname.startsWith(getScimRoute(orgSlug))) { + return "SCIM"; + } if (pathname.startsWith(getBackgroundAgentsRoute(orgSlug))) { return "Shared Workspaces"; } @@ -218,6 +223,13 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) { icon: KeyRound, }] : []), + ...(access.canManageScim + ? [{ + href: activeOrg ? getScimRoute(activeOrg.slug) : "#", + label: "SCIM", + icon: Shield, + }] + : []), { href: activeOrg ? getBillingRoute(activeOrg.slug) : "/checkout", label: "Billing", diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/scim-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/scim-screen.tsx new file mode 100644 index 000000000..5d31ea44f --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/scim-screen.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { Copy, RefreshCw, Shield, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; +import { DenButton } from "../../../../_components/ui/button"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { + type DenOrgScimConnection, + getOrgAccessFlags, + parseOrgScimPayload, +} from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +function formatDateTime(value: string | null) { + if (!value) { + return "Not configured"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Not configured"; + } + + return date.toLocaleString(); +} + +export function ScimScreen() { + const { orgId, orgContext } = useOrgDashboard(); + const [baseUrl, setBaseUrl] = useState(null); + const [connection, setConnection] = useState(null); + const [visibleToken, setVisibleToken] = useState(null); + const [busy, setBusy] = useState(false); + const [rotating, setRotating] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + const [copiedValue, setCopiedValue] = useState<"base-url" | "token" | null>(null); + + const access = useMemo( + () => + getOrgAccessFlags( + orgContext?.currentMember.role ?? "member", + orgContext?.currentMember.isOwner ?? false, + ), + [orgContext?.currentMember.isOwner, orgContext?.currentMember.role], + ); + + async function loadScimConfig() { + if (!orgId || !access.canManageScim) { + setBaseUrl(null); + setConnection(null); + return; + } + + setBusy(true); + setError(null); + try { + const { response, payload } = await requestJson( + "/v1/scim", + { method: "GET" }, + 12000, + ); + + if (!response.ok) { + throw new Error( + getErrorMessage(payload, `Failed to load SCIM settings (${response.status}).`), + ); + } + + const parsed = parseOrgScimPayload(payload); + setBaseUrl(parsed.baseUrl); + setConnection(parsed.connection); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to load SCIM settings.", + ); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void loadScimConfig(); + }, [orgId, access.canManageScim]); + + useEffect(() => { + if (!copiedValue) { + return; + } + + const timeout = window.setTimeout(() => setCopiedValue(null), 1500); + return () => window.clearTimeout(timeout); + }, [copiedValue]); + + async function copyValue(value: string | null, kind: "base-url" | "token") { + if (!value) { + return; + } + + try { + await navigator.clipboard.writeText(value); + setCopiedValue(kind); + } catch { + setError(`Could not copy the ${kind === "token" ? "SCIM token" : "SCIM base URL"}.`); + } + } + + async function handleRotateToken() { + if (!orgId) { + setError("Organization not found."); + return; + } + + setRotating(true); + setError(null); + setVisibleToken(null); + try { + const { response, payload } = await requestJson( + "/v1/scim/token", + { method: "POST", body: JSON.stringify({}) }, + 12000, + ); + + if (!response.ok) { + throw new Error( + getErrorMessage(payload, `Failed to rotate SCIM token (${response.status}).`), + ); + } + + const parsed = parseOrgScimPayload(payload); + if (!parsed.baseUrl || !parsed.connection || !parsed.scimToken) { + throw new Error("SCIM token rotation succeeded, but the response was incomplete."); + } + + setBaseUrl(parsed.baseUrl); + setConnection(parsed.connection); + setVisibleToken(parsed.scimToken); + setCopiedValue(null); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to rotate SCIM token.", + ); + } finally { + setRotating(false); + } + } + + async function handleDeleteConnection() { + if ( + !orgId || + !window.confirm( + "Delete this SCIM connection? The current bearer token will stop working immediately.", + ) + ) { + return; + } + + setDeleting(true); + setError(null); + try { + const { response, payload } = await requestJson( + "/v1/scim", + { method: "DELETE" }, + 12000, + ); + + if (response.status !== 204 && !response.ok) { + throw new Error( + getErrorMessage(payload, `Failed to delete SCIM connection (${response.status}).`), + ); + } + + setConnection(null); + setVisibleToken(null); + setCopiedValue(null); + await loadScimConfig(); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to delete SCIM connection.", + ); + } finally { + setDeleting(false); + } + } + + if (!orgContext) { + return ( + +
+ Loading organization details... +
+
+ ); + } + + return ( + + {!access.canManageScim ? ( +
+ Only organization owners and admins can manage SCIM. +
+ ) : ( + <> + {error ? ( +
+ {error} +
+ ) : null} + +
+
+
+

+ SCIM base URL +

+

+ Use this URL when your identity provider asks for the SCIM endpoint. +

+
+ void copyValue(baseUrl, "base-url")} + disabled={!baseUrl} + > + {copiedValue === "base-url" ? "Copied" : "Copy URL"} + +
+ +
+ + {baseUrl ?? (busy ? "Loading..." : "Not available")} + +
+
+ +
+
+
+

+ Connector token +

+

+ {connection + ? "Rotate the bearer token whenever your identity provider needs a fresh secret." + : "Create the workspace SCIM connector and generate its first bearer token."} +

+
+ +
+ void handleRotateToken()} loading={rotating}> + {connection ? "Rotate token" : "Create connector"} + + {connection ? ( + void handleDeleteConnection()} + loading={deleting} + > + Delete connector + + ) : null} +
+
+ +
+
+

+ Status +

+

+ {busy ? "Loading..." : connection ? "Connected" : "Not configured"} +

+

+ Last rotated {formatDateTime(connection?.updatedAt ?? null)} +

+ {connection ? ( +

+ Internal provider id: {connection.providerId} +

+ ) : null} +
+ +
+ SCIM deprovisioning removes workspace access and the SCIM provider account, but it does not blindly delete the global OpenWork user record. +
+
+ + {visibleToken ? ( +
+
+
+

+ Your SCIM bearer token is ready +

+

+ Copy it now. This value is only shown immediately after creation or rotation. +

+
+ void copyValue(visibleToken, "token")} + > + {copiedValue === "token" ? "Copied" : "Copy token"} + +
+ +
+ + {visibleToken} + +
+
+ ) : null} +
+ + )} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/scim/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/scim/page.tsx new file mode 100644 index 000000000..df76d3374 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/scim/page.tsx @@ -0,0 +1,5 @@ +import { ScimScreen } from "../_components/scim-screen"; + +export default function ScimPage() { + return ; +} diff --git a/ee/apps/den-web/next-env.d.ts b/ee/apps/den-web/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/ee/apps/den-web/next-env.d.ts +++ b/ee/apps/den-web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/ee/packages/den-db/drizzle/0014_scim_provider.sql b/ee/packages/den-db/drizzle/0014_scim_provider.sql new file mode 100644 index 000000000..5e94e8533 --- /dev/null +++ b/ee/packages/den-db/drizzle/0014_scim_provider.sql @@ -0,0 +1,11 @@ +CREATE TABLE `scim_provider` ( + `id` varchar(64) NOT NULL, + `provider_id` varchar(255) NOT NULL, + `scim_token` text NOT NULL, + `organization_id` varchar(64) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT (now()), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `scim_provider_id` PRIMARY KEY(`id`), + CONSTRAINT `scim_provider_provider_id` UNIQUE(`provider_id`), + CONSTRAINT `scim_provider_organization_id` UNIQUE(`organization_id`) +); diff --git a/ee/packages/den-db/drizzle/meta/0014_snapshot.json b/ee/packages/den-db/drizzle/meta/0014_snapshot.json new file mode 100644 index 000000000..3884a7d72 --- /dev/null +++ b/ee/packages/den-db/drizzle/meta/0014_snapshot.json @@ -0,0 +1,5056 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "f4ab960c-7d2a-4be7-8f7e-970b19f08592", + "prevId": "e465c2af-b7a2-457f-89cd-2a0f02645bc8", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "account_user_id": { + "name": "account_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id": { + "name": "account_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_config_id": { + "name": "apikey_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikey_reference_id": { + "name": "apikey_reference_id", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikey_key": { + "name": "apikey_key", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "apikey_id": { + "name": "apikey_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "session_token": { + "name": "session_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_id": { + "name": "session_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "session_id": { + "name": "session_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_id": { + "name": "verification_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "scim_provider": { + "name": "scim_provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scim_token": { + "name": "scim_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "scim_provider_provider_id": { + "name": "scim_provider_provider_id", + "columns": [ + "provider_id" + ], + "isUnique": true + }, + "scim_provider_organization_id": { + "name": "scim_provider_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "scim_provider_id": { + "name": "scim_provider_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "desktop_handoff_grant": { + "name": "desktop_handoff_grant", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "desktop_handoff_grant_user_id": { + "name": "desktop_handoff_grant_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "desktop_handoff_grant_expires_at": { + "name": "desktop_handoff_grant_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "desktop_handoff_grant_id": { + "name": "desktop_handoff_grant_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "invitation_organization_id": { + "name": "invitation_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email": { + "name": "invitation_email", + "columns": [ + "email" + ], + "isUnique": false + }, + "invitation_status": { + "name": "invitation_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "invitation_team_id": { + "name": "invitation_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "invitation_id": { + "name": "invitation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "member_organization_id": { + "name": "member_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_user_id": { + "name": "member_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "member_organization_user": { + "name": "member_organization_user", + "columns": [ + "organization_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "member_id": { + "name": "member_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_role": { + "name": "organization_role", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "organization_role_organization_id": { + "name": "organization_role_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_role_name": { + "name": "organization_role_name", + "columns": [ + "organization_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_role_id": { + "name": "organization_role_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_email_domains": { + "name": "allowed_email_domains", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "desktop_app_restrictions": { + "name": "desktop_app_restrictions", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_object())" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "organization_slug": { + "name": "organization_slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_id": { + "name": "organization_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "temp_template_sharing": { + "name": "temp_template_sharing", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_member_id": { + "name": "creator_member_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_user_id": { + "name": "creator_user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template_json": { + "name": "template_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "temp_template_sharing_org_id": { + "name": "temp_template_sharing_org_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "temp_template_sharing_creator_member_id": { + "name": "temp_template_sharing_creator_member_id", + "columns": [ + "creator_member_id" + ], + "isUnique": false + }, + "temp_template_sharing_creator_user_id": { + "name": "temp_template_sharing_creator_user_id", + "columns": [ + "creator_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "temp_template_sharing_id": { + "name": "temp_template_sharing_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llm_provider_access": { + "name": "llm_provider_access", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llm_provider_id": { + "name": "llm_provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "llm_provider_access_llm_provider_id": { + "name": "llm_provider_access_llm_provider_id", + "columns": [ + "llm_provider_id" + ], + "isUnique": false + }, + "llm_provider_access_org_membership_id": { + "name": "llm_provider_access_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "llm_provider_access_team_id": { + "name": "llm_provider_access_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "llm_provider_access_provider_org_membership": { + "name": "llm_provider_access_provider_org_membership", + "columns": [ + "llm_provider_id", + "org_membership_id" + ], + "isUnique": true + }, + "llm_provider_access_provider_team": { + "name": "llm_provider_access_provider_team", + "columns": [ + "llm_provider_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llm_provider_access_id": { + "name": "llm_provider_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llm_provider_model": { + "name": "llm_provider_model", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llm_provider_id": { + "name": "llm_provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_config": { + "name": "model_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "llm_provider_model_llm_provider_id": { + "name": "llm_provider_model_llm_provider_id", + "columns": [ + "llm_provider_id" + ], + "isUnique": false + }, + "llm_provider_model_model_id": { + "name": "llm_provider_model_model_id", + "columns": [ + "model_id" + ], + "isUnique": false + }, + "llm_provider_model_provider_model": { + "name": "llm_provider_model_provider_model", + "columns": [ + "llm_provider_id", + "model_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llm_provider_model_id": { + "name": "llm_provider_model_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llm_provider": { + "name": "llm_provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "enum('models_dev','custom')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "llm_provider_organization_id": { + "name": "llm_provider_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "llm_provider_created_by_org_membership_id": { + "name": "llm_provider_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "llm_provider_source": { + "name": "llm_provider_source", + "columns": [ + "source" + ], + "isUnique": false + }, + "llm_provider_provider_id": { + "name": "llm_provider_provider_id", + "columns": [ + "provider_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llm_provider_id": { + "name": "llm_provider_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "config_object_access_grant": { + "name": "config_object_access_grant", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_object_id": { + "name": "config_object_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "org_wide": { + "name": "org_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "enum('viewer','editor','manager')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "config_object_access_grant_organization_id": { + "name": "config_object_access_grant_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "config_object_access_grant_config_object_id": { + "name": "config_object_access_grant_config_object_id", + "columns": [ + "config_object_id" + ], + "isUnique": false + }, + "config_object_access_grant_org_membership_id": { + "name": "config_object_access_grant_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "config_object_access_grant_team_id": { + "name": "config_object_access_grant_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "config_object_access_grant_org_wide": { + "name": "config_object_access_grant_org_wide", + "columns": [ + "org_wide" + ], + "isUnique": false + }, + "config_object_access_grant_object_org_membership": { + "name": "config_object_access_grant_object_org_membership", + "columns": [ + "config_object_id", + "org_membership_id" + ], + "isUnique": true + }, + "config_object_access_grant_object_team": { + "name": "config_object_access_grant_object_team", + "columns": [ + "config_object_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "config_object_access_grant_id": { + "name": "config_object_access_grant_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "config_object": { + "name": "config_object", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_type": { + "name": "object_type", + "type": "enum('skill','agent','command','tool','mcp','hook','context','custom')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_mode": { + "name": "source_mode", + "type": "enum('cloud','import','connector')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_file_name": { + "name": "current_file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_file_extension": { + "name": "current_file_extension", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_relative_path": { + "name": "current_relative_path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('active','inactive','deleted','archived','ingestion_error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "config_object_organization_id": { + "name": "config_object_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "config_object_type": { + "name": "config_object_type", + "columns": [ + "object_type" + ], + "isUnique": false + }, + "config_object_source_mode": { + "name": "config_object_source_mode", + "columns": [ + "source_mode" + ], + "isUnique": false + }, + "config_object_status": { + "name": "config_object_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "config_object_created_by_org_membership_id": { + "name": "config_object_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "config_object_connector_instance_id": { + "name": "config_object_connector_instance_id", + "columns": [ + "connector_instance_id" + ], + "isUnique": false + }, + "config_object_current_relative_path": { + "name": "config_object_current_relative_path", + "columns": [ + "current_relative_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "config_object_id": { + "name": "config_object_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "config_object_version": { + "name": "config_object_version", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_object_id": { + "name": "config_object_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_payload_json": { + "name": "normalized_payload_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_source_text": { + "name": "raw_source_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "enum('cloud','import','connector','system')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_sync_event_id": { + "name": "connector_sync_event_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_revision_ref": { + "name": "source_revision_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted_version": { + "name": "is_deleted_version", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "config_object_version_organization_id": { + "name": "config_object_version_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "config_object_version_config_object_id": { + "name": "config_object_version_config_object_id", + "columns": [ + "config_object_id" + ], + "isUnique": false + }, + "config_object_version_created_by_org_membership_id": { + "name": "config_object_version_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "config_object_version_connector_sync_event_id": { + "name": "config_object_version_connector_sync_event_id", + "columns": [ + "connector_sync_event_id" + ], + "isUnique": false + }, + "config_object_version_source_revision_ref": { + "name": "config_object_version_source_revision_ref", + "columns": [ + "source_revision_ref" + ], + "isUnique": false + }, + "config_object_version_lookup_latest": { + "name": "config_object_version_lookup_latest", + "columns": [ + "config_object_id", + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "config_object_version_id": { + "name": "config_object_version_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_account": { + "name": "connector_account", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_type": { + "name": "connector_type", + "type": "enum('github')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_account_ref": { + "name": "external_account_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('active','inactive','disconnected','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "connector_account_organization_id": { + "name": "connector_account_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_account_created_by_org_membership_id": { + "name": "connector_account_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "connector_account_connector_type": { + "name": "connector_account_connector_type", + "columns": [ + "connector_type" + ], + "isUnique": false + }, + "connector_account_status": { + "name": "connector_account_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "connector_account_org_type_remote_id": { + "name": "connector_account_org_type_remote_id", + "columns": [ + "organization_id", + "connector_type", + "remote_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_account_id": { + "name": "connector_account_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_instance_access_grant": { + "name": "connector_instance_access_grant", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "org_wide": { + "name": "org_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "enum('viewer','editor','manager')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "connector_instance_access_grant_organization_id": { + "name": "connector_instance_access_grant_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_instance_access_grant_instance_id": { + "name": "connector_instance_access_grant_instance_id", + "columns": [ + "connector_instance_id" + ], + "isUnique": false + }, + "connector_instance_access_grant_org_membership_id": { + "name": "connector_instance_access_grant_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "connector_instance_access_grant_team_id": { + "name": "connector_instance_access_grant_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "connector_instance_access_grant_org_wide": { + "name": "connector_instance_access_grant_org_wide", + "columns": [ + "org_wide" + ], + "isUnique": false + }, + "connector_instance_access_grant_instance_org_membership": { + "name": "connector_instance_access_grant_instance_org_membership", + "columns": [ + "connector_instance_id", + "org_membership_id" + ], + "isUnique": true + }, + "connector_instance_access_grant_instance_team": { + "name": "connector_instance_access_grant_instance_team", + "columns": [ + "connector_instance_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_instance_access_grant_id": { + "name": "connector_instance_access_grant_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_instance": { + "name": "connector_instance", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_account_id": { + "name": "connector_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_type": { + "name": "connector_type", + "type": "enum('github')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('active','disabled','archived','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "instance_config_json": { + "name": "instance_config_json", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_status": { + "name": "last_sync_status", + "type": "enum('pending','queued','running','completed','failed','partial','ignored')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_cursor": { + "name": "last_sync_cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "connector_instance_organization_id": { + "name": "connector_instance_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_instance_connector_account_id": { + "name": "connector_instance_connector_account_id", + "columns": [ + "connector_account_id" + ], + "isUnique": false + }, + "connector_instance_created_by_org_membership_id": { + "name": "connector_instance_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "connector_instance_connector_type": { + "name": "connector_instance_connector_type", + "columns": [ + "connector_type" + ], + "isUnique": false + }, + "connector_instance_status": { + "name": "connector_instance_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "connector_instance_org_name": { + "name": "connector_instance_org_name", + "columns": [ + "organization_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_instance_id": { + "name": "connector_instance_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_mapping": { + "name": "connector_mapping", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_target_id": { + "name": "connector_target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_type": { + "name": "connector_type", + "type": "enum('github')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mapping_kind": { + "name": "mapping_kind", + "type": "enum('path','api','custom')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selector": { + "name": "selector", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_type": { + "name": "object_type", + "type": "enum('skill','agent','command','tool','mcp','hook','context','custom')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_add_to_plugin": { + "name": "auto_add_to_plugin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "mapping_config_json": { + "name": "mapping_config_json", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "connector_mapping_organization_id": { + "name": "connector_mapping_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_mapping_connector_instance_id": { + "name": "connector_mapping_connector_instance_id", + "columns": [ + "connector_instance_id" + ], + "isUnique": false + }, + "connector_mapping_connector_target_id": { + "name": "connector_mapping_connector_target_id", + "columns": [ + "connector_target_id" + ], + "isUnique": false + }, + "connector_mapping_object_type": { + "name": "connector_mapping_object_type", + "columns": [ + "object_type" + ], + "isUnique": false + }, + "connector_mapping_plugin_id": { + "name": "connector_mapping_plugin_id", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "connector_mapping_target_selector_object_type": { + "name": "connector_mapping_target_selector_object_type", + "columns": [ + "connector_target_id", + "selector", + "object_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_mapping_id": { + "name": "connector_mapping_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_source_binding": { + "name": "connector_source_binding", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_object_id": { + "name": "config_object_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_target_id": { + "name": "connector_target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_mapping_id": { + "name": "connector_mapping_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_type": { + "name": "connector_type", + "type": "enum('github')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_locator": { + "name": "external_locator", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_stable_ref": { + "name": "external_stable_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_source_revision_ref": { + "name": "last_seen_source_revision_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('active','inactive','deleted','archived','ingestion_error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "connector_source_binding_organization_id": { + "name": "connector_source_binding_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_source_binding_config_object_id": { + "name": "connector_source_binding_config_object_id", + "columns": [ + "config_object_id" + ], + "isUnique": false + }, + "connector_source_binding_connector_instance_id": { + "name": "connector_source_binding_connector_instance_id", + "columns": [ + "connector_instance_id" + ], + "isUnique": false + }, + "connector_source_binding_connector_target_id": { + "name": "connector_source_binding_connector_target_id", + "columns": [ + "connector_target_id" + ], + "isUnique": false + }, + "connector_source_binding_connector_mapping_id": { + "name": "connector_source_binding_connector_mapping_id", + "columns": [ + "connector_mapping_id" + ], + "isUnique": false + }, + "connector_source_binding_external_locator": { + "name": "connector_source_binding_external_locator", + "columns": [ + "external_locator" + ], + "isUnique": false + }, + "connector_source_binding_config_object": { + "name": "connector_source_binding_config_object", + "columns": [ + "config_object_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_source_binding_id": { + "name": "connector_source_binding_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_source_tombstone": { + "name": "connector_source_tombstone", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_target_id": { + "name": "connector_target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_mapping_id": { + "name": "connector_mapping_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_type": { + "name": "connector_type", + "type": "enum('github')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_locator": { + "name": "external_locator", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "former_config_object_id": { + "name": "former_config_object_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_in_sync_event_id": { + "name": "deleted_in_sync_event_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_source_revision_ref": { + "name": "deleted_source_revision_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "connector_source_tombstone_organization_id": { + "name": "connector_source_tombstone_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_source_tombstone_connector_instance_id": { + "name": "connector_source_tombstone_connector_instance_id", + "columns": [ + "connector_instance_id" + ], + "isUnique": false + }, + "connector_source_tombstone_connector_target_id": { + "name": "connector_source_tombstone_connector_target_id", + "columns": [ + "connector_target_id" + ], + "isUnique": false + }, + "connector_source_tombstone_connector_mapping_id": { + "name": "connector_source_tombstone_connector_mapping_id", + "columns": [ + "connector_mapping_id" + ], + "isUnique": false + }, + "connector_source_tombstone_external_locator": { + "name": "connector_source_tombstone_external_locator", + "columns": [ + "external_locator" + ], + "isUnique": false + }, + "connector_source_tombstone_former_config_object_id": { + "name": "connector_source_tombstone_former_config_object_id", + "columns": [ + "former_config_object_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_source_tombstone_id": { + "name": "connector_source_tombstone_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_sync_event": { + "name": "connector_sync_event", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_target_id": { + "name": "connector_target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_type": { + "name": "connector_type", + "type": "enum('github')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "enum('push','installation','installation_repositories','repository','manual_resync')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_event_ref": { + "name": "external_event_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_revision_ref": { + "name": "source_revision_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','queued','running','completed','failed','partial','ignored')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "summary_json": { + "name": "summary_json", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "connector_sync_event_organization_id": { + "name": "connector_sync_event_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_sync_event_connector_instance_id": { + "name": "connector_sync_event_connector_instance_id", + "columns": [ + "connector_instance_id" + ], + "isUnique": false + }, + "connector_sync_event_connector_target_id": { + "name": "connector_sync_event_connector_target_id", + "columns": [ + "connector_target_id" + ], + "isUnique": false + }, + "connector_sync_event_event_type": { + "name": "connector_sync_event_event_type", + "columns": [ + "event_type" + ], + "isUnique": false + }, + "connector_sync_event_status": { + "name": "connector_sync_event_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "connector_sync_event_source_revision_ref": { + "name": "connector_sync_event_source_revision_ref", + "columns": [ + "source_revision_ref" + ], + "isUnique": false + }, + "connector_sync_event_external_event_ref": { + "name": "connector_sync_event_external_event_ref", + "columns": [ + "external_event_ref" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_sync_event_id": { + "name": "connector_sync_event_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "connector_target": { + "name": "connector_target", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector_type": { + "name": "connector_type", + "type": "enum('github')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_id": { + "name": "remote_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "enum('repository_branch')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_target_ref": { + "name": "external_target_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "connector_target_organization_id": { + "name": "connector_target_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "connector_target_connector_instance_id": { + "name": "connector_target_connector_instance_id", + "columns": [ + "connector_instance_id" + ], + "isUnique": false + }, + "connector_target_connector_type": { + "name": "connector_target_connector_type", + "columns": [ + "connector_type" + ], + "isUnique": false + }, + "connector_target_target_kind": { + "name": "connector_target_target_kind", + "columns": [ + "target_kind" + ], + "isUnique": false + }, + "connector_target_instance_remote_id": { + "name": "connector_target_instance_remote_id", + "columns": [ + "connector_instance_id", + "remote_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connector_target_id": { + "name": "connector_target_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "marketplace_access_grant": { + "name": "marketplace_access_grant", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_id": { + "name": "marketplace_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "org_wide": { + "name": "org_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "enum('viewer','editor','manager')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "marketplace_access_grant_organization_id": { + "name": "marketplace_access_grant_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "marketplace_access_grant_marketplace_id": { + "name": "marketplace_access_grant_marketplace_id", + "columns": [ + "marketplace_id" + ], + "isUnique": false + }, + "marketplace_access_grant_org_membership_id": { + "name": "marketplace_access_grant_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "marketplace_access_grant_team_id": { + "name": "marketplace_access_grant_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "marketplace_access_grant_org_wide": { + "name": "marketplace_access_grant_org_wide", + "columns": [ + "org_wide" + ], + "isUnique": false + }, + "marketplace_access_grant_marketplace_org_membership": { + "name": "marketplace_access_grant_marketplace_org_membership", + "columns": [ + "marketplace_id", + "org_membership_id" + ], + "isUnique": true + }, + "marketplace_access_grant_marketplace_team": { + "name": "marketplace_access_grant_marketplace_team", + "columns": [ + "marketplace_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "marketplace_access_grant_id": { + "name": "marketplace_access_grant_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "marketplace_plugin": { + "name": "marketplace_plugin", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_id": { + "name": "marketplace_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_source": { + "name": "membership_source", + "type": "enum('manual','connector','api','system')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "marketplace_plugin_organization_id": { + "name": "marketplace_plugin_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "marketplace_plugin_marketplace_id": { + "name": "marketplace_plugin_marketplace_id", + "columns": [ + "marketplace_id" + ], + "isUnique": false + }, + "marketplace_plugin_plugin_id": { + "name": "marketplace_plugin_plugin_id", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "marketplace_plugin_marketplace_plugin": { + "name": "marketplace_plugin_marketplace_plugin", + "columns": [ + "marketplace_id", + "plugin_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "marketplace_plugin_id": { + "name": "marketplace_plugin_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "marketplace": { + "name": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('active','inactive','deleted','archived')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "marketplace_organization_id": { + "name": "marketplace_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "marketplace_created_by_org_membership_id": { + "name": "marketplace_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "marketplace_status": { + "name": "marketplace_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "marketplace_name": { + "name": "marketplace_name", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "marketplace_id": { + "name": "marketplace_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_access_grant": { + "name": "plugin_access_grant", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "org_wide": { + "name": "org_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "enum('viewer','editor','manager')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_access_grant_organization_id": { + "name": "plugin_access_grant_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "plugin_access_grant_plugin_id": { + "name": "plugin_access_grant_plugin_id", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_access_grant_org_membership_id": { + "name": "plugin_access_grant_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "plugin_access_grant_team_id": { + "name": "plugin_access_grant_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "plugin_access_grant_org_wide": { + "name": "plugin_access_grant_org_wide", + "columns": [ + "org_wide" + ], + "isUnique": false + }, + "plugin_access_grant_plugin_org_membership": { + "name": "plugin_access_grant_plugin_org_membership", + "columns": [ + "plugin_id", + "org_membership_id" + ], + "isUnique": true + }, + "plugin_access_grant_plugin_team": { + "name": "plugin_access_grant_plugin_team", + "columns": [ + "plugin_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_access_grant_id": { + "name": "plugin_access_grant_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_config_object": { + "name": "plugin_config_object", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_object_id": { + "name": "config_object_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_source": { + "name": "membership_source", + "type": "enum('manual','connector','api','system')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "connector_mapping_id": { + "name": "connector_mapping_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_config_object_organization_id": { + "name": "plugin_config_object_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "plugin_config_object_plugin_id": { + "name": "plugin_config_object_plugin_id", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_config_object_config_object_id": { + "name": "plugin_config_object_config_object_id", + "columns": [ + "config_object_id" + ], + "isUnique": false + }, + "plugin_config_object_connector_mapping_id": { + "name": "plugin_config_object_connector_mapping_id", + "columns": [ + "connector_mapping_id" + ], + "isUnique": false + }, + "plugin_config_object_plugin_config_object": { + "name": "plugin_config_object_plugin_config_object", + "columns": [ + "plugin_id", + "config_object_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_config_object_id": { + "name": "plugin_config_object_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin": { + "name": "plugin", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('active','inactive','deleted','archived')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_organization_id": { + "name": "plugin_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "plugin_created_by_org_membership_id": { + "name": "plugin_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "plugin_status": { + "name": "plugin_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "plugin_name": { + "name": "plugin_name", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_id": { + "name": "plugin_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_hub_member": { + "name": "skill_hub_member", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_hub_id": { + "name": "skill_hub_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "skill_hub_member_skill_hub_id": { + "name": "skill_hub_member_skill_hub_id", + "columns": [ + "skill_hub_id" + ], + "isUnique": false + }, + "skill_hub_member_org_membership_id": { + "name": "skill_hub_member_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "skill_hub_member_team_id": { + "name": "skill_hub_member_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "skill_hub_member_hub_org_membership": { + "name": "skill_hub_member_hub_org_membership", + "columns": [ + "skill_hub_id", + "org_membership_id" + ], + "isUnique": true + }, + "skill_hub_member_hub_team": { + "name": "skill_hub_member_hub_team", + "columns": [ + "skill_hub_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_hub_member_id": { + "name": "skill_hub_member_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_hub_skill": { + "name": "skill_hub_skill", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_hub_id": { + "name": "skill_hub_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "skill_hub_skill_skill_hub_id": { + "name": "skill_hub_skill_skill_hub_id", + "columns": [ + "skill_hub_id" + ], + "isUnique": false + }, + "skill_hub_skill_skill_id": { + "name": "skill_hub_skill_skill_id", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "skill_hub_skill_hub_skill": { + "name": "skill_hub_skill_hub_skill", + "columns": [ + "skill_hub_id", + "skill_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_hub_skill_id": { + "name": "skill_hub_skill_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_hub": { + "name": "skill_hub", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "skill_hub_organization_id": { + "name": "skill_hub_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "skill_hub_created_by_org_membership_id": { + "name": "skill_hub_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_hub_id": { + "name": "skill_hub_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill": { + "name": "skill", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skill_text": { + "name": "skill_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "enum('org','public')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "skill_organization_id": { + "name": "skill_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "skill_created_by_org_membership_id": { + "name": "skill_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "skill_shared": { + "name": "skill_shared", + "columns": [ + "shared" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_id": { + "name": "skill_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_member": { + "name": "team_member", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "team_member_team_id": { + "name": "team_member_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_member_org_membership_id": { + "name": "team_member_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "team_member_team_org_membership": { + "name": "team_member_team_org_membership", + "columns": [ + "team_id", + "org_membership_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_member_id": { + "name": "team_member_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "team_organization_id": { + "name": "team_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "team_organization_name": { + "name": "team_organization_name", + "columns": [ + "organization_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_id": { + "name": "team_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_event": { + "name": "audit_event", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "audit_event_org_id": { + "name": "audit_event_org_id", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "audit_event_worker_id": { + "name": "audit_event_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "audit_event_id": { + "name": "audit_event_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "daytona_sandbox": { + "name": "daytona_sandbox", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_volume_id": { + "name": "workspace_volume_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_volume_id": { + "name": "data_volume_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed_preview_url": { + "name": "signed_preview_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed_preview_url_expires_at": { + "name": "signed_preview_url_expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "daytona_sandbox_worker_id": { + "name": "daytona_sandbox_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": true + }, + "daytona_sandbox_sandbox_id": { + "name": "daytona_sandbox_sandbox_id", + "columns": [ + "sandbox_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daytona_sandbox_id": { + "name": "daytona_sandbox_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker_bundle": { + "name": "worker_bundle", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_url": { + "name": "storage_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "worker_bundle_worker_id": { + "name": "worker_bundle_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_bundle_id": { + "name": "worker_bundle_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker_instance": { + "name": "worker_instance", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('provisioning','healthy','failed','stopped')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "worker_instance_worker_id": { + "name": "worker_instance_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_instance_id": { + "name": "worker_instance_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker": { + "name": "worker", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "enum('local','cloud')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('provisioning','healthy','failed','stopped')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_version": { + "name": "image_version", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_path": { + "name": "workspace_path", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_backend": { + "name": "sandbox_backend", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "worker_org_id": { + "name": "worker_org_id", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "worker_created_by_user_id": { + "name": "worker_created_by_user_id", + "columns": [ + "created_by_user_id" + ], + "isUnique": false + }, + "worker_status": { + "name": "worker_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "worker_last_heartbeat_at": { + "name": "worker_last_heartbeat_at", + "columns": [ + "last_heartbeat_at" + ], + "isUnique": false + }, + "worker_last_active_at": { + "name": "worker_last_active_at", + "columns": [ + "last_active_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_id": { + "name": "worker_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker_token": { + "name": "worker_token", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "enum('client','host','activity')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worker_token_worker_id": { + "name": "worker_token_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + }, + "worker_token_token": { + "name": "worker_token_token", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_token_id": { + "name": "worker_token_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "admin_allowlist": { + "name": "admin_allowlist", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "admin_allowlist_email": { + "name": "admin_allowlist_email", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "admin_allowlist_id": { + "name": "admin_allowlist_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rate_limit_key": { + "name": "rate_limit_key", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rate_limit_id": { + "name": "rate_limit_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/ee/packages/den-db/drizzle/meta/_journal.json b/ee/packages/den-db/drizzle/meta/_journal.json index 24a6fe994..688b2e5d2 100644 --- a/ee/packages/den-db/drizzle/meta/_journal.json +++ b/ee/packages/den-db/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1776632400000, "tag": "0013_desktop_app_restrictions", "breakpoints": true + }, + { + "idx": 14, + "version": "5", + "when": 1776991876908, + "tag": "0014_scim_provider", + "breakpoints": true } ] } diff --git a/ee/packages/den-db/src/schema/auth.ts b/ee/packages/den-db/src/schema/auth.ts index 25a7f8ddc..2ded626bd 100644 --- a/ee/packages/den-db/src/schema/auth.ts +++ b/ee/packages/den-db/src/schema/auth.ts @@ -1,6 +1,6 @@ import { sql } from "drizzle-orm" import { bigint, boolean, index, int, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core" -import { denTypeIdColumn } from "../columns" +import { denTypeIdColumn, encryptedTextColumn } from "../columns" export const AuthUserTable = mysqlTable( "user", @@ -109,8 +109,27 @@ export const AuthApiKeyTable = mysqlTable( ], ) +export const ScimProviderTable = mysqlTable( + "scim_provider", + { + id: denTypeIdColumn("scimProvider", "id").notNull().primaryKey(), + providerId: varchar("provider_id", { length: 255 }).notNull(), + scimToken: encryptedTextColumn("scim_token").notNull(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), + }, + (table) => [ + uniqueIndex("scim_provider_provider_id").on(table.providerId), + uniqueIndex("scim_provider_organization_id").on(table.organizationId), + ], +) + export const user = AuthUserTable export const session = AuthSessionTable export const account = AuthAccountTable export const verification = AuthVerificationTable export const apikey = AuthApiKeyTable +export const scimProvider = ScimProviderTable diff --git a/ee/packages/utils/src/typeid.ts b/ee/packages/utils/src/typeid.ts index fe8967dfb..8f796f92f 100644 --- a/ee/packages/utils/src/typeid.ts +++ b/ee/packages/utils/src/typeid.ts @@ -47,6 +47,7 @@ export const idTypesMapNameToPrefix = { llmProviderModel: "lpm", llmProviderAccess: "lpa", organizationRole: "orl", + scimProvider: "scp", tempTemplateSharing: "tts", adminAllowlist: "aal", worker: "wrk", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a22a646e8..d1cfc6b42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -442,6 +442,9 @@ importers: '@better-auth/api-key': specifier: ^1.5.6 version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)) + '@better-auth/scim': + specifier: ^1.5.6 + version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(better-auth@1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)) '@daytonaio/sdk': specifier: ^0.150.0 version: 0.150.0(ws@8.19.0) @@ -1204,6 +1207,12 @@ packages: prisma: optional: true + '@better-auth/scim@1.5.6': + resolution: {integrity: sha512-DjElUct0hG+GtyTKQVaz0xVRlHTW6CRvBHLFsD7DyomesAWVojTxqgka8RPdhlm4DvgcZhG14RlINxTeLziC/w==} + peerDependencies: + '@better-auth/core': 1.5.6 + better-auth: 1.5.6 + '@better-auth/telemetry@1.5.6': resolution: {integrity: sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==} peerDependencies: @@ -8246,6 +8255,14 @@ snapshots: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 + '@better-auth/scim@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(better-auth@1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10))': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + better-auth: 1.5.6(@opentelemetry/api@1.9.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.15)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) + better-call: 1.3.2(zod@4.3.6) + zod: 4.3.6 + '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))': dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) diff --git a/prds/scim/den-api-scim-plan.md b/prds/scim/den-api-scim-plan.md new file mode 100644 index 000000000..e1acd522f --- /dev/null +++ b/prds/scim/den-api-scim-plan.md @@ -0,0 +1,158 @@ +# SCIM for Den API + +## Goal + +Add self-serve SCIM to `ee/apps/den-api` so an organization in OpenWork Cloud can: + +- create and manage its own SCIM connector +- rotate its own SCIM bearer token +- provision users only into that organization +- keep SCIM configuration and lifecycle clearly scoped to an organization + +This document also evaluates whether Better Auth's SCIM plugin matches those boundaries. + +## Short Answer + +Better Auth's SCIM plugin is partially suitable. + +- It does support organization-scoped SCIM connectors. +- It does scope SCIM management access by organization membership and role. +- It does provision a SCIM-created user into the organization attached to the SCIM token. + +But it is not fully organization-isolated at the user lifecycle layer. + +- The underlying `user` record is global to the whole app. +- If a SCIM-created email already exists, the plugin reuses that global user. +- `PUT` and `PATCH` update the global user row. +- `DELETE` deletes the global user, not just the organization membership. + +So the plugin is good for org-scoped connector management, but not safe enough as-is if we require strict per-org lifecycle separation. + +## Recommendation + +Use the Better Auth SCIM plugin for connector management and token auth, but do not trust its user lifecycle behavior blindly. + +Use the plugin for: + +- org-scoped SCIM connector records +- token generation and rotation +- SCIM request authentication +- org-scoped list/get filtering + +Do not ship the raw plugin lifecycle behavior unless we explicitly accept these semantics: + +- SCIM can attach an existing global app user into an org by email +- SCIM updates can change global app user profile fields +- SCIM delete can delete the whole app user + +For OpenWork Cloud, the safer stance is: + +- connector is per org +- membership is per org +- deletion should remove or disable org membership and connector account association, not blindly delete the whole user +- existing-user matching by email should be an explicit policy decision, not an accidental default + +## Implementation Options + +### Option A: adopt plugin as-is + +Pros: + +- fastest path +- lowest implementation effort + +Cons: + +- global user side effects +- weak lifecycle separation +- risky delete semantics + +Recommendation: + +- not preferred unless we explicitly accept app-global identity coupling + +### Option B: use plugin for auth and connector storage, override destructive lifecycle behavior + +Pros: + +- keeps the good parts of org-scoped SCIM +- reduces risk from global user mutation/deletion +- closer to OpenWork's org model + +Cons: + +- more integration work +- may require wrapping or forking parts of upstream behavior + +Recommendation: + +- preferred + +### Option C: custom SCIM implementation + +Pros: + +- full control over org boundaries + +Cons: + +- highest maintenance burden +- duplicates standards work already done upstream + +Recommendation: + +- only if upstream hooks or extension points are insufficient + +## Recommended Plan + +### Phase 1: wire core SCIM support + +1. Add `@better-auth/scim` to `ee/apps/den-api`. +2. Enable the plugin in `ee/apps/den-api/src/auth.ts`. +3. Update `ee/apps/den-api/src/routes/auth/index.ts` to forward `PUT`, `PATCH`, and `DELETE`. +4. Run the Better Auth migration so `scimProvider` exists in Den's schema. +5. Configure org-scoped `requiredRole` explicitly rather than relying on defaults. + +### Phase 2: build Den self-serve admin UX + +1. Add an org dashboard page in `den-web` for SCIM. +2. Show: + - SCIM base URL + - connection status + - generated token one time + - rotate token action + - delete connection action +3. Restrict visibility to owner/admin, aligned with Den org permissions. +4. Generate internal provider ids server-side. + +### Phase 3: resolve lifecycle separation + +1. Decide policy for email collision with an existing global app user. +2. Decide policy for SCIM update of name/email on an existing shared user. +3. Replace or wrap delete behavior so org offboarding does not unintentionally delete the global user. +4. Decide whether SCIM should deactivate membership, remove membership, or soft-delete an org-specific auth link. + +### Phase 4: optional enterprise hardening + +1. Add audit logging for token generation, rotation, and connector deletion. +2. Add group-to-role or group-to-team mapping if required. +3. Add connector health indicators and recent sync error visibility. +4. Add policy hooks for approved SCIM operators. + +## Decisions Needed Before Build + +1. Are OpenWork identities allowed to be globally shared across orgs when SCIM emails match? +2. Should SCIM delete remove the global user, remove only the org membership, or just deactivate access? +3. Should SCIM-provisioned users always land as `member`, or should org admins choose a default role? +4. Do we need SCIM only, or full enterprise identity including SSO in the same admin surface? + +## Proposed Decision + +Proceed with Option B. + +That means: + +- use Better Auth SCIM for org-scoped connector plumbing +- keep connectors directly attached to organizations +- build self-serve org admin UX in Den +- add a Den-owned lifecycle policy layer before trusting update/delete behavior in production diff --git a/prds/scim/scim-compatibility-layer-plan.md b/prds/scim/scim-compatibility-layer-plan.md new file mode 100644 index 000000000..fa829cc4d --- /dev/null +++ b/prds/scim/scim-compatibility-layer-plan.md @@ -0,0 +1,251 @@ +# SCIM Compatibility Layer Plan + +## Context + +The first SCIM test run showed that Better Auth's default SCIM plugin is useful plumbing, but it is not fully compatible with the SCIM tester or Den's identity model. + +Observed failures: + +- `/ResourceTypes` advertises only `User`; tester expects `Group` too. +- `/Schemas` advertises only the User schema; tester expects a Group schema too. +- arbitrary remote IDs such as `9876543210123456` can hit Den TypeID normalization and become `500` instead of SCIM `404`. +- `startIndex` and `count` are ignored in list responses. +- SCIM `userName` is not preserved; responses derive `userName` from local email. +- PATCH returns `204`, while the tester expects `200` with an updated user resource. + +## Goal + +Add a Den-owned SCIM compatibility layer that keeps Better Auth's useful connector/token pieces while giving OpenWork control over SCIM resource identity, response shape, pagination, and group semantics. + +## Identity Model + +Do not store SCIM remote identity directly on the global Better Auth `user` row by default. + +Use an org/provider-scoped identity table instead, because the same local user may be known differently by different organizations or identity providers. + +Proposed table: `scim_identity` + +- `id`: Den TypeID, e.g. `sci_...` +- `organizationId`: owning OpenWork org +- `providerId`: SCIM provider connection id +- `userId`: local Better Auth user id +- `remoteId`: remote immutable id when supplied by the IdP +- `externalId`: SCIM `externalId` +- `userName`: SCIM `userName` +- `displayName`: SCIM `displayName` +- `nameJson`: raw SCIM `name` object +- `emailsJson`: raw SCIM `emails` array +- `active`: SCIM active flag +- `createdAt` +- `updatedAt` + +Indexes and constraints: + +- unique `(organizationId, providerId, userId)` +- unique `(organizationId, providerId, externalId)` when externalId exists +- unique `(organizationId, providerId, userName)` when userName exists +- optional unique `(organizationId, providerId, remoteId)` when remoteId exists + +This lets Den return SCIM protocol fields without forcing those values into global app identity. + +## User Resource Behavior + +### Create User + +On `POST /scim/v2/Users`: + +1. Authenticate through the org-scoped SCIM provider/token. +2. Extract SCIM identity fields: + - `externalId` + - `userName` + - `name` + - `displayName` + - `emails` + - `active` +3. Resolve or create the local user using explicit Den policy: + - default: use primary email for local user email + - if an existing local user has that email, attach org membership only if policy allows +4. Create or update `scim_identity` for the provider/org/user mapping. +5. Return a SCIM User resource built from `scim_identity` plus local user metadata. + +Response rule: + +- `userName` should be the SCIM `userName`, not necessarily local email. +- local email can still appear under `emails`. +- `id` can remain the Den local user id unless we later choose opaque SCIM resource ids. + +### Get User + +On `GET /scim/v2/Users/:userId`: + +1. If `:userId` is not a valid Den user TypeID, return SCIM `404` rather than letting Drizzle/TypeID throw. +2. Lookup by local user id within the authenticated provider/org. +3. Return the SCIM resource from `scim_identity`. + +Future option: + +- If IdPs require their own resource IDs, add `scim_identity.remoteResourceId` and allow lookup by that value too. + +### List Users + +On `GET /scim/v2/Users`: + +1. List identities scoped to authenticated provider/org. +2. Support `startIndex` as SCIM 1-based pagination. +3. Support `count` as page size. +4. Return `startIndex` equal to the requested normalized value. +5. Return `itemsPerPage` equal to the number of resources in the current page. +6. Keep `totalResults` equal to total matching identities before pagination. + +### Update User + +On `PUT /scim/v2/Users/:userId`: + +1. Validate and resolve the user in provider/org scope. +2. Update `scim_identity` fields from the SCIM payload. +3. Update local user fields only according to explicit Den policy. +4. Return `200` with the updated SCIM resource. + +### Patch User + +On `PATCH /scim/v2/Users/:userId`: + +1. Apply supported SCIM patch operations to `scim_identity`. +2. Update local user fields only according to explicit Den policy. +3. Return `200` with the updated SCIM resource for compatibility with the current tester. + +### Delete User + +On `DELETE /scim/v2/Users/:userId`: + +1. Remove or deactivate org membership for the SCIM-managed org. +2. Remove or deactivate the `scim_identity` mapping. +3. Do not delete the global local user by default. +4. Return `204` when the org-scoped access was removed. + +## Group Resource Model + +SCIM `Group` is closest to OpenWork org teams, but it is not exactly the same thing. + +A SCIM Group is an IdP-owned membership container. It may represent teams, departments, roles, app assignments, or access policy groups. OpenWork org teams are local collaboration/access structures inside an organization. + +Recommended stance: + +- Treat SCIM Groups as remote-owned groups. +- Map them to org teams only if the org enables that behavior. +- Keep enough remote group metadata to avoid losing IdP identity even if no local team is created. + +Proposed table: `scim_group` + +- `id`: Den TypeID, e.g. `scg_...` +- `organizationId` +- `providerId` +- `teamId`: optional local org team id +- `remoteId`: remote immutable id when supplied +- `externalId`: SCIM `externalId` +- `displayName`: SCIM Group `displayName` +- `membersJson`: optional raw member references if we defer normalized membership +- `createdAt` +- `updatedAt` + +Proposed table: `scim_group_member` + +- `id` +- `organizationId` +- `providerId` +- `groupId` +- `identityId` +- `userId` +- `teamMemberId`: optional local team member link +- `createdAt` +- `updatedAt` + +## Group Support Phases + +### Phase 1: metadata compatibility + +Add Group schema and resource type to: + +- `/scim/v2/Schemas` +- `/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group` +- `/scim/v2/ResourceTypes` +- `/scim/v2/ResourceTypes/Group` + +This addresses metadata discovery failures without committing to full group lifecycle yet. + +### Phase 2: minimal Group CRUD + +Implement: + +- `GET /scim/v2/Groups` +- `GET /scim/v2/Groups/:groupId` +- `POST /scim/v2/Groups` +- `PUT /scim/v2/Groups/:groupId` +- `PATCH /scim/v2/Groups/:groupId` +- `DELETE /scim/v2/Groups/:groupId` + +Initial behavior can store remote group records without creating local teams. + +### Phase 3: optional team mapping + +If product wants SCIM groups to manage OpenWork teams: + +1. Add org setting: `scimGroupMappingMode`. +2. Modes: + - `metadata_only`: store groups, do not create teams. + - `create_teams`: create/update org teams from SCIM groups. + - `manual_mapping`: admin maps specific SCIM groups to specific teams. +3. Apply group membership changes to local team membership only when enabled. + +## Compatibility Route Layer + +Add Den-owned route handlers before or instead of Better Auth's raw SCIM lifecycle handlers for protocol resources. + +Keep Better Auth for: + +- SCIM provider table bootstrapping if still useful +- token generation +- token validation middleware or equivalent validation logic +- admin management where it matches Den org boundaries + +Own in Den: + +- User resource serialization +- Group resource serialization +- user/group CRUD semantics +- pagination +- invalid ID handling +- userName/externalId preservation +- local user mutation policy + +## Test Targets + +Add tests that cover the reported failures directly: + +1. `/ResourceTypes` includes both `User` and `Group`. +2. `/Schemas` includes both User and Group schemas. +3. invalid `GET /Users/:id` returns SCIM `404`, not `500`. +4. `GET /Users?startIndex=20&count=5` echoes `startIndex: 20` and correct `itemsPerPage`. +5. `POST /Users` returns the submitted SCIM `userName`. +6. `PUT /Users/:id` returns the updated SCIM `userName`. +7. `PATCH /Users/:id` returns `200` and updated resource. +8. `DELETE /Users/:id` removes org-scoped SCIM access without deleting the global user. + +## Open Questions + +1. Should local user email always come from SCIM primary email, or can SCIM `userName` be an email substitute? +2. Should `id` in SCIM responses be local `usr_...`, or should we expose an opaque SCIM resource id from `scim_identity`? +3. Should existing local users be auto-linked by email, or should collisions require admin approval? +4. Should SCIM Group-to-team mapping be enabled by default, opt-in, or deferred? + +## Proposed Decision + +Proceed with a Den-owned compatibility layer and `scim_identity` storage before declaring SCIM production-ready. + +Ship order: + +1. Add `scim_identity` and preserve remote user fields. +2. Override User list/get/create/update/patch/delete behavior enough to pass the tester. +3. Add Group schema/resource type metadata. +4. Add minimal Group storage. +5. Add optional org-team mapping after product confirms desired semantics. From 4277a8f66a001b72ce472be08713b40f855132f5 Mon Sep 17 00:00:00 2001 From: src-opn Date: Fri, 24 Apr 2026 13:37:35 -0700 Subject: [PATCH 2/3] feat(den): add org-scoped SSO support Wire Better Auth SSO into Den with org-scoped provider management, admin UI, generated setup URLs, and a dedicated org sign-in route. Add the SSO schema and external identity groundwork so SCIM and SSO can converge on shared enterprise identity state. --- ee/apps/den-api/package.json | 1 + ee/apps/den-api/src/auth.ts | 89 + ee/apps/den-api/src/routes/org/index.ts | 2 + ee/apps/den-api/src/routes/org/shared.ts | 24 + ee/apps/den-api/src/routes/org/sso.ts | 336 + ee/apps/den-api/src/sso.ts | 198 + ee/apps/den-web/app/(den)/_lib/den-org.ts | 72 + .../den-web/app/(den)/dashboard/sso/page.tsx | 1 + .../_components/org-dashboard-shell.tsx | 11 + .../dashboard/_components/sso-screen.tsx | 245 + .../(den)/o/[orgSlug]/dashboard/sso/page.tsx | 5 + ee/apps/den-web/app/sso/[orgSlug]/page.tsx | 77 + .../den-db/drizzle/0015_loud_manta.sql | 67 + .../den-db/drizzle/meta/0015_snapshot.json | 5510 +++++++++++++++++ ee/packages/den-db/drizzle/meta/_journal.json | 9 +- ee/packages/den-db/src/schema/auth.ts | 90 +- ee/packages/utils/src/typeid.ts | 3 + pnpm-lock.yaml | 179 +- prds/scim/den-api-sso-plan.md | 445 ++ 19 files changed, 7350 insertions(+), 14 deletions(-) create mode 100644 ee/apps/den-api/src/routes/org/sso.ts create mode 100644 ee/apps/den-api/src/sso.ts create mode 100644 ee/apps/den-web/app/(den)/dashboard/sso/page.tsx create mode 100644 ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/sso-screen.tsx create mode 100644 ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/sso/page.tsx create mode 100644 ee/apps/den-web/app/sso/[orgSlug]/page.tsx create mode 100644 ee/packages/den-db/drizzle/0015_loud_manta.sql create mode 100644 ee/packages/den-db/drizzle/meta/0015_snapshot.json create mode 100644 prds/scim/den-api-sso-plan.md diff --git a/ee/apps/den-api/package.json b/ee/apps/den-api/package.json index 11b25dc52..b2c083b4e 100644 --- a/ee/apps/den-api/package.json +++ b/ee/apps/den-api/package.json @@ -12,6 +12,7 @@ "dependencies": { "@better-auth/api-key": "^1.5.6", "@better-auth/scim": "^1.5.6", + "@better-auth/sso": "^1.5.6", "@daytonaio/sdk": "^0.150.0", "@hono/node-server": "^1.13.8", "@hono/standard-validator": "^0.2.2", diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index 567d08547..e4f79d73f 100644 --- a/ee/apps/den-api/src/auth.ts +++ b/ee/apps/den-api/src/auth.ts @@ -20,9 +20,11 @@ import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"; import * as schema from "@openwork-ee/den-db/schema"; import { apiKey } from "@better-auth/api-key"; import { scim } from "@better-auth/scim"; +import { sso } from "@better-auth/sso"; import { APIError } from "better-call"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { and, eq } from "@openwork-ee/den-db/drizzle"; import { emailOTP, organization } from "better-auth/plugins"; const socialProviders = { @@ -52,6 +54,20 @@ function hasRole(roleValue: string, roleName: string) { .includes(roleName); } +function maybeString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function pickRemoteIdentity(userInfo: Record) { + return ( + maybeString(userInfo.sub) ?? + maybeString(userInfo.id) ?? + maybeString(userInfo.nameID) ?? + maybeString(userInfo.nameId) ?? + maybeString(userInfo.email) + ); +} + function getInvitationOrigin() { return ( env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? @@ -130,6 +146,12 @@ export const auth = betterAuth({ return createDenTypeId("organizationRole"); case "scimProvider": return createDenTypeId("scimProvider"); + case "ssoProvider": + return createDenTypeId("ssoProvider"); + case "ssoConnection": + return createDenTypeId("ssoConnection"); + case "externalIdentity": + return createDenTypeId("externalIdentity"); default: return false; } @@ -260,6 +282,73 @@ export const auth = betterAuth({ } }, }), + sso({ + providersLimit: 1000, + provisionUserOnEveryLogin: true, + organizationProvisioning: { + disabled: false, + defaultRole: "member", + }, + saml: { + enableInResponseToValidation: true, + allowIdpInitiated: true, + algorithms: { + onDeprecated: "warn", + }, + }, + provisionUser: async ({ user, userInfo, provider }) => { + if (!provider.organizationId) { + return; + } + + const existingRows = await db + .select() + .from(schema.ExternalIdentityTable) + .where(and( + eq(schema.ExternalIdentityTable.organizationId, normalizeDenTypeId("organization", provider.organizationId)), + eq(schema.ExternalIdentityTable.userId, normalizeDenTypeId("user", user.id)), + )) + .limit(1); + const now = new Date(); + const existing = existingRows[0] ?? null; + const remoteId = pickRemoteIdentity(userInfo); + const displayName = maybeString(userInfo.name) ?? maybeString(userInfo.displayName) ?? maybeString(user.name); + const email = maybeString(userInfo.email) ?? maybeString(user.email); + const payload = { + organizationId: normalizeDenTypeId("organization", provider.organizationId), + userId: normalizeDenTypeId("user", user.id), + source: existing?.scimProviderId ? "scim+sso" : "sso", + ssoProviderId: provider.providerId, + remoteId, + userName: maybeString(userInfo.preferred_username) ?? email, + email, + displayName, + attributesJson: userInfo, + active: true, + lastSsoLoginAt: now, + }; + + if (existing) { + await db + .update(schema.ExternalIdentityTable) + .set({ + ...payload, + scimProviderId: existing.scimProviderId, + externalId: existing.externalId, + nameJson: existing.nameJson, + emailsJson: existing.emailsJson, + lastScimSyncAt: existing.lastScimSyncAt, + }) + .where(eq(schema.ExternalIdentityTable.id, existing.id)); + return; + } + + await db.insert(schema.ExternalIdentityTable).values({ + id: createDenTypeId("externalIdentity"), + ...payload, + }); + }, + }), apiKey({ defaultPrefix: DEN_API_KEY_DEFAULT_PREFIX, enableMetadata: true, diff --git a/ee/apps/den-api/src/routes/org/index.ts b/ee/apps/den-api/src/routes/org/index.ts index 109d00d29..8bd959d43 100644 --- a/ee/apps/den-api/src/routes/org/index.ts +++ b/ee/apps/den-api/src/routes/org/index.ts @@ -9,6 +9,7 @@ import { registerOrgMemberRoutes } from "./members.js" import { registerPluginArchRoutes } from "./plugin-system/routes.js" import { registerOrgRoleRoutes } from "./roles.js" import { registerOrgScimRoutes } from "./scim.js" +import { registerOrgSsoRoutes } from "./sso.js" import { registerOrgSkillRoutes } from "./skills.js" import { registerOrgTeamRoutes } from "./teams.js" import { registerOrgTemplateRoutes } from "./templates.js" @@ -43,6 +44,7 @@ export function registerOrgRoutes(ap registerOrgCoreRoutes(app) registerOrgApiKeyRoutes(app) registerOrgScimRoutes(app) + registerOrgSsoRoutes(app) registerOrgInvitationRoutes(app) registerOrgLlmProviderRoutes(app) registerOrgMemberRoutes(app) diff --git a/ee/apps/den-api/src/routes/org/shared.ts b/ee/apps/den-api/src/routes/org/shared.ts index e5929c959..cf2641ab1 100644 --- a/ee/apps/den-api/src/routes/org/shared.ts +++ b/ee/apps/den-api/src/routes/org/shared.ts @@ -183,6 +183,30 @@ export function ensureScimManager(c: { get: (key: "organizationContext") => OrgR } } +export function ensureSsoManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) { + const payload = c.get("organizationContext") + if (!payload) { + return { + ok: false as const, + response: { + error: "organization_not_found", + }, + } + } + + if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) { + return { ok: true as const } + } + + return { + ok: false as const, + response: { + error: "forbidden", + message: "Only workspace owners and admins can manage SSO.", + }, + } +} + export function createInvitationId() { return createDenTypeId("invitation") } diff --git a/ee/apps/den-api/src/routes/org/sso.ts b/ee/apps/den-api/src/routes/org/sso.ts new file mode 100644 index 000000000..536993131 --- /dev/null +++ b/ee/apps/den-api/src/routes/org/sso.ts @@ -0,0 +1,336 @@ +import type { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { z } from "zod" +import { auth } from "../../auth.js" +import { + deleteOrganizationSsoConnection, + getOrganizationSsoConnection, + getOrganizationSsoSignInPath, + getSsoAcsUrl, + getSsoMetadataUrl, + getSsoOidcRedirectUrl, + getSsoProviderForConnection, + registerOrganizationSsoConnection, +} from "../../sso.js" +import { requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" +import type { OrgRouteVariables } from "./shared.js" +import { ensureSsoManager } from "./shared.js" + +const invalidRequestSchema = z.object({ + error: z.literal("invalid_request"), + details: z.array(z.object({ + message: z.string(), + path: z.array(z.union([z.string(), z.number()])).optional(), + }).passthrough()), +}).meta({ ref: "SsoInvalidRequestError" }) + +const unauthorizedSchema = z.object({ + error: z.literal("unauthorized"), +}).meta({ ref: "SsoUnauthorizedError" }) + +const organizationNotFoundSchema = z.object({ + error: z.literal("organization_not_found"), +}).meta({ ref: "SsoOrganizationNotFoundError" }) + +const forbiddenSchema = z.object({ + error: z.literal("forbidden"), + message: z.string(), +}).meta({ ref: "SsoForbiddenError" }) + +const ssoConnectionSchema = z.object({ + id: z.string(), + providerId: z.string(), + kind: z.enum(["oidc", "saml"]), + issuer: z.string().url(), + domain: z.string(), + status: z.string(), + signInPath: z.string(), + signInUrl: z.string().url(), + redirectUrl: z.string().url(), + acsUrl: z.string().url().nullable(), + metadataUrl: z.string().url().nullable(), + domainVerified: z.boolean(), + lastTestedAt: z.string().datetime().nullable(), + lastError: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).meta({ ref: "OrganizationSsoConnection" }) + +const ssoConnectionResponseSchema = z.object({ + connection: ssoConnectionSchema.nullable(), +}).meta({ ref: "OrganizationSsoConnectionResponse" }) + +const baseRegistrationSchema = z.object({ + issuer: z.string().url(), + domain: z.string().min(1), +}) + +const samlRegistrationSchema = baseRegistrationSchema.extend({ + entryPoint: z.string().url(), + cert: z.string().min(1), + audience: z.string().url().optional(), + wantAssertionsSigned: z.boolean().optional(), + authnRequestsSigned: z.boolean().optional(), +}).meta({ ref: "RegisterOrganizationSamlSsoBody" }) + +const oidcRegistrationSchema = baseRegistrationSchema.extend({ + clientId: z.string().min(1), + clientSecret: z.string().min(1), + scopes: z.array(z.string()).optional(), + skipDiscovery: z.boolean().optional(), + authorizationEndpoint: z.string().url().optional(), + tokenEndpoint: z.string().url().optional(), + jwksEndpoint: z.string().url().optional(), + userInfoEndpoint: z.string().url().optional(), +}).meta({ ref: "RegisterOrganizationOidcSsoBody" }) + +const metadataQuerySchema = z.object({ + format: z.enum(["xml", "json"]).default("xml"), +}).meta({ ref: "OrganizationSsoMetadataQuery" }) + +function serializeConnection(input: { + connection: NonNullable>> + signInUrl: string + redirectUrl: string + acsUrl: string | null + metadataUrl: string | null + domainVerified: boolean +}) { + const { connection, signInUrl, redirectUrl, acsUrl, metadataUrl, domainVerified } = input + return { + id: connection.id, + providerId: connection.providerId, + kind: connection.kind === "saml" ? "saml" : "oidc", + issuer: connection.issuer, + domain: connection.domain, + status: connection.status, + signInPath: connection.signInPath, + signInUrl, + redirectUrl, + acsUrl, + metadataUrl, + domainVerified, + lastTestedAt: connection.lastTestedAt ? connection.lastTestedAt.toISOString() : null, + lastError: connection.lastError, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + } +} + +async function buildConnectionPayload(connection: NonNullable>>, origin: string) { + const provider = await getSsoProviderForConnection(connection) + const signInUrl = new URL(connection.signInPath || getOrganizationSsoSignInPath(""), origin).toString() + const redirectUrl = getSsoOidcRedirectUrl(connection.providerId) + const acsUrl = connection.kind === "saml" ? getSsoAcsUrl(connection.providerId) : null + const metadataUrl = connection.kind === "saml" ? getSsoMetadataUrl(connection.providerId) : null + return serializeConnection({ + connection, + signInUrl, + redirectUrl, + acsUrl, + metadataUrl, + domainVerified: provider?.domainVerified ?? false, + }) +} + +export function registerOrgSsoRoutes(app: Hono) { + app.get( + "/v1/sso", + describeRoute({ + tags: ["SSO"], + summary: "Get organization SSO connection", + description: "Returns the current organization SSO connection and setup URLs.", + security: [{ bearerAuth: [] }], + responses: { + 200: { description: "Organization SSO configuration", content: { "application/json": { schema: resolver(ssoConnectionResponseSchema) } } }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationSsoConnection(payload.organization.id) + if (!connection) { + return c.json({ connection: null }) + } + + return c.json({ + connection: await buildConnectionPayload(connection, c.req.url), + }) + }, + ) + + app.post( + "/v1/sso/saml", + describeRoute({ + tags: ["SSO"], + summary: "Register organization SAML SSO", + description: "Registers or replaces the active organization SAML SSO provider.", + security: [{ bearerAuth: [] }], + responses: { + 201: { description: "Organization SSO connection created", content: { "application/json": { schema: resolver(ssoConnectionResponseSchema) } } }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const parsed = samlRegistrationSchema.safeParse(await c.req.json()) + if (!parsed.success) { + return c.json({ + error: "invalid_request", + details: parsed.error.issues.map((issue) => ({ message: issue.message, path: issue.path })), + }, 400) + } + + const payload = c.get("organizationContext") + const connection = await registerOrganizationSsoConnection({ + kind: "saml", + organizationId: payload.organization.id, + organizationSlug: payload.organization.slug, + headers: c.req.raw.headers, + ...parsed.data, + }) + + return c.json({ connection: await buildConnectionPayload(connection, c.req.url) }, 201) + }, + ) + + app.post( + "/v1/sso/oidc", + describeRoute({ + tags: ["SSO"], + summary: "Register organization OIDC SSO", + description: "Registers or replaces the active organization OIDC SSO provider.", + security: [{ bearerAuth: [] }], + responses: { + 201: { description: "Organization SSO connection created", content: { "application/json": { schema: resolver(ssoConnectionResponseSchema) } } }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const parsed = oidcRegistrationSchema.safeParse(await c.req.json()) + if (!parsed.success) { + return c.json({ + error: "invalid_request", + details: parsed.error.issues.map((issue) => ({ message: issue.message, path: issue.path })), + }, 400) + } + + const payload = c.get("organizationContext") + const connection = await registerOrganizationSsoConnection({ + kind: "oidc", + organizationId: payload.organization.id, + organizationSlug: payload.organization.slug, + headers: c.req.raw.headers, + ...parsed.data, + }) + + return c.json({ connection: await buildConnectionPayload(connection, c.req.url) }, 201) + }, + ) + + app.delete( + "/v1/sso", + describeRoute({ + tags: ["SSO"], + summary: "Delete organization SSO connection", + description: "Deletes the active organization SSO connection.", + security: [{ bearerAuth: [] }], + responses: { + 204: { description: "Organization SSO connection deleted" }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + await deleteOrganizationSsoConnection(payload.organization.id) + return c.body(null, 204) + }, + ) + + app.get( + "/v1/sso/metadata", + describeRoute({ + tags: ["SSO"], + summary: "Get organization SAML SP metadata", + description: "Returns the generated Service Provider metadata for the current organization's SAML connection.", + security: [{ bearerAuth: [] }], + responses: { + 200: { description: "SAML metadata document" }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const parsed = metadataQuerySchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ + error: "invalid_request", + details: parsed.error.issues.map((issue) => ({ message: issue.message, path: issue.path })), + }, 400) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationSsoConnection(payload.organization.id) + if (!connection || connection.kind !== "saml") { + return c.json({ error: "organization_not_found" }, 404) + } + + const response = await auth.api.spMetadata({ + query: { + providerId: connection.providerId, + format: parsed.data.format, + }, + }) + + return response + }, + ) +} diff --git a/ee/apps/den-api/src/sso.ts b/ee/apps/den-api/src/sso.ts new file mode 100644 index 000000000..1d09ff00c --- /dev/null +++ b/ee/apps/den-api/src/sso.ts @@ -0,0 +1,198 @@ +import { and, eq } from "@openwork-ee/den-db/drizzle" +import { SsoConnectionTable, SsoProviderTable } from "@openwork-ee/den-db/schema" +import { createDenTypeId } from "@openwork-ee/utils/typeid" +import { auth } from "./auth.js" +import { db } from "./db.js" +import { env } from "./env.js" + +type SsoConnection = typeof SsoConnectionTable.$inferSelect +type OrganizationId = SsoConnection["organizationId"] + +type SamlRegistrationInput = { + kind: "saml" + issuer: string + domain: string + entryPoint: string + cert: string + audience?: string | null + wantAssertionsSigned?: boolean | null + authnRequestsSigned?: boolean | null +} + +type OidcRegistrationInput = { + kind: "oidc" + issuer: string + domain: string + clientId: string + clientSecret: string + scopes?: string[] | null + skipDiscovery?: boolean | null + authorizationEndpoint?: string | null + tokenEndpoint?: string | null + jwksEndpoint?: string | null + userInfoEndpoint?: string | null +} + +export type OrganizationSsoRegistrationInput = (SamlRegistrationInput | OidcRegistrationInput) & { + organizationId: OrganizationId + organizationSlug: string + headers: Headers +} + +export function buildOrganizationSsoProviderId(organizationId: OrganizationId) { + return `openwork-sso-${organizationId}` +} + +export function getOrganizationSsoSignInPath(organizationSlug: string) { + return `/sso/${encodeURIComponent(organizationSlug)}` +} + +export function getSsoAcsUrl(providerId: string) { + return `${env.betterAuthUrl}/api/auth/sso/saml2/sp/acs/${encodeURIComponent(providerId)}` +} + +export function getSsoMetadataUrl(providerId: string) { + return `${env.betterAuthUrl}/api/auth/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}` +} + +export function getSsoOidcRedirectUrl(providerId: string) { + return `${env.betterAuthUrl}/api/auth/sso/callback/${encodeURIComponent(providerId)}` +} + +export async function getOrganizationSsoConnection(organizationId: OrganizationId) { + const rows = await db + .select() + .from(SsoConnectionTable) + .where(eq(SsoConnectionTable.organizationId, organizationId)) + .limit(1) + + return rows[0] ?? null +} + +export async function deleteOrganizationSsoConnection(organizationId: OrganizationId) { + const connection = await getOrganizationSsoConnection(organizationId) + if (!connection) { + return false + } + + await db.delete(SsoConnectionTable).where(eq(SsoConnectionTable.id, connection.id)) + await db.delete(SsoProviderTable).where(eq(SsoProviderTable.providerId, connection.providerId)) + return true +} + +export async function registerOrganizationSsoConnection(input: OrganizationSsoRegistrationInput) { + const providerId = buildOrganizationSsoProviderId(input.organizationId) + await deleteOrganizationSsoConnection(input.organizationId) + + const common = { + providerId, + issuer: input.issuer, + domain: input.domain, + organizationId: input.organizationId, + } + + if (input.kind === "saml") { + await auth.api.registerSSOProvider({ + body: { + ...common, + samlConfig: { + entryPoint: input.entryPoint, + cert: input.cert, + callbackUrl: getSsoAcsUrl(providerId), + audience: input.audience || env.betterAuthUrl, + wantAssertionsSigned: input.wantAssertionsSigned ?? true, + authnRequestsSigned: input.authnRequestsSigned ?? false, + spMetadata: {}, + mapping: { + id: "nameID", + email: "email", + name: "displayName", + extraFields: { + department: "department", + role: "role", + groups: "groups", + }, + }, + }, + }, + headers: input.headers, + }) + } else { + await auth.api.registerSSOProvider({ + body: { + ...common, + oidcConfig: { + clientId: input.clientId, + clientSecret: input.clientSecret, + skipDiscovery: input.skipDiscovery ?? false, + authorizationEndpoint: input.authorizationEndpoint ?? undefined, + tokenEndpoint: input.tokenEndpoint ?? undefined, + jwksEndpoint: input.jwksEndpoint ?? undefined, + userInfoEndpoint: input.userInfoEndpoint ?? undefined, + scopes: input.scopes ?? ["openid", "email", "profile"], + pkce: true, + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "name", + image: "picture", + extraFields: { + department: "department", + role: "role", + groups: "groups", + }, + }, + }, + }, + headers: input.headers, + }) + } + + await db.insert(SsoConnectionTable).values({ + id: createDenTypeId("ssoConnection"), + organizationId: input.organizationId, + providerId, + kind: input.kind, + issuer: input.issuer, + domain: input.domain, + status: "enabled", + signInPath: getOrganizationSsoSignInPath(input.organizationSlug), + lastTestedAt: new Date(), + lastError: null, + }) + + const connection = await getOrganizationSsoConnection(input.organizationId) + if (!connection) { + throw new Error("SSO connection was created, but could not be loaded.") + } + + return connection +} + +export async function startOrganizationSsoSignIn(input: { + organizationSlug: string + callbackURL: string + loginHint?: string | null +}) { + return auth.api.signInSSO({ + body: { + organizationSlug: input.organizationSlug, + callbackURL: input.callbackURL, + loginHint: input.loginHint || undefined, + }, + }) +} + +export async function getSsoProviderForConnection(connection: SsoConnection) { + const rows = await db + .select() + .from(SsoProviderTable) + .where(and( + eq(SsoProviderTable.providerId, connection.providerId), + eq(SsoProviderTable.organizationId, connection.organizationId), + )) + .limit(1) + + return rows[0] ?? null +} diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts index 24cee4a04..3abc73f90 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-org.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -113,6 +113,25 @@ export type DenOrgScimConnection = { updatedAt: string | null; }; +export type DenOrgSsoConnection = { + id: string; + providerId: string; + kind: "oidc" | "saml"; + issuer: string; + domain: string; + status: string; + signInPath: string; + signInUrl: string; + redirectUrl: string; + acsUrl: string | null; + metadataUrl: string | null; + domainVerified: boolean; + lastTestedAt: string | null; + lastError: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + export type DenOrgContext = { organization: { id: string; @@ -255,6 +274,7 @@ export function getOrgAccessFlags(roleValue: string, isOwner: boolean) { canManageTeams: isAdmin, canManageApiKeys: isAdmin, canManageScim: isAdmin, + canManageSso: isAdmin, }; } @@ -326,6 +346,10 @@ export function getScimRoute(orgSlug?: string | null): string { return `${getOrgDashboardRoute(orgSlug)}/scim`; } +export function getSsoRoute(orgSlug?: string | null): string { + return `${getOrgDashboardRoute(orgSlug)}/sso`; +} + export function getSkillHubsRoute(orgSlug?: string | null): string { return `${getOrgDashboardRoute(orgSlug)}/skill-hubs`; } @@ -762,3 +786,51 @@ export function parseOrgScimPayload(payload: unknown): { scimToken: asString(payload.scimToken), }; } + +export function parseOrgSsoPayload(payload: unknown): { + connection: DenOrgSsoConnection | null; +} { + if (!isRecord(payload)) { + return { connection: null }; + } + + const rawConnection = isRecord(payload.connection) ? payload.connection : null; + const connection = rawConnection + ? (() => { + const id = asString(rawConnection.id); + const providerId = asString(rawConnection.providerId); + const kind = asString(rawConnection.kind); + const issuer = asString(rawConnection.issuer); + const domain = asString(rawConnection.domain); + const status = asString(rawConnection.status); + const signInPath = asString(rawConnection.signInPath); + const signInUrl = asString(rawConnection.signInUrl); + const redirectUrl = asString(rawConnection.redirectUrl); + + if (!id || !providerId || !issuer || !domain || !status || !signInPath || !signInUrl || !redirectUrl || (kind !== "oidc" && kind !== "saml")) { + return null; + } + + return { + id, + providerId, + kind, + issuer, + domain, + status, + signInPath, + signInUrl, + redirectUrl, + acsUrl: asString(rawConnection.acsUrl), + metadataUrl: asString(rawConnection.metadataUrl), + domainVerified: asBoolean(rawConnection.domainVerified), + lastTestedAt: asIsoString(rawConnection.lastTestedAt), + lastError: asString(rawConnection.lastError), + createdAt: asIsoString(rawConnection.createdAt), + updatedAt: asIsoString(rawConnection.updatedAt), + } satisfies DenOrgSsoConnection; + })() + : null; + + return { connection }; +} diff --git a/ee/apps/den-web/app/(den)/dashboard/sso/page.tsx b/ee/apps/den-web/app/(den)/dashboard/sso/page.tsx new file mode 100644 index 000000000..6b89b518d --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/sso/page.tsx @@ -0,0 +1 @@ +export { default } from "../../o/[orgSlug]/dashboard/sso/page"; diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx index 77b09902b..a3d24a3a6 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx @@ -35,6 +35,7 @@ import { getOrgSettingsRoute, getMarketplacesRoute, getPluginsRoute, + getSsoRoute, getScimRoute, getSharedSetupsRoute, getSkillHubsRoute, @@ -114,6 +115,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) { if (pathname.startsWith(getScimRoute(orgSlug))) { return "SCIM"; } + if (pathname.startsWith(getSsoRoute(orgSlug))) { + return "SSO"; + } if (pathname.startsWith(getBackgroundAgentsRoute(orgSlug))) { return "Shared Workspaces"; } @@ -230,6 +234,13 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) { icon: Shield, }] : []), + ...(access.canManageSso + ? [{ + href: activeOrg ? getSsoRoute(activeOrg.slug) : "#", + label: "SSO", + icon: Shield, + }] + : []), { href: activeOrg ? getBillingRoute(activeOrg.slug) : "/checkout", label: "Billing", diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/sso-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/sso-screen.tsx new file mode 100644 index 000000000..a0c79aa73 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/sso-screen.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { Copy, KeyRound, RefreshCw, Shield, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; +import { DenButton } from "../../../../_components/ui/button"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { getOrgAccessFlags, parseOrgSsoPayload, type DenOrgSsoConnection } from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +function formatDateTime(value: string | null) { + if (!value) return "Not configured"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Not configured"; + return date.toLocaleString(); +} + +type FormMode = "saml" | "oidc"; + +export function SsoScreen() { + const { orgId, orgContext } = useOrgDashboard(); + const [connection, setConnection] = useState(null); + const [busy, setBusy] = useState(false); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + const [copiedValue, setCopiedValue] = useState(null); + const [formMode, setFormMode] = useState("saml"); + const [issuer, setIssuer] = useState(""); + const [domain, setDomain] = useState(""); + const [entryPoint, setEntryPoint] = useState(""); + const [cert, setCert] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + + const access = useMemo( + () => getOrgAccessFlags(orgContext?.currentMember.role ?? "member", orgContext?.currentMember.isOwner ?? false), + [orgContext?.currentMember.isOwner, orgContext?.currentMember.role], + ); + + async function loadSsoConfig() { + if (!orgId || !access.canManageSso) { + setConnection(null); + return; + } + + setBusy(true); + setError(null); + try { + const { response, payload } = await requestJson("/v1/sso", { method: "GET" }, 12000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load SSO settings (${response.status}).`)); + } + + const parsed = parseOrgSsoPayload(payload); + setConnection(parsed.connection); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to load SSO settings."); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void loadSsoConfig(); + }, [orgId, access.canManageSso]); + + useEffect(() => { + if (!copiedValue) return; + const timeout = window.setTimeout(() => setCopiedValue(null), 1500); + return () => window.clearTimeout(timeout); + }, [copiedValue]); + + async function copyValue(value: string | null, key: string) { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopiedValue(key); + } catch { + setError("Could not copy that SSO value."); + } + } + + async function handleSave() { + if (!orgId) { + setError("Organization not found."); + return; + } + + setSaving(true); + setError(null); + try { + const path = formMode === "saml" ? "/v1/sso/saml" : "/v1/sso/oidc"; + const body = formMode === "saml" + ? { issuer, domain, entryPoint, cert } + : { issuer, domain, clientId, clientSecret }; + + const { response, payload } = await requestJson(path, { method: "POST", body: JSON.stringify(body) }, 20000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to save SSO settings (${response.status}).`)); + } + + const parsed = parseOrgSsoPayload(payload); + setConnection(parsed.connection); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to save SSO settings."); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!orgId || !window.confirm("Delete this SSO connection?")) { + return; + } + + setDeleting(true); + setError(null); + try { + const { response, payload } = await requestJson("/v1/sso", { method: "DELETE" }, 12000); + if (response.status !== 204 && !response.ok) { + throw new Error(getErrorMessage(payload, `Failed to delete SSO settings (${response.status}).`)); + } + setConnection(null); + await loadSsoConfig(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to delete SSO settings."); + } finally { + setDeleting(false); + } + } + + if (!orgContext) { + return ( + +
Loading organization details...
+
+ ); + } + + return ( + + {!access.canManageSso ? ( +
Only organization owners and admins can manage SSO.
+ ) : ( + <> + {error ?
{error}
: null} + +
+
+ setFormMode("saml")}>SAML + setFormMode("oidc")}>OIDC +
+ +
+ + + {formMode === "saml" ? ( + <> + +