Skip to content
Open
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 @@ -39,6 +39,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,29 @@
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "isScimEnabled" BOOLEAN NOT NULL DEFAULT false;

-- AlterTable
ALTER TABLE "UserToOrg" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "scimExternalId" TEXT;

-- 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;
30 changes: 30 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,12 @@ model Org {
connections Connection[]
repos Repo[]
apiKeys ApiKey[]
scimTokens ScimToken[]
isOnboarded Boolean @default(false)
imageUrl String?

isScimEnabled Boolean @default(false)

/// @deprecated This property can be controlled by the environment
/// variable `REQUIRE_APPROVAL_NEW_MEMBERS`. To ensure that we use
/// the correct setting, use the helper function `isMemberApprovalRequired`
Expand Down Expand Up @@ -397,7 +400,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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbd: do we actually need to store a isActive field here?


/// 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])
Comment on lines +410 to +413

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce per-org uniqueness for SCIM externalId.

scimExternalId currently has only a non-unique index, which permits duplicate IdP identities inside the same org and can make SCIM identity resolution/update flows ambiguous.

Suggested schema fix
-  @@index([orgId, scimExternalId])
+  @@unique([orgId, scimExternalId])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
scimExternalId String?
@@id([orgId, userId])
@@index([orgId, scimExternalId])
scimExternalId String?
@@id([orgId, userId])
@@unique([orgId, scimExternalId])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/prisma/schema.prisma` around lines 410 - 413, Replace the
non-unique index annotation @@index([orgId, scimExternalId]) with a unique
constraint @@unique([orgId, scimExternalId]) on the scimExternalId field to
enforce per-organization uniqueness of SCIM external identifiers. This prevents
duplicate IdP identities within the same org and ensures unambiguous identity
resolution in SCIM flows.

}

model ApiKey {
Expand All @@ -414,6 +427,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 @@ -42,7 +42,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 @@ -58,6 +58,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 @@ -59,6 +59,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
2 changes: 1 addition & 1 deletion packages/web/src/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const MOCK_ORG: Org = {
updatedAt: new Date(),
isOnboarded: true,
imageUrl: null,
metadata: null,
isScimEnabled: false,
memberApprovalRequired: false,
isCredentialsLoginEnabled: true,
isEmailCodeLoginEnabled: false,
Expand Down
122 changes: 6 additions & 116 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
'use server';

import { createAudit } from "@/ee/features/audit/audit";
import { env, getSMTPConnectionURL } from "@sourcebot/shared";
import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError";
import { __unsafePrisma } from "@/prisma";
import { render } from "@react-email/components";
import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared";
import { notFound, ServiceError } from "@/lib/serviceError";
import { sew } from "@/middleware/sew";
import { ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { createLogger, env, generateApiKey, getTokenFromConfig } from "@sourcebot/shared";
import { StatusCodes } from "http-status-codes";
import { cookies } from "next/headers";
import { createTransport } from "nodemailer";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID } from "./lib/constants";
import { RepositoryQuery } from "./lib/types";
import { getAuthenticatedUser, withAuth, withOptionalAuth } from "./middleware/withAuth";
import { getBrowsePath } from "./app/(app)/browse/hooks/utils";
import { sew } from "@/middleware/sew";
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "./lib/constants";
import { RepositoryQuery } from "./lib/types";
import { withAuth, withOptionalAuth } from "./middleware/withAuth";

const logger = createLogger('web-actions');

Expand Down Expand Up @@ -375,110 +369,6 @@ export const getRepoInfoByName = async (repoName: string) => sew(() =>
}
}));

// eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth
export const createAccountRequest = async () => sew(async () => {
const authResult = await getAuthenticatedUser();
if (!authResult) {
return notAuthenticated();
}

const { user } = authResult;

const org = await __unsafePrisma.org.findUnique({
where: {
id: SINGLE_TENANT_ORG_ID,
},
});

if (!org) {
return notFound("Organization not found");
}

const existingRequest = await __unsafePrisma.accountRequest.findUnique({
where: {
requestedById_orgId: {
requestedById: user.id,
orgId: org.id,
},
},
});

if (existingRequest) {
logger.warn(`User ${user.id} already has an account request for org ${org.id}. Skipping account request creation.`);
return {
success: true,
existingRequest: true,
}
}

if (!existingRequest) {
await __unsafePrisma.accountRequest.create({
data: {
requestedById: user.id,
orgId: org.id,
},
});

const smtpConnectionUrl = getSMTPConnectionURL();
if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) {
// TODO: This is needed because we can't fetch the origin from the request headers when this is called
// on user creation (the header isn't set when next-auth calls onCreateUser for some reason)
const deploymentUrl = env.AUTH_URL;

const owners = await __unsafePrisma.user.findMany({
where: {
orgs: {
some: {
orgId: org.id,
role: "OWNER",
},
},
},
});

if (owners.length === 0) {
logger.error(`Failed to find any owners for org ${org.id} when drafting email for account request from ${user.id}`);
} else {
const html = await render(JoinRequestSubmittedEmail({
baseUrl: deploymentUrl,
requestor: {
name: user.name ?? undefined,
email: user.email,
avatarUrl: user.image ?? undefined,
},
orgName: org.name,
orgImageUrl: org.imageUrl ?? undefined,
}));

const ownerEmails = owners
.map((owner) => owner.email)
.filter((email): email is string => email !== null);

const transport = createTransport(smtpConnectionUrl);
const result = await transport.sendMail({
to: ownerEmails,
from: env.EMAIL_FROM_ADDRESS,
subject: `New account request for ${org.name} on Sourcebot`,
html,
text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`,
});

const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length > 0) {
logger.error(`Failed to send account request email to ${ownerEmails.join(', ')}: ${failed}`);
}
}
} else {
logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`);
}
}

return {
success: true,
existingRequest: false,
}
});

export const getSearchContexts = async () => sew(() =>
withOptionalAuth(async ({ org, prisma }) => {
const searchContexts = await prisma.searchContext.findMany({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { auth } from "@/auth";
import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants";
import { HomeView } from "@/hooks/useHomeView";
import { getConnectionStats } from "@/actions";
import { getOrgAccountRequests } from "@/features/userManagement/actions";
import { getOrgAccountRequests } from "@/features/membership/actions";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
import { OrgRole } from "@prisma/client";
Expand Down

This file was deleted.

Loading
Loading