diff --git a/CHANGELOG.md b/CHANGELOG.md index cff95faf2..799b6cef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the ability to configure email code and credentials login from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) - Added a list of configured SSO providers from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303) +- [EE] Added a SCIM 2.0 server for automated user provisioning and deprovisioning from identity providers (Okta, Entra). [#1306](https://github.com/sourcebot-dev/sourcebot/pull/1306) ### Fixed - Validated that `SOURCEBOT_ENCRYPTION_KEY` is exactly 32 characters at startup, failing fast with an actionable message instead of a runtime encryption error. [#1305](https://github.com/sourcebot-dev/sourcebot/pull/1305) diff --git a/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql b/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql new file mode 100644 index 000000000..fc818fcee --- /dev/null +++ b/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "UserToOrg" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "scimExternalId" TEXT; + +-- CreateTable +CREATE TABLE "ScimToken" ( + "name" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + "orgId" INTEGER NOT NULL, + + CONSTRAINT "ScimToken_pkey" PRIMARY KEY ("hash") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScimToken_hash_key" ON "ScimToken"("hash"); + +-- CreateIndex +CREATE INDEX "ScimToken_orgId_idx" ON "ScimToken"("orgId"); + +-- CreateIndex +CREATE INDEX "UserToOrg_orgId_scimExternalId_idx" ON "UserToOrg"("orgId", "scimExternalId"); + +-- AddForeignKey +ALTER TABLE "ScimToken" ADD CONSTRAINT "ScimToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ae0a53840..ebf430426 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -272,6 +272,7 @@ model Org { connections Connection[] repos Repo[] apiKeys ApiKey[] + scimTokens ScimToken[] isOnboarded Boolean @default(false) imageUrl String? @@ -387,7 +388,17 @@ model UserToOrg { role OrgRole @default(MEMBER) + /// SCIM soft-deactivation flag. When false, the membership is suspended by + /// the IdP: the user is treated as a non-member for auth purposes (see + /// `getAuthContext`) but the row is preserved so the IdP can reactivate it. + isActive Boolean @default(true) + + /// The IdP-supplied `externalId` for this membership when provisioned via + /// SCIM. Null for members that joined through invites or self-serve sign-up. + scimExternalId String? + @@id([orgId, userId]) + @@index([orgId, scimExternalId]) } model ApiKey { @@ -404,6 +415,23 @@ model ApiKey { createdById String } +/// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate +/// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is +/// not tied to a user — it acts on behalf of the SCIM integration for the +/// whole org. Only the HMAC hash of the secret is stored. +model ScimToken { + name String + hash String @id @unique + + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@index([orgId]) +} + model Audit { id String @id @default(cuid()) timestamp DateTime @default(now()) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5bb33d146..c299ef1cc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -11,6 +11,7 @@ export const LEGACY_API_KEY_PREFIX = 'sourcebot-'; export const API_KEY_PREFIX = 'sbk_'; export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_'; export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_'; +export const SCIM_TOKEN_PREFIX = 'sbscim_'; /** * Default settings. diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index fbb4be79b..c5b8842be 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { env } from './env.server.js'; import { Token } from '@sourcebot/schemas/v3/shared.type'; import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; -import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js'; +import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX } from './constants.js'; const algorithm = 'aes-256-cbc'; const ivLength = 16; // 16 bytes for CBC @@ -56,6 +56,16 @@ export function generateApiKey(): { key: string; hash: string } { }; } +export function generateScimToken(): { token: string; hash: string } { + const secret = crypto.randomBytes(32).toString('hex'); + const hash = hashSecret(secret); + + return { + token: `${SCIM_TOKEN_PREFIX}${secret}`, + hash, + }; +} + export function generateOAuthToken(): { token: string; hash: string } { const secret = crypto.randomBytes(32).toString('hex'); const hash = hashSecret(secret); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index bcfdac6cd..e3c42ddcc 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -40,7 +40,8 @@ const ALL_ENTITLEMENTS = [ "org-management", "oauth", "ask", - "mcp" + "mcp", + "scim" ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 0c8f281a4..6147cdd08 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -56,6 +56,7 @@ export { decrypt, hashSecret, generateApiKey, + generateScimToken, generateOAuthToken, generateOAuthRefreshToken, verifySignature, diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index c34c126d0..3ba30c0ef 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -55,6 +55,13 @@ const nextConfig = { { source: "/api/mcp", destination: "/api/ee/mcp", + }, + // The SCIM 2.0 server lives under /api/ee/scim/v2 (EE-licensed route + // tree) but is exposed at the clean /scim/v2 path that IdPs (Okta, + // Entra) are configured to send provisioning requests to. + { + source: "/scim/v2/:path*", + destination: "/api/ee/scim/v2/:path*", } ]; }, diff --git a/packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx b/packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx new file mode 100644 index 000000000..58204f930 --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { generateScimToken, revokeScimToken } from "@/ee/features/scim/actions"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { isServiceError } from "@/lib/utils"; +import { Copy, Check, AlertTriangle, Loader2, KeyRound, Plus, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useToast } from "@/components/hooks/use-toast"; +import { formatDistanceToNow } from "date-fns"; +import { useRouter } from "next/navigation"; + +interface ScimToken { + name: string; + createdAt: Date; + lastUsedAt: Date | null; +} + +interface ScimProvisioningSettingsProps { + baseUrl: string; + tokens: ScimToken[]; +} + +export function ScimProvisioningSettings({ baseUrl, tokens }: ScimProvisioningSettingsProps) { + const { toast } = useToast(); + const router = useRouter(); + + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newTokenName, setNewTokenName] = useState(""); + const [isCreatingToken, setIsCreatingToken] = useState(false); + const [newlyCreatedToken, setNewlyCreatedToken] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + const [baseUrlCopied, setBaseUrlCopied] = useState(false); + + const handleCopyBaseUrl = () => { + navigator.clipboard.writeText(baseUrl) + .then(() => { + setBaseUrlCopied(true); + setTimeout(() => setBaseUrlCopied(false), 2000); + }) + .catch(() => { + toast({ title: "Error", description: "Failed to copy base URL", variant: "destructive" }); + }); + }; + + const handleCreateToken = async () => { + if (!newTokenName.trim()) { + toast({ title: "Error", description: "Token name cannot be empty", variant: "destructive" }); + return; + } + + setIsCreatingToken(true); + try { + const result = await generateScimToken(newTokenName.trim()); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to create SCIM token: ${result.message}`, variant: "destructive" }); + return; + } + setNewlyCreatedToken(result.token); + router.refresh(); + } catch (error) { + console.error(error); + toast({ title: "Error", description: `Failed to create SCIM token: ${error}`, variant: "destructive" }); + } finally { + setIsCreatingToken(false); + } + }; + + const handleCopyToken = () => { + if (!newlyCreatedToken) { + return; + } + navigator.clipboard.writeText(newlyCreatedToken) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + toast({ title: "Error", description: "Failed to copy token to clipboard", variant: "destructive" }); + }); + }; + + const handleCloseDialog = () => { + setIsCreateDialogOpen(false); + setNewTokenName(""); + setNewlyCreatedToken(null); + setCopySuccess(false); + }; + + const handleRevokeToken = async (name: string) => { + const result = await revokeScimToken(name); + if (isServiceError(result)) { + toast({ title: "Error", description: `Failed to revoke SCIM token: ${result.message}`, variant: "destructive" }); + return; + } + router.refresh(); + toast({ description: "SCIM token revoked" }); + }; + + const sortedTokens = useMemo( + () => [...tokens].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), + [tokens] + ); + + return ( +
+
+ SCIM connector base URL +
+
+ {baseUrl} +
+ +
+
+ +
+
+ + {tokens.length} SCIM token{tokens.length !== 1 ? "s" : ""} + + + + + + + + + {newlyCreatedToken ? 'Your New SCIM Token' : 'Create SCIM Token'} + + + {newlyCreatedToken ? ( +
+
+ +

+ This is the only time you'll see this token. Copy it now and paste it into your IdP. +

+
+ +
+
+ {newlyCreatedToken} +
+ +
+
+ ) : ( +
+ setNewTokenName(e.target.value)} + placeholder="Enter a name for your SCIM token" + className="mb-2" + /> +
+ )} + + + {newlyCreatedToken ? ( + + ) : ( + <> + + + + )} + +
+
+
+ + {sortedTokens.length === 0 ? ( +
+ No SCIM tokens yet. +
+ ) : ( +
+ {sortedTokens.map((token) => ( +
+
+ +
+
+ {token.name} + + Created {formatDistanceToNow(token.createdAt, { addSuffix: true })} + {" · "} + {token.lastUsedAt + ? `last used ${formatDistanceToNow(token.lastUsedAt, { addSuffix: true })}` + : "never used" + } + +
+ + + + + + + Revoke SCIM Token + + Are you sure you want to revoke {token.name}? Your IdP will no longer be able to provision or deprovision users with this token. This action cannot be undone. + + + + Cancel + handleRevokeToken(token.name)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Revoke + + + + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx b/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx new file mode 100644 index 000000000..f6f29487d --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx @@ -0,0 +1,39 @@ +"use client" + +import { useState } from "react" +import { Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" +import { SettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { UpsellDialog } from "@/features/billing/upsellDialog" + +export function ScimUpsellCard() { + const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false) + + return ( + <> + +
+
+
+ +
+
+

SCIM provisioning is a paid feature

+

Upgrade to provision and deprovision members automatically from your identity provider.

+
+
+ +
+
+ + + + ) +} diff --git a/packages/web/src/app/(app)/settings/security/page.tsx b/packages/web/src/app/(app)/settings/security/page.tsx index e48e88c5f..06a7dae5f 100644 --- a/packages/web/src/app/(app)/settings/security/page.tsx +++ b/packages/web/src/app/(app)/settings/security/page.tsx @@ -5,10 +5,13 @@ import { CredentialsLoginEnabledSettingsCard } from "./components/credentialsLog import { EmailCodeLoginEnabledSettingsCard } from "./components/emailCodeLoginEnabledSettingsCard"; import { IdentityProviderSettingsCard } from "./components/identityProviderSettingsCard"; import { IdentityProviderUpsellCard } from "./components/identityProviderUpsellCard"; +import { ScimProvisioningSettings } from "./components/scimProvisioningSettings"; +import { ScimUpsellCard } from "./components/scimUpsellCard"; +import { getScimTokens } from "@/ee/features/scim/actions"; import { UpgradeBadge } from "@/app/(app)/@sidebar/components/upgradeBadge"; import { getProviders, IdentityProvider } from "@/auth"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; -import { createInviteLink } from "@/lib/utils"; +import { createInviteLink, isServiceError } from "@/lib/utils"; import { authenticatedPage } from "@/middleware/authenticatedPage"; import { OrgRole } from "@sourcebot/db"; import { env, getSMTPConnectionURL, isCredentialsLoginEnabled, isEmailCodeLoginEnabled, isMemberApprovalRequired } from "@sourcebot/shared"; @@ -22,6 +25,11 @@ export default authenticatedPage(async ({ org }) => { const hasSSOEntitlement = await hasEntitlement("sso"); const identityProviders = await getConfiguredIdentityProviders(); + const hasScimEntitlement = await hasEntitlement("scim"); + const scimBaseUrl = `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2`; + const scimTokensResult = hasScimEntitlement ? await getScimTokens() : []; + const scimTokens = isServiceError(scimTokensResult) ? [] : scimTokensResult; + return (
@@ -107,6 +115,29 @@ export default authenticatedPage(async ({ org }) => { )} + +
+
+

SCIM Provisioning

+ {!hasScimEntitlement && } +
+

Provision and deprovision members automatically from your identity provider (Okta, Entra). Configure your IdP with the base URL below and a SCIM token.{" "} + + Learn more + +

+
+ + {!hasScimEntitlement ? ( + + ) : ( + + )}
) diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts new file mode 100644 index 000000000..5b6a4ee24 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts @@ -0,0 +1,10 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { scimJson, toScimListResponse } from '@/ee/features/scim/mapper'; +import { userResourceType } from '@/ee/features/scim/schemas'; +import { withScimAuth } from '@/ee/features/scim/withScimAuth'; +import { NextRequest } from 'next/server'; + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const GET = apiHandler(async (request: NextRequest) => + withScimAuth(request, async () => + scimJson(toScimListResponse([userResourceType], 1, 1), 200))); diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts new file mode 100644 index 000000000..90cd55eab --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts @@ -0,0 +1,10 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { scimJson, toScimListResponse } from '@/ee/features/scim/mapper'; +import { userSchemaDefinition } from '@/ee/features/scim/schemas'; +import { withScimAuth } from '@/ee/features/scim/withScimAuth'; +import { NextRequest } from 'next/server'; + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const GET = apiHandler(async (request: NextRequest) => + withScimAuth(request, async () => + scimJson(toScimListResponse([userSchemaDefinition], 1, 1), 200))); diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts new file mode 100644 index 000000000..6a01235ec --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts @@ -0,0 +1,9 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { scimJson } from '@/ee/features/scim/mapper'; +import { serviceProviderConfig } from '@/ee/features/scim/schemas'; +import { withScimAuth } from '@/ee/features/scim/withScimAuth'; +import { NextRequest } from 'next/server'; + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const GET = apiHandler(async (request: NextRequest) => + withScimAuth(request, async () => scimJson(serviceProviderConfig, 200))); diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts new file mode 100644 index 000000000..9511577c9 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts @@ -0,0 +1,135 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { deactivateScimMember, reactivateScimMember } from '@/ee/features/scim/membership'; +import { scimError, scimJson, toScimUser, type ScimMembership } from '@/ee/features/scim/mapper'; +import { + coerceActive, + resolveEmail, + scimPatchOpSchema, + scimUserReplaceSchema, +} from '@/ee/features/scim/schemas'; +import { withScimAuth, type ScimAuthContext } from '@/ee/features/scim/withScimAuth'; +import { isServiceError } from '@/lib/utils'; +import { NextRequest } from 'next/server'; + +const loadMembership = (prisma: ScimAuthContext['prisma'], orgId: number, userId: string): Promise => + prisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + include: { user: true }, + }); + +// Applies an active state transition, running the deactivate/reactivate helper +// only when the value actually changes. Returns a SCIM error Response on failure. +const applyActive = async (orgId: number, userId: string, current: boolean, next: boolean | undefined): Promise => { + if (next === undefined || next === current) { + return null; + } + const result = next + ? await reactivateScimMember(orgId, userId) + : await deactivateScimMember(orgId, userId); + if (isServiceError(result)) { + return scimError(result.statusCode, result.message); + } + return null; +}; + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const GET = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => + withScimAuth(request, async ({ org, prisma }) => { + const { id } = await params; + const membership = await loadMembership(prisma, org.id, id); + if (!membership) { + return scimError(404, `User ${id} not found`); + } + return scimJson(toScimUser(membership), 200); + })); + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const PUT = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => + withScimAuth(request, async ({ org, prisma }) => { + const { id } = await params; + const membership = await loadMembership(prisma, org.id, id); + if (!membership) { + return scimError(404, `User ${id} not found`); + } + + const parsed = scimUserReplaceSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return scimError(400, 'Invalid SCIM user payload', 'invalidValue'); + } + const payload = parsed.data; + + const name = payload.name?.formatted ?? payload.displayName ?? undefined; + const email = resolveEmail(payload); + await prisma.user.update({ + where: { id }, + data: { name, email }, + }); + + const activeError = await applyActive(org.id, id, membership.isActive, coerceActive(payload.active)); + if (activeError) { + return activeError; + } + + const refreshed = await loadMembership(prisma, org.id, id); + return scimJson(toScimUser(refreshed!), 200); + })); + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const PATCH = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => + withScimAuth(request, async ({ org, prisma }) => { + const { id } = await params; + const membership = await loadMembership(prisma, org.id, id); + if (!membership) { + return scimError(404, `User ${id} not found`); + } + + const parsed = scimPatchOpSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return scimError(400, 'Invalid SCIM PatchOp payload', 'invalidValue'); + } + + // Extract the desired `active` value. IdPs send it two ways: + // { op: "replace", path: "active", value: false } + // { op: "replace", value: { active: false } } + // `op` is case-insensitive. Other operations are ignored (lenient). + let nextActive: boolean | undefined; + for (const operation of parsed.data.Operations) { + const op = operation.op.toLowerCase(); + if (op !== 'replace' && op !== 'add') { + continue; + } + if (operation.path === 'active') { + nextActive = coerceActive(operation.value); + } else if (!operation.path && operation.value && typeof operation.value === 'object') { + const maybe = (operation.value as Record).active; + if (maybe !== undefined) { + nextActive = coerceActive(maybe); + } + } + } + + const activeError = await applyActive(org.id, id, membership.isActive, nextActive); + if (activeError) { + return activeError; + } + + const refreshed = await loadMembership(prisma, org.id, id); + return scimJson(toScimUser(refreshed!), 200); + })); + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const DELETE = apiHandler(async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => + withScimAuth(request, async ({ org, prisma }) => { + const { id } = await params; + const membership = await loadMembership(prisma, org.id, id); + if (!membership) { + return scimError(404, `User ${id} not found`); + } + // DELETE is treated as deactivation, not a hard delete, so the IdP can + // reactivate later and we preserve the user's data/history. + const result = await deactivateScimMember(org.id, id); + if (isServiceError(result)) { + return scimError(result.statusCode, result.message); + } + return new Response(null, { status: 204 }); + })); diff --git a/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts b/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts new file mode 100644 index 000000000..77cdf6b73 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts @@ -0,0 +1,110 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { orgHasAvailability } from '@/lib/authUtils'; +import { reactivateScimMember } from '@/ee/features/scim/membership'; +import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper'; +import { + coerceActive, + parseScimFilter, + resolveEmail, + scimUserCreateSchema, +} from '@/ee/features/scim/schemas'; +import { withScimAuth } from '@/ee/features/scim/withScimAuth'; +import { isServiceError } from '@/lib/utils'; +import { OrgRole } from '@sourcebot/db'; +import { env } from '@sourcebot/shared'; +import { NextRequest } from 'next/server'; +import { SCIM_DEFAULT_COUNT, SCIM_MAX_COUNT } from '@/ee/features/scim/constants'; + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const GET = apiHandler(async (request: NextRequest) => + withScimAuth(request, async ({ org, prisma }) => { + const params = request.nextUrl.searchParams; + const filterParam = params.get('filter'); + const startIndex = Math.max(1, parseInt(params.get('startIndex') ?? '1', 10) || 1); + const count = Math.min(SCIM_MAX_COUNT, Math.max(0, parseInt(params.get('count') ?? `${SCIM_DEFAULT_COUNT}`, 10) || SCIM_DEFAULT_COUNT)); + + // A filter that's present but unrecognized yields an empty result set + // (never a 404/400) so the IdP can decide create-vs-update safely. + const filter = parseScimFilter(filterParam); + if (filterParam && !filter) { + return scimJson(toScimListResponse([], 0, startIndex), 200); + } + + const where = { + orgId: org.id, + ...(filter?.attribute === 'userName' ? { user: { email: { equals: filter.value, mode: 'insensitive' as const } } } : {}), + ...(filter?.attribute === 'externalId' ? { scimExternalId: filter.value } : {}), + }; + + const [total, memberships] = await Promise.all([ + prisma.userToOrg.count({ where }), + prisma.userToOrg.findMany({ + where, + include: { user: true }, + orderBy: { joinedAt: 'asc' }, + skip: startIndex - 1, + take: count, + }), + ]); + + return scimJson(toScimListResponse(memberships.map(toScimUser), total, startIndex), 200); + })); + +// eslint-disable-next-line authz/require-auth-wrapper -- SCIM bearer auth via withScimAuth +export const POST = apiHandler(async (request: NextRequest) => + withScimAuth(request, async ({ org, prisma }) => { + const parsed = scimUserCreateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return scimError(400, 'Invalid SCIM user payload', 'invalidValue'); + } + const payload = parsed.data; + const email = resolveEmail(payload); + const name = payload.name?.formatted ?? payload.displayName ?? undefined; + const isActive = coerceActive(payload.active) ?? true; + + // Find-or-create the user by email. We deliberately bypass `onCreateUser` + // (its JIT/bootstrap logic is for interactive login, not provisioning). + let user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + user = await prisma.user.create({ data: { email, name } }); + } + + const existing = await prisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId: org.id, userId: user.id } }, + include: { user: true }, + }); + + if (existing) { + if (existing.isActive) { + return scimError(409, 'User is already a member of this organization', 'uniqueness'); + } + // Re-provisioning a previously deactivated user → reactivate. + const result = await reactivateScimMember(org.id, user.id, payload.externalId); + if (isServiceError(result)) { + return scimError(result.statusCode, result.message); + } + const refreshed = await prisma.userToOrg.findUniqueOrThrow({ + where: { orgId_userId: { orgId: org.id, userId: user.id } }, + include: { user: true }, + }); + return scimJson(toScimUser(refreshed), 200, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + } + + // New membership: enforce the seat cap before creating. + if (isActive && !(await orgHasAvailability(org.id))) { + return scimError(400, 'Organization seat limit reached', 'tooMany'); + } + + const membership = await prisma.userToOrg.create({ + data: { + userId: user.id, + orgId: org.id, + role: OrgRole.MEMBER, + isActive, + scimExternalId: payload.externalId, + }, + include: { user: true }, + }); + + return scimJson(toScimUser(membership), 201, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + })); diff --git a/packages/web/src/ee/features/audit/types.ts b/packages/web/src/ee/features/audit/types.ts index b936f700b..13c6bcc8c 100644 --- a/packages/web/src/ee/features/audit/types.ts +++ b/packages/web/src/ee/features/audit/types.ts @@ -2,19 +2,20 @@ import { z } from "zod"; export const auditActorSchema = z.object({ id: z.string(), - type: z.enum(["user", "api_key"]), + type: z.enum(["user", "api_key", "scim_token"]), }) export type AuditActor = z.infer; export const auditTargetSchema = z.object({ id: z.string(), - type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat"]), + type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat", "scim_token"]), }) export type AuditTarget = z.infer; export const auditMetadataSchema = z.object({ message: z.string().optional(), api_key: z.string().optional(), + scim_token: z.string().optional(), emails: z.string().optional(), // comma separated list of emails source: z.string().optional(), // request source (e.g., 'mcp') from X-Sourcebot-Client-Source header }) diff --git a/packages/web/src/ee/features/scim/actions.ts b/packages/web/src/ee/features/scim/actions.ts new file mode 100644 index 000000000..657c33e6f --- /dev/null +++ b/packages/web/src/ee/features/scim/actions.ts @@ -0,0 +1,129 @@ +'use server'; + +import { createAudit } from "@/ee/features/audit/audit"; +import { ErrorCode } from "@/lib/errorCodes"; +import { hasEntitlement } from "@/lib/entitlements"; +import { ServiceError } from "@/lib/serviceError"; +import { sew } from "@/middleware/sew"; +import { withAuth } from "@/middleware/withAuth"; +import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { OrgRole } from "@sourcebot/db"; +import { env, generateScimToken as generateScimTokenSecret } from "@sourcebot/shared"; +import { StatusCodes } from "http-status-codes"; + +const scimNotAvailable = (): ServiceError => ({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "SCIM provisioning is not available in your current plan", +}); + +/** + * The base URL an IdP (Okta, Entra) is configured to send SCIM requests to. + * Exposed at the clean `/scim/v2` path via a rewrite in `next.config.mjs`. + */ +export const getScimBaseUrl = async (): Promise<{ baseUrl: string } | ServiceError> => sew(() => + withAuth(async ({ role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('scim')) { + return scimNotAvailable(); + } + return { baseUrl: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2` }; + }))); + +export const generateScimToken = async (name: string): Promise<{ token: string } | ServiceError> => sew(() => + withAuth(async ({ org, user, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('scim')) { + return scimNotAvailable(); + } + + const existing = await prisma.scimToken.findFirst({ + where: { + orgId: org.id, + name, + }, + }); + + if (existing) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.API_KEY_ALREADY_EXISTS, + message: `A SCIM token named "${name}" already exists`, + } satisfies ServiceError; + } + + const { token, hash } = generateScimTokenSecret(); + const scimToken = await prisma.scimToken.create({ + data: { + name, + hash, + orgId: org.id, + }, + }); + + await createAudit({ + action: "scim_token.created", + actor: { id: user.id, type: "user" }, + target: { id: scimToken.hash, type: "scim_token" }, + orgId: org.id, + metadata: { scim_token: name }, + }); + + return { token }; + }))); + +export const revokeScimToken = async (name: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ org, user, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('scim')) { + return scimNotAvailable(); + } + + const scimToken = await prisma.scimToken.findFirst({ + where: { + orgId: org.id, + name, + }, + }); + + if (!scimToken) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.API_KEY_NOT_FOUND, + message: `SCIM token "${name}" not found`, + } satisfies ServiceError; + } + + await prisma.scimToken.delete({ + where: { hash: scimToken.hash }, + }); + + await createAudit({ + action: "scim_token.deleted", + actor: { id: user.id, type: "user" }, + target: { id: scimToken.hash, type: "scim_token" }, + orgId: org.id, + metadata: { scim_token: name }, + }); + + return { success: true }; + }))); + +export const getScimTokens = async (): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('scim')) { + return scimNotAvailable(); + } + + const tokens = await prisma.scimToken.findMany({ + where: { orgId: org.id }, + orderBy: { createdAt: 'desc' }, + }); + + return tokens.map((token) => ({ + name: token.name, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + })); + }))); diff --git a/packages/web/src/ee/features/scim/constants.ts b/packages/web/src/ee/features/scim/constants.ts new file mode 100644 index 000000000..dae10ec9a --- /dev/null +++ b/packages/web/src/ee/features/scim/constants.ts @@ -0,0 +1,14 @@ +// SCIM 2.0 schema URNs (RFC 7643 / 7644). +export const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"; +export const SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; +export const SCIM_PATCH_OP_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; +export const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; +export const SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"; +export const SCIM_RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; +export const SCIM_SCHEMA_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema"; + +export const SCIM_CONTENT_TYPE = "application/scim+json"; + +// Default and max page sizes for list responses. +export const SCIM_DEFAULT_COUNT = 100; +export const SCIM_MAX_COUNT = 200; diff --git a/packages/web/src/ee/features/scim/mapper.ts b/packages/web/src/ee/features/scim/mapper.ts new file mode 100644 index 000000000..c83d93ad5 --- /dev/null +++ b/packages/web/src/ee/features/scim/mapper.ts @@ -0,0 +1,80 @@ +import { Prisma } from "@sourcebot/db"; +import { env } from "@sourcebot/shared"; +import { + SCIM_CONTENT_TYPE, + SCIM_ERROR_SCHEMA, + SCIM_LIST_RESPONSE_SCHEMA, + SCIM_USER_SCHEMA, +} from "./constants"; + +// A membership row with its linked user, as returned by the SCIM endpoints. +export type ScimMembership = Prisma.UserToOrgGetPayload<{ include: { user: true } }>; + +const scimUserLocation = (userId: string): string => + `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${userId}`; + +/** + * Maps a Sourcebot membership + user into a SCIM 2.0 User resource. The SCIM + * `id` is the stable `User.id`; `userName` and the primary email are the + * user's email; `active` reflects the membership's `isActive` flag. + */ +export const toScimUser = (membership: ScimMembership) => { + const { user } = membership; + const [givenName, ...rest] = (user.name ?? "").split(" "); + const familyName = rest.join(" "); + + return { + schemas: [SCIM_USER_SCHEMA], + id: user.id, + ...(membership.scimExternalId ? { externalId: membership.scimExternalId } : {}), + userName: user.email ?? undefined, + name: user.name ? { + formatted: user.name, + givenName: givenName || undefined, + familyName: familyName || undefined, + } : undefined, + emails: user.email ? [{ value: user.email, primary: true }] : [], + active: membership.isActive, + meta: { + resourceType: "User", + created: membership.joinedAt.toISOString(), + lastModified: membership.joinedAt.toISOString(), + location: scimUserLocation(user.id), + }, + }; +}; + +/** Wraps a list of SCIM resources in a SCIM ListResponse envelope. */ +export const toScimListResponse = ( + resources: unknown[], + totalResults: number, + startIndex: number, +) => ({ + schemas: [SCIM_LIST_RESPONSE_SCHEMA], + totalResults, + startIndex, + itemsPerPage: resources.length, + Resources: resources, +}); + +/** Builds a `Response` with the SCIM content type. */ +export const scimJson = (body: unknown, status: number, headers?: Record): Response => + new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": SCIM_CONTENT_TYPE, + ...headers, + }, + }); + +/** + * Builds a SCIM error `Response`. Per RFC 7644 the `status` in the body is a + * string and must match the HTTP status. + */ +export const scimError = (status: number, detail: string, scimType?: string): Response => + scimJson({ + schemas: [SCIM_ERROR_SCHEMA], + status: status.toString(), + ...(scimType ? { scimType } : {}), + detail, + }, status); diff --git a/packages/web/src/ee/features/scim/membership.ts b/packages/web/src/ee/features/scim/membership.ts new file mode 100644 index 000000000..d6018f581 --- /dev/null +++ b/packages/web/src/ee/features/scim/membership.ts @@ -0,0 +1,119 @@ +import { createAudit } from "@/ee/features/audit/audit"; +import { orgHasAvailability } from "@/lib/authUtils"; +import { ErrorCode } from "@/lib/errorCodes"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { __unsafePrisma } from "@/prisma"; +import { syncWithLighthouse } from "@/features/billing/servicePing"; +import { + invalidateAllSessionsForUser, + revokeUserApiKeysInOrg, + revokeUserOAuthTokens, +} from "@/features/userManagement/membershipMutations"; +import { OrgRole, Prisma } from "@sourcebot/db"; +import { StatusCodes } from "http-status-codes"; + +/** + * SCIM soft-deactivation. Mirrors `_removeUserFromOrg` but, instead of deleting + * the membership, sets `isActive = false` so the IdP can later reactivate it. + * Bumps `sessionVersion` (forcing logout on next request) and revokes the + * user's API keys + OAuth tokens so a deactivated user has no path back in. + */ +export const deactivateScimMember = async (orgId: number, userId: string): Promise => { + const result = await __unsafePrisma.$transaction(async (tx) => { + const target = await tx.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + + if (!target) { + return notFound("Member not found in this organization"); + } + + // Refuse to deactivate the last active owner — doing so would lock + // everyone out of org administration. + if (target.role === OrgRole.OWNER && target.isActive) { + const activeOwnerCount = await tx.userToOrg.count({ + where: { orgId, role: OrgRole.OWNER, isActive: true }, + }); + + if (activeOwnerCount <= 1) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED, + message: "Cannot deactivate the last owner of the organization.", + } satisfies ServiceError; + } + } + + await invalidateAllSessionsForUser(tx, userId); + await revokeUserOAuthTokens(tx, userId); + await revokeUserApiKeysInOrg(tx, userId, orgId); + + await tx.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { isActive: false }, + }); + + return null; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + if (!isServiceError(result)) { + await syncWithLighthouse(orgId).catch(() => { /* ignore error */ }); + await createAudit({ + action: "org.member_deactivated", + actor: { id: "scim", type: "scim_token" }, + target: { id: userId, type: "user" }, + orgId, + }); + } + + return result; +}; + +/** + * SCIM reactivation: flips `isActive` back to true. Re-checks seat availability + * first, since deactivated users free their seat and it may have been filled. + * Optionally updates the stored IdP `externalId`. + */ +export const reactivateScimMember = async ( + orgId: number, + userId: string, + scimExternalId?: string, +): Promise => { + const target = await __unsafePrisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + + if (!target) { + return notFound("Member not found in this organization"); + } + + if (!target.isActive) { + const hasAvailability = await orgHasAvailability(orgId); + if (!hasAvailability) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", + } satisfies ServiceError; + } + } + + await __unsafePrisma.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { + isActive: true, + ...(scimExternalId ? { scimExternalId } : {}), + }, + }); + + await syncWithLighthouse(orgId).catch(() => { /* ignore error */ }); + await createAudit({ + action: "org.member_reactivated", + actor: { id: "scim", type: "scim_token" }, + target: { id: userId, type: "user" }, + orgId, + }); + + return null; +}; diff --git a/packages/web/src/ee/features/scim/schemas.ts b/packages/web/src/ee/features/scim/schemas.ts new file mode 100644 index 000000000..dee7351b8 --- /dev/null +++ b/packages/web/src/ee/features/scim/schemas.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import { + SCIM_RESOURCE_TYPE_SCHEMA, + SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA, + SCIM_USER_SCHEMA, +} from "./constants"; + +// ----- Request body schemas (lenient: IdPs send extra attributes) ----- + +const scimEmailSchema = z.object({ + value: z.string(), + primary: z.boolean().optional(), + type: z.string().optional(), +}).passthrough(); + +const scimNameSchema = z.object({ + formatted: z.string().optional(), + givenName: z.string().optional(), + familyName: z.string().optional(), +}).passthrough(); + +export const scimUserCreateSchema = z.object({ + userName: z.string(), + externalId: z.string().optional(), + name: scimNameSchema.optional(), + emails: z.array(scimEmailSchema).optional(), + // `active` may arrive as a boolean or a string ("true"/"false"). + active: z.union([z.boolean(), z.string()]).optional(), + displayName: z.string().optional(), +}).passthrough(); +export type ScimUserCreate = z.infer; + +export const scimUserReplaceSchema = scimUserCreateSchema; +export type ScimUserReplace = z.infer; + +export const scimPatchOpSchema = z.object({ + schemas: z.array(z.string()).optional(), + Operations: z.array(z.object({ + op: z.string(), + path: z.string().optional(), + value: z.unknown().optional(), + }).passthrough()), +}).passthrough(); +export type ScimPatchOp = z.infer; + +/** Coerces a SCIM `active` value (boolean | "true"/"false" | undefined). */ +export const coerceActive = (value: unknown): boolean | undefined => { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + if (value.toLowerCase() === "true") { + return true; + } + if (value.toLowerCase() === "false") { + return false; + } + } + return undefined; +}; + +/** Resolves the primary email from a SCIM user payload. */ +export const resolveEmail = (payload: ScimUserCreate): string => { + const primary = payload.emails?.find((e) => e.primary)?.value; + return (primary ?? payload.emails?.[0]?.value ?? payload.userName).toLowerCase(); +}; + +// ----- Filter parsing ----- + +export type ScimFilter = + | { attribute: "userName" | "externalId"; value: string } + | null; + +/** + * Parses the narrow set of SCIM filters IdPs actually send: + * `userName eq "value"` and `externalId eq "value"`. Operator and attribute + * are matched case-insensitively. Anything else returns `null`, which callers + * treat as "no matching results" rather than an error. + */ +export const parseScimFilter = (filter: string | null): ScimFilter => { + if (!filter) { + return null; + } + const match = filter.match(/^\s*(userName|externalId)\s+eq\s+"([^"]*)"\s*$/i); + if (!match) { + return null; + } + const attribute = match[1].toLowerCase() === "username" ? "userName" : "externalId"; + return { attribute, value: match[2] }; +}; + +// ----- Static discovery documents ----- + +export const serviceProviderConfig = { + schemas: [SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA], + patch: { supported: true }, + bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 }, + filter: { supported: true, maxResults: 200 }, + changePassword: { supported: false }, + sort: { supported: false }, + etag: { supported: false }, + authenticationSchemes: [{ + type: "oauthbearertoken", + name: "OAuth Bearer Token", + description: "Authentication via the SCIM bearer token generated in Sourcebot settings.", + primary: true, + }], + meta: { resourceType: "ServiceProviderConfig" }, +}; + +export const userResourceType = { + schemas: [SCIM_RESOURCE_TYPE_SCHEMA], + id: "User", + name: "User", + endpoint: "/Users", + description: "User Account", + schema: SCIM_USER_SCHEMA, + meta: { resourceType: "ResourceType" }, +}; + +export const userSchemaDefinition = { + id: SCIM_USER_SCHEMA, + name: "User", + description: "User Account", + attributes: [ + { name: "userName", type: "string", multiValued: false, required: true, caseExact: false, mutability: "readWrite", returned: "default", uniqueness: "server" }, + { name: "active", type: "boolean", multiValued: false, required: false, mutability: "readWrite", returned: "default" }, + { + name: "name", type: "complex", multiValued: false, required: false, mutability: "readWrite", returned: "default", + subAttributes: [ + { name: "formatted", type: "string", multiValued: false, required: false }, + { name: "givenName", type: "string", multiValued: false, required: false }, + { name: "familyName", type: "string", multiValued: false, required: false }, + ], + }, + { + name: "emails", type: "complex", multiValued: true, required: false, mutability: "readWrite", returned: "default", + subAttributes: [ + { name: "value", type: "string", multiValued: false, required: false }, + { name: "primary", type: "boolean", multiValued: false, required: false }, + ], + }, + ], + meta: { resourceType: "Schema" }, +}; diff --git a/packages/web/src/ee/features/scim/withScimAuth.ts b/packages/web/src/ee/features/scim/withScimAuth.ts new file mode 100644 index 000000000..0cbbee44f --- /dev/null +++ b/packages/web/src/ee/features/scim/withScimAuth.ts @@ -0,0 +1,76 @@ +import { __unsafePrisma } from "@/prisma"; +import { hasEntitlement } from "@/lib/entitlements"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { hashSecret, SCIM_TOKEN_PREFIX, createLogger } from "@sourcebot/shared"; +import { Org, PrismaClient } from "@sourcebot/db"; +import { NextRequest } from "next/server"; +import { scimError } from "./mapper"; + +const logger = createLogger('scim-auth'); + +export type ScimAuthContext = { + org: Org; + // SCIM acts on behalf of the IdP integration for the whole org — there is + // no user, so we use the unscoped client rather than the user-scoped one. + prisma: PrismaClient; +}; + +/** + * Authenticates a SCIM request via its `Authorization: Bearer sbscim_…` token + * and runs `fn` with an org-scoped (userless) context. Unlike `withAuth`, this + * does NOT resolve a user/role or apply the user-scoped Prisma extension: the + * caller is the IdP provisioning integration, acting org-wide. All responses + * (including errors) use the SCIM content type and error envelope. + */ +export const withScimAuth = async ( + request: NextRequest, + fn: (ctx: ScimAuthContext) => Promise, +): Promise => { + const authorization = request.headers.get("Authorization") ?? undefined; + if (!authorization?.startsWith("Bearer ")) { + return scimError(401, "Missing or malformed Authorization header"); + } + + const bearer = authorization.slice("Bearer ".length); + if (!bearer.startsWith(SCIM_TOKEN_PREFIX)) { + return scimError(401, "Invalid SCIM token"); + } + + const secret = bearer.slice(SCIM_TOKEN_PREFIX.length); + if (!secret) { + return scimError(401, "Invalid SCIM token"); + } + + const scimToken = await __unsafePrisma.scimToken.findUnique({ + where: { hash: hashSecret(secret) }, + }); + if (!scimToken) { + return scimError(401, "Invalid SCIM token"); + } + + // Enforce the entitlement per-request so a license downgrade disables SCIM + // immediately, even with valid tokens still configured in the IdP. + if (!await hasEntitlement('scim')) { + return scimError(403, "SCIM provisioning is not available in your current plan"); + } + + const org = await __unsafePrisma.org.findUnique({ + where: { id: SINGLE_TENANT_ORG_ID }, + }); + if (!org) { + return scimError(500, "Organization not found"); + } + + // Best-effort usage tracking; never block the request on it. + __unsafePrisma.scimToken.update({ + where: { hash: scimToken.hash }, + data: { lastUsedAt: new Date() }, + }).catch(() => { /* ignore */ }); + + try { + return await fn({ org, prisma: __unsafePrisma }); + } catch (error) { + logger.error(`Unhandled SCIM error: ${error instanceof Error ? error.message : String(error)}`); + return scimError(500, "Internal server error"); + } +}; diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts index 859617f91..8427c1e59 100644 --- a/packages/web/src/features/userManagement/actions.ts +++ b/packages/web/src/features/userManagement/actions.ts @@ -5,6 +5,7 @@ import { syncWithLighthouse } from "@/features/billing/servicePing"; import InviteUserEmail from "@/emails/inviteUserEmail"; import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; +import { invalidateAllSessionsForUser, revokeUserApiKeysInOrg, revokeUserOAuthTokens } from "./membershipMutations"; import { ErrorCode } from "@/lib/errorCodes"; import { notFound, ServiceError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; @@ -486,6 +487,7 @@ export const getOrgMembers = async () => sew(() => avatarUrl: member.user.image ?? undefined, role: member.role, joinedAt: member.joinedAt, + isActive: member.isActive, })); }))); @@ -527,53 +529,4 @@ export const getOrgAccountRequests = async () => sew(() => })); }))); -/** - * Invalidates every active JWT cookie for the given user by incrementing - * their `sessionVersion`. The next request from any of their active - * sessions will compare the cookie's baked-in version against the - * (now-bumped) value on the User row, fail, and be treated as logged out. - */ -const invalidateAllSessionsForUser = async ( - prisma: Prisma.TransactionClient, - userId: string, -): Promise => { - await prisma.user.update({ - where: { id: userId }, - data: { sessionVersion: { increment: 1 } }, - }); -}; - -const revokeUserApiKeysInOrg = async ( - prisma: Prisma.TransactionClient, - userId: string, - orgId: number, -): Promise => { - await prisma.apiKey.deleteMany({ - where: { - createdById: userId, - orgId, - } - }); -}; - -const revokeUserOAuthTokens = async ( - prisma: Prisma.TransactionClient, - userId: string, -): Promise => { - await prisma.oAuthToken.deleteMany({ - where: { - userId - } - }); - await prisma.oAuthRefreshToken.deleteMany({ - where: { - userId - } - }); - await prisma.oAuthAuthorizationCode.deleteMany({ - where: { - userId - } - }); -}; diff --git a/packages/web/src/features/userManagement/membershipMutations.ts b/packages/web/src/features/userManagement/membershipMutations.ts new file mode 100644 index 000000000..3b55f5bed --- /dev/null +++ b/packages/web/src/features/userManagement/membershipMutations.ts @@ -0,0 +1,58 @@ +import { Prisma } from "@sourcebot/db"; + +/** + * Low-level membership mutation helpers shared between user-management server + * actions and SCIM provisioning. These are plain functions (not server + * actions) so they can be imported by both `actions.ts` and the SCIM feature; + * they must NOT live in a `'use server'` module. + */ + +/** + * Invalidates every active JWT cookie for the given user by incrementing + * their `sessionVersion`. The next request from any of their active + * sessions will compare the cookie's baked-in version against the + * (now-bumped) value on the User row, fail, and be treated as logged out. + */ +export const invalidateAllSessionsForUser = async ( + prisma: Prisma.TransactionClient, + userId: string, +): Promise => { + await prisma.user.update({ + where: { id: userId }, + data: { sessionVersion: { increment: 1 } }, + }); +}; + +export const revokeUserApiKeysInOrg = async ( + prisma: Prisma.TransactionClient, + userId: string, + orgId: number, +): Promise => { + await prisma.apiKey.deleteMany({ + where: { + createdById: userId, + orgId, + } + }); +}; + +export const revokeUserOAuthTokens = async ( + prisma: Prisma.TransactionClient, + userId: string, +): Promise => { + await prisma.oAuthToken.deleteMany({ + where: { + userId + } + }); + await prisma.oAuthRefreshToken.deleteMany({ + where: { + userId + } + }); + await prisma.oAuthAuthorizationCode.deleteMany({ + where: { + userId + } + }); +}; diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index 9215cabb7..da9a0dda8 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -12,6 +12,20 @@ import { hasEntitlement } from "./entitlements"; const logger = createLogger('web-auth-utils'); +/** + * SCIM is "enabled" for an org once it has at least one SCIM token configured + * (and the entitlement is present). When enabled, the IdP directory is the + * source of truth for membership, so interactive-login JIT auto-join is + * suppressed — users must be provisioned via SCIM. + */ +export const isScimEnabled = async (orgId: number): Promise => { + if (!await hasEntitlement('scim')) { + return false; + } + const tokenCount = await __unsafePrisma.scimToken.count({ where: { orgId } }); + return tokenCount > 0; +}; + export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { if (!user.id) { logger.error("User ID is undefined on user creation"); @@ -115,7 +129,11 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { // distinction exists without the entitlement). If memberApprovalRequired // is true, the user is left without a membership and must submit an // AccountRequest for an owner to approve via addUserToOrganization. - else if (!defaultOrg.memberApprovalRequired) { + // + // When SCIM is enabled, auto-join is suppressed entirely: the IdP is the + // source of truth, so a login for a user the IdP hasn't provisioned creates + // the User row but no membership (they're denied until SCIM provisions them). + else if (!defaultOrg.memberApprovalRequired && !(await isScimEnabled(SINGLE_TENANT_ORG_ID))) { // Don't exceed the licensed seat count. The user row still exists; // they just aren't attached to the org until a seat frees up. const hasAvailability = await orgHasAvailability(defaultOrg.id); @@ -162,23 +180,22 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { * the offline license key, if available. */ export const orgHasAvailability = async (orgId: number): Promise => { - const org = await __unsafePrisma.org.findUniqueOrThrow({ + const seatCap = getSeatCap(); + + // SCIM-deactivated members don't consume a seat, so they free up capacity + // for new provisions while their membership row is preserved. + const memberCount = await __unsafePrisma.userToOrg.count({ where: { - id: orgId, + orgId, + isActive: true, }, - include: { - members: true, - } }); - const seatCap = getSeatCap(); - const memberCount = org.members.length; - if ( seatCap && memberCount >= seatCap ) { - logger.error(`orgHasAvailability: org ${org.id} has reached max capacity`); + logger.error(`orgHasAvailability: org ${orgId} has reached max capacity`); return false; } diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 3ad283254..c172de94a 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -10,6 +10,7 @@ export type UpsellSource = 'license_settings' | 'mcp_settings' | 'sso_settings' | + 'scim_settings' | 'chat_connectors'; export type SourcebotWebClientSource = 'sourcebot-web-client'; diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index bc0586615..6fe06a4f1 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -319,6 +319,8 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); @@ -349,6 +351,8 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); @@ -415,6 +419,8 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); @@ -437,6 +443,8 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); @@ -460,6 +468,8 @@ describe('getAuthContext', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); setMockSession(createMockSession({ user: { id: userId } })); @@ -493,6 +503,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); vi.mocked(userScopedPrismaClientExtension).mockResolvedValue(extension as never); @@ -522,6 +534,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -552,6 +566,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -582,6 +598,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -617,6 +635,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -652,6 +672,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -687,6 +709,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -722,6 +746,8 @@ describe('withAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); setMockSession(null); @@ -765,6 +791,8 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -795,6 +823,8 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); @@ -825,6 +855,8 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -860,6 +892,8 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -895,6 +929,8 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -930,6 +966,8 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.OWNER, }); prisma.apiKey.findUnique.mockResolvedValue({ @@ -965,6 +1003,8 @@ describe('withOptionalAuth', () => { joinedAt: new Date(), userId: userId, orgId: MOCK_ORG.id, + isActive: true, + scimExternalId: null, role: OrgRole.MEMBER, }); setMockSession(null); diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 0e930fa63..649d39e5b 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -85,7 +85,10 @@ export const getAuthContext = async (): Promise