Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the ability to configure email code and credentials login from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303)
- Added a list of configured SSO providers from the security settings. [#1303](https://github.com/sourcebot-dev/sourcebot/pull/1303)
- [EE] Added a SCIM 2.0 server for automated user provisioning and deprovisioning from identity providers (Okta, Entra). [#1306](https://github.com/sourcebot-dev/sourcebot/pull/1306)

### Fixed
- Validated that `SOURCEBOT_ENCRYPTION_KEY` is exactly 32 characters at startup, failing fast with an actionable message instead of a runtime encryption error. [#1305](https://github.com/sourcebot-dev/sourcebot/pull/1305)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- AlterTable
ALTER TABLE "UserToOrg" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "scimExternalId" TEXT;

-- CreateTable
CREATE TABLE "ScimToken" (
"name" TEXT NOT NULL,
"hash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),
"orgId" INTEGER NOT NULL,

CONSTRAINT "ScimToken_pkey" PRIMARY KEY ("hash")
);

-- CreateIndex
CREATE UNIQUE INDEX "ScimToken_hash_key" ON "ScimToken"("hash");

-- CreateIndex
CREATE INDEX "ScimToken_orgId_idx" ON "ScimToken"("orgId");

-- CreateIndex
CREATE INDEX "UserToOrg_orgId_scimExternalId_idx" ON "UserToOrg"("orgId", "scimExternalId");

-- AddForeignKey
ALTER TABLE "ScimToken" ADD CONSTRAINT "ScimToken_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
28 changes: 28 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ model Org {
connections Connection[]
repos Repo[]
apiKeys ApiKey[]
scimTokens ScimToken[]
isOnboarded Boolean @default(false)
imageUrl String?

Expand Down Expand Up @@ -387,7 +388,17 @@ model UserToOrg {

role OrgRole @default(MEMBER)

/// SCIM soft-deactivation flag. When false, the membership is suspended by
/// the IdP: the user is treated as a non-member for auth purposes (see
/// `getAuthContext`) but the row is preserved so the IdP can reactivate it.
isActive Boolean @default(true)

/// The IdP-supplied `externalId` for this membership when provisioned via
/// SCIM. Null for members that joined through invites or self-serve sign-up.
scimExternalId String?

@@id([orgId, userId])
@@index([orgId, scimExternalId])
}

model ApiKey {
Expand All @@ -404,6 +415,23 @@ model ApiKey {
createdById String
}

/// Org-scoped bearer token presented by an IdP (Okta, Entra) to authenticate
/// against the SCIM provisioning endpoints. Unlike `ApiKey`, a SCIM token is
/// not tied to a user — it acts on behalf of the SCIM integration for the
/// whole org. Only the HMAC hash of the secret is stored.
model ScimToken {
name String
hash String @id @unique

createdAt DateTime @default(now())
lastUsedAt DateTime?

org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int

@@index([orgId])
}

model Audit {
id String @id @default(cuid())
timestamp DateTime @default(now())
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const LEGACY_API_KEY_PREFIX = 'sourcebot-';
export const API_KEY_PREFIX = 'sbk_';
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';
export const SCIM_TOKEN_PREFIX = 'sbscim_';

/**
* Default settings.
Expand Down
12 changes: 11 additions & 1 deletion packages/shared/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from 'zod';
import { env } from './env.server.js';
import { Token } from '@sourcebot/schemas/v3/shared.type';
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js';
import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX, SCIM_TOKEN_PREFIX } from './constants.js';

const algorithm = 'aes-256-cbc';
const ivLength = 16; // 16 bytes for CBC
Expand Down Expand Up @@ -56,6 +56,16 @@ export function generateApiKey(): { key: string; hash: string } {
};
}

export function generateScimToken(): { token: string; hash: string } {
const secret = crypto.randomBytes(32).toString('hex');
const hash = hashSecret(secret);

return {
token: `${SCIM_TOKEN_PREFIX}${secret}`,
hash,
};
}

export function generateOAuthToken(): { token: string; hash: string } {
const secret = crypto.randomBytes(32).toString('hex');
const hash = hashSecret(secret);
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ const ALL_ENTITLEMENTS = [
"org-management",
"oauth",
"ask",
"mcp"
"mcp",
"scim"
] as const;
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export {
decrypt,
hashSecret,
generateApiKey,
generateScimToken,
generateOAuthToken,
generateOAuthRefreshToken,
verifySignature,
Expand Down
7 changes: 7 additions & 0 deletions packages/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ const nextConfig = {
{
source: "/api/mcp",
destination: "/api/ee/mcp",
},
// The SCIM 2.0 server lives under /api/ee/scim/v2 (EE-licensed route
// tree) but is exposed at the clean /scim/v2 path that IdPs (Okta,
// Entra) are configured to send provisioning requests to.
{
source: "/scim/v2/:path*",
destination: "/api/ee/scim/v2/:path*",
}
];
},
Expand Down
Loading
Loading