From e0b9f028bcd9afaafe1b78b3fe75d572ca370d3e Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Fri, 12 Jun 2026 17:29:52 -0700 Subject: [PATCH 1/5] feat: add SCIM 2.0 user provisioning (EE) Adds a SCIM 2.0 server so an IdP (Okta, Entra) can provision, update, and deprovision org members. Users-only scope; deprovisioning soft-deactivates the membership (forces logout + revokes tokens) rather than deleting it, and JIT auto-join is suppressed when SCIM is enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../migration.sql | 26 ++ packages/db/prisma/schema.prisma | 28 ++ packages/shared/src/constants.ts | 1 + packages/shared/src/crypto.ts | 12 +- packages/shared/src/entitlements.ts | 3 +- packages/shared/src/index.server.ts | 1 + packages/web/next.config.mjs | 7 + .../components/scimProvisioningSettings.tsx | 263 ++++++++++++++++++ .../security/components/scimUpsellCard.tsx | 39 +++ .../src/app/(app)/settings/security/page.tsx | 33 ++- .../ee/scim/v2/ResourceTypes/route.ts | 10 + .../api/(server)/ee/scim/v2/Schemas/route.ts | 10 + .../ee/scim/v2/ServiceProviderConfig/route.ts | 9 + .../(server)/ee/scim/v2/Users/[id]/route.ts | 135 +++++++++ .../api/(server)/ee/scim/v2/Users/route.ts | 110 ++++++++ packages/web/src/ee/features/audit/types.ts | 5 +- packages/web/src/ee/features/scim/actions.ts | 129 +++++++++ .../web/src/ee/features/scim/constants.ts | 14 + packages/web/src/ee/features/scim/mapper.ts | 80 ++++++ .../web/src/ee/features/scim/membership.ts | 119 ++++++++ packages/web/src/ee/features/scim/schemas.ts | 145 ++++++++++ .../web/src/ee/features/scim/withScimAuth.ts | 76 +++++ .../src/features/userManagement/actions.ts | 51 +--- .../userManagement/membershipMutations.ts | 58 ++++ packages/web/src/lib/authUtils.ts | 37 ++- packages/web/src/lib/posthogEvents.ts | 1 + packages/web/src/middleware/withAuth.test.ts | 40 +++ packages/web/src/middleware/withAuth.ts | 5 +- 29 files changed, 1383 insertions(+), 65 deletions(-) create mode 100644 packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql create mode 100644 packages/web/src/app/(app)/settings/security/components/scimProvisioningSettings.tsx create mode 100644 packages/web/src/app/(app)/settings/security/components/scimUpsellCard.tsx create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/ResourceTypes/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/Schemas/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/ServiceProviderConfig/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/Users/[id]/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/scim/v2/Users/route.ts create mode 100644 packages/web/src/ee/features/scim/actions.ts create mode 100644 packages/web/src/ee/features/scim/constants.ts create mode 100644 packages/web/src/ee/features/scim/mapper.ts create mode 100644 packages/web/src/ee/features/scim/membership.ts create mode 100644 packages/web/src/ee/features/scim/schemas.ts create mode 100644 packages/web/src/ee/features/scim/withScimAuth.ts create mode 100644 packages/web/src/features/userManagement/membershipMutations.ts 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 Date: Mon, 15 Jun 2026 16:33:38 -0700 Subject: [PATCH 2/5] derive org from scim token --- packages/web/src/ee/features/scim/withScimAuth.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/web/src/ee/features/scim/withScimAuth.ts b/packages/web/src/ee/features/scim/withScimAuth.ts index 0cbbee44f..4f25f0b19 100644 --- a/packages/web/src/ee/features/scim/withScimAuth.ts +++ b/packages/web/src/ee/features/scim/withScimAuth.ts @@ -1,6 +1,5 @@ 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"; @@ -10,8 +9,6 @@ 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; }; @@ -43,6 +40,7 @@ export const withScimAuth = async ( const scimToken = await __unsafePrisma.scimToken.findUnique({ where: { hash: hashSecret(secret) }, + include: { org: true }, }); if (!scimToken) { return scimError(401, "Invalid SCIM token"); @@ -54,12 +52,6 @@ export const withScimAuth = async ( 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({ @@ -68,7 +60,10 @@ export const withScimAuth = async ( }).catch(() => { /* ignore */ }); try { - return await fn({ org, prisma: __unsafePrisma }); + return await fn({ + org: scimToken.org, + prisma: __unsafePrisma + }); } catch (error) { logger.error(`Unhandled SCIM error: ${error instanceof Error ? error.message : String(error)}`); return scimError(500, "Internal server error"); From ed5bc429fd0236c203c7a6dbbfbd564a65a323ba Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 15 Jun 2026 16:57:47 -0700 Subject: [PATCH 3/5] await onCreateUser call --- packages/web/src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 3240824e5..c69911329 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -143,7 +143,7 @@ export const getProviders = async () => { sessionVersion: newUser.sessionVersion, } - onCreateUser({ user: authJsUser }); + await onCreateUser({ user: authJsUser }); return authJsUser; // Otherwise, the user exists, so verify the password. From 564895750751d397ae6045453a3f304b476a875c Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 16 Jun 2026 10:09:41 -0700 Subject: [PATCH 4/5] nit --- packages/web/src/features/scim/utils.ts | 10 ++++++++++ packages/web/src/lib/authUtils.ts | 22 ++++++---------------- 2 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/features/scim/utils.ts diff --git a/packages/web/src/features/scim/utils.ts b/packages/web/src/features/scim/utils.ts new file mode 100644 index 000000000..f117cd846 --- /dev/null +++ b/packages/web/src/features/scim/utils.ts @@ -0,0 +1,10 @@ +import { __unsafePrisma } from "@/prisma"; +import { hasEntitlement } from "@/lib/entitlements"; + +export const isScimEnabled = async (orgId: number): Promise => { + if (!await hasEntitlement('scim')) { + return false; + } + const tokenCount = await __unsafePrisma.scimToken.count({ where: { orgId } }); + return tokenCount > 0; +}; diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index da9a0dda8..d5747d0c0 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -3,29 +3,16 @@ import { __unsafePrisma } from "@/prisma"; import { OrgRole } from "@sourcebot/db"; import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError"; -import { createLogger, getSeatCap } from "@sourcebot/shared"; +import { createLogger, getSeatCap, isMemberApprovalRequired } from "@sourcebot/shared"; import { createAudit } from "@/ee/features/audit/audit"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "./errorCodes"; import { syncWithLighthouse } from "@/features/billing/servicePing"; import { hasEntitlement } from "./entitlements"; +import { isScimEnabled } from "@/features/scim/utils"; 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"); @@ -133,7 +120,10 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { // 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))) { + else if ( + !isMemberApprovalRequired(defaultOrg) && + !(await isScimEnabled(defaultOrg.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); From 199c2bd3016560e15892f4066d05a7e2fe5feac3 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 20 Jun 2026 14:28:42 -0700 Subject: [PATCH 5/5] refactor --- .../migration.sql | 3 + packages/db/prisma/schema.prisma | 2 + packages/web/src/__mocks__/prisma.ts | 2 +- packages/web/src/actions.ts | 122 +--- .../components/defaultSidebar/index.tsx | 2 +- .../components/submitAccountRequestButton.tsx | 62 -- .../(app)/components/submitJoinRequest.tsx | 43 -- packages/web/src/app/(app)/layout.tsx | 43 +- .../settings/components/settingsCard.tsx | 8 +- .../web/src/app/(app)/settings/layout.tsx | 2 +- .../members/components/inviteMemberCard.tsx | 2 +- .../members/components/invitesList.tsx | 2 +- .../members/components/membersList.tsx | 33 +- .../members/components/requestsList.tsx | 2 +- .../src/app/(app)/settings/members/page.tsx | 22 +- .../inviteLinkEnabledSettingsCard.tsx | 11 +- .../memberApprovalRequiredSettingsCard.tsx | 6 +- .../components/scimEnabledSettingsCard.tsx | 117 ++++ .../src/app/(app)/settings/security/page.tsx | 14 +- .../(server)/ee/scim/v2/Users/[id]/route.ts | 56 +- .../api/(server)/ee/scim/v2/Users/route.ts | 71 ++- .../app/components/joinOrganizationButton.tsx | 54 -- .../app/components/joinOrganizationCard.tsx | 23 - .../src/app/components/logoutEscapeHatch.tsx | 26 +- packages/web/src/app/invite/actions.ts | 206 ------- packages/web/src/app/invite/page.tsx | 14 +- .../redeem/components/acceptInviteCard.tsx | 2 +- packages/web/src/app/redeem/page.tsx | 22 +- packages/web/src/auth.ts | 2 +- .../web/src/ee/features/membership/actions.ts | 54 ++ packages/web/src/ee/features/scim/actions.ts | 40 ++ .../web/src/ee/features/scim/membership.ts | 119 ---- .../web/src/ee/features/scim/schemas.test.ts | 124 ++++ packages/web/src/ee/features/scim/schemas.ts | 104 ++++ .../web/src/ee/features/scim/withScimAuth.ts | 6 + packages/web/src/ee/features/sso/sso.ts | 2 +- .../src/ee/features/userManagement/actions.ts | 156 ----- .../membership/actions/accountRequests.ts | 278 +++++++++ .../src/features/membership/actions/index.ts | 3 + .../features/membership/actions/invites.ts | 436 ++++++++++++++ .../features/membership/actions/members.ts | 66 +++ .../components/deactivatedMemberBadge.tsx | 23 + .../components/joinOrganizationCard.tsx | 72 +++ .../components/managedByScimBadge.tsx | 32 ++ .../components/managedByScimNotice.tsx | 15 + .../components/notProvisionedCard.tsx | 41 ++ .../components/pendingApprovalCard.tsx} | 10 +- .../components/submitJoinRequestCard.tsx | 93 +++ .../web/src/features/membership/errors.ts | 32 ++ .../web/src/features/membership/logger.ts | 3 + .../membership/membership.service.test.ts | 334 +++++++++++ .../features/membership/membership.service.ts | 332 +++++++++++ .../src/features/membership/onCreateUser.ts | 120 ++++ packages/web/src/features/membership/utils.ts | 43 ++ packages/web/src/features/scim/utils.ts | 6 +- .../src/features/userManagement/actions.ts | 532 ------------------ .../userManagement/membershipMutations.ts | 58 -- packages/web/src/lib/authUtils.ts | 292 ---------- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/middleware/withAuth.test.ts | 120 ++++ 60 files changed, 2714 insertions(+), 1807 deletions(-) rename packages/db/prisma/migrations/{20260612235524_add_scim_users_support => 20260619214548_add_scim_users_support}/migration.sql (89%) delete mode 100644 packages/web/src/app/(app)/components/submitAccountRequestButton.tsx delete mode 100644 packages/web/src/app/(app)/components/submitJoinRequest.tsx create mode 100644 packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx delete mode 100644 packages/web/src/app/components/joinOrganizationButton.tsx delete mode 100644 packages/web/src/app/components/joinOrganizationCard.tsx delete mode 100644 packages/web/src/app/invite/actions.ts create mode 100644 packages/web/src/ee/features/membership/actions.ts delete mode 100644 packages/web/src/ee/features/scim/membership.ts create mode 100644 packages/web/src/ee/features/scim/schemas.test.ts delete mode 100644 packages/web/src/ee/features/userManagement/actions.ts create mode 100644 packages/web/src/features/membership/actions/accountRequests.ts create mode 100644 packages/web/src/features/membership/actions/index.ts create mode 100644 packages/web/src/features/membership/actions/invites.ts create mode 100644 packages/web/src/features/membership/actions/members.ts create mode 100644 packages/web/src/features/membership/components/deactivatedMemberBadge.tsx create mode 100644 packages/web/src/features/membership/components/joinOrganizationCard.tsx create mode 100644 packages/web/src/features/membership/components/managedByScimBadge.tsx create mode 100644 packages/web/src/features/membership/components/managedByScimNotice.tsx create mode 100644 packages/web/src/features/membership/components/notProvisionedCard.tsx rename packages/web/src/{app/(app)/components/pendingApproval.tsx => features/membership/components/pendingApprovalCard.tsx} (91%) create mode 100644 packages/web/src/features/membership/components/submitJoinRequestCard.tsx create mode 100644 packages/web/src/features/membership/errors.ts create mode 100644 packages/web/src/features/membership/logger.ts create mode 100644 packages/web/src/features/membership/membership.service.test.ts create mode 100644 packages/web/src/features/membership/membership.service.ts create mode 100644 packages/web/src/features/membership/onCreateUser.ts create mode 100644 packages/web/src/features/membership/utils.ts delete mode 100644 packages/web/src/features/userManagement/actions.ts delete mode 100644 packages/web/src/features/userManagement/membershipMutations.ts delete mode 100644 packages/web/src/lib/authUtils.ts diff --git a/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql similarity index 89% rename from packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql rename to packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql index fc818fcee..7259bda3e 100644 --- a/packages/db/prisma/migrations/20260612235524_add_scim_users_support/migration.sql +++ b/packages/db/prisma/migrations/20260619214548_add_scim_users_support/migration.sql @@ -1,3 +1,6 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "isScimEnabled" BOOLEAN NOT NULL DEFAULT false; + -- AlterTable ALTER TABLE "UserToOrg" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true, ADD COLUMN "scimExternalId" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f7659150d..0a336ae3d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -276,6 +276,8 @@ model Org { isOnboarded Boolean @default(false) imageUrl String? + isScimEnabled Boolean @default(false) + /// @deprecated This property can be controlled by the environment /// variable `REQUIRE_APPROVAL_NEW_MEMBERS`. To ensure that we use /// the correct setting, use the helper function `isMemberApprovalRequired` diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 5e5c28682..556d896fc 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -17,7 +17,7 @@ export const MOCK_ORG: Org = { updatedAt: new Date(), isOnboarded: true, imageUrl: null, - metadata: null, + isScimEnabled: false, memberApprovalRequired: false, isCredentialsLoginEnabled: true, isEmailCodeLoginEnabled: false, diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index efd996c1b..c64979173 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,26 +1,20 @@ 'use server'; import { createAudit } from "@/ee/features/audit/audit"; -import { env, getSMTPConnectionURL } from "@sourcebot/shared"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; -import { __unsafePrisma } from "@/prisma"; -import { render } from "@react-email/components"; -import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { sew } from "@/middleware/sew"; import { ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db"; -import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { createLogger, env, generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; -import { createTransport } from "nodemailer"; -import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from "./lib/constants"; -import { RepositoryQuery } from "./lib/types"; -import { getAuthenticatedUser, withAuth, withOptionalAuth } from "./middleware/withAuth"; import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; -import { sew } from "@/middleware/sew"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants"; +import { RepositoryQuery } from "./lib/types"; +import { withAuth, withOptionalAuth } from "./middleware/withAuth"; const logger = createLogger('web-actions'); @@ -375,110 +369,6 @@ export const getRepoInfoByName = async (repoName: string) => sew(() => } })); -// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth -export const createAccountRequest = async () => sew(async () => { - const authResult = await getAuthenticatedUser(); - if (!authResult) { - return notAuthenticated(); - } - - const { user } = authResult; - - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return notFound("Organization not found"); - } - - const existingRequest = await __unsafePrisma.accountRequest.findUnique({ - where: { - requestedById_orgId: { - requestedById: user.id, - orgId: org.id, - }, - }, - }); - - if (existingRequest) { - logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`); - return { - success: true, - existingRequest: true, - } - } - - if (!existingRequest) { - await __unsafePrisma.accountRequest.create({ - data: { - requestedById: user.id, - orgId: org.id, - }, - }); - - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - // TODO: This is needed because we can't fetch the origin from the request headers when this is called - // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) - const deploymentUrl = env.AUTH_URL; - - const owners = await __unsafePrisma.user.findMany({ - where: { - orgs: { - some: { - orgId: org.id, - role: "OWNER", - }, - }, - }, - }); - - if (owners.length === 0) { - logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`); - } else { - const html = await render(JoinRequestSubmittedEmail({ - baseUrl: deploymentUrl, - requestor: { - name: user.name ?? undefined, - email: user.email, - avatarUrl: user.image ?? undefined, - }, - orgName: org.name, - orgImageUrl: org.imageUrl ?? undefined, - })); - - const ownerEmails = owners - .map((owner) => owner.email) - .filter((email): email is string => email !== null); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: ownerEmails, - from: env.EMAIL_FROM_ADDRESS, - subject: `New account request for ${org.name} on Sourcebot`, - html, - text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`); - } - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); - } - } - - return { - success: true, - existingRequest: false, - } -}); - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index 39edf49df..04ff3caf6 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -3,7 +3,7 @@ import { auth } from "@/auth"; import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; import { HomeView } from "@/hooks/useHomeView"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; diff --git a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx b/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx deleted file mode 100644 index 85398a7db..000000000 --- a/packages/web/src/app/(app)/components/submitAccountRequestButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { Clock } from "lucide-react" -import { useState } from "react" -import { useToast } from "@/components/hooks/use-toast" -import { createAccountRequest } from "@/actions" -import { isServiceError } from "@/lib/utils" -import { useRouter } from "next/navigation" - - -export function SubmitAccountRequestButton() { - const { toast } = useToast() - const router = useRouter() - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleSubmit = async () => { - setIsSubmitting(true) - const result = await createAccountRequest() - if (!isServiceError(result)) { - if (result.existingRequest) { - toast({ - title: "Request Already Submitted", - description: "Your request to join the organization has already been submitted. Please wait for it to be approved.", - variant: "default", - }) - } else { - toast({ - title: "Request Submitted", - description: "Your request to join the organization has been submitted.", - variant: "default", - }) - } - // Refresh the page to trigger layout re-render and show PendingApprovalCard - router.refresh() - } else { - toast({ - title: "Failed to Submit", - description: `There was an error submitting your request. Reason: ${result.message}`, - variant: "destructive", - }) - } - setIsSubmitting(false) - } - - return ( -
{ - e.preventDefault(); - handleSubmit(); - }}> - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/components/submitJoinRequest.tsx b/packages/web/src/app/(app)/components/submitJoinRequest.tsx deleted file mode 100644 index fdfdc0a20..000000000 --- a/packages/web/src/app/(app)/components/submitJoinRequest.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" -import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import { SubmitAccountRequestButton } from "./submitAccountRequestButton" - -export const SubmitJoinRequest = async () => { - return ( -
- - -
-
- - -
-
- - - -
- -
-

- Request Access -

-

- Submit a request to join this organization -

-
-
- -
-
- -
-
-
-
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index ec355a56d..cd7cb1c4e 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -10,12 +10,14 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, OPTIONAL_PROVID import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; import { notFound, redirect } from "next/navigation"; -import { PendingApprovalCard } from "./components/pendingApproval"; -import { SubmitJoinRequest } from "./components/submitJoinRequest"; +import { PendingApprovalCard } from "../../features/membership/components/pendingApprovalCard"; +import { SubmitJoinRequestCard } from "../../features/membership/components/submitJoinRequestCard"; +import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard"; +import { isScimEnabled } from "@/features/scim/utils"; import { env, getOfflineLicenseMetadata, SOURCEBOT_VERSION, isMemberApprovalRequired } from "@sourcebot/shared"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; import { GcpIapAuth } from "./components/gcpIapAuth"; -import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; +import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { GitHubStarToast } from "./components/githubStarToast"; import { getLinkedAccounts } from "@/ee/features/sso/actions"; @@ -76,26 +78,25 @@ export default async function Layout(props: LayoutProps) { // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. if (!membership) { + if (await isScimEnabled(org)) { + return ; + } + if (!isMemberApprovalRequired(org)) { - return ( -
- - -
- ) - } else { - const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ - where: { - orgId: org.id, - requestedById: session.user.id - } - }); - - if (hasPendingApproval) { - return - } else { - return + return ; + } + + const hasPendingApproval = await __unsafePrisma.accountRequest.findFirst({ + where: { + orgId: org.id, + requestedById: session.user.id } + }); + + if (hasPendingApproval) { + return + } else { + return } } diff --git a/packages/web/src/app/(app)/settings/components/settingsCard.tsx b/packages/web/src/app/(app)/settings/components/settingsCard.tsx index 6649e002b..a9c267506 100644 --- a/packages/web/src/app/(app)/settings/components/settingsCard.tsx +++ b/packages/web/src/app/(app)/settings/components/settingsCard.tsx @@ -30,15 +30,19 @@ interface BasicSettingsCardProps { description?: string; children: ReactNode; footer?: ReactNode; + badge?: ReactNode; className?: string; } -export function BasicSettingsCard({ name, description, children, footer, className }: BasicSettingsCardProps) { +export function BasicSettingsCard({ name, description, children, footer, badge, className }: BasicSettingsCardProps) { return (
-

{name}

+
+

{name}

+ {badge} +
{description && (

{description}

)} diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index cf640ac11..442f9a546 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { isServiceError } from "@/lib/utils"; import { getConnectionStats } from "@/actions"; -import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgAccountRequests } from "@/features/membership/actions"; import { ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@prisma/client"; import { env } from "@sourcebot/shared"; diff --git a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx index b81c16996..9d1eff8c1 100644 --- a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx @@ -11,7 +11,7 @@ import { z } from "zod"; import { PlusCircleIcon, Loader2, AlertTriangle } from "lucide-react"; import { OrgRole } from "@prisma/client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { createInvites } from "@/features/userManagement/actions"; +import { createInvites } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; diff --git a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx index b5ebb4883..6492bd2f1 100644 --- a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx +++ b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx @@ -11,7 +11,7 @@ import { createPathWithQueryParams, isServiceError } from "@/lib/utils"; import { UserAvatar } from "@/components/userAvatar"; import { Copy, MoreVertical, Search } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; -import { cancelInvite } from "@/features/userManagement/actions"; +import { cancelInvite } from "@/features/membership/actions"; import { useRouter } from "next/navigation"; import useCaptureEvent from "@/hooks/useCaptureEvent"; interface Invite { diff --git a/packages/web/src/app/(app)/settings/members/components/membersList.tsx b/packages/web/src/app/(app)/settings/members/components/membersList.tsx index 9976f11de..eb64970d2 100644 --- a/packages/web/src/app/(app)/settings/members/components/membersList.tsx +++ b/packages/web/src/app/(app)/settings/members/components/membersList.tsx @@ -9,14 +9,16 @@ import { useCallback, useMemo, useState } from "react"; import { OrgRole } from "@prisma/client"; import { UserAvatar } from "@/components/userAvatar"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions"; -import { leaveOrg, removeMemberFromOrg } from "@/features/userManagement/actions"; +import { promoteToOwner, demoteToMember } from "@/ee/features/membership/actions"; +import { leaveOrg, removeMemberFromOrg } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useRouter } from "next/navigation"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import Link from "next/link"; +import { ManagedByScimBadge } from "@/features/membership/components/managedByScimBadge"; +import { DeactivatedMemberBadge } from "@/features/membership/components/deactivatedMemberBadge"; type Member = { id: string @@ -25,19 +27,20 @@ type Member = { role: OrgRole joinedAt: Date avatarUrl?: string + scimManaged?: boolean + isActive?: boolean } export interface MembersListProps { members: Member[], currentUserId: string, currentUserRole: OrgRole, - orgName: string, hasOrgManagement: boolean, } const ROLES_AND_PERMISSIONS_DOCS_LINK = "https://docs.sourcebot.dev/docs/configuration/auth/roles-and-permissions" -export const MembersList = ({ members, currentUserId, currentUserRole, orgName, hasOrgManagement }: MembersListProps) => { +export const MembersList = ({ members, currentUserId, currentUserRole, hasOrgManagement }: MembersListProps) => { const [searchQuery, setSearchQuery] = useState("") const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all") const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") @@ -64,6 +67,12 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName, return matchesSearch && matchesRole; }) .sort((a, b) => { + // Deactivated members sink to the bottom, regardless of date sort. + const aActive = a.isActive !== false; + const bActive = b.isActive !== false; + if (aActive !== bActive) { + return aActive ? -1 : 1; + } return dateSort === "newest" ? b.joinedAt.getTime() - a.joinedAt.getTime() : a.joinedAt.getTime() - b.joinedAt.getTime() @@ -197,18 +206,22 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName,
) : ( filteredMembers.map((member) => ( -
-
+
+
-
-
{member.name}
-
{member.email}
+
+
+ {member.name} + {member.isActive === false && } + {member.scimManaged && } +
+
{member.email}
-
+
{member.role.toLowerCase()} diff --git a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx index 2412f0eb7..4fb066880 100644 --- a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx +++ b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx @@ -10,7 +10,7 @@ import { isServiceError } from "@/lib/utils"; import { UserAvatar } from "@/components/userAvatar"; import { CheckCircle, Search, XCircle } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; -import { rejectAccountRequest, approveAccountRequest } from "@/features/userManagement/actions"; +import { rejectAccountRequest, approveAccountRequest } from "@/features/membership/actions"; import { useRouter } from "next/navigation"; import useCaptureEvent from "@/hooks/useCaptureEvent"; diff --git a/packages/web/src/app/(app)/settings/members/page.tsx b/packages/web/src/app/(app)/settings/members/page.tsx index d991b5df6..33762d41d 100644 --- a/packages/web/src/app/(app)/settings/members/page.tsx +++ b/packages/web/src/app/(app)/settings/members/page.tsx @@ -1,5 +1,5 @@ import { MembersList } from "./components/membersList"; -import { getOrgMembers, getOrgInvites, getOrgAccountRequests } from "@/features/userManagement/actions"; +import { getOrgInvites, getOrgMembers, getOrgAccountRequests} from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { InviteMemberCard } from "./components/inviteMemberCard"; import { Tabs, TabsContent } from "@/components/ui/tabs"; @@ -12,7 +12,9 @@ import { OrgRole } from "@sourcebot/db"; import { NotificationDot } from "../../components/notificationDot"; import { Badge } from "@/components/ui/badge"; import { authenticatedPage } from "@/middleware/authenticatedPage"; -import { orgHasAvailability } from "@/lib/authUtils"; +import { orgHasAvailability } from "@/features/membership/utils"; +import { isScimEnabled } from "@/features/scim/utils"; +import { ManagedByScimNotice } from "@/features/membership/components/managedByScimNotice"; import { getSeatCap } from "@sourcebot/shared"; type MembersSettingsPageProps = { @@ -47,6 +49,7 @@ export default authenticatedPage(async ({ org, role, u const hasAvailability = await orgHasAvailability(org.id); const seatCap = getSeatCap(); + const scimEnabled = await isScimEnabled(org); return (
@@ -67,10 +70,16 @@ export default authenticatedPage(async ({ org, role, u )}
- + {scimEnabled ? ( + + SCIM provisioning is enabled. Members are provisioned through your identity provider. + + ) : ( + + )}
@@ -128,7 +137,6 @@ export default authenticatedPage(async ({ org, role, u members={members} currentUserId={user.id} currentUserRole={role} - orgName={org.name} hasOrgManagement={await hasEntitlement('org-management')} /> diff --git a/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx index 863fbe182..b43be38a7 100644 --- a/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx +++ b/packages/web/src/app/(app)/settings/security/components/inviteLinkEnabledSettingsCard.tsx @@ -9,13 +9,15 @@ import { useToast } from "@/components/hooks/use-toast" import { setInviteLinkEnabled } from "@/app/(app)/settings/security/actions" import { cn, isServiceError } from "@/lib/utils" import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { ManagedByScimBadge } from "@/features/membership/components/managedByScimBadge" interface InviteLinkEnabledSettingsCardProps { inviteLinkEnabled: boolean inviteLink: string | null + scimManaged?: boolean } -export function InviteLinkEnabledSettingsCard({ inviteLinkEnabled, inviteLink }: InviteLinkEnabledSettingsCardProps) { +export function InviteLinkEnabledSettingsCard({ inviteLinkEnabled, inviteLink, scimManaged = false }: InviteLinkEnabledSettingsCardProps) { const [enabled, setEnabled] = useState(inviteLinkEnabled) const [isLoading, setIsLoading] = useState(false) const [copied, setCopied] = useState(false) @@ -70,7 +72,8 @@ export function InviteLinkEnabledSettingsCard({ inviteLinkEnabled, inviteLink }: : undefined} + footer={scimManaged ? undefined : (
- } + )} > ) diff --git a/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx index 3fca0e003..cd797df78 100644 --- a/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx +++ b/packages/web/src/app/(app)/settings/security/components/memberApprovalRequiredSettingsCard.tsx @@ -6,13 +6,16 @@ import { setMemberApprovalRequired } from "@/app/(app)/settings/security/actions import { isServiceError } from "@/lib/utils" import { useToast } from "@/components/hooks/use-toast" import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { ManagedByScimBadge } from "@/features/membership/components/managedByScimBadge" interface MemberApprovalRequiredSettingsCardProps { memberApprovalRequired: boolean + scimManaged?: boolean } export const MemberApprovalRequiredSettingsCard = ({ memberApprovalRequired, + scimManaged = false, }: MemberApprovalRequiredSettingsCardProps) => { const [enabled, setEnabled] = useState(memberApprovalRequired) const [isLoading, setIsLoading] = useState(false) @@ -49,11 +52,12 @@ export const MemberApprovalRequiredSettingsCard = ({ : undefined} > ) diff --git a/packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx b/packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx new file mode 100644 index 000000000..688ae6d9e --- /dev/null +++ b/packages/web/src/app/(app)/settings/security/components/scimEnabledSettingsCard.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { setScimEnabled } from "@/ee/features/scim/actions" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" +import { BasicSettingsCard } from "@/app/(app)/settings/components/settingsCard" +import { useRouter } from "next/navigation" + +interface ScimEnabledSettingsCardProps { + isScimEnabled: boolean +} + +export const ScimEnabledSettingsCard = ({ + isScimEnabled, +}: ScimEnabledSettingsCardProps) => { + const [enabled, setEnabled] = useState(isScimEnabled) + const [isLoading, setIsLoading] = useState(false) + // The toggle value awaiting confirmation; null when no dialog is open. + const [pendingChange, setPendingChange] = useState(null) + const { toast } = useToast() + const router = useRouter() + + // Both directions change how membership is governed, so confirm either way. + const handleToggle = (checked: boolean) => { + setPendingChange(checked) + } + + const applyToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setScimEnabled(checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message, + variant: "destructive", + }) + return + } + + setEnabled(checked) + router.refresh() + } catch (error) { + console.error("Error updating SCIM provisioning setting:", error) + toast({ + title: "Error", + description: "Failed to update SCIM provisioning setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( + <> + + + + + { + if (!open) { + setPendingChange(null) + } + }} + > + + + + {pendingChange === false ? "Disable SCIM provisioning?" : "Enable SCIM provisioning?"} + + + {pendingChange === false + ? "Members will no longer be synced from your identity provider. Existing members keep their access. Your SCIM tokens aren't revoked, but they'll stop working until you re-enable SCIM." + : "Your identity provider will become the source of truth for membership. While SCIM is enabled, invite links, member approval, and join requests are disabled. Members can only be added or removed through your IdP."} + + + + Cancel + { + if (pendingChange !== null) { + void applyToggle(pendingChange) + } + }} + disabled={isLoading} + > + {pendingChange === false ? "Disable" : "Enable"} + + + + + + ) +} diff --git a/packages/web/src/app/(app)/settings/security/page.tsx b/packages/web/src/app/(app)/settings/security/page.tsx index 06a7dae5f..e1d56ea85 100644 --- a/packages/web/src/app/(app)/settings/security/page.tsx +++ b/packages/web/src/app/(app)/settings/security/page.tsx @@ -6,6 +6,7 @@ import { EmailCodeLoginEnabledSettingsCard } from "./components/emailCodeLoginEn import { IdentityProviderSettingsCard } from "./components/identityProviderSettingsCard"; import { IdentityProviderUpsellCard } from "./components/identityProviderUpsellCard"; import { ScimProvisioningSettings } from "./components/scimProvisioningSettings"; +import { ScimEnabledSettingsCard } from "./components/scimEnabledSettingsCard"; import { ScimUpsellCard } from "./components/scimUpsellCard"; import { getScimTokens } from "@/ee/features/scim/actions"; import { UpgradeBadge } from "@/app/(app)/@sidebar/components/upgradeBadge"; @@ -18,6 +19,7 @@ import { env, getSMTPConnectionURL, isCredentialsLoginEnabled, isEmailCodeLoginE import { SettingsCardGroup } from "../components/settingsCard"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Info } from "lucide-react"; +import { isScimEnabled } from "@/features/scim/utils"; export default authenticatedPage(async ({ org }) => { const anonymousAccessEnabled = await isAnonymousAccessEnabled(); @@ -29,6 +31,7 @@ export default authenticatedPage(async ({ org }) => { const scimBaseUrl = `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2`; const scimTokensResult = hasScimEntitlement ? await getScimTokens() : []; const scimTokens = isServiceError(scimTokensResult) ? [] : scimTokensResult; + const scimEnabled = await isScimEnabled(org) return ( @@ -53,9 +56,11 @@ export default authenticatedPage(async ({ org }) => { { {!hasScimEntitlement ? ( ) : ( - + <> + + + + {scimEnabled && ( + + )} + )}
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 index 9511577c9..d05149ec7 100644 --- 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 @@ -1,8 +1,9 @@ import { apiHandler } from '@/lib/apiHandler'; -import { deactivateScimMember, reactivateScimMember } from '@/ee/features/scim/membership'; +import { removeMember, setMemberActive } from '@/features/membership/membership.service'; import { scimError, scimJson, toScimUser, type ScimMembership } from '@/ee/features/scim/mapper'; import { coerceActive, + parseScimPatchOperations, resolveEmail, scimPatchOpSchema, scimUserReplaceSchema, @@ -17,15 +18,15 @@ const loadMembership = (prisma: ScimAuthContext['prisma'], orgId: number, 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. +// Applies an active state transition, toggling the membership 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); + const result = await setMemberActive(orgId, userId, next, { + actor: { id: 'scim', type: 'scim_token' }, + }); if (isServiceError(result)) { return scimError(result.statusCode, result.message); } @@ -88,27 +89,23 @@ export const PATCH = apiHandler(async (request: NextRequest, { params }: { param 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); - } - } + // Reduce the operations into the attributes we persist (name, email, + // active). IdPs send these via path-based ops or a no-path bulk object; + // `parseScimPatchOperations` normalizes both. Unrecognized ops/paths are + // ignored rather than rejected, per the SCIM lenient-parsing convention. + const changes = parseScimPatchOperations(parsed.data.Operations); + + if (changes.name !== undefined || changes.email !== undefined) { + await prisma.user.update({ + where: { id }, + data: { + ...(changes.name !== undefined ? { name: changes.name } : {}), + ...(changes.email !== undefined ? { email: changes.email } : {}), + }, + }); } - const activeError = await applyActive(org.id, id, membership.isActive, nextActive); + const activeError = await applyActive(org.id, id, membership.isActive, changes.active); if (activeError) { return activeError; } @@ -125,9 +122,12 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: { para 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); + // Per RFC 7644, DELETE removes the resource: hard-delete the membership + // (the User row is preserved so re-provisioning reuses the same SCIM id). + // Reversible suspension is still available via PATCH/PUT `active: false`. + const result = await removeMember(org.id, id, { + actor: { id: 'scim', type: 'scim_token' }, + }); if (isServiceError(result)) { return scimError(result.statusCode, result.message); } 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 index 77cdf6b73..03635a614 100644 --- 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 @@ -1,6 +1,5 @@ import { apiHandler } from '@/lib/apiHandler'; -import { orgHasAvailability } from '@/lib/authUtils'; -import { reactivateScimMember } from '@/ee/features/scim/membership'; +import { addMember, setMemberActive } from '@/features/membership/membership.service'; import { scimError, scimJson, toScimListResponse, toScimUser } from '@/ee/features/scim/mapper'; import { coerceActive, @@ -9,6 +8,7 @@ import { scimUserCreateSchema, } from '@/ee/features/scim/schemas'; import { withScimAuth } from '@/ee/features/scim/withScimAuth'; +import { ErrorCode } from '@/lib/errorCodes'; import { isServiceError } from '@/lib/utils'; import { OrgRole } from '@sourcebot/db'; import { env } from '@sourcebot/shared'; @@ -60,51 +60,62 @@ export const POST = apiHandler(async (request: NextRequest) => const payload = parsed.data; const email = resolveEmail(payload); const name = payload.name?.formatted ?? payload.displayName ?? undefined; - const isActive = coerceActive(payload.active) ?? true; + const desiredActive = 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). + // Find-or-create the user by email. let user = await prisma.user.findUnique({ where: { email } }); if (!user) { user = await prisma.user.create({ data: { email, name } }); } + const scimActor = { id: 'scim', type: 'scim_token' } as const; 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); + // Map the membership state to the SCIM response: an active member is a + // conflict, a deactivated member is reactivated (role preserved), and a + // brand-new member is created. + let httpStatus: number; + if (existing?.isActive) { + return scimError(409, 'User is already a member of this organization', 'uniqueness'); + } else if (existing) { + const result = await setMemberActive(org.id, user.id, true, { + actor: scimActor, + scimExternalId: payload.externalId, + }); if (isServiceError(result)) { - return scimError(result.statusCode, result.message); + const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined; + return scimError(result.statusCode, result.message, scimType); } - const refreshed = await prisma.userToOrg.findUniqueOrThrow({ - where: { orgId_userId: { orgId: org.id, userId: user.id } }, - include: { user: true }, + httpStatus = 200; + } else { + const result = await addMember(org.id, user.id, { + actor: scimActor, + role: OrgRole.MEMBER, + scimExternalId: payload.externalId, }); - return scimJson(toScimUser(refreshed), 200, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + if (isServiceError(result)) { + const scimType = result.errorCode === ErrorCode.ORG_SEAT_COUNT_REACHED ? 'tooMany' : undefined; + return scimError(result.statusCode, result.message, scimType); + } + httpStatus = 201; } - // New membership: enforce the seat cap before creating. - if (isActive && !(await orgHasAvailability(org.id))) { - return scimError(400, 'Organization seat limit reached', 'tooMany'); + // IdPs normally provision active and suspend later via PATCH; honor a rare + // explicit `active: false` on provisioning. + if (!desiredActive) { + const deactivated = await setMemberActive(org.id, user.id, false, { actor: scimActor }); + if (isServiceError(deactivated)) { + return scimError(deactivated.statusCode, deactivated.message); + } } - const membership = await prisma.userToOrg.create({ - data: { - userId: user.id, - orgId: org.id, - role: OrgRole.MEMBER, - isActive, - scimExternalId: payload.externalId, - }, + const membership = await prisma.userToOrg.findUniqueOrThrow({ + where: { orgId_userId: { orgId: org.id, userId: user.id } }, include: { user: true }, }); - - return scimJson(toScimUser(membership), 201, { Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}` }); + return scimJson(toScimUser(membership), httpStatus, { + Location: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2/Users/${user.id}`, + }); })); diff --git a/packages/web/src/app/components/joinOrganizationButton.tsx b/packages/web/src/app/components/joinOrganizationButton.tsx deleted file mode 100644 index dda376cb7..000000000 --- a/packages/web/src/app/components/joinOrganizationButton.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { useRouter } from "next/navigation"; -import { useToast } from "@/components/hooks/use-toast"; -import { useState } from "react"; -import { Loader2 } from "lucide-react"; -import { joinOrganization } from "../invite/actions"; -import { isServiceError } from "@/lib/utils"; - -export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string }) { - const [isLoading, setIsLoading] = useState(false); - const router = useRouter(); - const { toast } = useToast(); - - const handleJoinOrganization = async () => { - setIsLoading(true); - - try { - const result = await joinOrganization(inviteLinkId); - - if (isServiceError(result)) { - toast({ - title: "Failed to join organization", - description: result.message, - variant: "destructive", - }); - return; - } - - router.refresh(); - } catch (error) { - console.error("Error joining organization:", error); - toast({ - title: "Error", - description: "An unexpected error occurred. Please try again.", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; - - return ( - - ); -} \ No newline at end of file diff --git a/packages/web/src/app/components/joinOrganizationCard.tsx b/packages/web/src/app/components/joinOrganizationCard.tsx deleted file mode 100644 index bb7b1d390..000000000 --- a/packages/web/src/app/components/joinOrganizationCard.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { SourcebotLogo } from "@/app/components/sourcebotLogo"; -import { JoinOrganizationButton } from "./joinOrganizationButton"; - -export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) { - return ( -
- - - - - -
-

- Welcome to Sourcebot! Click the button below to join this organization. -

-
- -
-
-
- ); -} \ No newline at end of file diff --git a/packages/web/src/app/components/logoutEscapeHatch.tsx b/packages/web/src/app/components/logoutEscapeHatch.tsx index ce7754362..ec317f6e5 100644 --- a/packages/web/src/app/components/logoutEscapeHatch.tsx +++ b/packages/web/src/app/components/logoutEscapeHatch.tsx @@ -1,5 +1,7 @@ +"use client"; + import { LogOutIcon } from "lucide-react"; -import { signOut } from "@/auth"; +import { signOut } from "next-auth/react"; import posthog from "posthog-js"; interface LogoutEscapeHatchProps { @@ -11,24 +13,20 @@ export const LogoutEscapeHatch = ({ }: LogoutEscapeHatchProps) => { return (
-
{ - "use server"; - await signOut({ + -
+ + Log out +
); -} \ No newline at end of file +} diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts deleted file mode 100644 index 2803c34b9..000000000 --- a/packages/web/src/app/invite/actions.ts +++ /dev/null @@ -1,206 +0,0 @@ -"use server"; - -import { createAudit } from "@/ee/features/audit/audit"; -import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; -import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; -import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError"; -import { isServiceError } from "@/lib/utils"; -import { sew } from "@/middleware/sew"; -import { getAuthenticatedUser } from "@/middleware/withAuth"; -import { __unsafePrisma } from "@/prisma"; -import { StatusCodes } from "http-status-codes"; -import { isMemberApprovalRequired } from "@sourcebot/shared"; - -// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing -export const joinOrganization = async (inviteLinkId?: string) => sew(async () => { - const authResult = await getAuthenticatedUser(); - if (!authResult) { - return notAuthenticated(); - } - - const { user } = authResult; - - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return orgNotFound(); - } - - - // If member approval is required we must be using a valid invite link - if (isMemberApprovalRequired(org)) { - if (!org.inviteLinkEnabled) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVITE_LINK_NOT_ENABLED, - message: "Invite link is not enabled.", - } satisfies ServiceError; - } - - if (org.inviteLinkId !== inviteLinkId) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE_LINK, - message: "Invalid invite link.", - } satisfies ServiceError; - } - } - - const addUserToOrgRes = await addUserToOrganization(user.id, org.id); - if (isServiceError(addUserToOrgRes)) { - return addUserToOrgRes; - } - - await createAudit({ - action: "org.member_added", - actor: { id: user.id, type: "user" }, - target: { id: user.id, type: "user" }, - orgId: org.id, - metadata: { - message: `${user.id} joined the organization via invite link`, - }, - }); - - return { - success: true, - } -}); - -// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing -export const redeemInvite = async (inviteId: string): Promise<{ success: boolean; } | ServiceError> => sew(async () => { - const authResult = await getAuthenticatedUser(); - if (!authResult) { - return notAuthenticated(); - } - - const { user } = authResult; - - const invite = await __unsafePrisma.invite.findUnique({ - where: { - id: inviteId, - }, - include: { - org: true, - } - }); - - if (!invite) { - return notFound(); - } - - const failAuditCallback = async (error: string) => { - await createAudit({ - action: "user.invite_accept_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: inviteId, - type: "invite" - }, - orgId: invite.org.id, - metadata: { - message: error - } - }); - }; - - const hasAvailability = await orgHasAvailability(invite.org.id); - if (!hasAvailability) { - await failAuditCallback("Organization is at max capacity"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "Organization is at max capacity", - } satisfies ServiceError; - } - - // Check if the user is the recipient of the invite - if (user.email !== invite.recipientEmail) { - await failAuditCallback("User is not the recipient of the invite"); - return notFound(); - } - - const addUserToOrgRes = await addUserToOrganization(user.id, invite.orgId); - if (isServiceError(addUserToOrgRes)) { - await failAuditCallback(addUserToOrgRes.message); - return addUserToOrgRes; - } - - await createAudit({ - action: "user.invite_accepted", - actor: { - id: user.id, - type: "user" - }, - orgId: invite.org.id, - target: { - id: inviteId, - type: "invite" - } - }); - - await createAudit({ - action: "org.member_added", - actor: { id: user.id, type: "user" }, - target: { id: user.id, type: "user" }, - orgId: invite.org.id, - metadata: { - message: `${user.id} joined the organization by accepting invite ${inviteId}`, - }, - }); - - return { - success: true, - }; -}); - - -// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since the invitee is not yet a member -export const getInviteInfo = async (inviteId: string) => sew(async () => { - const authResult = await getAuthenticatedUser(); - if (!authResult) { - return notAuthenticated(); - } - - const { user } = authResult; - - const invite = await __unsafePrisma.invite.findUnique({ - where: { - id: inviteId, - }, - include: { - org: true, - host: true, - } - }); - - if (!invite) { - return notFound(); - } - - if (invite.recipientEmail !== user.email) { - return notFound(); - } - - return { - id: invite.id, - orgName: invite.org.name, - orgImageUrl: invite.org.imageUrl ?? undefined, - host: { - name: invite.host.name ?? undefined, - email: invite.host.email, - avatarUrl: invite.host.image ?? undefined, - }, - recipient: { - name: user.name ?? undefined, - email: user.email, - } - }; -}); diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx index b8173dee0..f00348658 100644 --- a/packages/web/src/app/invite/page.tsx +++ b/packages/web/src/app/invite/page.tsx @@ -5,8 +5,9 @@ import { notFound, redirect } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; -import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; -import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; +import { JoinOrganizationCard } from "@/features/membership/components/joinOrganizationCard"; +import { NotProvisionedCard } from "@/features/membership/components/notProvisionedCard"; +import { isScimEnabled } from "@/features/scim/utils"; interface InvitePageProps { searchParams: Promise<{ @@ -45,12 +46,13 @@ export default async function InvitePage(props: InvitePageProps) { redirect(`/`); } + if (await isScimEnabled(org)) { + return ; + } + // User is logged in but not a member, show join invitation return ( -
- - -
+ ); } diff --git a/packages/web/src/app/redeem/components/acceptInviteCard.tsx b/packages/web/src/app/redeem/components/acceptInviteCard.tsx index 295ec38af..9a634205a 100644 --- a/packages/web/src/app/redeem/components/acceptInviteCard.tsx +++ b/packages/web/src/app/redeem/components/acceptInviteCard.tsx @@ -9,7 +9,7 @@ import placeholderAvatar from "@/public/placeholder_avatar.png"; import { ArrowRight, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useCallback, useState } from "react"; -import { redeemInvite } from "@/app/invite/actions"; +import { redeemInvite } from "@/features/membership/actions"; import { useRouter } from "next/navigation"; import { useToast } from "@/components/hooks/use-toast"; import { isServiceError } from "@/lib/utils"; diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index 8710e27dc..cbb144fa6 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -1,12 +1,14 @@ import { notFound, redirect } from 'next/navigation'; import { auth } from "@/auth"; -import { getInviteInfo } from "../invite/actions"; +import { getInviteInfo } from "@/features/membership/actions"; import { isServiceError } from "@/lib/utils"; import { AcceptInviteCard } from './components/acceptInviteCard'; import { LogoutEscapeHatch } from '../components/logoutEscapeHatch'; import { InviteNotFoundCard } from './components/inviteNotFoundCard'; import { SINGLE_TENANT_ORG_ID } from '@/lib/constants'; import { __unsafePrisma } from '@/prisma'; +import { isScimEnabled } from '@/features/scim/utils'; +import { NotProvisionedCard } from '@/features/membership/components/notProvisionedCard'; interface RedeemPageProps { searchParams: Promise<{ @@ -31,6 +33,24 @@ export default async function RedeemPage(props: RedeemPageProps) { return redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${inviteId}`)}`); } + const membership = await __unsafePrisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: org.id, + userId: session.user.id + } + } + }); + + // If already a member, redirect to the organization + if (membership) { + redirect(`/`); + } + + if (await isScimEnabled(org)) { + return + } + const inviteInfo = await getInviteInfo(inviteId); return ( diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 4486a4add..f22114ce8 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -15,13 +15,13 @@ import MagicLinkEmail from './emails/magicLinkEmail'; import bcrypt from 'bcryptjs'; import { getEEIdentityProviders } from '@/ee/features/sso/sso'; import { hasEntitlement } from '@/lib/entitlements'; -import { onCreateUser } from '@/lib/authUtils'; import { createAudit } from '@/ee/features/audit/audit'; import { SINGLE_TENANT_ORG_ID } from './lib/constants'; import { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter'; import { getAnonymousId } from '@/lib/anonymousId'; import { captureEvent } from '@/lib/posthog'; import { isEmailCodeLoginEnabled, isCredentialsLoginEnabled } from '@sourcebot/shared' +import { onCreateUser } from './features/membership/onCreateUser'; export const runtime = 'nodejs'; diff --git a/packages/web/src/ee/features/membership/actions.ts b/packages/web/src/ee/features/membership/actions.ts new file mode 100644 index 000000000..f848945ac --- /dev/null +++ b/packages/web/src/ee/features/membership/actions.ts @@ -0,0 +1,54 @@ +'use server'; + +import { sew } from "@/middleware/sew"; +import { ErrorCode } from "@/lib/errorCodes"; +import { ServiceError } from "@/lib/serviceError"; +import { withAuth } from "@/middleware/withAuth"; +import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { OrgRole } from "@sourcebot/db"; +import { hasEntitlement } from "@/lib/entitlements"; +import { isServiceError } from "@/lib/utils"; +import { setMemberRole } from "@/features/membership/membership.service"; +import { StatusCodes } from "http-status-codes"; + +const orgManagementNotAvailable = (): ServiceError => ({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "Organization management is not available in your current plan", +}); + +export const promoteToOwner = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ user, org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('org-management')) { + return orgManagementNotAvailable(); + } + + const result = await setMemberRole(org.id, memberId, OrgRole.OWNER, { + actor: { id: user.id, type: "user" }, + }); + if (isServiceError(result)) { + return result; + } + + return { success: true }; + })) +); + +export const demoteToMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ user, org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('org-management')) { + return orgManagementNotAvailable(); + } + + const result = await setMemberRole(org.id, memberId, OrgRole.MEMBER, { + actor: { id: user.id, type: "user" }, + }); + if (isServiceError(result)) { + return result; + } + + return { success: true }; + })) +); diff --git a/packages/web/src/ee/features/scim/actions.ts b/packages/web/src/ee/features/scim/actions.ts index 657c33e6f..0d3d31276 100644 --- a/packages/web/src/ee/features/scim/actions.ts +++ b/packages/web/src/ee/features/scim/actions.ts @@ -30,6 +30,46 @@ export const getScimBaseUrl = async (): Promise<{ baseUrl: string } | ServiceErr return { baseUrl: `${env.AUTH_URL.replace(/\/$/, '')}/scim/v2` }; }))); +/** + * Whether SCIM provisioning is currently enabled (toggled on) for the org. + * This is the explicit opt-in switch, independent of whether any tokens exist. + */ +export const getIsScimEnabled = async (): Promise<{ enabled: boolean } | ServiceError> => sew(() => + withAuth(async ({ org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('scim')) { + return scimNotAvailable(); + } + return { enabled: org.isScimEnabled }; + }))); + +/** + * Enables or disables SCIM provisioning for the org. Disabling is a kill switch: + * existing tokens stop authenticating and JIT suppression lifts, but tokens are + * preserved so provisioning can be resumed by toggling back on. + */ +export const setScimEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ org, user, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!await hasEntitlement('scim')) { + return scimNotAvailable(); + } + + await prisma.org.update({ + where: { id: org.id }, + data: { isScimEnabled: enabled }, + }); + + await createAudit({ + action: enabled ? "scim.enabled" : "scim.disabled", + actor: { id: user.id, type: "user" }, + target: { id: org.id.toString(), type: "org" }, + orgId: org.id, + }); + + return { success: true }; + }))); + export const generateScimToken = async (name: string): Promise<{ token: string } | ServiceError> => sew(() => withAuth(async ({ org, user, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { diff --git a/packages/web/src/ee/features/scim/membership.ts b/packages/web/src/ee/features/scim/membership.ts deleted file mode 100644 index d6018f581..000000000 --- a/packages/web/src/ee/features/scim/membership.ts +++ /dev/null @@ -1,119 +0,0 @@ -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.test.ts b/packages/web/src/ee/features/scim/schemas.test.ts new file mode 100644 index 000000000..aa36271ac --- /dev/null +++ b/packages/web/src/ee/features/scim/schemas.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'vitest'; +import { parseScimPatchOperations, scimPatchOpSchema } from './schemas'; + +// Builds a typed Operations array via the schema, mirroring what the route +// passes into parseScimPatchOperations after validation. +const ops = (operations: unknown[]): ReturnType['Operations'] => + scimPatchOpSchema.parse({ Operations: operations }).Operations; + +describe('parseScimPatchOperations', () => { + test('extracts active from a path-based replace (boolean)', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'active', value: false }, + ]))).toEqual({ active: false }); + }); + + test('coerces a stringified active value', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'active', value: 'false' }, + ]))).toEqual({ active: false }); + }); + + test('extracts active from the no-path bulk form', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', value: { active: false } }, + ]))).toEqual({ active: false }); + }); + + test('extracts a name change from displayName', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'displayName', value: 'Jane Doe' }, + ]))).toEqual({ name: 'Jane Doe' }); + }); + + test('extracts a name change from name.formatted', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'name.formatted', value: 'Jane Doe' }, + ]))).toEqual({ name: 'Jane Doe' }); + }); + + test('extracts an email change from userName (lowercased)', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'userName', value: 'Jane.New@Corp.COM' }, + ]))).toEqual({ email: 'jane.new@corp.com' }); + }); + + test('extracts an email change from a filtered emails path', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'emails[type eq "work"].value', value: 'jane@corp.com' }, + ]))).toEqual({ email: 'jane@corp.com' }); + }); + + test('matches op and path case-insensitively', () => { + expect(parseScimPatchOperations(ops([ + { op: 'REPLACE', path: 'Active', value: true }, + ]))).toEqual({ active: true }); + }); + + test('honors `add` operations as well as `replace`', () => { + expect(parseScimPatchOperations(ops([ + { op: 'add', path: 'displayName', value: 'New Name' }, + ]))).toEqual({ name: 'New Name' }); + }); + + test('ignores unrecognized operations (e.g. remove)', () => { + expect(parseScimPatchOperations(ops([ + { op: 'remove', path: 'name.givenName' }, + ]))).toEqual({}); + }); + + test('ignores unrecognized paths', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'title', value: 'Engineer' }, + ]))).toEqual({}); + }); + + test('combines name, email, and active across multiple operations', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'name.formatted', value: 'Jane Doe' }, + { op: 'replace', path: 'userName', value: 'jane@corp.com' }, + { op: 'replace', path: 'active', value: false }, + ]))).toEqual({ name: 'Jane Doe', email: 'jane@corp.com', active: false }); + }); + + test('handles a no-path bulk object with multiple attributes', () => { + expect(parseScimPatchOperations(ops([ + { + op: 'replace', + value: { + active: true, + userName: 'jane@corp.com', + name: { formatted: 'Jane Doe' }, + }, + }, + ]))).toEqual({ name: 'Jane Doe', email: 'jane@corp.com', active: true }); + }); + + test('prefers the primary email from a bulk emails array', () => { + expect(parseScimPatchOperations(ops([ + { + op: 'replace', + value: { + emails: [ + { value: 'secondary@corp.com', primary: false }, + { value: 'primary@corp.com', primary: true }, + ], + }, + }, + ]))).toEqual({ email: 'primary@corp.com' }); + }); + + test('later operations override earlier ones', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'active', value: true }, + { op: 'replace', path: 'active', value: false }, + ]))).toEqual({ active: false }); + }); + + test('returns an empty object when no relevant operations are present', () => { + expect(parseScimPatchOperations(ops([ + { op: 'replace', path: 'locale', value: 'en-US' }, + { op: 'remove', path: 'title' }, + ]))).toEqual({}); + }); +}); diff --git a/packages/web/src/ee/features/scim/schemas.ts b/packages/web/src/ee/features/scim/schemas.ts index dee7351b8..15cd7c03f 100644 --- a/packages/web/src/ee/features/scim/schemas.ts +++ b/packages/web/src/ee/features/scim/schemas.ts @@ -65,6 +65,110 @@ export const resolveEmail = (payload: ScimUserCreate): string => { return (primary ?? payload.emails?.[0]?.value ?? payload.userName).toLowerCase(); }; +/** The subset of attributes Sourcebot persists from a SCIM PatchOp. */ +export interface ScimPatchChanges { + name?: string; + email?: string; + active?: boolean; +} + +// Resolves a display name from a SCIM `name` complex value / `displayName`, +// mirroring the precedence used elsewhere (formatted, then displayName). +const resolveNameFromValue = (value: Record): string | undefined => { + const name = value.name; + const formatted = (name && typeof name === "object" && !Array.isArray(name)) + ? (name as Record).formatted + : undefined; + if (typeof formatted === "string") { + return formatted; + } + if (typeof value.displayName === "string") { + return value.displayName; + } + return undefined; +}; + +// Resolves the primary email from a SCIM `emails` array / `userName` value. +const resolveEmailFromValue = (value: Record): string | undefined => { + const emails = value.emails; + if (Array.isArray(emails)) { + const primary = emails.find((e) => e && typeof e === "object" && (e as Record).primary) + ?? emails[0]; + const email = (primary && typeof primary === "object") ? (primary as Record).value : undefined; + if (typeof email === "string") { + return email.toLowerCase(); + } + } + if (typeof value.userName === "string") { + return value.userName.toLowerCase(); + } + return undefined; +}; + +/** + * Reduces a SCIM PatchOp's operations into the subset of changes Sourcebot + * persists: display name, email, and active state. Handles both path-based ops + * (`{op,path,value}`, e.g. `name.formatted`, `userName`, `active`) and the + * no-path bulk form (`{op,value:{...}}`). Operator and attribute names are + * matched case-insensitively. Later operations override earlier ones, and any + * unrecognized op/path is ignored (lenient, never an error). + */ +export const parseScimPatchOperations = (operations: ScimPatchOp["Operations"]): ScimPatchChanges => { + const changes: ScimPatchChanges = {}; + + for (const operation of operations) { + const op = operation.op.toLowerCase(); + if (op !== "replace" && op !== "add") { + continue; + } + + const value = operation.value; + const path = operation.path?.toLowerCase(); + + // No-path bulk form: `value` is an object of attributes to replace. + if (path === undefined) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + const active = coerceActive(record.active); + if (active !== undefined) { + changes.active = active; + } + const name = resolveNameFromValue(record); + if (name !== undefined) { + changes.name = name; + } + const email = resolveEmailFromValue(record); + if (email !== undefined) { + changes.email = email; + } + } + continue; + } + + if (path === "active") { + const active = coerceActive(value); + if (active !== undefined) { + changes.active = active; + } + } else if (path === "username") { + if (typeof value === "string") { + changes.email = value.toLowerCase(); + } + } else if (path === "displayname" || path === "name.formatted") { + if (typeof value === "string") { + changes.name = value; + } + } else if (path.startsWith("emails")) { + // e.g. `emails[type eq "work"].value` → maps to the primary email. + if (typeof value === "string") { + changes.email = value.toLowerCase(); + } + } + } + + return changes; +}; + // ----- Filter parsing ----- export type ScimFilter = diff --git a/packages/web/src/ee/features/scim/withScimAuth.ts b/packages/web/src/ee/features/scim/withScimAuth.ts index 4f25f0b19..c10d5b13d 100644 --- a/packages/web/src/ee/features/scim/withScimAuth.ts +++ b/packages/web/src/ee/features/scim/withScimAuth.ts @@ -52,6 +52,12 @@ export const withScimAuth = async ( return scimError(403, "SCIM provisioning is not available in your current plan"); } + // SCIM is an explicit opt-in: a valid token is rejected unless an owner has + // toggled provisioning on. Disabling acts as a kill switch that pauses all + // provisioning without requiring tokens to be revoked. + if (!scimToken.org.isScimEnabled) { + return scimError(403, "SCIM provisioning is disabled for this organization"); + } // Best-effort usage tracking; never block the request on it. __unsafePrisma.scimToken.update({ diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts index 30777536a..ca5b6b686 100644 --- a/packages/web/src/ee/features/sso/sso.ts +++ b/packages/web/src/ee/features/sso/sso.ts @@ -1,5 +1,4 @@ import type { IdentityProvider } from "@/auth"; -import { onCreateUser } from "@/lib/authUtils"; import { __unsafePrisma } from "@/prisma"; import { hasEntitlement } from "@/lib/entitlements"; import { createLogger, env, getIdentityProviderConfigs, getTokenFromConfig } from "@sourcebot/shared"; @@ -16,6 +15,7 @@ import Google from "next-auth/providers/google"; import Keycloak from "next-auth/providers/keycloak"; import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import Okta from "next-auth/providers/okta"; +import { onCreateUser } from "@/features/membership/onCreateUser"; const logger = createLogger('web-sso'); diff --git a/packages/web/src/ee/features/userManagement/actions.ts b/packages/web/src/ee/features/userManagement/actions.ts deleted file mode 100644 index d02b98b4b..000000000 --- a/packages/web/src/ee/features/userManagement/actions.ts +++ /dev/null @@ -1,156 +0,0 @@ -'use server'; - -import { sew } from "@/middleware/sew"; -import { createAudit } from "@/ee/features/audit/audit"; -import { ErrorCode } from "@/lib/errorCodes"; -import { notFound, ServiceError } from "@/lib/serviceError"; -import { withAuth } from "@/middleware/withAuth"; -import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; -import { OrgRole, Prisma } from "@sourcebot/db"; -import { hasEntitlement } from "@/lib/entitlements"; -import { StatusCodes } from "http-status-codes"; - -const orgManagementNotAvailable = (): ServiceError => ({ - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, - message: "Organization management is not available in your current plan", -}); - -export const promoteToOwner = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ user, org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - if (!await hasEntitlement('org-management')) { - return orgManagementNotAvailable(); - } - - if (memberId === user.id) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "You are already an owner.", - } satisfies ServiceError; - } - - const targetMember = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - orgId: org.id, - userId: memberId, - }, - }, - }); - - if (!targetMember) { - return notFound("Member not found in this organization"); - } - - if (targetMember.role === OrgRole.OWNER) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "This member is already an owner.", - } satisfies ServiceError; - } - - await prisma.userToOrg.update({ - where: { - orgId_userId: { - orgId: org.id, - userId: memberId, - }, - }, - data: { - role: "OWNER", - }, - }); - - await createAudit({ - action: "org.member_promoted_to_owner", - actor: { id: user.id, type: "user" }, - target: { id: memberId, type: "user" }, - orgId: org.id, - metadata: { - message: `${user.id} promoted ${memberId} to owner`, - }, - }); - - return { success: true }; - })) -); - -export const demoteToMember = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ user, org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - if (!await hasEntitlement('org-management')) { - return orgManagementNotAvailable(); - } - - const guardError = await prisma.$transaction(async (tx) => { - const targetMember = await tx.userToOrg.findUnique({ - where: { - orgId_userId: { - orgId: org.id, - userId: memberId, - }, - }, - }); - - if (!targetMember) { - return notFound("Member not found in this organization"); - } - - if (targetMember.role !== OrgRole.OWNER) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "This member is not an owner.", - } satisfies ServiceError; - } - - const ownerCount = await tx.userToOrg.count({ - where: { - orgId: org.id, - role: OrgRole.OWNER, - }, - }); - - if (ownerCount <= 1) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED, - message: "Cannot demote the last owner. Promote another member to owner first.", - } satisfies ServiceError; - } - - await tx.userToOrg.update({ - where: { - orgId_userId: { - orgId: org.id, - userId: memberId, - }, - }, - data: { - role: "MEMBER", - }, - }); - - return null; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); - - if (guardError) { - return guardError; - } - - await createAudit({ - action: "org.owner_demoted_to_member", - actor: { id: user.id, type: "user" }, - target: { id: memberId, type: "user" }, - orgId: org.id, - metadata: { - message: `${user.id} demoted ${memberId} to member`, - }, - }); - - return { success: true }; - })) -); diff --git a/packages/web/src/features/membership/actions/accountRequests.ts b/packages/web/src/features/membership/actions/accountRequests.ts new file mode 100644 index 000000000..34d787816 --- /dev/null +++ b/packages/web/src/features/membership/actions/accountRequests.ts @@ -0,0 +1,278 @@ +'use server'; + +import { createAudit } from "@/ee/features/audit/audit"; +import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail"; +import { addMember } from "@/features/membership/membership.service"; +import { getDefaultMemberRole } from "@/features/membership/utils"; +import { membershipManagedByIdpError } from "@/features/membership/errors"; +import { isScimEnabled } from "@/features/scim/utils"; +import { notAuthenticated, notFound } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { sew } from "@/middleware/sew"; +import { getAuthenticatedUser, withAuth } from "@/middleware/withAuth"; +import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { render } from "@react-email/components"; +import { OrgRole } from "@sourcebot/db"; +import { env, getSMTPConnectionURL } from "@sourcebot/shared"; +import { createTransport } from "nodemailer"; +import { logger } from "../logger"; +import { __unsafePrisma } from "@/prisma"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import JoinRequestSubmittedEmail from "@/emails/joinRequestSubmittedEmail"; + +// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth +export const createAccountRequest = async () => sew(async () => { + const authResult = await getAuthenticatedUser(); + if (!authResult) { + return notAuthenticated(); + } + + const { user } = authResult; + + const org = await __unsafePrisma.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + }); + + if (!org) { + return notFound("Organization not found"); + } + + // With SCIM enabled the IdP is the source of truth for membership, so + // un-provisioned users can't request to join. + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + + const existingRequest = await __unsafePrisma.accountRequest.findUnique({ + where: { + requestedById_orgId: { + requestedById: user.id, + orgId: org.id, + }, + }, + }); + + if (existingRequest) { + logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`); + return { + success: true, + existingRequest: true, + } + } + + if (!existingRequest) { + await __unsafePrisma.accountRequest.create({ + data: { + requestedById: user.id, + orgId: org.id, + }, + }); + + const smtpConnectionUrl = getSMTPConnectionURL(); + if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { + // TODO: This is needed because we can't fetch the origin from the request headers when this is called + // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) + const deploymentUrl = env.AUTH_URL; + + const owners = await __unsafePrisma.user.findMany({ + where: { + orgs: { + some: { + orgId: org.id, + role: "OWNER", + }, + }, + }, + }); + + if (owners.length === 0) { + logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`); + } else { + const html = await render(JoinRequestSubmittedEmail({ + baseUrl: deploymentUrl, + requestor: { + name: user.name ?? undefined, + email: user.email, + avatarUrl: user.image ?? undefined, + }, + orgName: org.name, + orgImageUrl: org.imageUrl ?? undefined, + })); + + const ownerEmails = owners + .map((owner) => owner.email) + .filter((email): email is string => email !== null); + + const transport = createTransport(smtpConnectionUrl); + const result = await transport.sendMail({ + to: ownerEmails, + from: env.EMAIL_FROM_ADDRESS, + subject: `New account request for ${org.name} on Sourcebot`, + html, + text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`); + } + } + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); + } + } + + return { + success: true, + existingRequest: false, + } +}); + +export const approveAccountRequest = async (requestId: string) => sew(async () => + withAuth(async ({ org, user, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + // With SCIM enabled the IdP is the source of truth for membership; + // approving a request would mint a member it never provisioned. + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + + const failAuditCallback = async (error: string) => { + await createAudit({ + action: "user.join_request_approve_failed", + actor: { + id: user.id, + type: "user" + }, + target: { + id: requestId, + type: "account_join_request" + }, + orgId: org.id, + metadata: { + message: error, + } + }); + } + + const request = await prisma.accountRequest.findUnique({ + where: { + id: requestId, + }, + include: { + requestedBy: true, + }, + }); + + if (!request || request.orgId !== org.id) { + await failAuditCallback("Request not found"); + return notFound(); + } + + const addUserToOrgRes = await addMember(org.id, request.requestedById, { + actor: { id: request.requestedById, type: "user" }, + role: await getDefaultMemberRole(), + }); + if (isServiceError(addUserToOrgRes)) { + await failAuditCallback(addUserToOrgRes.message); + return addUserToOrgRes; + } + + + await createAudit({ + action: "user.join_request_approved", + actor: { + id: user.id, + type: "user" + }, + orgId: org.id, + target: { + id: requestId, + type: "account_join_request" + } + }); + + // Send approval email to the user + const smtpConnectionUrl = getSMTPConnectionURL(); + if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { + const html = await render(JoinRequestApprovedEmail({ + baseUrl: env.AUTH_URL, + user: { + name: request.requestedBy.name ?? undefined, + email: request.requestedBy.email, + avatarUrl: request.requestedBy.image ?? undefined, + }, + orgName: org.name, + })); + + const transport = createTransport(smtpConnectionUrl); + const result = await transport.sendMail({ + to: request.requestedBy.email, + from: env.EMAIL_FROM_ADDRESS, + subject: `Your request to join ${org.name} has been approved`, + html, + text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); + } + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); + } + + return { + success: true, + } + }) + )); + +export const rejectAccountRequest = async (requestId: string) => sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const request = await prisma.accountRequest.findUnique({ + where: { + id: requestId, + }, + }); + + if (!request || request.orgId !== org.id) { + return notFound(); + } + + await prisma.accountRequest.delete({ + where: { + id: requestId, + }, + }); + + return { + success: true, + } + }) + )); + +export const getOrgAccountRequests = async () => sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const requests = await prisma.accountRequest.findMany({ + where: { + orgId: org.id, + }, + include: { + requestedBy: true, + }, + }); + + return requests.map((request) => ({ + id: request.id, + email: request.requestedBy.email, + createdAt: request.createdAt, + name: request.requestedBy.name ?? undefined, + image: request.requestedBy.image ?? undefined, + })); + }))); + diff --git a/packages/web/src/features/membership/actions/index.ts b/packages/web/src/features/membership/actions/index.ts new file mode 100644 index 000000000..13ef2b22f --- /dev/null +++ b/packages/web/src/features/membership/actions/index.ts @@ -0,0 +1,3 @@ +export * from './members'; +export * from './invites'; +export * from './accountRequests'; \ No newline at end of file diff --git a/packages/web/src/features/membership/actions/invites.ts b/packages/web/src/features/membership/actions/invites.ts new file mode 100644 index 000000000..d2b4bf519 --- /dev/null +++ b/packages/web/src/features/membership/actions/invites.ts @@ -0,0 +1,436 @@ +'use server'; + +import { createAudit } from "@/ee/features/audit/audit"; +import InviteUserEmail from "@/emails/inviteUserEmail"; +import { addMember } from "@/features/membership/membership.service"; +import { getDefaultMemberRole, orgHasAvailability } from "@/features/membership/utils"; +import { membershipManagedByIdpError } from "@/features/membership/errors"; +import { isScimEnabled } from "@/features/scim/utils"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { ErrorCode } from "@/lib/errorCodes"; +import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { sew } from "@/middleware/sew"; +import { getAuthenticatedUser, withAuth } from "@/middleware/withAuth"; +import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { __unsafePrisma } from "@/prisma"; +import { render } from "@react-email/components"; +import { OrgRole } from "@sourcebot/db"; +import { env, getSMTPConnectionURL, isMemberApprovalRequired } from "@sourcebot/shared"; +import { StatusCodes } from "http-status-codes"; +import { createTransport } from "nodemailer"; +import { logger } from "../logger"; + +export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ org, user, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + // With SCIM enabled the IdP is the source of truth for membership; + // invites would add members outside it. + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + + const failAuditCallback = async (error: string) => { + await createAudit({ + action: "user.invite_failed", + actor: { + id: user.id, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + message: error, + emails: emails.join(", ") + } + }); + } + + const hasAvailability = await orgHasAvailability(org.id); + if (!hasAvailability) { + await createAudit({ + action: "user.invite_failed", + actor: { + id: user.id, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + message: "Organization has reached maximum number of seats", + emails: emails.join(", ") + } + }); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "The organization has reached the maximum number of seats. Unable to create a new invite", + } satisfies ServiceError; + } + + // Check for existing invites + const existingInvites = await prisma.invite.findMany({ + where: { + recipientEmail: { + in: emails + }, + orgId: org.id, + } + }); + + if (existingInvites.length > 0) { + await failAuditCallback("A pending invite already exists for one or more of the provided emails"); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_INVITE, + message: `A pending invite already exists for one or more of the provided emails.`, + } satisfies ServiceError; + } + + // Check for members that are already in the org + const existingMembers = await prisma.userToOrg.findMany({ + where: { + user: { + email: { + in: emails, + } + }, + orgId: org.id, + }, + }); + + if (existingMembers.length > 0) { + await failAuditCallback("One or more of the provided emails are already members of this org"); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_INVITE, + message: `One or more of the provided emails are already members of this org.`, + } satisfies ServiceError; + } + + await prisma.invite.createMany({ + data: emails.map((email) => ({ + recipientEmail: email, + hostUserId: user.id, + orgId: org.id, + })), + skipDuplicates: true, + }); + + // Send invites to recipients + const smtpConnectionUrl = getSMTPConnectionURL(); + if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { + await Promise.all(emails.map(async (email) => { + const invite = await prisma.invite.findUnique({ + where: { + recipientEmail_orgId: { + recipientEmail: email, + orgId: org.id, + }, + }, + include: { + org: true, + } + }); + + if (!invite) { + return; + } + + const recipient = await prisma.user.findUnique({ + where: { + email, + }, + }); + const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`; + const transport = createTransport(smtpConnectionUrl); + const html = await render(InviteUserEmail({ + baseUrl: env.AUTH_URL, + host: { + name: user.name ?? undefined, + email: user.email, + avatarUrl: user.image ?? undefined, + }, + recipient: { + name: recipient?.name ?? undefined, + }, + orgName: invite.org.name, + orgImageUrl: invite.org.imageUrl ?? undefined, + inviteLink, + })); + + const result = await transport.sendMail({ + to: email, + from: env.EMAIL_FROM_ADDRESS, + subject: `Join ${invite.org.name} on Sourcebot`, + html, + text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + logger.error(`Failed to send invite email to ${email}: ${failed}`); + } + })); + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); + } + + await createAudit({ + action: "user.invites_created", + actor: { + id: user.id, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + emails: emails.join(", ") + } + }); + return { + success: true, + } + }) + )); + +export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + orgId: org.id, + }, + }); + + if (!invite) { + return notFound(); + } + + await prisma.invite.delete({ + where: { + id: inviteId, + }, + }); + + return { + success: true, + } + }) + )); + +export const getOrgInvites = async () => sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const invites = await prisma.invite.findMany({ + where: { + orgId: org.id, + }, + }); + + return invites.map((invite) => ({ + id: invite.id, + email: invite.recipientEmail, + createdAt: invite.createdAt, + })); + }))); + +// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing +export const joinOrganization = async (inviteLinkId?: string) => sew(async () => { + const authResult = await getAuthenticatedUser(); + if (!authResult) { + return notAuthenticated(); + } + + const { user } = authResult; + + const org = await __unsafePrisma.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + }); + + if (!org) { + return orgNotFound(); + } + + // With SCIM enabled the IdP is the source of truth for membership; joining + // via an invite link would bypass it. + if (await isScimEnabled(org)) { + return membershipManagedByIdpError(); + } + + // If member approval is required we must be using a valid invite link + if (isMemberApprovalRequired(org)) { + if (!org.inviteLinkEnabled) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVITE_LINK_NOT_ENABLED, + message: "Invite link is not enabled.", + } satisfies ServiceError; + } + + if (org.inviteLinkId !== inviteLinkId) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_INVITE_LINK, + message: "Invalid invite link.", + } satisfies ServiceError; + } + } + + const addUserToOrgRes = await addMember(org.id, user.id, { + actor: { id: user.id, type: "user" }, + role: await getDefaultMemberRole(), + }); + if (isServiceError(addUserToOrgRes)) { + return addUserToOrgRes; + } + + return { + success: true, + } +}); + +// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing +export const redeemInvite = async (inviteId: string): Promise<{ success: boolean; } | ServiceError> => sew(async () => { + const authResult = await getAuthenticatedUser(); + if (!authResult) { + return notAuthenticated(); + } + + const { user } = authResult; + + const invite = await __unsafePrisma.invite.findUnique({ + where: { + id: inviteId, + }, + include: { + org: true, + } + }); + + if (!invite) { + return notFound(); + } + + // With SCIM enabled the IdP is the source of truth for membership; accepting + // an invite would bypass it. + if (await isScimEnabled(invite.org)) { + return membershipManagedByIdpError(); + } + + const failAuditCallback = async (error: string) => { + await createAudit({ + action: "user.invite_accept_failed", + actor: { + id: user.id, + type: "user" + }, + target: { + id: inviteId, + type: "invite" + }, + orgId: invite.org.id, + metadata: { + message: error + } + }); + }; + + const hasAvailability = await orgHasAvailability(invite.org.id); + if (!hasAvailability) { + await failAuditCallback("Organization is at max capacity"); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", + } satisfies ServiceError; + } + + // Check if the user is the recipient of the invite + if (user.email !== invite.recipientEmail) { + await failAuditCallback("User is not the recipient of the invite"); + return notFound(); + } + + const addUserToOrgRes = await addMember(invite.orgId, user.id, { + actor: { id: user.id, type: "user" }, + role: await getDefaultMemberRole(), + }); + if (isServiceError(addUserToOrgRes)) { + await failAuditCallback(addUserToOrgRes.message); + return addUserToOrgRes; + } + + await createAudit({ + action: "user.invite_accepted", + actor: { + id: user.id, + type: "user" + }, + orgId: invite.org.id, + target: { + id: inviteId, + type: "invite" + } + }); + + return { + success: true, + }; +}); + + +// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since the invitee is not yet a member +export const getInviteInfo = async (inviteId: string) => sew(async () => { + const authResult = await getAuthenticatedUser(); + if (!authResult) { + return notAuthenticated(); + } + + const { user } = authResult; + + const invite = await __unsafePrisma.invite.findUnique({ + where: { + id: inviteId, + }, + include: { + org: true, + host: true, + } + }); + + if (!invite) { + return notFound(); + } + + if (invite.recipientEmail !== user.email) { + return notFound(); + } + + return { + id: invite.id, + orgName: invite.org.name, + orgImageUrl: invite.org.imageUrl ?? undefined, + host: { + name: invite.host.name ?? undefined, + email: invite.host.email, + avatarUrl: invite.host.image ?? undefined, + }, + recipient: { + name: user.name ?? undefined, + email: user.email, + } + }; +}); diff --git a/packages/web/src/features/membership/actions/members.ts b/packages/web/src/features/membership/actions/members.ts new file mode 100644 index 000000000..06ee44827 --- /dev/null +++ b/packages/web/src/features/membership/actions/members.ts @@ -0,0 +1,66 @@ +'use server'; + +import { removeMember } from "@/features/membership/membership.service"; +import { ServiceError } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { sew } from "@/middleware/sew"; +import { withAuth } from "@/middleware/withAuth"; +import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { OrgRole } from "@sourcebot/db"; + +export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ user, org, role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const result = await removeMember(org.id, memberId, { + actor: { id: user.id, type: "user" }, + }); + + if (isServiceError(result)) { + return result; + } + + return { success: true }; + })) +); + + +export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async ({ user, org }) => { + const result = await removeMember(org.id, user.id, { + actor: { id: user.id, type: "user" }, + reason: "left", + }); + + if (isServiceError(result)) { + return result; + } + + return { + success: true, + } + })); + + +export const getOrgMembers = async () => sew(() => + withAuth(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const members = await prisma.userToOrg.findMany({ + where: { + orgId: org.id, + }, + include: { + user: true, + }, + }); + + return members.map((member) => ({ + id: member.userId, + email: member.user.email, + name: member.user.name ?? undefined, + avatarUrl: member.user.image ?? undefined, + role: member.role, + joinedAt: member.joinedAt, + isActive: member.isActive, + scimManaged: !!member.scimExternalId, + })); + }))); \ No newline at end of file diff --git a/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx b/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx new file mode 100644 index 000000000..34396246f --- /dev/null +++ b/packages/web/src/features/membership/components/deactivatedMemberBadge.tsx @@ -0,0 +1,23 @@ +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Info } from "lucide-react"; + +/** + * Marks a member whose membership has been deactivated (`isActive = false`, e.g. + * via SCIM). They keep their membership row but can no longer access the org. + */ +export const DeactivatedMemberBadge = () => ( + + + + + Deactivated + + + + + This member has been deactivated and can no longer access the organization. + + + +); diff --git a/packages/web/src/features/membership/components/joinOrganizationCard.tsx b/packages/web/src/features/membership/components/joinOrganizationCard.tsx new file mode 100644 index 000000000..37f2f0023 --- /dev/null +++ b/packages/web/src/features/membership/components/joinOrganizationCard.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { joinOrganization } from "@/features/membership/actions"; +import { isServiceError } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { LogoutEscapeHatch } from "../../../app/components/logoutEscapeHatch"; + +export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const handleJoinOrganization = async () => { + setIsLoading(true); + + try { + const result = await joinOrganization(inviteLinkId); + + if (isServiceError(result)) { + toast({ + title: "Failed to join organization", + description: result.message, + variant: "destructive", + }); + return; + } + + router.refresh(); + } catch (error) { + console.error("Error joining organization:", error); + toast({ + title: "Error", + description: "An unexpected error occurred. Please try again.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + + + + +
+

+ Welcome to Sourcebot! Click the button below to join this organization. +

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/features/membership/components/managedByScimBadge.tsx b/packages/web/src/features/membership/components/managedByScimBadge.tsx new file mode 100644 index 000000000..5de93d8f9 --- /dev/null +++ b/packages/web/src/features/membership/components/managedByScimBadge.tsx @@ -0,0 +1,32 @@ +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Info } from "lucide-react"; +import { type ReactNode } from "react"; + +interface ManagedByScimBadgeProps { + /** Tooltip explaining the SCIM relationship in the badge's context. */ + tooltip?: ReactNode; +} + +/** + * Marks something governed by SCIM provisioning (a setting that's superseded, or + * a member provisioned by the IdP). Pair it with a disabled control where it + * marks a setting. The tooltip is context-specific via the `tooltip` prop. + */ +export const ManagedByScimBadge = ({ + tooltip = "Provisioned through your identity provider.", +}: ManagedByScimBadgeProps) => ( + + + + + Managed by SCIM + + + + + {tooltip} + + + +); diff --git a/packages/web/src/features/membership/components/managedByScimNotice.tsx b/packages/web/src/features/membership/components/managedByScimNotice.tsx new file mode 100644 index 000000000..cf8b9022b --- /dev/null +++ b/packages/web/src/features/membership/components/managedByScimNotice.tsx @@ -0,0 +1,15 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Info } from "lucide-react"; +import { type ReactNode } from "react"; + +/** + * Inline notice shown on settings surfaces whose controls are superseded when + * SCIM provisioning is enabled (the IdP owns membership). The message is passed + * as children so each surface can phrase it for its own controls. + */ +export const ManagedByScimNotice = ({ children }: { children: ReactNode }) => ( + + + {children} + +); diff --git a/packages/web/src/features/membership/components/notProvisionedCard.tsx b/packages/web/src/features/membership/components/notProvisionedCard.tsx new file mode 100644 index 000000000..5f95b5ace --- /dev/null +++ b/packages/web/src/features/membership/components/notProvisionedCard.tsx @@ -0,0 +1,41 @@ +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; + +/** + * Shown to an authenticated user who is not a member of the org while SCIM + * provisioning is enabled. Membership is owned by the IdP, so the usual + * join / request-to-join flows don't apply — they must be provisioned upstream. + */ +export const NotProvisionedCard = () => { + return ( +
+ + +
+
+ + +
+
+ + + +
+ +
+

+ Account not provisioned +

+

+ Access to this organization is managed by your identity provider. Ask your administrator to provision your account. +

+
+
+
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/components/pendingApproval.tsx b/packages/web/src/features/membership/components/pendingApprovalCard.tsx similarity index 91% rename from packages/web/src/app/(app)/components/pendingApproval.tsx rename to packages/web/src/features/membership/components/pendingApprovalCard.tsx index 4ffceb209..0034a5041 100644 --- a/packages/web/src/app/(app)/components/pendingApproval.tsx +++ b/packages/web/src/features/membership/components/pendingApprovalCard.tsx @@ -1,17 +1,9 @@ import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" import { SourcebotLogo } from "@/app/components/sourcebotLogo" -import { auth } from "@/auth" export const PendingApprovalCard = async () => { - const session = await auth() - const userId = session?.user?.id - - if (!userId) { - return null - } - return ( -
+
diff --git a/packages/web/src/features/membership/components/submitJoinRequestCard.tsx b/packages/web/src/features/membership/components/submitJoinRequestCard.tsx new file mode 100644 index 000000000..286e12627 --- /dev/null +++ b/packages/web/src/features/membership/components/submitJoinRequestCard.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { useToast } from "@/components/hooks/use-toast" +import { LoadingButton } from "@/components/ui/loading-button"; +import { createAccountRequest } from "@/features/membership/actions" +import { isServiceError } from "@/lib/utils" +import { Clock } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" + +export const SubmitJoinRequestCard = () => { + const { toast } = useToast() + const router = useRouter() + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async () => { + setIsSubmitting(true) + const result = await createAccountRequest() + if (!isServiceError(result)) { + if (result.existingRequest) { + toast({ + title: "Request Already Submitted", + description: "Your request to join the organization has already been submitted. Please wait for it to be approved.", + variant: "default", + }) + } else { + toast({ + title: "Request Submitted", + description: "Your request to join the organization has been submitted.", + variant: "default", + }) + } + // Refresh the page to trigger layout re-render and show PendingApprovalCard + router.refresh() + } else { + toast({ + title: "Failed to Submit", + description: `There was an error submitting your request. Reason: ${result.message}`, + variant: "destructive", + }) + } + setIsSubmitting(false) + } + + return ( +
+ + +
+
+ + +
+
+ + + +
+ +
+

+ Request Access +

+

+ Submit a request to join this organization +

+
+
+ +
+
+ + {!isSubmitting && } + Submit Request + +
+
+
+
+
+ ) +} diff --git a/packages/web/src/features/membership/errors.ts b/packages/web/src/features/membership/errors.ts new file mode 100644 index 000000000..9926788f1 --- /dev/null +++ b/packages/web/src/features/membership/errors.ts @@ -0,0 +1,32 @@ +import { ErrorCode } from "@/lib/errorCodes"; +import { ServiceError } from "@/lib/serviceError"; +import { StatusCodes } from "http-status-codes"; + + +export const seatLimitReached = (): ServiceError => ({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", +}); + +export const lastOwnerError = (reason: "removed" | "left"): ServiceError => ({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED, + message: reason === "left" + ? "You are the last owner of this organization. Promote another member to owner before leaving." + : "Cannot remove the last owner of the organization", +}); + +export const lastOwnerDemoteError = (): ServiceError => ({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED, + message: "Cannot demote the last owner. Promote another member to owner first.", +}); + +// When SCIM is enabled the IdP is the source of truth for membership, so paths +// that would grant membership outside it (invites, join requests) are disabled. +export const membershipManagedByIdpError = (): ServiceError => ({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.MEMBERSHIP_MANAGED_BY_IDP, + message: "SCIM provisioning is enabled. Membership is managed through your identity provider.", +}); \ No newline at end of file diff --git a/packages/web/src/features/membership/logger.ts b/packages/web/src/features/membership/logger.ts new file mode 100644 index 000000000..f2b56522b --- /dev/null +++ b/packages/web/src/features/membership/logger.ts @@ -0,0 +1,3 @@ +import { createLogger } from "@sourcebot/shared"; + +export const logger = createLogger('membership'); \ No newline at end of file diff --git a/packages/web/src/features/membership/membership.service.test.ts b/packages/web/src/features/membership/membership.service.test.ts new file mode 100644 index 000000000..cc79bf7fb --- /dev/null +++ b/packages/web/src/features/membership/membership.service.test.ts @@ -0,0 +1,334 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { addMember, removeMember, setMemberRole, setMemberActive } from './membership.service'; +import { prisma, MOCK_USER_WITH_ACCOUNTS } from '@/__mocks__/prisma'; +import { OrgRole, type UserToOrg } from '@sourcebot/db'; +import { ErrorCode } from '@/lib/errorCodes'; +import { isServiceError } from '@/lib/utils'; +import type { ServiceError } from '@/lib/serviceError'; + +const mocks = vi.hoisted(() => ({ + orgHasAvailability: vi.fn(), + syncWithLighthouse: vi.fn(), + createAudit: vi.fn(), +})); + +vi.mock('@/prisma', async () => { + const actual = await vi.importActual('@/__mocks__/prisma'); + return { ...actual }; +}); +vi.mock('server-only', () => ({ default: vi.fn() })); +vi.mock('@/features/membership/utils', () => ({ orgHasAvailability: mocks.orgHasAvailability })); +vi.mock('@/features/billing/servicePing', () => ({ syncWithLighthouse: mocks.syncWithLighthouse })); +vi.mock('@/ee/features/audit/audit', () => ({ createAudit: mocks.createAudit })); + +const ORG_ID = 1; +const USER_ID = 'user-1'; +const ACTOR = { id: 'scim', type: 'scim_token' } as const; + +const makeMembership = (overrides: Partial = {}): UserToOrg => ({ + orgId: ORG_ID, + userId: USER_ID, + role: OrgRole.MEMBER, + joinedAt: new Date(), + isActive: true, + scimExternalId: null, + ...overrides, +}); + +const mockUser = { ...MOCK_USER_WITH_ACCOUNTS, id: USER_ID, email: 'user@example.com' }; + +beforeEach(() => { + mocks.orgHasAvailability.mockReset().mockResolvedValue(true); + mocks.syncWithLighthouse.mockReset().mockResolvedValue(undefined); + mocks.createAudit.mockReset().mockResolvedValue(undefined); + // Run $transaction callbacks against the same deep mock as the tx client. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (prisma.$transaction as any).mockImplementation(async (cb: any) => cb(prisma)); +}); + +describe('addMember', () => { + test('creates a new active membership when none exists', async () => { + const created = makeMembership(); + prisma.user.findUnique.mockResolvedValue(mockUser); + prisma.userToOrg.findUnique.mockResolvedValue(null); + prisma.userToOrg.create.mockResolvedValue(created); + + const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + + expect(isServiceError(result)).toBe(false); + expect(result).toEqual(created); + expect(prisma.userToOrg.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: USER_ID, orgId: ORG_ID, role: OrgRole.MEMBER, isActive: true }), + }), + ); + expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(ORG_ID); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_added' })); + }); + + test('records scimExternalId on create when provided', async () => { + prisma.user.findUnique.mockResolvedValue(mockUser); + prisma.userToOrg.findUnique.mockResolvedValue(null); + prisma.userToOrg.create.mockResolvedValue(makeMembership({ scimExternalId: 'ext-1' })); + + await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER, scimExternalId: 'ext-1' }); + + expect(prisma.userToOrg.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ scimExternalId: 'ext-1' }) }), + ); + }); + + test('clears pending invites and account requests on create', async () => { + prisma.user.findUnique.mockResolvedValue(mockUser); + prisma.userToOrg.findUnique.mockResolvedValue(null); + prisma.userToOrg.create.mockResolvedValue(makeMembership()); + + await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + + expect(prisma.accountRequest.deleteMany).toHaveBeenCalledWith({ where: { requestedById: USER_ID, orgId: ORG_ID } }); + expect(prisma.invite.deleteMany).toHaveBeenCalledWith({ where: { recipientEmail: mockUser.email, orgId: ORG_ID } }); + }); + + test('is an idempotent no-op when an ACTIVE membership already exists', async () => { + const existing = makeMembership({ isActive: true }); + prisma.user.findUnique.mockResolvedValue(mockUser); + prisma.userToOrg.findUnique.mockResolvedValue(existing); + + const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + + expect(result).toEqual(existing); + expect(prisma.userToOrg.create).not.toHaveBeenCalled(); + expect(mocks.createAudit).not.toHaveBeenCalled(); + }); + + test('is a no-op when an INACTIVE membership exists (does not reactivate)', async () => { + const existing = makeMembership({ isActive: false }); + prisma.user.findUnique.mockResolvedValue(mockUser); + prisma.userToOrg.findUnique.mockResolvedValue(existing); + + const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + + expect(result).toEqual(existing); + expect(isServiceError(result)).toBe(false); + expect(prisma.userToOrg.create).not.toHaveBeenCalled(); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + expect(mocks.createAudit).not.toHaveBeenCalled(); + }); + + test('errors when the org is at seat capacity', async () => { + prisma.user.findUnique.mockResolvedValue(mockUser); + prisma.userToOrg.findUnique.mockResolvedValue(null); + mocks.orgHasAvailability.mockResolvedValue(false); + + const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + + expect(isServiceError(result)).toBe(true); + expect((result as ServiceError).errorCode).toBe(ErrorCode.ORG_SEAT_COUNT_REACHED); + expect(prisma.userToOrg.create).not.toHaveBeenCalled(); + }); + + test('errors when the user does not exist', async () => { + prisma.user.findUnique.mockResolvedValue(null); + + const result = await addMember(ORG_ID, USER_ID, { actor: ACTOR, role: OrgRole.MEMBER }); + + expect(isServiceError(result)).toBe(true); + expect(prisma.userToOrg.create).not.toHaveBeenCalled(); + }); +}); + +describe('removeMember', () => { + test('deletes the membership and revokes sessions + tokens', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership()); + + const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR }); + + expect(result).toBeNull(); + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: USER_ID }, data: { sessionVersion: { increment: 1 } } }); + expect(prisma.apiKey.deleteMany).toHaveBeenCalledWith({ where: { createdById: USER_ID, orgId: ORG_ID } }); + expect(prisma.oAuthToken.deleteMany).toHaveBeenCalled(); + expect(prisma.userToOrg.delete).toHaveBeenCalledWith({ where: { orgId_userId: { orgId: ORG_ID, userId: USER_ID } } }); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_removed' })); + }); + + test('errors when the membership does not exist', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(null); + + const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect(prisma.userToOrg.delete).not.toHaveBeenCalled(); + }); + + test('blocks removing the last active owner', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.count.mockResolvedValue(1); + + const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect((result as ServiceError).errorCode).toBe(ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED); + expect(prisma.userToOrg.delete).not.toHaveBeenCalled(); + }); + + test('allows removing an owner when others remain', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.count.mockResolvedValue(2); + + const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR }); + + expect(result).toBeNull(); + expect(prisma.userToOrg.delete).toHaveBeenCalled(); + }); + + test('reason "left" audits org.member_left', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership()); + + const result = await removeMember(ORG_ID, USER_ID, { actor: ACTOR, reason: 'left' }); + + expect(result).toBeNull(); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_left' })); + }); +}); + +describe('setMemberRole', () => { + test('promotes a member to owner and audits it', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.MEMBER })); + + const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.OWNER, { actor: ACTOR }); + + expect(result).toBeNull(); + expect(prisma.userToOrg.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { role: OrgRole.OWNER } }), + ); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_promoted_to_owner' })); + }); + + test('demotes an owner to member when other owners remain', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.count.mockResolvedValue(2); + + const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); + + expect(result).toBeNull(); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.owner_demoted_to_member' })); + }); + + test('blocks demoting the last active owner', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, isActive: true })); + prisma.userToOrg.count.mockResolvedValue(1); + + const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect((result as ServiceError).errorCode).toBe(ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + }); + + test('is a no-op when the role is unchanged', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.MEMBER })); + + const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.MEMBER, { actor: ACTOR }); + + expect(result).toBeNull(); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + expect(mocks.createAudit).not.toHaveBeenCalled(); + }); + + test('errors when the membership does not exist', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(null); + + const result = await setMemberRole(ORG_ID, USER_ID, OrgRole.OWNER, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + }); +}); + +describe('setMemberActive', () => { + describe('deactivate', () => { + test('deactivates an active member and revokes access', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true })); + + const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR }); + + expect(result).toBeNull(); + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: USER_ID }, data: { sessionVersion: { increment: 1 } } }); + expect(prisma.apiKey.deleteMany).toHaveBeenCalledWith({ where: { createdById: USER_ID, orgId: ORG_ID } }); + expect(prisma.oAuthToken.deleteMany).toHaveBeenCalled(); + expect(prisma.userToOrg.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { isActive: false } }), + ); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_deactivated' })); + }); + + test('is a no-op when already inactive', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false })); + + const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR }); + + expect(result).toBeNull(); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(mocks.createAudit).not.toHaveBeenCalled(); + }); + + test('errors when the membership does not exist', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(null); + + const result = await setMemberActive(ORG_ID, USER_ID, false, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + }); + }); + + describe('reactivate', () => { + test('reactivates an inactive member when a seat is available', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false })); + mocks.orgHasAvailability.mockResolvedValue(true); + + const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' }); + + expect(result).toBeNull(); + expect(prisma.userToOrg.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ isActive: true, scimExternalId: 'ext-1' }) }), + ); + expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_reactivated' })); + }); + + test('errors when the org is at seat capacity', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: false })); + mocks.orgHasAvailability.mockResolvedValue(false); + + const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR }); + + expect(isServiceError(result)).toBe(true); + expect((result as ServiceError).errorCode).toBe(ErrorCode.ORG_SEAT_COUNT_REACHED); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + }); + + test('is a no-op when already active (no audit, no seat check)', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'ext-1' })); + + const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'ext-1' }); + + expect(result).toBeNull(); + expect(prisma.userToOrg.update).not.toHaveBeenCalled(); + expect(mocks.orgHasAvailability).not.toHaveBeenCalled(); + expect(mocks.createAudit).not.toHaveBeenCalled(); + }); + + test('refreshes externalId when already active and it changed', async () => { + prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ isActive: true, scimExternalId: 'old' })); + + const result = await setMemberActive(ORG_ID, USER_ID, true, { actor: ACTOR, scimExternalId: 'new' }); + + expect(result).toBeNull(); + expect(prisma.userToOrg.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { scimExternalId: 'new' } }), + ); + expect(mocks.createAudit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/web/src/features/membership/membership.service.ts b/packages/web/src/features/membership/membership.service.ts new file mode 100644 index 000000000..0303cef68 --- /dev/null +++ b/packages/web/src/features/membership/membership.service.ts @@ -0,0 +1,332 @@ +import 'server-only'; + +import { createAudit } from "@/ee/features/audit/audit"; +import { type AuditActor } from "@/ee/features/audit/types"; +import { syncWithLighthouse } from "@/features/billing/servicePing"; +import { orgHasAvailability } from "@/features/membership/utils"; +import { notFound, type ServiceError } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { __unsafePrisma as prisma } from "@/prisma"; +import { OrgRole, Prisma, type UserToOrg } from "@sourcebot/db"; +import { lastOwnerDemoteError, lastOwnerError, seatLimitReached } from "./errors"; + +export interface AddMemberOptions { + actor: AuditActor; + role: OrgRole; + scimExternalId?: string; +} + +/** + * Ensures a membership exists for the user in the org. Idempotent: if a + * membership already exists (active or inactive) it is returned unchanged — this + * does NOT reactivate a deactivated membership (that's `setMemberActive`'s job) + * or change its role. On create, enforces the seat cap and clears any pending + * invites / account requests for the user. + * + * Note: a returned membership may be pre-existing and inactive, so a successful + * result does not by itself imply the user is active. + */ +export const addMember = async ( + orgId: number, + userId: string, + options: AddMemberOptions, +): Promise => { + const { actor, role, scimExternalId } = options; + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return notFound("User not found"); + } + + const existing = await prisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + if (existing) { + return existing; + } + + if (!(await orgHasAvailability(orgId))) { + return seatLimitReached(); + } + + const membership = await prisma.$transaction(async (tx) => { + const created = await tx.userToOrg.create({ + data: { + userId, + orgId, + role, + isActive: true, + ...(scimExternalId ? { scimExternalId } : {}), + }, + }); + + await tx.accountRequest.deleteMany({ + where: { requestedById: userId, orgId }, + }); + await tx.invite.deleteMany({ + where: { recipientEmail: user.email, orgId }, + }); + + return created; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); + await createAudit({ + action: "org.member_added", + actor, + target: { id: userId, type: "user" }, + orgId, + }); + + return membership; +}; + + +export interface RemoveMemberOptions { + actor: AuditActor; + reason?: "removed" | "left"; +} + +/** + * Hard-removes a membership (deletes the join row, preserving the `User`). + * Bumps `sessionVersion` and revokes the user's API keys + OAuth tokens. + */ +export const removeMember = async ( + orgId: number, + userId: string, + options: RemoveMemberOptions, +): Promise => { + const { actor, reason = "removed" } = options; + + const result = await prisma.$transaction(async (tx) => { + const target = await tx.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + if (!target) { + return notFound("Member not found in this organization"); + } + + if (target.role === OrgRole.OWNER && target.isActive) { + if ((await countActiveOwners(tx, orgId)) <= 1) { + return lastOwnerError(reason); + } + } + + await revokeAllUserAuthCredentials(tx, userId, orgId); + + await tx.userToOrg.delete({ + where: { orgId_userId: { orgId, userId } }, + }); + + return null; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + if (!isServiceError(result)) { + await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); + await createAudit({ + action: reason === "left" ? "org.member_left" : "org.member_removed", + actor, + target: { id: userId, type: "user" }, + orgId, + }); + } + + return result; +}; + + +export interface SetMemberRoleOptions { + actor: AuditActor; +} + +/** + * Changes a member's role (no-op when unchanged). No session/token revocation: + * role is resolved from the DB on every request, so a change takes effect on the + * member's next request. Seats are unaffected, so no lighthouse sync. + */ +export const setMemberRole = async ( + orgId: number, + userId: string, + role: OrgRole, + options: SetMemberRoleOptions, +): Promise => { + const { actor } = options; + + let didChange = false; + + const result = await prisma.$transaction(async (tx) => { + const target = await tx.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + if (!target) { + return notFound("Member not found in this organization"); + } + + if (target.role === role) { + return null; + } + + const isDemotionFromOwner = target.role === OrgRole.OWNER && role !== OrgRole.OWNER; + if (isDemotionFromOwner && target.isActive) { + if ((await countActiveOwners(tx, orgId)) <= 1) { + return lastOwnerDemoteError(); + } + } + + await tx.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { role }, + }); + didChange = true; + + return null; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + if (!isServiceError(result) && didChange) { + await createAudit({ + action: role === OrgRole.OWNER ? "org.member_promoted_to_owner" : "org.owner_demoted_to_member", + actor, + target: { id: userId, type: "user" }, + orgId, + }); + } + + return result; +}; + +export interface SetMemberActiveOptions { + actor: AuditActor; + scimExternalId?: string; +} + +/** + * Suspends (`active: false`) or restores (`active: true`) a membership without + * deleting it. Deactivation bumps `sessionVersion` + revokes tokens; reactivation + * re-checks the seat cap. A no-op when already in the requested state. + */ +export const setMemberActive = async ( + orgId: number, + userId: string, + active: boolean, + options: SetMemberActiveOptions, +): Promise => { + const { actor, scimExternalId } = options; + + if (!active) { + let didChange = false; + + const result = await prisma.$transaction(async (tx) => { + const target = await tx.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + if (!target) { + return notFound("Member not found in this organization"); + } + if (!target.isActive) { + return null; + } + + await revokeAllUserAuthCredentials(tx, userId, orgId); + + await tx.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { isActive: false }, + }); + didChange = true; + + return null; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + if (!isServiceError(result) && didChange) { + await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); + await createAudit({ + action: "org.member_deactivated", + actor, + target: { id: userId, type: "user" }, + orgId, + }); + } + + return result; + } + + const target = await prisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId, userId } }, + }); + if (!target) { + return notFound("Member not found in this organization"); + } + + if (target.isActive) { + if (scimExternalId && target.scimExternalId !== scimExternalId) { + await prisma.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { scimExternalId }, + }); + } + return null; + } + + if (!(await orgHasAvailability(orgId))) { + return seatLimitReached(); + } + + await prisma.userToOrg.update({ + where: { orgId_userId: { orgId, userId } }, + data: { + isActive: true, + ...(scimExternalId ? { scimExternalId } : {}), + }, + }); + + await syncWithLighthouse(orgId).catch(() => { /* best effort */ }); + await createAudit({ + action: "org.member_reactivated", + actor, + target: { id: userId, type: "user" }, + orgId, + }); + + return null; +}; + +const countActiveOwners = (tx: Prisma.TransactionClient, orgId: number): Promise => + tx.userToOrg.count({ + where: { orgId, role: OrgRole.OWNER, isActive: true }, + }); + +const revokeAllUserAuthCredentials = async ( + prisma: Prisma.TransactionClient, + userId: string, + orgId: number, +): Promise => { + // JWT token + await prisma.user.update({ + where: { id: userId }, + data: { sessionVersion: { increment: 1 } }, + }); + + // API Keys + await prisma.apiKey.deleteMany({ + where: { + createdById: userId, + orgId, + } + }); + + // OAuth tokens + 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/membership/onCreateUser.ts b/packages/web/src/features/membership/onCreateUser.ts new file mode 100644 index 000000000..1244b1ca8 --- /dev/null +++ b/packages/web/src/features/membership/onCreateUser.ts @@ -0,0 +1,120 @@ +import type { User as AuthJsUser } from "next-auth"; +import { __unsafePrisma } from "@/prisma"; +import { OrgRole } from "@sourcebot/db"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { isMemberApprovalRequired } from "@sourcebot/shared"; +import { createAudit } from "@/ee/features/audit/audit"; +import { isScimEnabled } from "@/features/scim/utils"; +import { getDefaultMemberRole } from "@/features/membership/utils"; +import { isServiceError } from "@/lib/utils"; +import { addMember } from "@/features/membership/membership.service"; +import { logger } from "./logger"; +import { captureEvent } from "@/lib/posthog"; + +/** + * Auth-layer hook invoked whenever a new user account is created (NextAuth's + * `createUser` event, the credentials provider, and SSO). Handles org onboarding: + * the first user bootstraps the org as OWNER, subsequent users auto-join as + * members in open self-serve mode, and auto-join is suppressed when member + * approval is required or SCIM is enabled (the IdP is then the source of truth). + * Membership writes go through the membership service. + */ +export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { + if (!user.id) { + logger.error("User ID is undefined on user creation"); + await createAudit({ + action: "user.creation_failed", + actor: { + id: "undefined", + type: "user" + }, + target: { + id: "undefined", + type: "user" + }, + orgId: SINGLE_TENANT_ORG_ID, + metadata: { + message: "User ID is undefined on user creation" + } + }); + throw new Error("User ID is undefined on user creation"); + } + + const defaultOrg = await __unsafePrisma.org.findUnique({ + where: { + id: SINGLE_TENANT_ORG_ID, + }, + include: { + members: true, + } + }); + + if (defaultOrg === null) { + await createAudit({ + action: "user.creation_failed", + actor: { + id: user.id, + type: "user" + }, + target: { + id: user.id, + type: "user" + }, + orgId: SINGLE_TENANT_ORG_ID, + metadata: { + message: "Default org not found on single tenant user creation" + } + }); + throw new Error("Default org not found on single tenant user creation"); + } + + // @note when creating a user, there are two cases for when + // we should be adding them to the single tenant organization. + // + // 1. The organization is empty. In this case, we add the + // user as a member with the OWNER role. + const isFirstUser = defaultOrg.members.length === 0; + if (isFirstUser) { + const result = await addMember(SINGLE_TENANT_ORG_ID, user.id, { + actor: { id: user.id, type: "user" }, + role: OrgRole.OWNER, + }); + if (isServiceError(result)) { + throw new Error(`Failed to bootstrap initial owner for user ${user.id}: ${result.message}`); + } + + await createAudit({ + action: "user.owner_created", + actor: { + id: user.id, + type: "user" + }, + orgId: SINGLE_TENANT_ORG_ID, + target: { + id: SINGLE_TENANT_ORG_ID.toString(), + type: "org" + } + }); + } + + // 2. Otherwise, if both member approvals is disabled && + // scim is disabled, then we add the user as a member with + // whatever the default role is. + else if ( + !isMemberApprovalRequired(defaultOrg) && + !(await isScimEnabled(defaultOrg)) + ) { + const result = await addMember(SINGLE_TENANT_ORG_ID, user.id, { + actor: { id: user.id, type: "user" }, + role: await getDefaultMemberRole(), + }); + if (isServiceError(result)) { + logger.warn(`onCreateUser: user ${user.id} was not auto-joined to org ${SINGLE_TENANT_ORG_ID}: ${result.message}`); + return; + } + } + + // Dynamic import to avoid circular dependency: + // authUtils -> posthog -> auth -> authUtils + await captureEvent('wa_user_created', { userId: user.id }); +}; \ No newline at end of file diff --git a/packages/web/src/features/membership/utils.ts b/packages/web/src/features/membership/utils.ts new file mode 100644 index 000000000..f431f3b95 --- /dev/null +++ b/packages/web/src/features/membership/utils.ts @@ -0,0 +1,43 @@ +import { __unsafePrisma } from "@/prisma"; +import { hasEntitlement } from "@/lib/entitlements"; +import { createLogger, getSeatCap } from "@sourcebot/shared"; +import { OrgRole } from "@sourcebot/db"; + +const logger = createLogger("membership-utils"); + +/** + * Resolves the role a user receives when joining the org via invite, + * account-request approval, or interactive-login auto-join. On paid plans (the + * `org-management` entitlement) joiners are MEMBERs; on free plans there is no + * role distinction, so they join as OWNER. + */ +export const getDefaultMemberRole = async (): Promise => + (await hasEntitlement("org-management")) ? OrgRole.MEMBER : OrgRole.OWNER; + +/** + * Checks to see if the given organization has seat availability. Seat + * availability is determined by the `seats` parameter in the offline license + * key, if available. + */ +export const orgHasAvailability = async (orgId: number): Promise => { + 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 activeUserCount = await __unsafePrisma.userToOrg.count({ + where: { + orgId, + isActive: true, + }, + }); + + if ( + seatCap && + activeUserCount >= seatCap + ) { + logger.error(`orgHasAvailability: org ${orgId} has reached max capacity`); + return false; + } + + return true; +}; diff --git a/packages/web/src/features/scim/utils.ts b/packages/web/src/features/scim/utils.ts index f117cd846..a9c0fc680 100644 --- a/packages/web/src/features/scim/utils.ts +++ b/packages/web/src/features/scim/utils.ts @@ -1,10 +1,10 @@ import { __unsafePrisma } from "@/prisma"; import { hasEntitlement } from "@/lib/entitlements"; +import { Org } from "@sourcebot/db"; -export const isScimEnabled = async (orgId: number): Promise => { +export const isScimEnabled = async (org: Org): Promise => { if (!await hasEntitlement('scim')) { return false; } - const tokenCount = await __unsafePrisma.scimToken.count({ where: { orgId } }); - return tokenCount > 0; + return org?.isScimEnabled ?? false; }; diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts deleted file mode 100644 index fb00de77e..000000000 --- a/packages/web/src/features/userManagement/actions.ts +++ /dev/null @@ -1,532 +0,0 @@ -'use server'; - -import { createAudit } from "@/ee/features/audit/audit"; -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"; -import { sew } from "@/middleware/sew"; -import { withAuth } from "@/middleware/withAuth"; -import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; -import { render } from "@react-email/components"; -import { OrgRole, Prisma, PrismaClient } from "@sourcebot/db"; -import { createLogger, env, getSMTPConnectionURL } from "@sourcebot/shared"; -import { StatusCodes } from "http-status-codes"; -import { createTransport } from "nodemailer"; - -const logger = createLogger('user-management'); - -export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ user, org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const guardError = await _removeUserFromOrg(prisma, { - orgId: org.id, - userId: memberId, - lastOwnerMessage: "Cannot remove the last owner of the organization.", - }); - - if (guardError) { - return guardError; - } - - await createAudit({ - action: "org.member_removed", - actor: { id: user.id, type: "user" }, - target: { id: memberId, type: "user" }, - orgId: org.id, - metadata: { - message: `${user.id} removed ${memberId} from the organization`, - }, - }); - - return { success: true }; - })) -); - -export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ user, org, prisma }) => { - const guardError = await _removeUserFromOrg(prisma, { - orgId: org.id, - userId: user.id, - lastOwnerMessage: "You are the last owner of this organization. Promote another member to owner before leaving.", - }); - - if (guardError) { - return guardError; - } - - await createAudit({ - action: "org.member_left", - actor: { id: user.id, type: "user" }, - target: { id: user.id, type: "user" }, - orgId: org.id, - metadata: { - message: `${user.id} left the organization`, - }, - }); - - return { - success: true, - } - })); - - -const _removeUserFromOrg = async ( - prisma: PrismaClient, - { orgId, userId, lastOwnerMessage }: { orgId: number; userId: string; lastOwnerMessage: string }, -): Promise => { - const result = await prisma.$transaction(async (tx) => { - const target = await tx.userToOrg.findUnique({ - where: { - orgId_userId: { - orgId, - userId, - } - } - }); - - if (!target) { - return notFound("Member not found in this organization"); - } - - if (target.role === OrgRole.OWNER) { - const ownerCount = await tx.userToOrg.count({ - where: { - orgId, - role: OrgRole.OWNER, - }, - }); - - if (ownerCount <= 1) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED, - message: lastOwnerMessage, - } satisfies ServiceError; - } - } - - await invalidateAllSessionsForUser(tx, userId); - await revokeUserOAuthTokens(tx, userId); - await revokeUserApiKeysInOrg(tx, userId, orgId); - - await tx.userToOrg.delete({ - where: { - orgId_userId: { - orgId, - userId, - } - } - }); - - return null; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); - - // Sync with lighthouse s.t., the subscription - // quantity will update immediately. - if (!isServiceError(result)) { - await syncWithLighthouse(orgId).catch(() => { /* ignore error */ }); - } - - return result; -}; - - -export const rejectAccountRequest = async (requestId: string) => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - }); - - if (!request || request.orgId !== org.id) { - return notFound(); - } - - await prisma.accountRequest.delete({ - where: { - id: requestId, - }, - }); - - return { - success: true, - } - }) - )); - - -export const approveAccountRequest = async (requestId: string) => sew(async () => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await createAudit({ - action: "user.join_request_approve_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: requestId, - type: "account_join_request" - }, - orgId: org.id, - metadata: { - message: error, - } - }); - } - - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - include: { - requestedBy: true, - }, - }); - - if (!request || request.orgId !== org.id) { - await failAuditCallback("Request not found"); - return notFound(); - } - - const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id); - if (isServiceError(addUserToOrgRes)) { - await failAuditCallback(addUserToOrgRes.message); - return addUserToOrgRes; - } - - - await createAudit({ - action: "user.join_request_approved", - actor: { - id: user.id, - type: "user" - }, - orgId: org.id, - target: { - id: requestId, - type: "account_join_request" - } - }); - - await createAudit({ - action: "org.member_added", - actor: { id: user.id, type: "user" }, - target: { id: request.requestedById, type: "user" }, - orgId: org.id, - metadata: { - message: `${user.id} approved join request ${requestId} for ${request.requestedById}`, - }, - }); - - // Send approval email to the user - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - const html = await render(JoinRequestApprovedEmail({ - baseUrl: env.AUTH_URL, - user: { - name: request.requestedBy.name ?? undefined, - email: request.requestedBy.email, - avatarUrl: request.requestedBy.image ?? undefined, - }, - orgName: org.name, - })); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: request.requestedBy.email, - from: env.EMAIL_FROM_ADDRESS, - subject: `Your request to join ${org.name} has been approved`, - html, - text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); - } - - return { - success: true, - } - }) - )); - - - -export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: error, - emails: emails.join(", ") - } - }); - } - - const hasAvailability = await orgHasAvailability(org.id); - if (!hasAvailability) { - await createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: "Organization has reached maximum number of seats", - emails: emails.join(", ") - } - }); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "The organization has reached the maximum number of seats. Unable to create a new invite", - } satisfies ServiceError; - } - - // Check for existing invites - const existingInvites = await prisma.invite.findMany({ - where: { - recipientEmail: { - in: emails - }, - orgId: org.id, - } - }); - - if (existingInvites.length > 0) { - await failAuditCallback("A pending invite already exists for one or more of the provided emails"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `A pending invite already exists for one or more of the provided emails.`, - } satisfies ServiceError; - } - - // Check for members that are already in the org - const existingMembers = await prisma.userToOrg.findMany({ - where: { - user: { - email: { - in: emails, - } - }, - orgId: org.id, - }, - }); - - if (existingMembers.length > 0) { - await failAuditCallback("One or more of the provided emails are already members of this org"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `One or more of the provided emails are already members of this org.`, - } satisfies ServiceError; - } - - await prisma.invite.createMany({ - data: emails.map((email) => ({ - recipientEmail: email, - hostUserId: user.id, - orgId: org.id, - })), - skipDuplicates: true, - }); - - // Send invites to recipients - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - await Promise.all(emails.map(async (email) => { - const invite = await prisma.invite.findUnique({ - where: { - recipientEmail_orgId: { - recipientEmail: email, - orgId: org.id, - }, - }, - include: { - org: true, - } - }); - - if (!invite) { - return; - } - - const recipient = await prisma.user.findUnique({ - where: { - email, - }, - }); - const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`; - const transport = createTransport(smtpConnectionUrl); - const html = await render(InviteUserEmail({ - baseUrl: env.AUTH_URL, - host: { - name: user.name ?? undefined, - email: user.email, - avatarUrl: user.image ?? undefined, - }, - recipient: { - name: recipient?.name ?? undefined, - }, - orgName: invite.org.name, - orgImageUrl: invite.org.imageUrl ?? undefined, - inviteLink, - })); - - const result = await transport.sendMail({ - to: email, - from: env.EMAIL_FROM_ADDRESS, - subject: `Join ${invite.org.name} on Sourcebot`, - html, - text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send invite email to ${email}: ${failed}`); - } - })); - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); - } - - await createAudit({ - action: "user.invites_created", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - emails: emails.join(", ") - } - }); - return { - success: true, - } - }) - )); - -export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - orgId: org.id, - }, - }); - - if (!invite) { - return notFound(); - } - - await prisma.invite.delete({ - where: { - id: inviteId, - }, - }); - - return { - success: true, - } - }) - )); - - -export const getOrgMembers = async () => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const members = await prisma.userToOrg.findMany({ - where: { - orgId: org.id, - }, - include: { - user: true, - }, - }); - - return members.map((member) => ({ - id: member.userId, - email: member.user.email, - name: member.user.name ?? undefined, - avatarUrl: member.user.image ?? undefined, - role: member.role, - joinedAt: member.joinedAt, - isActive: member.isActive, - })); - }))); - -export const getOrgInvites = async () => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const invites = await prisma.invite.findMany({ - where: { - orgId: org.id, - }, - }); - - return invites.map((invite) => ({ - id: invite.id, - email: invite.recipientEmail, - createdAt: invite.createdAt, - })); - }))); - - -export const getOrgAccountRequests = async () => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const requests = await prisma.accountRequest.findMany({ - where: { - orgId: org.id, - }, - include: { - requestedBy: true, - }, - }); - - return requests.map((request) => ({ - id: request.id, - email: request.requestedBy.email, - createdAt: request.createdAt, - name: request.requestedBy.name ?? undefined, - image: request.requestedBy.image ?? undefined, - })); - }))); - - diff --git a/packages/web/src/features/userManagement/membershipMutations.ts b/packages/web/src/features/userManagement/membershipMutations.ts deleted file mode 100644 index 3b55f5bed..000000000 --- a/packages/web/src/features/userManagement/membershipMutations.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index dfe03d6c0..000000000 --- a/packages/web/src/lib/authUtils.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { User as AuthJsUser } from "next-auth"; -import { __unsafePrisma } from "@/prisma"; -import { OrgRole } from "@sourcebot/db"; -import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; -import { orgNotFound, ServiceError, userNotFound } from "@/lib/serviceError"; -import { createLogger, getSeatCap, isMemberApprovalRequired } from "@sourcebot/shared"; -import { createAudit } from "@/ee/features/audit/audit"; -import { StatusCodes } from "http-status-codes"; -import { ErrorCode } from "./errorCodes"; -import { syncWithLighthouse } from "@/features/billing/servicePing"; -import { hasEntitlement } from "./entitlements"; -import { isScimEnabled } from "@/features/scim/utils"; - -const logger = createLogger('web-auth-utils'); - -export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { - if (!user.id) { - logger.error("User ID is undefined on user creation"); - await createAudit({ - action: "user.creation_failed", - actor: { - id: "undefined", - type: "user" - }, - target: { - id: "undefined", - type: "user" - }, - orgId: SINGLE_TENANT_ORG_ID, - metadata: { - message: "User ID is undefined on user creation" - } - }); - throw new Error("User ID is undefined on user creation"); - } - - const defaultOrg = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - include: { - members: true, - } - }); - - if (defaultOrg === null) { - await createAudit({ - action: "user.creation_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: user.id, - type: "user" - }, - orgId: SINGLE_TENANT_ORG_ID, - metadata: { - message: "Default org not found on single tenant user creation" - } - }); - throw new Error("Default org not found on single tenant user creation"); - } - - // First user to sign up bootstraps the org as its OWNER. This is how a - // fresh deployment gets its initial admin without manual setup. - const isFirstUser = defaultOrg.members.length === 0; - if (isFirstUser) { - await __unsafePrisma.$transaction(async (tx) => { - await tx.org.update({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - data: { - members: { - create: { - role: OrgRole.OWNER, - user: { - connect: { - id: user.id, - } - } - } - } - } - }); - }); - - await createAudit({ - action: "user.owner_created", - actor: { - id: user.id, - type: "user" - }, - orgId: SINGLE_TENANT_ORG_ID, - target: { - id: SINGLE_TENANT_ORG_ID.toString(), - type: "org" - } - }); - - await createAudit({ - action: "org.member_added", - actor: { id: user.id, type: "user" }, - target: { id: user.id, type: "user" }, - orgId: SINGLE_TENANT_ORG_ID, - metadata: { - message: `${user.id} joined the organization as the initial owner`, - }, - }); - } - - // Subsequent users auto-join only when the org is in open self-serve - // mode. Their role depends on the `org-management` entitlement: on paid - // plans they join as MEMBER, on free they join as OWNER (no role - // 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. - // - // 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 ( - !isMemberApprovalRequired(defaultOrg) && - !(await isScimEnabled(defaultOrg.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); - if (!hasAvailability) { - logger.warn(`onCreateUser: org ${SINGLE_TENANT_ORG_ID} has reached max capacity. User ${user.id} was not added to the org.`); - return; - } - - const hasOrgManagement = await hasEntitlement("org-management"); - - await __unsafePrisma.userToOrg.create({ - data: { - userId: user.id, - orgId: SINGLE_TENANT_ORG_ID, - role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER, - } - }); - - await createAudit({ - action: "org.member_added", - actor: { id: user.id, type: "user" }, - target: { id: user.id, type: "user" }, - orgId: SINGLE_TENANT_ORG_ID, - metadata: { - message: `${user.id} joined the organization (member approval not required)`, - }, - }); - } - - // Dynamic import to avoid circular dependency: - // authUtils -> posthog -> auth -> authUtils - const { captureEvent } = await import("@/lib/posthog"); - await captureEvent('wa_user_created', { userId: user.id }); - - // Sync with lighthouse s.t., the subscription - // quantity will update immediately. - await syncWithLighthouse(defaultOrg.id).catch(() => { /* ignore error */ }); -}; - - -/** - * Checks to see if the given organization has seat availability. - * Seat availability is determined by the `seats` parameter in - * the offline license key, if available. - */ -export const orgHasAvailability = async (orgId: number): Promise => { - 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: { - orgId, - isActive: true, - }, - }); - - if ( - seatCap && - memberCount >= seatCap - ) { - logger.error(`orgHasAvailability: org ${orgId} has reached max capacity`); - return false; - } - - return true; -} - -export const addUserToOrganization = async (userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => { - const user = await __unsafePrisma.user.findUnique({ - where: { - id: userId, - }, - }); - - if (!user) { - logger.error(`addUserToOrganization: user not found for id ${userId}`); - return userNotFound(); - } - - const org = await __unsafePrisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - logger.error(`addUserToOrganization: org not found for id ${orgId}`); - return orgNotFound(); - } - - const hasAvailability = await orgHasAvailability(org.id); - if (!hasAvailability) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "Organization is at max capacity", - } satisfies ServiceError; - } - - const hasOrgManagement = await hasEntitlement('org-management'); - - await __unsafePrisma.$transaction(async (tx) => { - // Upsert rather than create: the user may already be a member from the - // self-serve auto-join in onCreateUser, in which case this call is - // just here to trigger the AccountRequest / Invite cleanup below. - await tx.userToOrg.upsert({ - where: { - orgId_userId: { - orgId: org.id, - userId: user.id, - }, - }, - create: { - userId: user.id, - orgId: org.id, - role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER, - }, - update: {}, - }); - - // Delete the account request if it exists since we've added the user to the org - const accountRequest = await tx.accountRequest.findUnique({ - where: { - requestedById_orgId: { - requestedById: user.id, - orgId: orgId, - } - }, - }); - - if (accountRequest) { - logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've been added to the org`); - await tx.accountRequest.delete({ - where: { - id: accountRequest.id, - } - }); - } - - // Delete any invites that may exist for this user since we've added them to the org - const invites = await tx.invite.findMany({ - where: { - recipientEmail: user.email, - orgId: org.id, - }, - }) - - for (const invite of invites) { - logger.info(`Deleting invite ${invite.id} for ${user.email} since they've been added to the org`); - await tx.invite.delete({ - where: { - id: invite.id, - }, - }); - } - }); - - // Sync with lighthouse s.t., the subscription - // quantity will update immediately. - await syncWithLighthouse(org.id).catch(() => { /* ignore error */ }); - - return { - success: true, - } -}; \ No newline at end of file diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 2cea6d4ac..6f20a5e5e 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -44,4 +44,5 @@ export enum ErrorCode { EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED = 'EMAIL_CODE_LOGIN_CANNOT_BE_DISABLED', MEMBER_APPROVAL_CONTROLLED_BY_ENV = 'MEMBER_APPROVAL_CONTROLLED_BY_ENV', ANONYMOUS_ACCESS_CONTROLLED_BY_ENV = 'ANONYMOUS_ACCESS_CONTROLLED_BY_ENV', + MEMBERSHIP_MANAGED_BY_IDP = 'MEMBERSHIP_MANAGED_BY_IDP', } diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 6fe06a4f1..da6975c18 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -409,6 +409,71 @@ describe('getAuthContext', () => { }); }); + test('should not grant a role when the membership is SCIM-deactivated (isActive: false), even though the membership row exists', async () => { + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId: userId, + orgId: MOCK_ORG.id, + isActive: false, + scimExternalId: null, + role: OrgRole.OWNER, + }); + + setMockSession(createMockSession({ user: { id: userId } })); + const authContext = await getAuthContext(); + expect(authContext).toStrictEqual({ + user: { + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }, + org: MOCK_ORG, + prisma: undefined, + }); + }); + + test('should not grant a role to a SCIM-deactivated member authenticating via API key (API-key auth bypasses the JWT sessionVersion logout, so this gate is what denies them)', async () => { + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId: userId, + orgId: MOCK_ORG.id, + isActive: false, + scimExternalId: null, + role: OrgRole.MEMBER, + }); + prisma.apiKey.findUnique.mockResolvedValue({ + ...MOCK_API_KEY, + hash: 'apikey', + createdById: userId, + }); + setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); + + const authContext = await getAuthContext(); + expect(authContext).toStrictEqual({ + user: { + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }, + org: MOCK_ORG, + prisma: undefined, + }); + }); + describe('DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS', () => { test('should return a 403 service error when flag is enabled and a non-owner authenticates via api key', async () => { mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true'; @@ -775,6 +840,61 @@ describe('withAuth', () => { expect(cb).not.toHaveBeenCalled(); expect(result).toStrictEqual(notAuthenticated()); }); + + test('should return a service error when the membership is SCIM-deactivated (isActive: false), even with a valid session', async () => { + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId: userId, + orgId: MOCK_ORG.id, + isActive: false, + scimExternalId: null, + role: OrgRole.OWNER, + }); + setMockSession(createMockSession({ user: { id: userId } })); + + const cb = vi.fn(); + const result = await withAuth(cb); + expect(cb).not.toHaveBeenCalled(); + expect(result).toStrictEqual(notAuthenticated()); + }); + + test('should deny a SCIM-deactivated member authenticating via API key', async () => { + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ + ...MOCK_USER_WITH_ACCOUNTS, + id: userId, + }); + prisma.org.findUnique.mockResolvedValue({ + ...MOCK_ORG, + }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId: userId, + orgId: MOCK_ORG.id, + isActive: false, + scimExternalId: null, + role: OrgRole.MEMBER, + }); + prisma.apiKey.findUnique.mockResolvedValue({ + ...MOCK_API_KEY, + hash: 'apikey', + createdById: userId, + }); + setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); + + const cb = vi.fn(); + const result = await withAuth(cb); + expect(cb).not.toHaveBeenCalled(); + expect(result).toStrictEqual(notAuthenticated()); + }); }); describe('withOptionalAuth', () => {