diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcadabc55..5129c3859 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Added service accounts: non-human identities that own their own API keys for programmatic access, managed under Settings -> Service Accounts. [#1583](https://github.com/sourcebot-dev/sourcebot/pull/1583)
+
## [5.1.7] - 2026-08-13
### Added
diff --git a/packages/db/prisma/migrations/20260813182022_add_service_accounts/migration.sql b/packages/db/prisma/migrations/20260813182022_add_service_accounts/migration.sql
new file mode 100644
index 000000000..115131006
--- /dev/null
+++ b/packages/db/prisma/migrations/20260813182022_add_service_accounts/migration.sql
@@ -0,0 +1,10 @@
+-- CreateEnum
+CREATE TYPE "UserType" AS ENUM ('HUMAN', 'SERVICE');
+
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "createdById" TEXT,
+ADD COLUMN "description" TEXT,
+ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'HUMAN';
+
+-- AddForeignKey
+ALTER TABLE "User" ADD CONSTRAINT "User_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index b4b43c8f0..d73a313f7 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -399,6 +399,16 @@ enum OrgRole {
MEMBER
}
+/// Discriminates a normal human account from a non-interactive service
+/// account. Service accounts never authenticate via session/OAuth — only
+/// via API key — and are excluded from seat counts and member-facing
+/// listings (see `humanMembershipWhere` and its call sites in
+/// `features/membership`).
+enum UserType {
+ HUMAN
+ SERVICE
+}
+
enum McpServerClientInfoSource {
DYNAMIC
STATIC
@@ -569,6 +579,20 @@ model User {
/// Last time the user performed an authenticated action.
lastActiveAt DateTime?
+ /// Discriminates a human account from a service account. See `UserType`.
+ type UserType @default(HUMAN)
+
+ /// Free-text description shown in the Service Accounts settings UI. Unused
+ /// for HUMAN users.
+ description String?
+
+ /// The human member who created this service account, kept for
+ /// attribution/display only (never used in an auth check). Null for HUMAN
+ /// users, and for SERVICE users whose creator was later removed.
+ createdBy User? @relation("ServiceAccountCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
+ createdById String?
+
+ createdServiceAccounts User[] @relation("ServiceAccountCreatedBy")
}
enum AccountPermissionSyncJobStatus {
diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts
index c2ac79cd0..0020964e7 100644
--- a/packages/web/src/__mocks__/prisma.ts
+++ b/packages/web/src/__mocks__/prisma.ts
@@ -1,5 +1,5 @@
import { SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
-import { Account, ApiKey, OAuthRefreshToken, OAuthToken, Org, PrismaClient, User } from '@prisma/client';
+import { Account, ApiKey, OAuthRefreshToken, OAuthToken, Org, PrismaClient, User, UserType } from '@prisma/client';
import { beforeEach, vi } from 'vitest';
import { mockDeep, mockReset } from 'vitest-mock-extended';
@@ -46,6 +46,26 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = {
lastActiveAt: null,
image: null,
sessionVersion: 0,
+ type: UserType.HUMAN,
+ description: null,
+ createdById: null,
+ accounts: [],
+}
+
+export const MOCK_SERVICE_ACCOUNT_USER: User & { accounts: Account[] } = {
+ id: 'service-1',
+ name: 'Test Service Account',
+ email: 'svc+service-1@service.internal',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ hashedPassword: null,
+ emailVerified: null,
+ lastActiveAt: new Date(),
+ image: null,
+ sessionVersion: 0,
+ type: UserType.SERVICE,
+ description: 'A test service account',
+ createdById: MOCK_USER_WITH_ACCOUNTS.id,
accounts: [],
}
diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index c64979173..5cab01407 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -1,6 +1,7 @@
'use server';
import { createAudit } from "@/ee/features/audit/audit";
+import { auditActorForUser } from "@/ee/features/audit/utils";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, ServiceError } from "@/lib/serviceError";
import { sew } from "@/middleware/sew";
@@ -54,10 +55,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv
if (existingApiKey) {
await createAudit({
action: "api_key.creation_failed",
- actor: {
- id: user.id,
- type: "user"
- },
+ actor: auditActorForUser(user),
target: {
id: org.id.toString(),
type: "org"
@@ -87,10 +85,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv
await createAudit({
action: "api_key.created",
- actor: {
- id: user.id,
- type: "user"
- },
+ actor: auditActorForUser(user),
target: {
id: apiKey.hash,
type: "api_key"
@@ -115,10 +110,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } |
if (!apiKey) {
await createAudit({
action: "api_key.deletion_failed",
- actor: {
- id: user.id,
- type: "user"
- },
+ actor: auditActorForUser(user),
target: {
id: org.id.toString(),
type: "org"
@@ -144,10 +136,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } |
await createAudit({
action: "api_key.deleted",
- actor: {
- id: user.id,
- type: "user"
- },
+ actor: auditActorForUser(user),
target: {
id: apiKey.hash,
type: "api_key"
diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx
index 655ca28f5..7ad6a5032 100644
--- a/packages/web/src/app/(app)/settings/layout.tsx
+++ b/packages/web/src/app/(app)/settings/layout.tsx
@@ -116,6 +116,12 @@ export const getSidebarNavGroups = async () =>
href: `/settings/members`,
icon: "users" as const,
},
+ {
+ title: "Service Accounts",
+ href: `/settings/serviceAccounts`,
+ hrefRegex: `/settings/serviceAccounts(/.*)?$`,
+ icon: "server" as const,
+ },
{
title: "Connections",
href: `/settings/connections`,
diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/[id]/page.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/page.tsx
new file mode 100644
index 000000000..b07840b29
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/page.tsx
@@ -0,0 +1,53 @@
+import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { getServiceAccountApiKeysAction, listServiceAccounts } from "@/features/serviceAccounts/actions";
+import { isServiceError } from "@/lib/utils";
+import { ServiceErrorException } from "@/lib/serviceError";
+import { OrgRole } from "@sourcebot/db";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import { ServiceAccountApiKeysPage } from "./serviceAccountApiKeysPage";
+
+export default authenticatedPage<{ params: Promise<{ id: string }> }>(async (_auth, { params }) => {
+ const { id } = await params;
+
+ const [serviceAccounts, apiKeys] = await Promise.all([
+ listServiceAccounts(),
+ getServiceAccountApiKeysAction(id),
+ ]);
+
+ if (isServiceError(serviceAccounts)) {
+ throw new ServiceErrorException(serviceAccounts);
+ }
+
+ const serviceAccount = serviceAccounts.find((sa) => sa.id === id);
+ if (!serviceAccount) {
+ return notFound();
+ }
+
+ if (isServiceError(apiKeys)) {
+ throw new ServiceErrorException(apiKeys);
+ }
+
+ return (
+
+
+
+
+ Service Accounts
+
+
{serviceAccount.name}
+
+ Create and manage API keys for this service account.
+
+
+
+
+ );
+}, {
+ minRole: OrgRole.OWNER,
+ redirectTo: '/settings',
+});
diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/[id]/serviceAccountApiKeysPage.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/serviceAccountApiKeysPage.tsx
new file mode 100644
index 000000000..dcfe84fcd
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/serviceAccounts/[id]/serviceAccountApiKeysPage.tsx
@@ -0,0 +1,243 @@
+'use client';
+
+import { useToast } from "@/components/hooks/use-toast";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import useCaptureEvent from "@/hooks/useCaptureEvent";
+import { createServiceAccountApiKeyAction, deleteServiceAccountApiKeyAction } from "@/features/serviceAccounts/actions";
+import { isServiceError } from "@/lib/utils";
+import { formatDistanceToNow } from "date-fns";
+import { AlertTriangle, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useMemo, useState } from "react";
+
+interface ApiKey {
+ name: string;
+ createdAt: Date;
+ lastUsedAt: Date | null;
+}
+
+interface ServiceAccountApiKeysPageProps {
+ serviceAccountId: string;
+ apiKeys: ApiKey[];
+}
+
+export function ServiceAccountApiKeysPage({ serviceAccountId, apiKeys }: ServiceAccountApiKeysPageProps) {
+ const { toast } = useToast();
+ const captureEvent = useCaptureEvent();
+ const router = useRouter();
+
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
+ const [newKeyName, setNewKeyName] = useState("");
+ const [isCreatingKey, setIsCreatingKey] = useState(false);
+ const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
+ const [copySuccess, setCopySuccess] = useState(false);
+
+ const handleCreateApiKey = async () => {
+ if (!newKeyName.trim()) {
+ toast({ title: "Error", description: "API key name cannot be empty", variant: "destructive" });
+ return;
+ }
+
+ setIsCreatingKey(true);
+ try {
+ const result = await createServiceAccountApiKeyAction(serviceAccountId, newKeyName.trim());
+ if (isServiceError(result)) {
+ toast({ title: "Error", description: `Failed to create API key: ${result.message}`, variant: "destructive" });
+ captureEvent('wa_service_account_api_key_creation_fail', {});
+ return;
+ }
+
+ setNewlyCreatedKey(result.key);
+ router.refresh();
+ captureEvent('wa_service_account_api_key_created', {});
+ } finally {
+ setIsCreatingKey(false);
+ }
+ };
+
+ const handleCopyApiKey = () => {
+ if (!newlyCreatedKey) {
+ return;
+ }
+
+ navigator.clipboard.writeText(newlyCreatedKey)
+ .then(() => {
+ setCopySuccess(true);
+ setTimeout(() => setCopySuccess(false), 2000);
+ })
+ .catch(() => {
+ toast({ title: "Error", description: "Failed to copy API key to clipboard", variant: "destructive" });
+ });
+ };
+
+ const handleCloseDialog = () => {
+ setIsCreateDialogOpen(false);
+ setNewKeyName("");
+ setNewlyCreatedKey(null);
+ setCopySuccess(false);
+ };
+
+ const handleDeleteApiKey = async (name: string) => {
+ const result = await deleteServiceAccountApiKeyAction(serviceAccountId, name);
+ if (isServiceError(result)) {
+ toast({ title: "Error", description: `Failed to delete API key: ${result.message}`, variant: "destructive" });
+ return;
+ }
+ router.refresh();
+ toast({ description: "API key deleted" });
+ };
+
+ const sortedKeys = useMemo(
+ () => [...apiKeys].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
+ [apiKeys]
+ );
+
+ return (
+
+
+
+ {apiKeys.length} API key{apiKeys.length !== 1 ? "s" : ""}
+
+
+
+
+ {
+ setNewlyCreatedKey(null);
+ setNewKeyName("");
+ setIsCreateDialogOpen(true);
+ }}
+ >
+
+ New API key
+
+
+
+
+ {newlyCreatedKey ? 'Your New API Key' : 'Create API Key'}
+
+
+ {newlyCreatedKey ? (
+
+
+
+
+ This is the only time you'll see this API key. Make sure to copy it now.
+
+
+
+
+
+ {newlyCreatedKey}
+
+
+ {copySuccess ? (
+
+ ) : (
+
+ )}
+
+
+
+ ) : (
+
+ setNewKeyName(e.target.value)}
+ placeholder="Enter a name for this API key"
+ className="mb-2"
+ />
+
+ )}
+
+
+ {newlyCreatedKey ? (
+ Done
+ ) : (
+ <>
+ Cancel
+
+ {isCreatingKey && }
+ Create
+
+ >
+ )}
+
+
+
+
+
+ {sortedKeys.length === 0 ? (
+
+ No API keys yet.
+
+ ) : (
+
+ {sortedKeys.map((key) => (
+
+
+
+
+
+ {key.name}
+
+ Created {formatDistanceToNow(key.createdAt, { addSuffix: true })}
+ {" · "}
+ {key.lastUsedAt
+ ? `last used ${formatDistanceToNow(key.lastUsedAt, { addSuffix: true })}`
+ : "never used"}
+
+
+
+
+
+
+
+
+
+
+ Delete API Key
+
+ Are you sure you want to delete the API key {key.name} ? Any applications using this key will no longer be able to access the API. This action cannot be undone.
+
+
+
+ Cancel
+ handleDeleteApiKey(key.name)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Delete
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/layout.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/layout.tsx
new file mode 100644
index 000000000..599b986cb
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/serviceAccounts/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function ServiceAccountsSettingsLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/page.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/page.tsx
new file mode 100644
index 000000000..07181ba94
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/serviceAccounts/page.tsx
@@ -0,0 +1,29 @@
+import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { listServiceAccounts } from "@/features/serviceAccounts/actions";
+import { isServiceError } from "@/lib/utils";
+import { ServiceErrorException } from "@/lib/serviceError";
+import { OrgRole } from "@sourcebot/db";
+import { ServiceAccountsPage } from "./serviceAccountsPage";
+
+export default authenticatedPage(async () => {
+ const serviceAccounts = await listServiceAccounts();
+ if (isServiceError(serviceAccounts)) {
+ throw new ServiceErrorException(serviceAccounts);
+ }
+
+ return (
+
+
+
Service Accounts
+
+ Service accounts are non-human identities for programmatic access to Sourcebot.
+ Unlike a member's own API keys, service accounts are managed centrally, don't consume a seat, and don't appear in your members list.
+
+
+
+
+ );
+}, {
+ minRole: OrgRole.OWNER,
+ redirectTo: '/settings',
+});
diff --git a/packages/web/src/app/(app)/settings/serviceAccounts/serviceAccountsPage.tsx b/packages/web/src/app/(app)/settings/serviceAccounts/serviceAccountsPage.tsx
new file mode 100644
index 000000000..c130cb371
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/serviceAccounts/serviceAccountsPage.tsx
@@ -0,0 +1,327 @@
+'use client';
+
+import { useToast } from "@/components/hooks/use-toast";
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import useCaptureEvent from "@/hooks/useCaptureEvent";
+import { isServiceError } from "@/lib/utils";
+import {
+ createServiceAccountAction,
+ reactivateServiceAccountAction,
+ removeServiceAccountAction,
+ renameServiceAccountAction,
+ setServiceAccountRoleAction,
+ suspendServiceAccountAction,
+} from "@/features/serviceAccounts/actions";
+import type { ServiceAccountSummary } from "@/features/serviceAccounts/serviceAccount.service";
+import { OrgRole } from "@sourcebot/db";
+import { formatDistanceToNow } from "date-fns";
+import { KeyRound, MoreVertical, Plus, Server } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+interface ServiceAccountsPageProps {
+ serviceAccounts: ServiceAccountSummary[];
+}
+
+export function ServiceAccountsPage({ serviceAccounts }: ServiceAccountsPageProps) {
+ const { toast } = useToast();
+ const captureEvent = useCaptureEvent();
+ const router = useRouter();
+
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
+ const [newName, setNewName] = useState("");
+ const [newDescription, setNewDescription] = useState("");
+ const [newRole, setNewRole] = useState(OrgRole.MEMBER);
+ const [isCreating, setIsCreating] = useState(false);
+
+ const [editingAccount, setEditingAccount] = useState(null);
+ const [editName, setEditName] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [isSavingEdit, setIsSavingEdit] = useState(false);
+
+ const openEditDialog = (serviceAccount: ServiceAccountSummary) => {
+ setEditingAccount(serviceAccount);
+ setEditName(serviceAccount.name ?? "");
+ setEditDescription(serviceAccount.description ?? "");
+ };
+
+ const handleSaveEdit = async () => {
+ if (!editingAccount || !editName.trim()) {
+ return;
+ }
+
+ setIsSavingEdit(true);
+ try {
+ const result = await renameServiceAccountAction(editingAccount.id, {
+ name: editName.trim(),
+ description: editDescription.trim() || undefined,
+ });
+
+ if (isServiceError(result)) {
+ toast({ title: "Error", description: `Failed to update service account: ${result.message}`, variant: "destructive" });
+ return;
+ }
+
+ setEditingAccount(null);
+ router.refresh();
+ toast({ description: "Service account updated" });
+ } finally {
+ setIsSavingEdit(false);
+ }
+ };
+
+ const handleCreate = async () => {
+ if (!newName.trim()) {
+ toast({ title: "Error", description: "Service account name cannot be empty", variant: "destructive" });
+ return;
+ }
+
+ setIsCreating(true);
+ try {
+ const result = await createServiceAccountAction({
+ name: newName.trim(),
+ description: newDescription.trim() || undefined,
+ role: newRole,
+ });
+
+ if (isServiceError(result)) {
+ toast({ title: "Error", description: `Failed to create service account: ${result.message}`, variant: "destructive" });
+ captureEvent('wa_service_account_creation_fail', {});
+ return;
+ }
+
+ setIsCreateDialogOpen(false);
+ setNewName("");
+ setNewDescription("");
+ setNewRole(OrgRole.MEMBER);
+ router.refresh();
+ captureEvent('wa_service_account_created', {});
+ toast({ description: `Service account "${result.name}" created` });
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleSetRole = async (id: string, role: OrgRole) => {
+ const result = await setServiceAccountRoleAction(id, role);
+ if (isServiceError(result)) {
+ toast({ title: "Error", description: `Failed to change role: ${result.message}`, variant: "destructive" });
+ return;
+ }
+ router.refresh();
+ };
+
+ const handleSuspend = async (id: string, suspend: boolean) => {
+ const result = suspend ? await suspendServiceAccountAction(id) : await reactivateServiceAccountAction(id);
+ if (isServiceError(result)) {
+ toast({ title: "Error", description: `Failed to ${suspend ? 'suspend' : 'reactivate'} service account: ${result.message}`, variant: "destructive" });
+ return;
+ }
+ router.refresh();
+ toast({ description: suspend ? "Service account suspended" : "Service account reactivated" });
+ };
+
+ const handleRemove = async (id: string) => {
+ const result = await removeServiceAccountAction(id);
+ if (isServiceError(result)) {
+ toast({ title: "Error", description: `Failed to remove service account: ${result.message}`, variant: "destructive" });
+ return;
+ }
+ router.refresh();
+ captureEvent('wa_service_account_removed', {});
+ toast({ description: "Service account removed" });
+ };
+
+ return (
+
+
+
+ {serviceAccounts.length} service account{serviceAccounts.length !== 1 ? "s" : ""}
+
+
+
+
+
+
+ New service account
+
+
+
+
+ Create Service Account
+
+
+ setNewName(e.target.value)}
+ placeholder="Name (e.g. CI Pipeline)"
+ />
+
+
+ setIsCreateDialogOpen(false)}>Cancel
+
+ Create
+
+
+
+
+
+
+ {serviceAccounts.length === 0 ? (
+
+ No service accounts yet.
+
+ ) : (
+
+ {serviceAccounts.map((serviceAccount) => (
+
+
+
+
+
+
+ {serviceAccount.name}
+
+ {serviceAccount.role}
+
+ {serviceAccount.suspendedAt && (
+ Suspended
+ )}
+
+ {serviceAccount.description && (
+
{serviceAccount.description}
+ )}
+
+ Created {formatDistanceToNow(serviceAccount.joinedAt, { addSuffix: true })}
+ {" · "}
+ {serviceAccount.lastActiveAt
+ ? `last used ${formatDistanceToNow(serviceAccount.lastActiveAt, { addSuffix: true })}`
+ : "never used"}
+
+
+
+
+
+
+ Manage keys
+
+
+
+
+
+
+
+
+
+
+ openEditDialog(serviceAccount)}>
+ Edit details
+
+ {serviceAccount.role === OrgRole.MEMBER ? (
+ handleSetRole(serviceAccount.id, OrgRole.OWNER)}>
+ Promote to Owner
+
+ ) : (
+ handleSetRole(serviceAccount.id, OrgRole.MEMBER)}>
+ Demote to Member
+
+ )}
+ {serviceAccount.suspendedAt ? (
+ handleSuspend(serviceAccount.id, false)}>
+ Reactivate
+
+ ) : (
+ handleSuspend(serviceAccount.id, true)}>
+ Suspend
+
+ )}
+
+
+
+ e.preventDefault()}
+ >
+ Remove
+
+
+
+
+ Remove Service Account
+
+ Are you sure you want to remove {serviceAccount.name} ? All of its API keys will stop working immediately. This action cannot be undone.
+
+
+
+ Cancel
+ handleRemove(serviceAccount.id)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Remove
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
!open && setEditingAccount(null)}>
+
+
+ Edit Service Account
+
+
+ setEditName(e.target.value)}
+ placeholder="Name"
+ />
+
+
+ setEditingAccount(null)}>Cancel
+
+ Save
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts
index e6216743c..6e080cc9b 100644
--- a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts
+++ b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts
@@ -9,6 +9,7 @@ import { StatusCodes } from "http-status-codes";
import { NextRequest } from "next/server";
import { z } from "zod";
import { activeMembershipWhere } from "@/features/membership/utils";
+import { UserType } from "@sourcebot/db";
const searchMembersQueryParamsSchema = z.object({
query: z.string().default(''),
@@ -99,7 +100,8 @@ export const GET = apiHandler(async (
...sharedWithUsers.map((s) => s.userId),
]);
- // Search org members
+ // Search org members. Excludes service accounts — nobody should be
+ // able to "share a chat with" a service account.
const members = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
@@ -108,6 +110,7 @@ export const GET = apiHandler(async (
notIn: Array.from(excludeUserIds),
},
user: {
+ type: UserType.HUMAN,
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ email: { contains: query, mode: 'insensitive' } },
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 8d2bd4345..d7e65c0d3 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
@@ -11,7 +11,7 @@ import {
import { withScimAuth } from '@/ee/features/scim/withScimAuth';
import { ErrorCode } from '@/lib/errorCodes';
import { isServiceError } from '@/lib/utils';
-import { OrgRole } from '@sourcebot/db';
+import { OrgRole, UserType } from '@sourcebot/db';
import { env } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
@@ -37,9 +37,15 @@ export const GET = apiHandler(async (request: NextRequest) =>
return scimJson(toScimListResponse([], 0, startIndex), 200);
}
+ // Service accounts are not IdP-managed identities and must never sync
+ // to/from SCIM. Inlined (rather than spread from `humanMembershipWhere`)
+ // alongside the `userName` filter below, since both target the `user`
+ // relation and a naive spread would let one clobber the other.
const where = {
orgId: org.id,
- ...(filter?.attribute === 'userName' ? { user: { email: { equals: filter.value, mode: 'insensitive' as const } } } : {}),
+ ...(filter?.attribute === 'userName'
+ ? { user: { type: UserType.HUMAN, email: { equals: filter.value, mode: 'insensitive' as const } } }
+ : { user: { type: UserType.HUMAN } }),
...(filter?.attribute === 'externalId' ? { scimExternalId: filter.value } : {}),
};
diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts
index 07ff6f102..c469ce44c 100644
--- a/packages/web/src/app/api/(server)/ee/users/route.ts
+++ b/packages/web/src/app/api/(server)/ee/users/route.ts
@@ -1,6 +1,8 @@
'use server';
import { createAudit } from "@/ee/features/audit/audit";
+import { auditActorForUser } from "@/ee/features/audit/utils";
+import { humanMembershipWhere } from "@/features/membership/utils";
import { toPublicUser } from "../utils";
import { apiHandler } from "@/lib/apiHandler";
import { serviceErrorResponse } from "@/lib/serviceError";
@@ -27,9 +29,12 @@ export const GET = apiHandler(async () => {
const result = await withAuth(async ({ prisma, org, role, user }) => {
return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
try {
+ // Service accounts are managed separately and must not leak
+ // into this org-management-facing user listing.
const memberships = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
+ ...humanMembershipWhere(),
},
include: {
user: true,
@@ -40,10 +45,7 @@ export const GET = apiHandler(async () => {
await createAudit({
action: "user.list",
- actor: {
- id: user.id,
- type: "user"
- },
+ actor: auditActorForUser(user),
target: {
id: org.id.toString(),
type: "org"
diff --git a/packages/web/src/ee/features/audit/types.ts b/packages/web/src/ee/features/audit/types.ts
index 13c6bcc8c..bdd8147de 100644
--- a/packages/web/src/ee/features/audit/types.ts
+++ b/packages/web/src/ee/features/audit/types.ts
@@ -2,13 +2,13 @@ import { z } from "zod";
export const auditActorSchema = z.object({
id: z.string(),
- type: z.enum(["user", "api_key", "scim_token"]),
+ type: z.enum(["user", "api_key", "scim_token", "service_account"]),
})
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", "scim_token"]),
+ type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat", "scim_token", "service_account"]),
})
export type AuditTarget = z.infer;
diff --git a/packages/web/src/ee/features/audit/utils.ts b/packages/web/src/ee/features/audit/utils.ts
new file mode 100644
index 000000000..ad6aca59e
--- /dev/null
+++ b/packages/web/src/ee/features/audit/utils.ts
@@ -0,0 +1,13 @@
+import { UserType, UserWithAccounts } from "@sourcebot/db";
+import { AuditActor } from "./types";
+
+/**
+ * Builds the audit actor for an action performed by a resolved `withAuth`
+ * user. Resolves to `"service_account"` when the acting identity is a
+ * service account (e.g. its own API key was used to call an audited action)
+ * and `"user"` otherwise.
+ */
+export const auditActorForUser = (user: UserWithAccounts): AuditActor => ({
+ id: user.id,
+ type: user.type === UserType.SERVICE ? "service_account" : "user",
+});
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
index 8a2a9d5ea..b0a8846bc 100644
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -2,13 +2,14 @@
import { sew } from "@/middleware/sew";
import { createAudit } from "@/ee/features/audit/audit";
+import { auditActorForUser } from "@/ee/features/audit/utils";
import { getAnonymousId, getOrCreateAnonymousId } from "@/lib/anonymousId";
import { captureEvent } from "@/lib/posthog";
import { notFound } from "@/lib/serviceError";
import { withAuth, withOptionalAuth } from "@/middleware/withAuth";
import { ChatVisibility, Prisma } from "@sourcebot/db";
import { SBChatMessage } from "./types";
-import { activeMembershipWhere } from "../membership/utils";
+import { activeMembershipWhere, humanMembershipWhere } from "../membership/utils";
import { checkAskEntitlement, deleteOrphanedAttachments, isChatSharedWithUser, isOwnerOfChat, resolveChatAccess } from "./utils.server";
export const createChat = async ({ source }: { source?: string } = {}) => sew(() =>
@@ -37,10 +38,7 @@ export const createChat = async ({ source }: { source?: string } = {}) => sew(()
if (!isGuestUser) {
await createAudit({
action: "user.created_ask_chat",
- actor: {
- id: user.id,
- type: "user",
- },
+ actor: auditActorForUser(user),
target: {
id: org.id.toString(),
type: "org",
@@ -164,7 +162,7 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
await createAudit({
action: "chat.visibility_updated",
- actor: { id: user.id, type: "user" },
+ actor: auditActorForUser(user),
target: { id: chatId, type: "chat" },
orgId: org.id,
metadata: { message: `Visibility changed to ${visibility}` },
@@ -215,7 +213,7 @@ export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
await createAudit({
action: "chat.deleted",
- actor: { id: user.id, type: "user" },
+ actor: auditActorForUser(user),
target: { id: chatId, type: "chat" },
orgId: org.id,
});
@@ -402,10 +400,13 @@ export const shareChatWithUsers = async ({ chatId, userIds }: { chatId: string,
}
+ // Excludes service accounts, so a service-account id slipped into
+ // `userIds` can't silently pass validation and receive chat access.
const memberships = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
...activeMembershipWhere(),
+ ...humanMembershipWhere(),
userId: {
in: userIds,
},
@@ -426,7 +427,7 @@ export const shareChatWithUsers = async ({ chatId, userIds }: { chatId: string,
await createAudit({
action: "chat.shared_with_users",
- actor: { id: user.id, type: "user" },
+ actor: auditActorForUser(user),
target: { id: chatId, type: "chat" },
orgId: org.id,
metadata: { message: userIds.join(", ") },
@@ -471,7 +472,7 @@ export const unshareChatWithUser = async ({ chatId, userId }: { chatId: string,
await createAudit({
action: "chat.unshared_with_user",
- actor: { id: user.id, type: "user" },
+ actor: auditActorForUser(user),
target: { id: chatId, type: "chat" },
orgId: org.id,
metadata: { message: userId },
diff --git a/packages/web/src/features/mcp/prismaScope.test.ts b/packages/web/src/features/mcp/prismaScope.test.ts
index 4b86264db..419387d4e 100644
--- a/packages/web/src/features/mcp/prismaScope.test.ts
+++ b/packages/web/src/features/mcp/prismaScope.test.ts
@@ -10,6 +10,10 @@ const user = {
emailVerified: null,
image: null,
sessionVersion: 0,
+ type: 'HUMAN',
+ description: null,
+ createdById: null,
+ lastActiveAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
accounts: [],
diff --git a/packages/web/src/features/membership/actions/invites.ts b/packages/web/src/features/membership/actions/invites.ts
index e6ca6bc07..9d9bd2d65 100644
--- a/packages/web/src/features/membership/actions/invites.ts
+++ b/packages/web/src/features/membership/actions/invites.ts
@@ -15,7 +15,7 @@ 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 { OrgRole, UserType } from "@sourcebot/db";
import { env, getSMTPConnectionURL, isMemberApprovalRequired } from "@sourcebot/shared";
import { StatusCodes } from "http-status-codes";
import { createTransport } from "nodemailer";
@@ -93,13 +93,17 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
} satisfies ServiceError;
}
- // Check for members that are already in the org
+ // Check for members that are already in the org. Excludes
+ // service accounts: their synthetic emails can't collide with a
+ // real invite address, but this keeps the check symmetric with
+ // every other member-listing call site.
const existingMembers = await prisma.userToOrg.findMany({
where: {
user: {
+ type: UserType.HUMAN,
email: {
in: emails,
- }
+ },
},
orgId: org.id,
},
diff --git a/packages/web/src/features/membership/actions/members.ts b/packages/web/src/features/membership/actions/members.ts
index edffc30e1..2cfb11d10 100644
--- a/packages/web/src/features/membership/actions/members.ts
+++ b/packages/web/src/features/membership/actions/members.ts
@@ -2,6 +2,8 @@
import { membershipManagedByIdpError } from "@/features/membership/errors";
import { removeMember, setMembershipSuspended } from "@/features/membership/membership.service";
+import { humanMembershipWhere } from "@/features/membership/utils";
+import { auditActorForUser } from "@/ee/features/audit/utils";
import { isScimEnabled } from "@/features/scim/utils";
import { ServiceError } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
@@ -18,7 +20,7 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success:
}
const result = await removeMember(org.id, memberId, {
- actor: { id: user.id, type: "user" },
+ actor: auditActorForUser(user),
});
if (isServiceError(result)) {
@@ -37,7 +39,7 @@ export const suspendMember = async (memberId: string): Promise<{ success: boolea
}
const result = await setMembershipSuspended(org.id, memberId, true, {
- actor: { id: user.id, type: "user" },
+ actor: auditActorForUser(user),
});
if (isServiceError(result)) {
@@ -56,7 +58,7 @@ export const reactivateMember = async (memberId: string): Promise<{ success: boo
}
const result = await setMembershipSuspended(org.id, memberId, false, {
- actor: { id: user.id, type: "user" },
+ actor: auditActorForUser(user),
});
if (isServiceError(result)) {
@@ -91,9 +93,12 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
export const getOrgMembers = async () => sew(() =>
withAuth(async ({ org, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ // Service accounts have their own management surface (Settings ->
+ // Service Accounts) and must not appear in the human members list.
const members = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
+ ...humanMembershipWhere(),
},
include: {
user: true,
diff --git a/packages/web/src/features/membership/membership.service.test.ts b/packages/web/src/features/membership/membership.service.test.ts
index dbe48db93..51c7edc1b 100644
--- a/packages/web/src/features/membership/membership.service.test.ts
+++ b/packages/web/src/features/membership/membership.service.test.ts
@@ -22,6 +22,7 @@ vi.mock('@/features/membership/utils', () => ({
activeMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: { not: null } }),
pendingMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: null }),
unsuspendedMembershipWhere: () => ({ suspendedAt: null }),
+ humanMembershipWhere: () => ({ user: { type: 'HUMAN' } }),
}));
vi.mock('@/features/billing/servicePing', () => ({ syncWithLighthouse: mocks.syncWithLighthouse }));
vi.mock('@/ee/features/audit/audit', () => ({ createAudit: mocks.createAudit }));
@@ -252,6 +253,19 @@ describe('removeMember', () => {
expect(prisma.userToOrg.delete).not.toHaveBeenCalled();
});
+ test('excludes service accounts when counting active owners (a lone service-account owner still blocks removal)', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null }));
+ prisma.userToOrg.count.mockResolvedValue(1);
+
+ await removeMember(ORG_ID, USER_ID, { actor: ACTOR });
+
+ expect(prisma.userToOrg.count).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ user: { type: 'HUMAN' } }),
+ }),
+ );
+ });
+
test('allows removing an owner when others remain', async () => {
prisma.userToOrg.findUnique.mockResolvedValue(makeMembership({ role: OrgRole.OWNER, suspendedAt: null }));
prisma.userToOrg.count.mockResolvedValue(2);
@@ -270,6 +284,16 @@ describe('removeMember', () => {
expect(result).toBeNull();
expect(mocks.createAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'org.member_left' }));
});
+
+ test('audits with the caller-supplied targetType (used by service-account removal)', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembership());
+
+ await removeMember(ORG_ID, USER_ID, { actor: ACTOR, targetType: 'service_account' });
+
+ expect(mocks.createAudit).toHaveBeenCalledWith(
+ expect.objectContaining({ target: { id: USER_ID, type: 'service_account' } }),
+ );
+ });
});
describe('setMemberRole', () => {
diff --git a/packages/web/src/features/membership/membership.service.ts b/packages/web/src/features/membership/membership.service.ts
index 5004a6122..74f9f1143 100644
--- a/packages/web/src/features/membership/membership.service.ts
+++ b/packages/web/src/features/membership/membership.service.ts
@@ -1,9 +1,9 @@
import 'server-only';
import { createAudit } from "@/ee/features/audit/audit";
-import { type AuditActor } from "@/ee/features/audit/types";
+import { type AuditActor, type AuditTarget } from "@/ee/features/audit/types";
import { syncWithLighthouse } from "@/features/billing/servicePing";
-import { activeMembershipWhere, orgHasAvailability, pendingMembershipWhere } from "@/features/membership/utils";
+import { activeMembershipWhere, humanMembershipWhere, orgHasAvailability, pendingMembershipWhere } from "@/features/membership/utils";
import { notFound, type ServiceError } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { __unsafePrisma as prisma } from "@/prisma";
@@ -139,6 +139,10 @@ export const ensureActiveMember = async (
export interface RemoveMemberOptions {
actor: AuditActor;
reason?: "removed" | "left";
+ /** Audit target type for `userId`. Defaults to "user"; the service-account
+ * management flows pass "service_account" here so their own removals are
+ * attributed correctly, since this function is shared by both. */
+ targetType?: AuditTarget['type'];
}
/**
@@ -150,7 +154,7 @@ export const removeMember = async (
userId: string,
options: RemoveMemberOptions,
): Promise => {
- const { actor, reason = "removed" } = options;
+ const { actor, reason = "removed", targetType = "user" } = options;
const result = await prisma.$transaction(async (tx) => {
const target = await tx.userToOrg.findUnique({
@@ -180,7 +184,7 @@ export const removeMember = async (
await createAudit({
action: reason === "left" ? "org.member_left" : "org.member_removed",
actor,
- target: { id: userId, type: "user" },
+ target: { id: userId, type: targetType },
orgId,
});
}
@@ -191,6 +195,8 @@ export const removeMember = async (
export interface SetMemberRoleOptions {
actor: AuditActor;
+ /** See `RemoveMemberOptions.targetType`. */
+ targetType?: AuditTarget['type'];
}
/**
@@ -204,7 +210,7 @@ export const setMemberRole = async (
role: OrgRole,
options: SetMemberRoleOptions,
): Promise => {
- const { actor } = options;
+ const { actor, targetType = "user" } = options;
let didChange = false;
@@ -244,7 +250,7 @@ export const setMemberRole = async (
await createAudit({
action: role === OrgRole.OWNER ? "org.member_promoted_to_owner" : "org.owner_demoted_to_member",
actor,
- target: { id: userId, type: "user" },
+ target: { id: userId, type: targetType },
orgId,
});
}
@@ -255,6 +261,8 @@ export const setMemberRole = async (
export interface SetMembershipSuspendedOptions {
actor: AuditActor;
scimExternalId?: string;
+ /** See `RemoveMemberOptions.targetType`. */
+ targetType?: AuditTarget['type'];
}
/**
@@ -268,7 +276,7 @@ export const setMembershipSuspended = async (
suspended: boolean,
options: SetMembershipSuspendedOptions,
): Promise => {
- const { actor, scimExternalId } = options;
+ const { actor, scimExternalId, targetType = "user" } = options;
if (suspended) {
let didChange = false;
@@ -305,7 +313,7 @@ export const setMembershipSuspended = async (
await createAudit({
action: "org.member_deactivated",
actor,
- target: { id: userId, type: "user" },
+ target: { id: userId, type: targetType },
orgId,
});
}
@@ -353,7 +361,7 @@ export const setMembershipSuspended = async (
await createAudit({
action: "org.member_reactivated",
actor,
- target: { id: userId, type: "user" },
+ target: { id: userId, type: targetType },
orgId,
});
}
@@ -362,11 +370,15 @@ export const setMembershipSuspended = async (
}
};
+// Excludes service accounts: a service account holding OWNER doesn't count
+// towards "at least one human owner remains", since it can't sign into the
+// settings UI to administer the org.
const countActiveOwners = (tx: Prisma.TransactionClient, orgId: number): Promise =>
tx.userToOrg.count({
where: {
orgId,
...activeMembershipWhere(),
+ ...humanMembershipWhere(),
role: OrgRole.OWNER,
},
});
diff --git a/packages/web/src/features/membership/utils.test.ts b/packages/web/src/features/membership/utils.test.ts
new file mode 100644
index 000000000..ede11d5e3
--- /dev/null
+++ b/packages/web/src/features/membership/utils.test.ts
@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { prisma } from '@/__mocks__/prisma';
+import { UserType } from '@sourcebot/db';
+
+const mocks = vi.hoisted(() => ({
+ getSeatCap: vi.fn((): number | undefined => undefined),
+ hasEntitlement: vi.fn(async (_entitlement: string) => false),
+}));
+
+vi.mock('@/prisma', async () => {
+ const actual = await vi.importActual('@/__mocks__/prisma');
+ return { ...actual };
+});
+vi.mock('@sourcebot/shared', () => ({
+ getSeatCap: mocks.getSeatCap,
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+vi.mock('@/lib/entitlements', () => ({
+ hasEntitlement: mocks.hasEntitlement,
+}));
+
+import { activeMembershipWhere, humanMembershipWhere, orgHasAvailability } from './utils';
+
+const ORG_ID = 1;
+
+beforeEach(() => {
+ mocks.getSeatCap.mockReset().mockReturnValue(undefined);
+});
+
+describe('humanMembershipWhere', () => {
+ test('matches only HUMAN-typed users', () => {
+ expect(humanMembershipWhere()).toEqual({ user: { type: UserType.HUMAN } });
+ });
+});
+
+describe('orgHasAvailability', () => {
+ test('has availability when there is no seat cap, regardless of count', async () => {
+ prisma.userToOrg.count.mockResolvedValue(1000);
+
+ const result = await orgHasAvailability(ORG_ID, prisma);
+
+ expect(result).toBe(true);
+ });
+
+ test('excludes service accounts from the active-seat count', async () => {
+ prisma.userToOrg.count.mockResolvedValue(0);
+
+ await orgHasAvailability(ORG_ID, prisma);
+
+ expect(prisma.userToOrg.count).toHaveBeenCalledWith({
+ where: {
+ orgId: ORG_ID,
+ ...activeMembershipWhere(),
+ ...humanMembershipWhere(),
+ },
+ });
+ });
+
+ test('is at capacity when the human active count meets the seat cap', async () => {
+ mocks.getSeatCap.mockReturnValue(2);
+ prisma.userToOrg.count.mockResolvedValue(2);
+
+ const result = await orgHasAvailability(ORG_ID, prisma);
+
+ expect(result).toBe(false);
+ });
+
+ test('has availability when the human active count is below the seat cap', async () => {
+ mocks.getSeatCap.mockReturnValue(2);
+ prisma.userToOrg.count.mockResolvedValue(1);
+
+ const result = await orgHasAvailability(ORG_ID, prisma);
+
+ expect(result).toBe(true);
+ });
+});
diff --git a/packages/web/src/features/membership/utils.ts b/packages/web/src/features/membership/utils.ts
index 572e65288..a1931ec15 100644
--- a/packages/web/src/features/membership/utils.ts
+++ b/packages/web/src/features/membership/utils.ts
@@ -1,6 +1,6 @@
import { hasEntitlement } from "@/lib/entitlements";
import { createLogger, getSeatCap } from "@sourcebot/shared";
-import { OrgRole, Prisma } from "@sourcebot/db";
+import { OrgRole, Prisma, UserType } from "@sourcebot/db";
const logger = createLogger("membership-utils");
@@ -43,19 +43,33 @@ export const activeMembershipWhere = (): Prisma.UserToOrgWhereInput => ({
lastActiveAt: { not: null },
});
+/**
+ * Matches memberships belonging to human users, i.e., excludes service
+ * accounts. Compose this into member-facing/seat queries where "member"
+ * should mean a person. Do not use it for auth/access checks where a service
+ * account's own membership is the thing being evaluated — a service account
+ * is unsuspended/active/pending exactly like a human, just excluded from
+ * seat counts and human-facing listings.
+ */
+export const humanMembershipWhere = (): Prisma.UserToOrgWhereInput => ({
+ user: { type: UserType.HUMAN },
+});
+
/**
* 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.
+ * key, if available. Service accounts don't consume seats.
*/
export const orgHasAvailability = async (orgId: number, tx: Prisma.TransactionClient): Promise => {
const seatCap = getSeatCap();
// Pending and suspended members are preserved but don't consume seats.
+ // Service accounts never consume seats.
const activeUserCount = await tx.userToOrg.count({
where: {
orgId,
...activeMembershipWhere(),
+ ...humanMembershipWhere(),
},
});
diff --git a/packages/web/src/features/serviceAccounts/actions.test.ts b/packages/web/src/features/serviceAccounts/actions.test.ts
new file mode 100644
index 000000000..3a8e950b8
--- /dev/null
+++ b/packages/web/src/features/serviceAccounts/actions.test.ts
@@ -0,0 +1,151 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { OrgRole } from "@sourcebot/db";
+import { ErrorCode } from "@/lib/errorCodes";
+import { isServiceError } from "@/lib/utils";
+import type { ServiceError } from "@/lib/serviceError";
+
+const MOCK_ORG = { id: 1 };
+const MOCK_USER = { id: "human-1", type: "HUMAN" };
+
+const mocks = vi.hoisted(() => ({
+ role: undefined as unknown,
+ createServiceAccount: vi.fn(),
+ updateServiceAccount: vi.fn(),
+ setServiceAccountRole: vi.fn(),
+ suspendServiceAccount: vi.fn(),
+ reactivateServiceAccount: vi.fn(),
+ removeServiceAccount: vi.fn(),
+ listServiceAccounts: vi.fn(),
+ createServiceAccountApiKey: vi.fn(),
+ deleteServiceAccountApiKey: vi.fn(),
+ getServiceAccountApiKeys: vi.fn(),
+}));
+
+vi.mock("@/middleware/withAuth", () => ({
+ withAuth: vi.fn((callback: (context: unknown) => unknown) => callback({
+ org: MOCK_ORG,
+ user: MOCK_USER,
+ role: mocks.role,
+ prisma: {},
+ })),
+}));
+
+vi.mock("@/ee/features/audit/utils", () => ({
+ auditActorForUser: (user: { id: string }) => ({ id: user.id, type: "user" }),
+}));
+
+vi.mock("./serviceAccount.service", () => ({
+ createServiceAccount: mocks.createServiceAccount,
+ updateServiceAccount: mocks.updateServiceAccount,
+ setServiceAccountRole: mocks.setServiceAccountRole,
+ suspendServiceAccount: mocks.suspendServiceAccount,
+ reactivateServiceAccount: mocks.reactivateServiceAccount,
+ removeServiceAccount: mocks.removeServiceAccount,
+ listServiceAccounts: mocks.listServiceAccounts,
+ createServiceAccountApiKey: mocks.createServiceAccountApiKey,
+ deleteServiceAccountApiKey: mocks.deleteServiceAccountApiKey,
+ getServiceAccountApiKeys: mocks.getServiceAccountApiKeys,
+}));
+
+vi.mock("@sourcebot/shared", () => ({
+ createLogger: () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() }),
+}));
+
+const {
+ createServiceAccountAction,
+ listServiceAccounts: listServiceAccountsAction,
+ removeServiceAccountAction,
+ suspendServiceAccountAction,
+ createServiceAccountApiKeyAction,
+} = await import("./actions");
+
+beforeEach(() => {
+ mocks.role = OrgRole.OWNER;
+ mocks.createServiceAccount.mockReset();
+ mocks.updateServiceAccount.mockReset();
+ mocks.setServiceAccountRole.mockReset();
+ mocks.suspendServiceAccount.mockReset();
+ mocks.reactivateServiceAccount.mockReset();
+ mocks.removeServiceAccount.mockReset();
+ mocks.listServiceAccounts.mockReset();
+ mocks.createServiceAccountApiKey.mockReset();
+ mocks.deleteServiceAccountApiKey.mockReset();
+ mocks.getServiceAccountApiKeys.mockReset();
+});
+
+describe("OWNER-only authorization boundary", () => {
+ test("a MEMBER cannot create a service account", async () => {
+ mocks.role = OrgRole.MEMBER;
+
+ const result = await createServiceAccountAction({ name: "CI", role: OrgRole.MEMBER });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.INSUFFICIENT_PERMISSIONS);
+ expect(mocks.createServiceAccount).not.toHaveBeenCalled();
+ });
+
+ test("a MEMBER cannot list service accounts", async () => {
+ mocks.role = OrgRole.MEMBER;
+
+ const result = await listServiceAccountsAction();
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.INSUFFICIENT_PERMISSIONS);
+ expect(mocks.listServiceAccounts).not.toHaveBeenCalled();
+ });
+
+ test("a MEMBER cannot remove a service account", async () => {
+ mocks.role = OrgRole.MEMBER;
+
+ const result = await removeServiceAccountAction("svc-1");
+
+ expect(isServiceError(result)).toBe(true);
+ expect(mocks.removeServiceAccount).not.toHaveBeenCalled();
+ });
+
+ test("a MEMBER cannot suspend a service account", async () => {
+ mocks.role = OrgRole.MEMBER;
+
+ const result = await suspendServiceAccountAction("svc-1");
+
+ expect(isServiceError(result)).toBe(true);
+ expect(mocks.suspendServiceAccount).not.toHaveBeenCalled();
+ });
+
+ test("a MEMBER cannot create a service account's API key", async () => {
+ mocks.role = OrgRole.MEMBER;
+
+ const result = await createServiceAccountApiKeyAction("svc-1", "ci-key");
+
+ expect(isServiceError(result)).toBe(true);
+ expect(mocks.createServiceAccountApiKey).not.toHaveBeenCalled();
+ });
+
+ test("an OWNER can create a service account, attributed to the calling human", async () => {
+ mocks.role = OrgRole.OWNER;
+ mocks.createServiceAccount.mockResolvedValue({
+ id: "svc-1",
+ name: "CI",
+ description: null,
+ createdById: MOCK_USER.id,
+ });
+
+ const result = await createServiceAccountAction({ name: "CI", role: OrgRole.MEMBER });
+
+ expect(isServiceError(result)).toBe(false);
+ expect(mocks.createServiceAccount).toHaveBeenCalledWith(
+ MOCK_ORG.id,
+ expect.objectContaining({ actor: { id: MOCK_USER.id, type: "user" }, name: "CI", role: OrgRole.MEMBER }),
+ );
+ });
+
+ test("an OWNER can list service accounts", async () => {
+ mocks.role = OrgRole.OWNER;
+ mocks.listServiceAccounts.mockResolvedValue([]);
+
+ const result = await listServiceAccountsAction();
+
+ expect(result).toEqual([]);
+ expect(mocks.listServiceAccounts).toHaveBeenCalledWith(MOCK_ORG.id);
+ });
+});
diff --git a/packages/web/src/features/serviceAccounts/actions.ts b/packages/web/src/features/serviceAccounts/actions.ts
new file mode 100644
index 000000000..18281852a
--- /dev/null
+++ b/packages/web/src/features/serviceAccounts/actions.ts
@@ -0,0 +1,150 @@
+'use server';
+
+import { auditActorForUser } from "@/ee/features/audit/utils";
+import {
+ createServiceAccount,
+ createServiceAccountApiKey,
+ deleteServiceAccountApiKey,
+ getServiceAccountApiKeys,
+ listServiceAccounts as listServiceAccountsService,
+ reactivateServiceAccount,
+ removeServiceAccount,
+ ServiceAccountSummary,
+ setServiceAccountRole,
+ suspendServiceAccount,
+ updateServiceAccount,
+} from "@/features/serviceAccounts/serviceAccount.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";
+
+// Service accounts are managed strictly by org OWNERs, unconditionally --
+// unlike a human's own API keys, this isn't gated by
+// DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS/DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS,
+// since those flags govern a human's *own* keys, not org-wide credential
+// management.
+export const listServiceAccounts = async (): Promise => sew(() =>
+ withAuth(async ({ org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ return listServiceAccountsService(org.id);
+ })));
+
+export const createServiceAccountAction = async (input: { name: string; description?: string; role: OrgRole }): Promise => sew(() =>
+ withAuth(async ({ org, user, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const result = await createServiceAccount(org.id, {
+ actor: auditActorForUser(user),
+ ...input,
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return {
+ id: result.id,
+ name: result.name,
+ description: result.description,
+ role: input.role,
+ createdById: result.createdById,
+ joinedAt: new Date(),
+ suspendedAt: null,
+ lastActiveAt: new Date(),
+ } satisfies ServiceAccountSummary;
+ })));
+
+export const renameServiceAccountAction = async (serviceAccountId: string, input: { name?: string; description?: string }): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const result = await updateServiceAccount(org.id, serviceAccountId, {
+ actor: auditActorForUser(user),
+ ...input,
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ })));
+
+export const setServiceAccountRoleAction = async (serviceAccountId: string, role: OrgRole): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role: callerRole }) =>
+ withMinimumOrgRole(callerRole, OrgRole.OWNER, async () => {
+ const result = await setServiceAccountRole(org.id, serviceAccountId, role, {
+ actor: auditActorForUser(user),
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ })));
+
+export const suspendServiceAccountAction = async (serviceAccountId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const result = await suspendServiceAccount(org.id, serviceAccountId, {
+ actor: auditActorForUser(user),
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ })));
+
+export const reactivateServiceAccountAction = async (serviceAccountId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const result = await reactivateServiceAccount(org.id, serviceAccountId, {
+ actor: auditActorForUser(user),
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ })));
+
+export const removeServiceAccountAction = async (serviceAccountId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const result = await removeServiceAccount(org.id, serviceAccountId, {
+ actor: auditActorForUser(user),
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { success: true };
+ })));
+
+export const createServiceAccountApiKeyAction = async (serviceAccountId: string, name: string): Promise<{ key: string } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ return createServiceAccountApiKey(org.id, serviceAccountId, name, {
+ actor: auditActorForUser(user),
+ });
+ })));
+
+export const deleteServiceAccountApiKeyAction = async (serviceAccountId: string, name: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, user, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ return deleteServiceAccountApiKey(org.id, serviceAccountId, name, {
+ actor: auditActorForUser(user),
+ });
+ })));
+
+export const getServiceAccountApiKeysAction = async (serviceAccountId: string) => sew(() =>
+ withAuth(async ({ org, role }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ return getServiceAccountApiKeys(org.id, serviceAccountId);
+ })));
diff --git a/packages/web/src/features/serviceAccounts/errors.ts b/packages/web/src/features/serviceAccounts/errors.ts
new file mode 100644
index 000000000..566c28951
--- /dev/null
+++ b/packages/web/src/features/serviceAccounts/errors.ts
@@ -0,0 +1,9 @@
+import { ErrorCode } from "@/lib/errorCodes";
+import { ServiceError } from "@/lib/serviceError";
+import { StatusCodes } from "http-status-codes";
+
+export const serviceAccountNotFoundError = (): ServiceError => ({
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.SERVICE_ACCOUNT_NOT_FOUND,
+ message: "Service account not found",
+});
diff --git a/packages/web/src/features/serviceAccounts/serviceAccount.service.test.ts b/packages/web/src/features/serviceAccounts/serviceAccount.service.test.ts
new file mode 100644
index 000000000..4c039bce8
--- /dev/null
+++ b/packages/web/src/features/serviceAccounts/serviceAccount.service.test.ts
@@ -0,0 +1,268 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { prisma, MOCK_SERVICE_ACCOUNT_USER, MOCK_USER_WITH_ACCOUNTS } from '@/__mocks__/prisma';
+import { OrgRole, Prisma, UserToOrg, UserType, type User } 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(),
+ generateApiKey: 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,
+ activeMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: { not: null } }),
+ pendingMembershipWhere: () => ({ suspendedAt: null, lastActiveAt: null }),
+ humanMembershipWhere: () => ({ user: { type: 'HUMAN' } }),
+}));
+vi.mock('@/features/billing/servicePing', () => ({ syncWithLighthouse: mocks.syncWithLighthouse }));
+vi.mock('@/ee/features/audit/audit', () => ({ createAudit: mocks.createAudit }));
+vi.mock('@sourcebot/shared', () => ({ generateApiKey: mocks.generateApiKey }));
+
+import {
+ createServiceAccount,
+ createServiceAccountApiKey,
+ deleteServiceAccountApiKey,
+ listServiceAccounts,
+ removeServiceAccount,
+ setServiceAccountRole,
+ suspendServiceAccount,
+} from './serviceAccount.service';
+
+const ORG_ID = 1;
+const HUMAN_ACTOR = { id: 'human-1', type: 'user' } as const;
+
+const makeMembership = (overrides: Partial = {}): UserToOrg => ({
+ orgId: ORG_ID,
+ userId: MOCK_SERVICE_ACCOUNT_USER.id,
+ role: OrgRole.MEMBER,
+ joinedAt: new Date(),
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: new Date(),
+ ...overrides,
+});
+
+type MembershipWithUser = Prisma.UserToOrgGetPayload<{ include: { user: true } }>;
+
+const makeMembershipWithUser = (user: User, overrides: Partial = {}): MembershipWithUser => ({
+ ...makeMembership({ userId: user.id, ...overrides }),
+ user,
+});
+
+beforeEach(() => {
+ mocks.orgHasAvailability.mockReset().mockResolvedValue(true);
+ mocks.syncWithLighthouse.mockReset().mockResolvedValue(undefined);
+ mocks.createAudit.mockReset().mockResolvedValue(undefined);
+ mocks.generateApiKey.mockReset().mockReturnValue({ key: 'sbk_secret', hash: 'hash-1' });
+ // 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('createServiceAccount', () => {
+ test('creates a SERVICE-typed user with an immediately active membership and no seat check', async () => {
+ prisma.user.create.mockResolvedValue(MOCK_SERVICE_ACCOUNT_USER);
+ prisma.userToOrg.create.mockResolvedValue(makeMembership());
+
+ const result = await createServiceAccount(ORG_ID, {
+ actor: HUMAN_ACTOR,
+ name: 'CI Pipeline',
+ role: OrgRole.MEMBER,
+ });
+
+ expect(isServiceError(result)).toBe(false);
+ expect(prisma.user.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ type: UserType.SERVICE,
+ name: 'CI Pipeline',
+ createdById: HUMAN_ACTOR.id,
+ }),
+ }),
+ );
+ expect(prisma.userToOrg.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ orgId: ORG_ID,
+ role: OrgRole.MEMBER,
+ lastActiveAt: expect.any(Date),
+ }),
+ }),
+ );
+ // No seat-cap gate for service accounts.
+ expect(mocks.orgHasAvailability).not.toHaveBeenCalled();
+ expect(mocks.createAudit).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'service_account.created', target: { id: MOCK_SERVICE_ACCOUNT_USER.id, type: 'service_account' } }),
+ );
+ });
+
+ test("synthesizes a non-user-facing email, never using the caller's input", async () => {
+ prisma.user.create.mockResolvedValue(MOCK_SERVICE_ACCOUNT_USER);
+ prisma.userToOrg.create.mockResolvedValue(makeMembership());
+
+ await createServiceAccount(ORG_ID, { actor: HUMAN_ACTOR, name: 'CI Pipeline', role: OrgRole.MEMBER });
+
+ const createCall = prisma.user.create.mock.calls[0]?.[0];
+ expect(createCall.data.email).toMatch(/^svc\+.+@service\.internal$/);
+ });
+});
+
+describe('setServiceAccountRole / suspendServiceAccount / removeServiceAccount guards', () => {
+ test('rejects operating on a HUMAN-typed id', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembershipWithUser(MOCK_USER_WITH_ACCOUNTS));
+
+ const roleResult = await setServiceAccountRole(ORG_ID, MOCK_USER_WITH_ACCOUNTS.id, OrgRole.OWNER, { actor: HUMAN_ACTOR });
+ const suspendResult = await suspendServiceAccount(ORG_ID, MOCK_USER_WITH_ACCOUNTS.id, { actor: HUMAN_ACTOR });
+ const removeResult = await removeServiceAccount(ORG_ID, MOCK_USER_WITH_ACCOUNTS.id, { actor: HUMAN_ACTOR });
+
+ for (const result of [roleResult, suspendResult, removeResult]) {
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.SERVICE_ACCOUNT_NOT_FOUND);
+ }
+ expect(prisma.userToOrg.delete).not.toHaveBeenCalled();
+ expect(prisma.user.delete).not.toHaveBeenCalled();
+ });
+
+ test('rejects an id that has no membership in this org', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(null);
+
+ const result = await suspendServiceAccount(ORG_ID, 'nonexistent', { actor: HUMAN_ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.SERVICE_ACCOUNT_NOT_FOUND);
+ });
+});
+
+describe('suspendServiceAccount', () => {
+ test('revokes all of its API keys and OAuth credentials via the shared membership suspension path', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembershipWithUser(MOCK_SERVICE_ACCOUNT_USER, { suspendedAt: null }));
+ prisma.userToOrg.update.mockResolvedValue(makeMembership({ suspendedAt: new Date() }));
+
+ const result = await suspendServiceAccount(ORG_ID, MOCK_SERVICE_ACCOUNT_USER.id, { actor: HUMAN_ACTOR });
+
+ expect(isServiceError(result)).toBe(false);
+ expect(prisma.apiKey.deleteMany).toHaveBeenCalledWith({
+ where: { createdById: MOCK_SERVICE_ACCOUNT_USER.id, orgId: ORG_ID },
+ });
+ expect(mocks.createAudit).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'org.member_deactivated', target: { id: MOCK_SERVICE_ACCOUNT_USER.id, type: 'service_account' } }),
+ );
+ });
+});
+
+describe('removeServiceAccount', () => {
+ test('hard-deletes the User row after removing its membership', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembershipWithUser(MOCK_SERVICE_ACCOUNT_USER));
+
+ const result = await removeServiceAccount(ORG_ID, MOCK_SERVICE_ACCOUNT_USER.id, { actor: HUMAN_ACTOR });
+
+ expect(result).toBeNull();
+ expect(prisma.userToOrg.delete).toHaveBeenCalledWith({
+ where: { orgId_userId: { orgId: ORG_ID, userId: MOCK_SERVICE_ACCOUNT_USER.id } },
+ });
+ expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: MOCK_SERVICE_ACCOUNT_USER.id } });
+ expect(mocks.createAudit).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'service_account.removed', target: { id: MOCK_SERVICE_ACCOUNT_USER.id, type: 'service_account' } }),
+ );
+ });
+
+ test('blocks removing the last active owner even when it is a service account', async () => {
+ prisma.userToOrg.findUnique.mockResolvedValueOnce(makeMembershipWithUser(MOCK_SERVICE_ACCOUNT_USER, { role: OrgRole.OWNER, suspendedAt: null }));
+ // membership.service.removeMember's own lookup (second call inside the transaction).
+ prisma.userToOrg.findUnique.mockResolvedValueOnce(makeMembership({ role: OrgRole.OWNER, suspendedAt: null }));
+ prisma.userToOrg.count.mockResolvedValue(1);
+
+ const result = await removeServiceAccount(ORG_ID, MOCK_SERVICE_ACCOUNT_USER.id, { actor: HUMAN_ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED);
+ expect(prisma.user.delete).not.toHaveBeenCalled();
+ });
+});
+
+describe('service account API key management', () => {
+ beforeEach(() => {
+ prisma.userToOrg.findUnique.mockResolvedValue(makeMembershipWithUser(MOCK_SERVICE_ACCOUNT_USER));
+ });
+
+ test('creates a key scoped to the service account, not the calling human', async () => {
+ prisma.apiKey.findFirst.mockResolvedValue(null);
+ prisma.apiKey.create.mockResolvedValue({
+ name: 'ci-key',
+ hash: 'hash-1',
+ createdAt: new Date(),
+ lastUsedAt: null,
+ orgId: ORG_ID,
+ createdById: MOCK_SERVICE_ACCOUNT_USER.id,
+ });
+
+ const result = await createServiceAccountApiKey(ORG_ID, MOCK_SERVICE_ACCOUNT_USER.id, 'ci-key', { actor: HUMAN_ACTOR });
+
+ expect(isServiceError(result)).toBe(false);
+ expect(prisma.apiKey.create).toHaveBeenCalledWith(
+ expect.objectContaining({ data: expect.objectContaining({ createdById: MOCK_SERVICE_ACCOUNT_USER.id }) }),
+ );
+ expect(mocks.createAudit).toHaveBeenCalledWith(
+ expect.objectContaining({ actor: HUMAN_ACTOR, action: 'api_key.created' }),
+ );
+ });
+
+ test('rejects a duplicate key name for the same service account', async () => {
+ prisma.apiKey.findFirst.mockResolvedValue({
+ name: 'ci-key',
+ hash: 'existing',
+ createdAt: new Date(),
+ lastUsedAt: null,
+ orgId: ORG_ID,
+ createdById: MOCK_SERVICE_ACCOUNT_USER.id,
+ });
+
+ const result = await createServiceAccountApiKey(ORG_ID, MOCK_SERVICE_ACCOUNT_USER.id, 'ci-key', { actor: HUMAN_ACTOR });
+
+ expect(isServiceError(result)).toBe(true);
+ expect((result as ServiceError).errorCode).toBe(ErrorCode.API_KEY_ALREADY_EXISTS);
+ expect(prisma.apiKey.create).not.toHaveBeenCalled();
+ });
+
+ test('deletes a key scoped to the service account', async () => {
+ prisma.apiKey.findFirst.mockResolvedValue({
+ name: 'ci-key',
+ hash: 'hash-1',
+ createdAt: new Date(),
+ lastUsedAt: null,
+ orgId: ORG_ID,
+ createdById: MOCK_SERVICE_ACCOUNT_USER.id,
+ });
+
+ const result = await deleteServiceAccountApiKey(ORG_ID, MOCK_SERVICE_ACCOUNT_USER.id, 'ci-key', { actor: HUMAN_ACTOR });
+
+ expect(isServiceError(result)).toBe(false);
+ expect(prisma.apiKey.delete).toHaveBeenCalledWith({ where: { hash: 'hash-1' } });
+ });
+});
+
+describe('listServiceAccounts', () => {
+ test('lists only SERVICE-typed memberships for the org', async () => {
+ prisma.userToOrg.findMany.mockResolvedValue([makeMembershipWithUser(MOCK_SERVICE_ACCOUNT_USER)]);
+
+ const result = await listServiceAccounts(ORG_ID);
+
+ expect(prisma.userToOrg.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { orgId: ORG_ID, user: { type: UserType.SERVICE } },
+ }),
+ );
+ expect(result).toEqual([
+ expect.objectContaining({ id: MOCK_SERVICE_ACCOUNT_USER.id, name: MOCK_SERVICE_ACCOUNT_USER.name }),
+ ]);
+ });
+});
diff --git a/packages/web/src/features/serviceAccounts/serviceAccount.service.ts b/packages/web/src/features/serviceAccounts/serviceAccount.service.ts
new file mode 100644
index 000000000..97ab33399
--- /dev/null
+++ b/packages/web/src/features/serviceAccounts/serviceAccount.service.ts
@@ -0,0 +1,369 @@
+import 'server-only';
+
+import { createAudit } from "@/ee/features/audit/audit";
+import { type AuditActor } from "@/ee/features/audit/types";
+import {
+ removeMember,
+ setMemberRole,
+ setMembershipSuspended,
+} from "@/features/membership/membership.service";
+import { ErrorCode } from "@/lib/errorCodes";
+import { type ServiceError } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { __unsafePrisma as prisma } from "@/prisma";
+import { ApiKey, OrgRole, User, UserToOrg, UserType, UserWithAccounts } from "@sourcebot/db";
+import { generateApiKey } from "@sourcebot/shared";
+import { StatusCodes } from "http-status-codes";
+import { randomUUID } from "crypto";
+import { serviceAccountNotFoundError } from "./errors";
+
+export interface CreateServiceAccountOptions {
+ actor: AuditActor;
+ name: string;
+ description?: string;
+ role: OrgRole;
+}
+
+/**
+ * Creates a service account: a `User` (`type: SERVICE`) with an immediately
+ * active `UserToOrg` membership (no pending-invite flow, and no seat-cap
+ * check — service accounts don't consume seats, see `humanMembershipWhere`).
+ * Its repo visibility is governed purely by `role`, exactly like a human
+ * member.
+ */
+export const createServiceAccount = async (
+ orgId: number,
+ options: CreateServiceAccountOptions,
+): Promise => {
+ const { actor, name, description, role } = options;
+
+ const user = await prisma.$transaction(async (tx) => {
+ const created = await tx.user.create({
+ data: {
+ // Never user-facing or treated as a login identifier -- just
+ // satisfies the `email` unique constraint shared with human
+ // users.
+ email: `svc+${randomUUID()}@service.internal`,
+ name,
+ description,
+ type: UserType.SERVICE,
+ createdById: actor.type === "user" ? actor.id : null,
+ },
+ include: { accounts: true },
+ });
+
+ await tx.userToOrg.create({
+ data: {
+ orgId,
+ userId: created.id,
+ role,
+ lastActiveAt: new Date(),
+ },
+ });
+
+ return created;
+ });
+
+ await createAudit({
+ action: "service_account.created",
+ actor,
+ target: { id: user.id, type: "service_account" },
+ orgId,
+ metadata: { message: `Created service account "${name}"` },
+ });
+
+ return user;
+};
+
+export interface UpdateServiceAccountOptions {
+ actor: AuditActor;
+ name?: string;
+ description?: string;
+}
+
+export const updateServiceAccount = async (
+ orgId: number,
+ serviceAccountId: string,
+ options: UpdateServiceAccountOptions,
+): Promise => {
+ const { actor, name, description } = options;
+
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ const user = await prisma.user.update({
+ where: { id: serviceAccountId },
+ data: {
+ ...(name !== undefined ? { name } : {}),
+ ...(description !== undefined ? { description } : {}),
+ },
+ include: { accounts: true },
+ });
+
+ await createAudit({
+ action: "service_account.updated",
+ actor,
+ target: { id: user.id, type: "service_account" },
+ orgId,
+ });
+
+ return user;
+};
+
+export const setServiceAccountRole = async (
+ orgId: number,
+ serviceAccountId: string,
+ role: OrgRole,
+ options: { actor: AuditActor },
+): Promise => {
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ return setMemberRole(orgId, serviceAccountId, role, {
+ actor: options.actor,
+ targetType: "service_account",
+ });
+};
+
+/**
+ * Suspending a service account revokes all of its API keys and OAuth
+ * credentials immediately (via `setMembershipSuspended` ->
+ * `revokeAllUserAuthCredentials`) — the equivalent of "suspend = revoke
+ * everything it can authenticate with".
+ */
+export const suspendServiceAccount = async (
+ orgId: number,
+ serviceAccountId: string,
+ options: { actor: AuditActor },
+): Promise => {
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ return setMembershipSuspended(orgId, serviceAccountId, true, {
+ actor: options.actor,
+ targetType: "service_account",
+ });
+};
+
+export const reactivateServiceAccount = async (
+ orgId: number,
+ serviceAccountId: string,
+ options: { actor: AuditActor },
+): Promise => {
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ return setMembershipSuspended(orgId, serviceAccountId, false, {
+ actor: options.actor,
+ targetType: "service_account",
+ });
+};
+
+/**
+ * Hard-deletes the service account's `User` row (unlike human member removal,
+ * which preserves the `User` and only deletes the `UserToOrg` row). Cascades
+ * to its `UserToOrg` membership and `ApiKey`s via `onDelete: Cascade`.
+ */
+export const removeServiceAccount = async (
+ orgId: number,
+ serviceAccountId: string,
+ options: { actor: AuditActor },
+): Promise => {
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ // Reuses removeMember's last-owner protection and membership deletion,
+ // then hard-deletes the now-membership-less User row.
+ const result = await removeMember(orgId, serviceAccountId, {
+ actor: options.actor,
+ targetType: "service_account",
+ });
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ await prisma.user.delete({ where: { id: serviceAccountId } });
+
+ await createAudit({
+ action: "service_account.removed",
+ actor: options.actor,
+ target: { id: serviceAccountId, type: "service_account" },
+ orgId,
+ });
+
+ return null;
+};
+
+export interface ServiceAccountSummary {
+ id: string;
+ name: string | null;
+ description: string | null;
+ role: OrgRole;
+ createdById: string | null;
+ joinedAt: Date;
+ suspendedAt: Date | null;
+ lastActiveAt: Date | null;
+}
+
+export const listServiceAccounts = async (orgId: number): Promise => {
+ const memberships = await prisma.userToOrg.findMany({
+ where: {
+ orgId,
+ user: { type: UserType.SERVICE },
+ },
+ include: { user: true },
+ orderBy: { joinedAt: 'asc' },
+ });
+
+ return memberships.map((membership) => ({
+ id: membership.userId,
+ name: membership.user.name,
+ description: membership.user.description,
+ role: membership.role,
+ createdById: membership.user.createdById,
+ joinedAt: membership.joinedAt,
+ suspendedAt: membership.suspendedAt,
+ lastActiveAt: membership.lastActiveAt,
+ }));
+};
+
+/**
+ * Resolves `serviceAccountId` to a `User`, verifying it belongs to `orgId`
+ * and is a service account (never a human). Every mutation above guards with
+ * this so that, e.g., a human member's id can never be passed into
+ * `removeServiceAccount`.
+ */
+const getServiceAccount = async (orgId: number, serviceAccountId: string): Promise => {
+ const membership = await prisma.userToOrg.findUnique({
+ where: { orgId_userId: { orgId, userId: serviceAccountId } },
+ include: { user: true },
+ });
+
+ if (!membership || membership.user.type !== UserType.SERVICE) {
+ return serviceAccountNotFoundError();
+ }
+
+ return membership.user;
+};
+
+export interface CreateServiceAccountApiKeyOptions {
+ actor: AuditActor;
+}
+
+export const createServiceAccountApiKey = async (
+ orgId: number,
+ serviceAccountId: string,
+ name: string,
+ options: CreateServiceAccountApiKeyOptions,
+): Promise<{ key: string } | ServiceError> => {
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ const existingApiKey = await prisma.apiKey.findFirst({
+ where: { createdById: serviceAccountId, name },
+ });
+ if (existingApiKey) {
+ await createAudit({
+ action: "api_key.creation_failed",
+ actor: options.actor,
+ target: { id: serviceAccountId, type: "service_account" },
+ orgId,
+ metadata: { message: `API key ${name} already exists`, api_key: name },
+ });
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.API_KEY_ALREADY_EXISTS,
+ message: `API key ${name} already exists`,
+ } satisfies ServiceError;
+ }
+
+ const { key, hash } = generateApiKey();
+ const apiKey = await prisma.apiKey.create({
+ data: { name, hash, orgId, createdById: serviceAccountId },
+ });
+
+ await createAudit({
+ action: "api_key.created",
+ actor: options.actor,
+ target: { id: apiKey.hash, type: "api_key" },
+ orgId,
+ metadata: { message: `Created for service account ${serviceAccountId}`, api_key: name },
+ });
+
+ return { key };
+};
+
+export const deleteServiceAccountApiKey = async (
+ orgId: number,
+ serviceAccountId: string,
+ name: string,
+ options: { actor: AuditActor },
+): Promise<{ success: boolean } | ServiceError> => {
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ const apiKey = await prisma.apiKey.findFirst({
+ where: { name, createdById: serviceAccountId },
+ });
+ if (!apiKey) {
+ await createAudit({
+ action: "api_key.deletion_failed",
+ actor: options.actor,
+ target: { id: serviceAccountId, type: "service_account" },
+ orgId,
+ metadata: { message: `API key ${name} not found`, api_key: name },
+ });
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.API_KEY_NOT_FOUND,
+ message: `API key ${name} not found for this service account`,
+ } satisfies ServiceError;
+ }
+
+ await prisma.apiKey.delete({ where: { hash: apiKey.hash } });
+
+ await createAudit({
+ action: "api_key.deleted",
+ actor: options.actor,
+ target: { id: apiKey.hash, type: "api_key" },
+ orgId,
+ metadata: { message: `Deleted for service account ${serviceAccountId}`, api_key: name },
+ });
+
+ return { success: true };
+};
+
+export const getServiceAccountApiKeys = async (
+ orgId: number,
+ serviceAccountId: string,
+): Promise[] | ServiceError> => {
+ const target = await getServiceAccount(orgId, serviceAccountId);
+ if (isServiceError(target)) {
+ return target;
+ }
+
+ const apiKeys = await prisma.apiKey.findMany({
+ where: { orgId, createdById: serviceAccountId },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return apiKeys.map((key) => ({
+ name: key.name,
+ createdAt: key.createdAt,
+ lastUsedAt: key.lastUsedAt,
+ }));
+};
diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts
index 766842b9f..8b6e5c4f2 100644
--- a/packages/web/src/lib/errorCodes.ts
+++ b/packages/web/src/lib/errorCodes.ts
@@ -52,4 +52,5 @@ export enum ErrorCode {
MEMBERSHIP_MANAGED_BY_IDP = 'MEMBERSHIP_MANAGED_BY_IDP',
AGENT_SKILL_ALREADY_EXISTS = 'AGENT_SKILL_ALREADY_EXISTS',
AGENT_SKILL_NOT_FOUND = 'AGENT_SKILL_NOT_FOUND',
+ SERVICE_ACCOUNT_NOT_FOUND = 'SERVICE_ACCOUNT_NOT_FOUND',
}
diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts
index 152a293e4..1aa6e281f 100644
--- a/packages/web/src/lib/posthogEvents.ts
+++ b/packages/web/src/lib/posthogEvents.ts
@@ -184,6 +184,12 @@ export type PosthogEventMap = {
wa_api_key_created: {},
wa_api_key_creation_fail: {},
//////////////////////////////////////////////////////////////////
+ wa_service_account_created: {},
+ wa_service_account_creation_fail: {},
+ wa_service_account_removed: {},
+ wa_service_account_api_key_created: {},
+ wa_service_account_api_key_creation_fail: {},
+ //////////////////////////////////////////////////////////////////
wa_chat_feedback_submitted: {
feedback: 'like' | 'dislike',
chatId: string,
diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts
index a9d174496..dfe97c1a4 100644
--- a/packages/web/src/middleware/withAuth.test.ts
+++ b/packages/web/src/middleware/withAuth.test.ts
@@ -3,7 +3,7 @@ import { Session } from 'next-auth';
import { NextRequest } from 'next/server';
import { notAuthenticated } from '../lib/serviceError';
import { getAuthContext, getAuthenticatedUser, withAuth, withOptionalAuth } from './withAuth';
-import { MOCK_API_KEY, MOCK_OAUTH_TOKEN, MOCK_ORG, MOCK_USER_WITH_ACCOUNTS, prisma } from '../__mocks__/prisma';
+import { MOCK_API_KEY, MOCK_OAUTH_TOKEN, MOCK_ORG, MOCK_SERVICE_ACCOUNT_USER, MOCK_USER_WITH_ACCOUNTS, prisma } from '../__mocks__/prisma';
import { OrgRole } from '@sourcebot/db';
import { ErrorCode } from '../lib/errorCodes';
import { StatusCodes } from 'http-status-codes';
@@ -180,6 +180,33 @@ describe('getAuthenticatedUser', () => {
});
});
+ test('should return a service account exactly like a human user for a valid api key', async () => {
+ prisma.user.findUnique.mockResolvedValue(MOCK_SERVICE_ACCOUNT_USER);
+ prisma.apiKey.findUnique.mockResolvedValue({
+ ...MOCK_API_KEY,
+ hash: 'apikey',
+ createdById: MOCK_SERVICE_ACCOUNT_USER.id,
+ });
+
+ setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
+ const result = await getAuthenticatedUser();
+
+ expect(result).not.toBeUndefined();
+ expect(result?.user.id).toBe(MOCK_SERVICE_ACCOUNT_USER.id);
+ expect(result?.user.type).toBe('SERVICE');
+ // No special-casing: resolution is identical to a human's api_key path.
+ expect(result?.principal).toStrictEqual({ source: 'api_key' });
+ });
+
+ test('should reject a service account presenting a session (belt-and-braces -- nothing mints one for a service account today)', async () => {
+ setMockSession(createMockSession({ user: { id: MOCK_SERVICE_ACCOUNT_USER.id } }));
+ prisma.user.findUnique.mockResolvedValue(MOCK_SERVICE_ACCOUNT_USER);
+
+ const result = await getAuthenticatedUser();
+
+ expect(result).toBeUndefined();
+ });
+
test('should return a user object if a valid api key with the new sbk_ prefix is present', async () => {
const userId = 'test-user-id';
prisma.user.findUnique.mockResolvedValue({
@@ -707,6 +734,7 @@ describe('getAuthContext', () => {
orgId: MOCK_ORG.id,
suspendedAt: null,
lastActiveAt: { not: null },
+ user: { type: 'HUMAN' },
},
});
expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(MOCK_ORG.id);
@@ -1010,6 +1038,48 @@ describe('getAuthContext', () => {
});
});
+ test('gates a service account api key by its own OrgRole exactly like a human member', async () => {
+ mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true';
+ prisma.user.findUnique.mockResolvedValue(MOCK_SERVICE_ACCOUNT_USER);
+ prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG });
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: MOCK_SERVICE_ACCOUNT_USER.id,
+ orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: new Date(),
+ role: OrgRole.MEMBER,
+ });
+ prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: MOCK_SERVICE_ACCOUNT_USER.id });
+ setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' }));
+
+ // MEMBER-role service account: denied, same as a MEMBER-role human.
+ expect(await getAuthContext()).toStrictEqual({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.API_KEY_USAGE_DISABLED,
+ message: 'API key usage is disabled for non-admin users.',
+ });
+
+ // OWNER-role service account: allowed, same as an OWNER-role human.
+ prisma.userToOrg.findUnique.mockResolvedValue({
+ joinedAt: new Date(),
+ userId: MOCK_SERVICE_ACCOUNT_USER.id,
+ orgId: MOCK_ORG.id,
+ suspendedAt: null,
+ scimExternalId: null,
+ lastActiveAt: new Date(),
+ role: OrgRole.OWNER,
+ });
+ expect(await getAuthContext()).toStrictEqual({
+ user: MOCK_SERVICE_ACCOUNT_USER,
+ org: MOCK_ORG,
+ role: OrgRole.OWNER,
+ prisma: undefined,
+ principal: { source: 'api_key' },
+ });
+ });
+
test('should allow a non-owner to authenticate via session when flag is enabled', async () => {
mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true';
const userId = 'test-user-id';
diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts
index b004d6169..1825b8e08 100644
--- a/packages/web/src/middleware/withAuth.ts
+++ b/packages/web/src/middleware/withAuth.ts
@@ -1,6 +1,6 @@
import { __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma";
import { hashSecret, OAUTH_ACCESS_TOKEN_PREFIX, API_KEY_PREFIX, LEGACY_API_KEY_PREFIX, SCOPED_ACCESS_TOKEN_PREFIX, env } from "@sourcebot/shared";
-import { ApiKey, Org, OrgRole, PrismaClient, UserToOrg, UserWithAccounts } from "@sourcebot/db";
+import { ApiKey, Org, OrgRole, PrismaClient, UserToOrg, UserType, UserWithAccounts } from "@sourcebot/db";
import { headers } from "next/headers";
import { auth } from "../auth";
import { insufficientOAuthScope, notAuthenticated, notFound, ServiceError } from "../lib/serviceError";
@@ -131,6 +131,9 @@ export const getAuthContext = async (options: AuthOptions = {}): Promise =>
}
});
+ // Belt-and-braces: nothing today mints a session for a service
+ // account (it can only authenticate via API key), but reject one
+ // outright if it ever shows up here rather than silently trusting it.
+ if (user?.type === UserType.SERVICE) {
+ return undefined;
+ }
+
return user ? { user, principal: { source: 'session' } } : undefined;
}