Skip to content

Commit e0b9f02

Browse files
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) <noreply@anthropic.com>
1 parent 4ec87e1 commit e0b9f02

29 files changed

Lines changed: 1383 additions & 65 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
### Added
1414
- Added the ability to configure email code and credentials login from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303)
1515
- Added a list of configured SSO providers from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303)
16+
- [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)
1617

1718
### Fixed
1819
- 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)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- AlterTable
2+
ALTER TABLE "UserToOrg" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
3+
ADD COLUMN "scimExternalId" TEXT;
4+
5+
-- CreateTable
6+
CREATE TABLE "ScimToken" (
7+
"name" TEXT NOT NULL,
8+
"hash" TEXT NOT NULL,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
"lastUsedAt" TIMESTAMP(3),
11+
"orgId" INTEGER NOT NULL,
12+
13+
CONSTRAINT "ScimToken_pkey" PRIMARY KEY ("hash")
14+
);
15+
16+
-- CreateIndex
17+
CREATE UNIQUE INDEX "ScimToken_hash_key" ON "ScimToken"("hash");
18+
19+
-- CreateIndex
20+
CREATE INDEX "ScimToken_orgId_idx" ON "ScimToken"("orgId");
21+
22+
-- CreateIndex
23+
CREATE INDEX "UserToOrg_orgId_scimExternalId_idx" ON "UserToOrg"("orgId", "scimExternalId");
24+
25+
-- AddForeignKey
26+
ALTER TABLE "ScimToken" ADD CONSTRAINT "ScimToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ model Org {
272272
connections Connection[]
273273
repos Repo[]
274274
apiKeys ApiKey[]
275+
scimTokens ScimToken[]
275276
isOnboarded Boolean @default(false)
276277
imageUrl String?
277278
@@ -387,7 +388,17 @@ model UserToOrg {
387388
388389
role OrgRole @default(MEMBER)
389390
391+
/// SCIM soft-deactivation flag. When false, the membership is suspended by
392+
/// the IdP: the user is treated as a non-member for auth purposes (see
393+
/// `getAuthContext`) but the row is preserved so the IdP can reactivate it.
394+
isActive Boolean @default(true)
395+
396+
/// The IdP-supplied `externalId` for this membership when provisioned via
397+
/// SCIM. Null for members that joined through invites or self-serve sign-up.
398+
scimExternalId String?
399+
390400
@@id([orgId, userId])
401+
@@index([orgId, scimExternalId])
391402
}
392403

393404
model ApiKey {
@@ -404,6 +415,23 @@ model ApiKey {
404415
createdById String
405416
}
406417

418+
/// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate
419+
/// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is
420+
/// not tied to a user — it acts on behalf of the SCIM integration for the
421+
/// whole org. Only the HMAC hash of the secret is stored.
422+
model ScimToken {
423+
name String
424+
hash String @id @unique
425+
426+
createdAt DateTime @default(now())
427+
lastUsedAt DateTime?
428+
429+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
430+
orgId Int
431+
432+
@@index([orgId])
433+
}
434+
407435
model Audit {
408436
id String @id @default(cuid())
409437
timestamp DateTime @default(now())

packages/shared/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const LEGACY_API_KEY_PREFIX = 'sourcebot-';
1111
export const API_KEY_PREFIX = 'sbk_';
1212
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
1313
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';
14+
export const SCIM_TOKEN_PREFIX = 'sbscim_';
1415

1516
/**
1617
* Default settings.

packages/shared/src/crypto.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from 'zod';
44
import { env } from './env.server.js';
55
import { Token } from '@sourcebot/schemas/v3/shared.type';
66
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
7-
import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js';
7+
import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX } from './constants.js';
88

99
const algorithm = 'aes-256-cbc';
1010
const ivLength = 16; // 16 bytes for CBC
@@ -56,6 +56,16 @@ export function generateApiKey(): { key: string; hash: string } {
5656
};
5757
}
5858

59+
export function generateScimToken(): { token: string; hash: string } {
60+
const secret = crypto.randomBytes(32).toString('hex');
61+
const hash = hashSecret(secret);
62+
63+
return {
64+
token: `${SCIM_TOKEN_PREFIX}${secret}`,
65+
hash,
66+
};
67+
}
68+
5969
export function generateOAuthToken(): { token: string; hash: string } {
6070
const secret = crypto.randomBytes(32).toString('hex');
6171
const hash = hashSecret(secret);

packages/shared/src/entitlements.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ const ALL_ENTITLEMENTS = [
4040
"org-management",
4141
"oauth",
4242
"ask",
43-
"mcp"
43+
"mcp",
44+
"scim"
4445
] as const;
4546
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];
4647

packages/shared/src/index.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export {
5656
decrypt,
5757
hashSecret,
5858
generateApiKey,
59+
generateScimToken,
5960
generateOAuthToken,
6061
generateOAuthRefreshToken,
6162
verifySignature,

packages/web/next.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ const nextConfig = {
5555
{
5656
source: "/api/mcp",
5757
destination: "/api/ee/mcp",
58+
},
59+
// The SCIM 2.0 server lives under /api/ee/scim/v2 (EE-licensed route
60+
// tree) but is exposed at the clean /scim/v2 path that IdPs (Okta,
61+
// Entra) are configured to send provisioning requests to.
62+
{
63+
source: "/scim/v2/:path*",
64+
destination: "/api/ee/scim/v2/:path*",
5865
}
5966
];
6067
},

0 commit comments

Comments
 (0)