diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1015b186..90dcca43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Added audit logging. [#355](https://github.com/sourcebot-dev/sourcebot/pull/355)
### Fixed
diff --git a/docs/docs.json b/docs/docs.json
index 9d6d96d5..eb75dcb5 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -75,7 +75,8 @@
]
},
"docs/configuration/transactional-emails",
- "docs/configuration/structured-logging"
+ "docs/configuration/structured-logging",
+ "docs/configuration/audit-logs"
]
},
{
diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx
new file mode 100644
index 00000000..52440160
--- /dev/null
+++ b/docs/docs/configuration/audit-logs.mdx
@@ -0,0 +1,212 @@
+---
+title: Audit Logs
+sidebarTitle: Audit logs
+---
+
+import LicenseKeyRequired from '/snippets/license-key-required.mdx'
+
+
+
+Audit logs are a collection of notable events performed by users within a Sourcebot deployment. Each audit log records information on the action taken, the user who performed the
+action, and when the action took place.
+
+This feature gives security and compliance teams the necessary information to ensure proper governance and administration of your Sourcebot deployment.
+
+## Enabling Audit Logs
+Audit logs must be explicitly enabled by setting the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables) to `true`
+
+## Fetching Audit Logs
+Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API:
+
+```bash icon="terminal" Fetch audit logs
+curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
+ --header 'X-Org-Domain: ~' \
+ --header 'X-Sourcebot-Api-Key: $SOURCEBOT_OWNER_API_KEY'
+```
+
+```json icon="brackets-curly" wrap expandable Fetch audit logs example response
+[
+ {
+ "id": "cmc146k7m0003xgo2tri5t4br",
+ "timestamp": "2025-06-17T22:48:08.914Z",
+ "action": "api_key.created",
+ "actorId": "cmc12tnje0000xgn58jj8655h",
+ "actorType": "user",
+ "targetId": "205d1da1c6c3772b81d4ad697f5851fa11195176c211055ff0c1509772645d6d",
+ "targetType": "api_key",
+ "sourcebotVersion": "unknown",
+ "orgId": 1
+ },
+ {
+ "id": "cmc146c8r0001xgo2xyu0p463",
+ "timestamp": "2025-06-17T22:47:58.587Z",
+ "action": "query.code_search",
+ "actorId": "cmc12tnje0000xgn58jj8655h",
+ "actorType": "user",
+ "targetId": "1",
+ "targetType": "org",
+ "sourcebotVersion": "unknown",
+ "metadata": {
+ "message": "render branch:HEAD"
+ },
+ "orgId": 1
+ },
+ {
+ "id": "cmc12vqgb0008xgn5nv5hl9y5",
+ "timestamp": "2025-06-17T22:11:44.171Z",
+ "action": "query.code_search",
+ "actorId": "cmc12tnje0000xgn58jj8655h",
+ "actorType": "user",
+ "targetId": "1",
+ "targetType": "org",
+ "sourcebotVersion": "unknown",
+ "metadata": {
+ "message": "render branch:HEAD"
+ },
+ "orgId": 1
+ },
+ {
+ "id": "cmc12txwn0006xgn51ow1odid",
+ "timestamp": "2025-06-17T22:10:20.519Z",
+ "action": "query.code_search",
+ "actorId": "cmc12tnje0000xgn58jj8655h",
+ "actorType": "user",
+ "targetId": "1",
+ "targetType": "org",
+ "sourcebotVersion": "unknown",
+ "metadata": {
+ "message": "render branch:HEAD"
+ },
+ "orgId": 1
+ },
+ {
+ "id": "cmc12tnjx0004xgn5qqeiv1ao",
+ "timestamp": "2025-06-17T22:10:07.101Z",
+ "action": "user.owner_created",
+ "actorId": "cmc12tnje0000xgn58jj8655h",
+ "actorType": "user",
+ "targetId": "1",
+ "targetType": "org",
+ "sourcebotVersion": "unknown",
+ "metadata": null,
+ "orgId": 1
+ },
+ {
+ "id": "cmc12tnjh0002xgn5h6vzu3rl",
+ "timestamp": "2025-06-17T22:10:07.086Z",
+ "action": "user.signed_in",
+ "actorId": "cmc12tnje0000xgn58jj8655h",
+ "actorType": "user",
+ "targetId": "cmc12tnje0000xgn58jj8655h",
+ "targetType": "user",
+ "sourcebotVersion": "unknown",
+ "metadata": null,
+ "orgId": 1
+ }
+]
+```
+
+## Audit action types
+
+| Action | Actor Type | Target Type |
+| :------- | :------ | :------|
+| `api_key.creation_failed` | `user` | `org` |
+| `api_key.created` | `user` | `api_key` |
+| `api_key.deletion_failed` | `user` | `org` |
+| `api_key.deleted` | `user` | `api_key` |
+| `user.creation_failed` | `user` | `user` |
+| `user.owner_created` | `user` | `org` |
+| `user.jit_provisioning_failed` | `user` | `org` |
+| `user.jit_provisioned` | `user` | `org` |
+| `user.join_request_creation_failed` | `user` | `org` |
+| `user.join_requested` | `user` | `org` |
+| `user.join_request_approve_failed` | `user` | `account_join_request` |
+| `user.join_request_approved` | `user` | `account_join_request` |
+| `user.join_request_removed` | `user` | `account_join_request` |
+| `user.invite_failed` | `user` | `org` |
+| `user.invites_created` | `user` | `org` |
+| `user.invite_accept_failed` | `user` | `invite` |
+| `user.invite_accepted` | `user` | `invite` |
+| `user.signed_in` | `user` | `user` |
+| `user.signed_out` | `user` | `user` |
+| `org.ownership_transfer_failed` | `user` | `org` |
+| `org.ownership_transferred` | `user` | `org` |
+| `query.file_source` | `user \| api_key` | `file` |
+| `query.code_search` | `user \| api_key` | `org` |
+| `query.list_repositories` | `user \| api_key` | `org` |
+
+
+## Response schema
+
+```json icon="brackets-curly" expandable wrap Audit log fetch response schema
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "FetchAuditLogsResponse",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "id",
+ "timestamp",
+ "action",
+ "actorId",
+ "actorType",
+ "targetId",
+ "targetType",
+ "sourcebotVersion",
+ "metadata",
+ "orgId"
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "action": {
+ "type": "string"
+ },
+ "actorId": {
+ "type": "string"
+ },
+ "actorType": {
+ "type": "string",
+ "enum": ["user", "api_key"]
+ },
+ "targetId": {
+ "type": "string"
+ },
+ "targetType": {
+ "type": "string",
+ "enum": ["user", "org", "file", "api_key", "account_join_request", "invite"]
+ },
+ "sourcebotVersion": {
+ "type": "string"
+ },
+ "metadata": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "message": { "type": "string" },
+ "api_key": { "type": "string" },
+ "emails": { "type": "string" }
+ },
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "orgId": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+ }
+}
+
+```
\ No newline at end of file
diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx
index 88e12bce..96f8e329 100644
--- a/docs/docs/configuration/environment-variables.mdx
+++ b/docs/docs/configuration/environment-variables.mdx
@@ -39,6 +39,7 @@ The following environment variables allow you to configure your Sourcebot deploy
### Enterprise Environment Variables
| Variable | Default | Description |
| :------- | :------ | :---------- |
+| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `false` |
Enables/disables audit logging
|
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | Enables/disables just-in-time user provisioning for SSO providers.
|
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | The base URL for GitHub Enterprise SSO authentication.
|
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | The client ID for GitHub Enterprise SSO authentication.
|
diff --git a/docs/docs/configuration/structured-logging.mdx b/docs/docs/configuration/structured-logging.mdx
index 65fd06a0..1bc0c9a0 100644
--- a/docs/docs/configuration/structured-logging.mdx
+++ b/docs/docs/configuration/structured-logging.mdx
@@ -1,5 +1,6 @@
---
-title: Structured logging
+title: Structured Logging
+sidebarTitle: Structured logging
---
By default, Sourcebot will output logs to the console in a human readable format. If you'd like Sourcebot to output structured JSON logs, set the following env vars:
diff --git a/docs/docs/deployment-guide.mdx b/docs/docs/deployment-guide.mdx
index 63395ab4..50ba2d53 100644
--- a/docs/docs/deployment-guide.mdx
+++ b/docs/docs/deployment-guide.mdx
@@ -33,7 +33,7 @@ Watch this 1:51 minute video to get a quick overview of how to deploy Sourcebot
Create a `config.json` file that tells Sourcebot which repositories to sync and index:
- ```bash
+ ```bash wrap icon="terminal" Create example config
touch config.json
echo '{
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
@@ -58,7 +58,7 @@ Watch this 1:51 minute video to get a quick overview of how to deploy Sourcebot
In the same directory as `config.json`, run the following command to start your instance:
- ``` bash
+ ``` bash icon="terminal" Start the Sourcebot container
docker run \
-p 3000:3000 \
--pull=always \
diff --git a/packages/db/prisma/migrations/20250617031335_add_audit_table/migration.sql b/packages/db/prisma/migrations/20250617031335_add_audit_table/migration.sql
new file mode 100644
index 00000000..f7854061
--- /dev/null
+++ b/packages/db/prisma/migrations/20250617031335_add_audit_table/migration.sql
@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE "Audit" (
+ "id" TEXT NOT NULL,
+ "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "action" TEXT NOT NULL,
+ "actorId" TEXT NOT NULL,
+ "actorType" TEXT NOT NULL,
+ "targetId" TEXT NOT NULL,
+ "targetType" TEXT NOT NULL,
+ "sourcebotVersion" TEXT NOT NULL,
+ "metadata" JSONB,
+ "orgId" INTEGER NOT NULL,
+
+ CONSTRAINT "Audit_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Audit_actorId_actorType_targetId_targetType_orgId_idx" ON "Audit"("actorId", "actorType", "targetId", "targetType", "orgId");
+
+-- AddForeignKey
+ALTER TABLE "Audit" ADD CONSTRAINT "Audit_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 0619dcd6..113e13f0 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -172,6 +172,8 @@ model Org {
/// List of pending invites to this organization
invites Invite[]
+ audits Audit[]
+
accountRequests AccountRequest[]
searchContexts SearchContext[]
@@ -227,6 +229,24 @@ model ApiKey {
}
+model Audit {
+ id String @id @default(cuid())
+ timestamp DateTime @default(now())
+
+ action String
+ actorId String
+ actorType String
+ targetId String
+ targetType String
+ sourcebotVersion String
+ metadata Json?
+
+ org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
+ orgId Int
+
+ @@index([actorId, actorType, targetId, targetType, orgId])
+}
+
// @see : https://authjs.dev/concepts/database-models#user
model User {
id String @id @default(cuid())
diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts
index 14aaba53..478fe66d 100644
--- a/packages/shared/src/entitlements.ts
+++ b/packages/shared/src/entitlements.ts
@@ -36,15 +36,16 @@ const entitlements = [
"public-access",
"multi-tenancy",
"sso",
- "code-nav"
+ "code-nav",
+ "audit"
] as const;
export type Entitlement = (typeof entitlements)[number];
const entitlementsByPlan: Record = {
oss: [],
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
- "self-hosted:enterprise": ["search-contexts", "sso", "code-nav"],
- "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav"],
+ "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit"],
+ "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit"],
// Special entitlement for https://demo.sourcebot.dev
"cloud:demo": ["public-access", "code-nav", "search-contexts"],
} as const;
diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index 5d39e205..3497e397 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -36,12 +36,14 @@ import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess";
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
import { createLogger } from "@sourcebot/logger";
+import { getAuditService } from "@/ee/features/audit/factory";
const ajv = new Ajv({
validateFormats: false,
});
const logger = createLogger('web-actions');
+const auditService = getAuditService();
/**
* "Service Error Wrapper".
@@ -59,7 +61,7 @@ export const sew = async (fn: () => Promise): Promise =>
}
}
-export const withAuth = async (fn: (userId: string) => Promise, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
+export const withAuth = async (fn: (userId: string, apiKeyHash: string | undefined) => Promise, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
const session = await auth();
if (!session) {
@@ -93,7 +95,7 @@ export const withAuth = async (fn: (userId: string) => Promise, allowSingl
},
});
- return fn(user.id);
+ return fn(user.id, apiKeyOrError.apiKey.hash);
} else if (
env.SOURCEBOT_TENANCY_MODE === 'single' &&
allowSingleTenantUnauthedAccess &&
@@ -107,11 +109,11 @@ export const withAuth = async (fn: (userId: string) => Promise, allowSingl
}
// To support unauthed access a guest user is created in initialize.ts, which we return here
- return fn(SOURCEBOT_GUEST_USER_ID);
+ return fn(SOURCEBOT_GUEST_USER_ID, undefined);
}
return notAuthenticated();
}
- return fn(session.user.id);
+ return fn(session.user.id, undefined);
}
export const orgHasAvailability = async (domain: string): Promise => {
@@ -460,6 +462,22 @@ export const createApiKey = async (name: string, domain: string): Promise<{ key:
});
if (existingApiKey) {
+ await auditService.createAudit({
+ action: "api_key.creation_failed",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: `API key ${name} already exists`,
+ api_key: name
+ }
+ });
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.API_KEY_ALREADY_EXISTS,
@@ -468,7 +486,7 @@ export const createApiKey = async (name: string, domain: string): Promise<{ key:
}
const { key, hash } = generateApiKey();
- await prisma.apiKey.create({
+ const apiKey = await prisma.apiKey.create({
data: {
name,
hash,
@@ -477,6 +495,19 @@ export const createApiKey = async (name: string, domain: string): Promise<{ key:
}
});
+ await auditService.createAudit({
+ action: "api_key.created",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: apiKey.hash,
+ type: "api_key"
+ },
+ orgId: org.id
+ });
+
return {
key,
}
@@ -484,7 +515,7 @@ export const createApiKey = async (name: string, domain: string): Promise<{ key:
export const deleteApiKey = async (name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
- withOrgMembership(userId, domain, async () => {
+ withOrgMembership(userId, domain, async ({ org }) => {
const apiKey = await prisma.apiKey.findFirst({
where: {
name,
@@ -493,6 +524,22 @@ export const deleteApiKey = async (name: string, domain: string): Promise<{ succ
});
if (!apiKey) {
+ await auditService.createAudit({
+ action: "api_key.deletion_failed",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: domain,
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: `API key ${name} not found for user ${userId}`,
+ api_key: name
+ }
+ });
return {
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.API_KEY_NOT_FOUND,
@@ -506,6 +553,22 @@ export const deleteApiKey = async (name: string, domain: string): Promise<{ succ
},
});
+ await auditService.createAudit({
+ action: "api_key.deleted",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: apiKey.hash,
+ type: "api_key"
+ },
+ orgId: org.id,
+ metadata: {
+ api_key: name
+ }
+ });
+
return {
success: true,
}
@@ -904,6 +967,24 @@ export const getCurrentUserRole = async (domain: string): Promise => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
+ const failAuditCallback = async (error: string) => {
+ await auditService.createAudit({
+ action: "user.invite_failed",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: error,
+ emails: emails.join(", ")
+ }
+ });
+ }
const user = await getMe();
if (isServiceError(user)) {
throw new ServiceErrorException(user);
@@ -911,6 +992,22 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
const hasAvailability = await orgHasAvailability(domain);
if (!hasAvailability) {
+ await auditService.createAudit({
+ action: "user.invite_failed",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: "Organization has reached maximum number of seats",
+ emails: emails.join(", ")
+ }
+ });
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
@@ -929,6 +1026,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
});
if (existingInvites.length > 0) {
+ await failAuditCallback("A pending invite already exists for one or more of the provided emails");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_INVITE,
@@ -949,6 +1047,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
});
if (existingMembers.length > 0) {
+ await failAuditCallback("One or more of the provided emails are already members of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_INVITE,
@@ -956,15 +1055,6 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
} satisfies ServiceError;
}
- await prisma.invite.createMany({
- data: emails.map((email) => ({
- recipientEmail: email,
- hostUserId: userId,
- orgId: org.id,
- })),
- skipDuplicates: true,
- });
-
// Send invites to recipients
if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) {
const origin = (await headers()).get('origin')!;
@@ -1023,6 +1113,21 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`);
}
+ await auditService.createAudit({
+ action: "user.invites_created",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ emails: emails.join(", ")
+ }
+ });
return {
success: true,
}
@@ -1090,6 +1195,11 @@ export const getMe = async () => sew(() =>
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async () => {
+ const user = await getMe();
+ if (isServiceError(user)) {
+ return user;
+ }
+
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
@@ -1103,13 +1213,28 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
return notFound();
}
- const user = await getMe();
- if (isServiceError(user)) {
- return user;
+ const failAuditCallback = async (error: string) => {
+ await auditService.createAudit({
+ action: "user.invite_accept_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: inviteId,
+ type: "invite"
+ },
+ orgId: invite.org.id,
+ metadata: {
+ message: error
+ }
+ });
}
+
const hasAvailability = await orgHasAvailability(invite.org.domain);
if (!hasAvailability) {
+ await failAuditCallback("Organization is at max capacity");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
@@ -1119,6 +1244,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
// Check if the user is the recipient of the invite
if (user.email !== invite.recipientEmail) {
+ await failAuditCallback("User is not the recipient of the invite");
return notFound();
}
@@ -1158,6 +1284,19 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
if (accountRequest) {
logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've redeemed an invite`);
+ await auditService.createAudit({
+ action: "user.join_request_removed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: invite.org.id,
+ target: {
+ id: accountRequest.id,
+ type: "account_join_request"
+ }
+ });
+
await tx.accountRequest.delete({
where: {
id: accountRequest.id,
@@ -1174,9 +1313,23 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
});
if (isServiceError(res)) {
+ await failAuditCallback(res.message);
return res;
}
+ await auditService.createAudit({
+ action: "user.invite_accepted",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: invite.org.id,
+ target: {
+ id: inviteId,
+ type: "invite"
+ }
+ });
+
return {
success: true,
}
@@ -1229,7 +1382,25 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;
+ const failAuditCallback = async (error: string) => {
+ await auditService.createAudit({
+ action: "org.ownership_transfer_failed",
+ actor: {
+ id: currentUserId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: error
+ }
+ })
+ }
if (newOwnerId === currentUserId) {
+ await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
@@ -1247,6 +1418,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
});
if (!newOwner) {
+ await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
@@ -1279,6 +1451,22 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
})
]);
+ await auditService.createAudit({
+ action: "org.ownership_transferred",
+ actor: {
+ id: currentUserId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
+ }
+ });
+
return {
success: true,
}
@@ -1579,9 +1767,27 @@ export const createAccountRequest = async (userId: string, domain: string) => se
}
});
-export const approveAccountRequest = async (requestId: string, domain: string) => sew(() =>
+export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
+ const failAuditCallback = async (error: string) => {
+ await auditService.createAudit({
+ action: "user.join_request_approve_failed",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: requestId,
+ type: "account_join_request"
+ },
+ orgId: org.id,
+ metadata: {
+ message: error,
+ }
+ });
+ }
+
const request = await prisma.accountRequest.findUnique({
where: {
id: requestId,
@@ -1592,11 +1798,13 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
});
if (!request || request.orgId !== org.id) {
+ await failAuditCallback("Request not found");
return notFound();
}
const hasAvailability = await orgHasAvailability(domain);
if (!hasAvailability) {
+ await failAuditCallback("Organization is at max capacity");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
@@ -1646,6 +1854,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
});
if (isServiceError(res)) {
+ await failAuditCallback(res.message);
return res;
}
@@ -1681,6 +1890,18 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`);
}
+ await auditService.createAudit({
+ action: "user.join_request_approved",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ orgId: org.id,
+ target: {
+ id: requestId,
+ type: "account_join_request"
+ }
+ });
return {
success: true,
}
@@ -1706,6 +1927,19 @@ export const rejectAccountRequest = async (requestId: string, domain: string) =>
},
});
+ await auditService.createAudit({
+ action: "user.join_request_removed",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ orgId: org.id,
+ target: {
+ id: requestId,
+ type: "account_join_request"
+ }
+ });
+
return {
success: true,
}
diff --git a/packages/web/src/app/api/(server)/ee/audit/route.ts b/packages/web/src/app/api/(server)/ee/audit/route.ts
new file mode 100644
index 00000000..80a05e2e
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/audit/route.ts
@@ -0,0 +1,46 @@
+'use server';
+
+import { NextRequest } from "next/server";
+import { fetchAuditRecords } from "@/ee/features/audit/actions";
+import { isServiceError } from "@/lib/utils";
+import { serviceErrorResponse } from "@/lib/serviceError";
+import { StatusCodes } from "http-status-codes";
+import { ErrorCode } from "@/lib/errorCodes";
+import { env } from "@/env.mjs";
+import { getEntitlements } from "@sourcebot/shared";
+
+export const GET = async (request: NextRequest) => {
+ const domain = request.headers.get("X-Org-Domain");
+ const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined;
+
+ if (!domain) {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
+ message: "Missing X-Org-Domain header",
+ });
+ }
+
+ if (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'false') {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.NOT_FOUND,
+ message: "Audit logging is not enabled",
+ });
+ }
+
+ const entitlements = getEntitlements();
+ if (!entitlements.includes('audit')) {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.FORBIDDEN,
+ errorCode: ErrorCode.NOT_FOUND,
+ message: "Audit logging is not enabled for your license",
+ });
+ }
+
+ const result = await fetchAuditRecords(domain, apiKey);
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+ return Response.json(result);
+};
\ No newline at end of file
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts
index 10f76927..a341192d 100644
--- a/packages/web/src/auth.ts
+++ b/packages/web/src/auth.ts
@@ -13,9 +13,13 @@ import { createTransport } from 'nodemailer';
import { render } from '@react-email/render';
import MagicLinkEmail from './emails/magicLinkEmail';
import bcrypt from 'bcryptjs';
-import { getSSOProviders } from '@/ee/sso/sso';
+import { getSSOProviders } from '@/ee/features/sso/sso';
import { hasEntitlement } from '@sourcebot/shared';
import { onCreateUser } from '@/lib/authUtils';
+import { getAuditService } from '@/ee/features/audit/factory';
+import { SINGLE_TENANT_ORG_ID } from './lib/constants';
+
+const auditService = getAuditService();
export const runtime = 'nodejs';
@@ -137,6 +141,39 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
events: {
createUser: onCreateUser,
+ signIn: async ({ user, account }) => {
+ if (user.id) {
+ await auditService.createAudit({
+ action: "user.signed_in",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID, // TODO(mt)
+ target: {
+ id: user.id,
+ type: "user"
+ }
+ });
+ }
+ },
+ signOut: async (message) => {
+ const token = message as { token: { userId: string } | null };
+ if (token?.token?.userId) {
+ await auditService.createAudit({
+ action: "user.signed_out",
+ actor: {
+ id: token.token.userId,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID, // TODO(mt)
+ target: {
+ id: token.token.userId,
+ type: "user"
+ }
+ });
+ }
+ }
},
callbacks: {
async jwt({ token, user: _user }) {
diff --git a/packages/web/src/ee/features/audit/actions.ts b/packages/web/src/ee/features/audit/actions.ts
new file mode 100644
index 00000000..4e741b0a
--- /dev/null
+++ b/packages/web/src/ee/features/audit/actions.ts
@@ -0,0 +1,49 @@
+import { prisma } from "@/prisma";
+import { ErrorCode } from "@/lib/errorCodes";
+import { StatusCodes } from "http-status-codes";
+import { sew, withAuth, withOrgMembership } from "@/actions";
+import { OrgRole } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/logger";
+import { ServiceError } from "@/lib/serviceError";
+import { getAuditService } from "@/ee/features/audit/factory";
+
+const auditService = getAuditService();
+const logger = createLogger('audit-utils');
+
+export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() =>
+ withAuth((userId) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
+ try {
+ const auditRecords = await prisma.audit.findMany({
+ where: {
+ orgId: org.id,
+ },
+ orderBy: {
+ timestamp: 'desc'
+ }
+ });
+
+ await auditService.createAudit({
+ action: "audit.fetch",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id
+ })
+
+ return auditRecords;
+ } catch (error) {
+ logger.error('Error fetching audit logs', { error });
+ return {
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
+ errorCode: ErrorCode.UNEXPECTED_ERROR,
+ message: "Failed to fetch audit logs",
+ } satisfies ServiceError;
+ }
+ }, /* minRequiredRole = */ OrgRole.OWNER), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
+);
diff --git a/packages/web/src/ee/features/audit/auditService.ts b/packages/web/src/ee/features/audit/auditService.ts
new file mode 100644
index 00000000..09cc647b
--- /dev/null
+++ b/packages/web/src/ee/features/audit/auditService.ts
@@ -0,0 +1,32 @@
+import { IAuditService, AuditEvent } from '@/ee/features/audit/types';
+import { prisma } from '@/prisma';
+import { Audit } from '@prisma/client';
+import { createLogger } from '@sourcebot/logger';
+
+const logger = createLogger('audit-service');
+
+export class AuditService implements IAuditService {
+ async createAudit(event: Omit): Promise {
+ const sourcebotVersion = process.env.NEXT_PUBLIC_SOURCEBOT_VERSION || 'unknown';
+
+ try {
+ const audit = await prisma.audit.create({
+ data: {
+ action: event.action,
+ actorId: event.actor.id,
+ actorType: event.actor.type,
+ targetId: event.target.id,
+ targetType: event.target.type,
+ sourcebotVersion,
+ metadata: event.metadata,
+ orgId: event.orgId,
+ },
+ });
+
+ return audit;
+ } catch (error) {
+ logger.error(`Error creating audit event: ${error}`, { event });
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/web/src/ee/features/audit/factory.ts b/packages/web/src/ee/features/audit/factory.ts
new file mode 100644
index 00000000..8833615b
--- /dev/null
+++ b/packages/web/src/ee/features/audit/factory.ts
@@ -0,0 +1,11 @@
+import { IAuditService } from '@/ee/features/audit/types';
+import { MockAuditService } from '@/ee/features/audit/mockAuditService';
+import { AuditService } from '@/ee/features/audit/auditService';
+import { env } from '@/env.mjs';
+
+let enterpriseService: IAuditService | undefined;
+
+export function getAuditService(): IAuditService {
+ enterpriseService = enterpriseService ?? (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true' ? new AuditService() : new MockAuditService());
+ return enterpriseService;
+}
\ No newline at end of file
diff --git a/packages/web/src/ee/features/audit/mockAuditService.ts b/packages/web/src/ee/features/audit/mockAuditService.ts
new file mode 100644
index 00000000..5e40d545
--- /dev/null
+++ b/packages/web/src/ee/features/audit/mockAuditService.ts
@@ -0,0 +1,8 @@
+import { IAuditService, AuditEvent } from '@/ee/features/audit/types';
+import { Audit } from '@prisma/client';
+
+export class MockAuditService implements IAuditService {
+ async createAudit(_event: Omit): Promise {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/packages/web/src/ee/features/audit/types.ts b/packages/web/src/ee/features/audit/types.ts
new file mode 100644
index 00000000..5cdf02ef
--- /dev/null
+++ b/packages/web/src/ee/features/audit/types.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+import { Audit } from "@prisma/client";
+
+export const auditActorSchema = z.object({
+ id: z.string(),
+ type: z.enum(["user", "api_key"]),
+})
+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"]),
+})
+export type AuditTarget = z.infer;
+
+export const auditMetadataSchema = z.object({
+ message: z.string().optional(),
+ api_key: z.string().optional(),
+ emails: z.string().optional(), // comma separated list of emails
+})
+export type AuditMetadata = z.infer;
+
+export const auditEventSchema = z.object({
+ action: z.string(),
+ actor: auditActorSchema,
+ target: auditTargetSchema,
+ sourcebotVersion: z.string(),
+ orgId: z.number(),
+ metadata: auditMetadataSchema.optional()
+})
+export type AuditEvent = z.infer;
+
+export interface IAuditService {
+ createAudit(event: Omit): Promise;
+}
\ No newline at end of file
diff --git a/packages/web/src/ee/sso/sso.tsx b/packages/web/src/ee/features/sso/sso.tsx
similarity index 99%
rename from packages/web/src/ee/sso/sso.tsx
rename to packages/web/src/ee/features/sso/sso.tsx
index 53c3979b..a7018c44 100644
--- a/packages/web/src/ee/sso/sso.tsx
+++ b/packages/web/src/ee/features/sso/sso.tsx
@@ -247,5 +247,4 @@ export const handleJITProvisioning = async (userId: string, domain: string): Pro
});
return true;
-});
-
+});
\ No newline at end of file
diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs
index 827a58dd..0a34c26f 100644
--- a/packages/web/src/env.mjs
+++ b/packages/web/src/env.mjs
@@ -85,6 +85,7 @@ export const env = createEnv({
// EE License
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
+ SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('false'),
// GitHub app for review agent
GITHUB_APP_ID: z.string().optional(),
diff --git a/packages/web/src/features/search/fileSourceApi.ts b/packages/web/src/features/search/fileSourceApi.ts
index d285aaa9..596c072c 100644
--- a/packages/web/src/features/search/fileSourceApi.ts
+++ b/packages/web/src/features/search/fileSourceApi.ts
@@ -7,12 +7,16 @@ import { isServiceError } from "../../lib/utils";
import { search } from "./searchApi";
import { sew, withAuth, withOrgMembership } from "@/actions";
import { OrgRole } from "@sourcebot/db";
+import { getAuditService } from "@/ee/features/audit/factory";
// @todo (bkellam) : We should really be using `git show :` to fetch file contents here.
// This will allow us to support permalinks to files at a specific revision that may not be indexed
// by zoekt.
+
+const auditService = getAuditService();
+
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise => sew(() =>
- withAuth((userId) =>
- withOrgMembership(userId, domain, async () => {
+ withAuth((userId, apiKeyHash) =>
+ withOrgMembership(userId, domain, async ({ org }) => {
const escapedFileName = escapeStringRegexp(fileName);
const escapedRepository = escapeStringRegexp(repository);
@@ -40,10 +44,24 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource
const file = files[0];
const source = file.content ?? '';
const language = file.language;
+
+ await auditService.createAudit({
+ action: "query.file_source",
+ actor: {
+ id: apiKeyHash ?? userId,
+ type: apiKeyHash ? "api_key" : "user"
+ },
+ orgId: org.id,
+ target: {
+ id: `${escapedRepository}/${escapedFileName}${branch ? `:${branch}` : ''}`,
+ type: "file"
+ }
+ });
return {
source,
language,
webUrl: file.webUrl,
} satisfies FileSourceResponse;
+
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
);
diff --git a/packages/web/src/features/search/listReposApi.ts b/packages/web/src/features/search/listReposApi.ts
index 296e1238..15e09ffb 100644
--- a/packages/web/src/features/search/listReposApi.ts
+++ b/packages/web/src/features/search/listReposApi.ts
@@ -4,9 +4,12 @@ import { ListRepositoriesResponse } from "./types";
import { zoektFetch } from "./zoektClient";
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
import { sew, withAuth, withOrgMembership } from "@/actions";
+import { getAuditService } from "@/ee/features/audit/factory";
+
+const auditService = getAuditService();
export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise => sew(() =>
- withAuth((userId) =>
+ withAuth((userId, apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => {
const body = JSON.stringify({
opts: {
@@ -42,6 +45,24 @@ export const listRepositories = async (domain: string, apiKey: string | undefine
}))
} satisfies ListRepositoriesResponse));
- return parser.parse(listBody);
+ const result = parser.parse(listBody);
+
+ await auditService.createAudit({
+ action: "query.list_repositories",
+ actor: {
+ id: apiKeyHash ?? userId,
+ type: apiKeyHash ? "api_key" : "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: result.repos.map((repo) => repo.name).join(", ")
+ }
+ });
+
+ return result;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
);
diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts
index a216765b..8ca08640 100644
--- a/packages/web/src/features/search/searchApi.ts
+++ b/packages/web/src/features/search/searchApi.ts
@@ -11,6 +11,9 @@ import { OrgRole, Repo } from "@sourcebot/db";
import * as Sentry from "@sentry/nextjs";
import { sew, withAuth, withOrgMembership } from "@/actions";
import { base64Decode } from "@sourcebot/shared";
+import { getAuditService } from "@/ee/features/audit/factory";
+
+const auditService = getAuditService();
// List of supported query prefixes in zoekt.
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
@@ -126,7 +129,7 @@ const getFileWebUrl = (template: string, branch: string, fileName: string): stri
}
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() =>
- withAuth((userId) =>
+ withAuth((userId, apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => {
const transformedQuery = await transformZoektQuery(query, org.id);
if (isServiceError(transformedQuery)) {
@@ -178,7 +181,6 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
const searchBody = await searchResponse.json();
const parser = zoektSearchResponseSchema.transform(async ({ Result }) => {
-
// @note (2025-05-12): in zoekt, repositories are identified by the `RepositoryID` field
// which corresponds to the `id` in the Repo table. In order to efficiently fetch repository
// metadata when transforming (potentially thousands) of file matches, we aggregate a unique
@@ -300,6 +302,22 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
}
}).filter((file) => file !== undefined) ?? [];
+ await auditService.createAudit({
+ action: "query.code_search",
+ actor: {
+ id: apiKeyHash ?? userId,
+ type: apiKeyHash ? "api_key" : "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ message: query,
+ }
+ });
+
return {
zoektStats: {
duration: Result.Duration,
@@ -347,4 +365,4 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
return parser.parseAsync(searchBody);
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
-)
+ );
diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts
index 55e186f2..9754ec4a 100644
--- a/packages/web/src/initialize.ts
+++ b/packages/web/src/initialize.ts
@@ -113,6 +113,11 @@ const syncDeclarativeConfig = async (configPath: string) => {
}
if (hasPublicAccessEntitlement) {
+ if (enablePublicAccess && env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
+ logger.error(`Audit logging is not supported when public access is enabled. Please disable audit logging or disable public access.`);
+ process.exit(1);
+ }
+
logger.info(`Setting public access status to ${!!enablePublicAccess} for org ${SINGLE_TENANT_ORG_DOMAIN}`);
const res = await setPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN, !!enablePublicAccess);
if (isServiceError(res)) {
@@ -153,6 +158,15 @@ const pruneOldGuestUser = async () => {
}
}
+const validateEntitlements = () => {
+ if (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
+ if (!hasEntitlement('audit')) {
+ logger.error(`Audit logging is enabled but your license does not include the audit logging entitlement. Please reach out to us to enquire about upgrading your license.`);
+ process.exit(1);
+ }
+ }
+}
+
const initSingleTenancy = async () => {
await prisma.org.upsert({
where: {
@@ -170,6 +184,9 @@ const initSingleTenancy = async () => {
// To keep things simple, we'll just delete the old guest user if it exists in the DB
await pruneOldGuestUser();
+ // Startup time entitlement/environment variable validation
+ validateEntitlements();
+
const hasPublicAccessEntitlement = hasEntitlement("public-access");
if (hasPublicAccessEntitlement) {
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);
diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts
index 01b113a1..2a158c8d 100644
--- a/packages/web/src/lib/authUtils.ts
+++ b/packages/web/src/lib/authUtils.ts
@@ -7,17 +7,37 @@ import { hasEntitlement } from "@sourcebot/shared";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
import { createAccountRequest } from "@/actions";
-import { handleJITProvisioning } from "@/ee/sso/sso";
+import { handleJITProvisioning } from "@/ee/features/sso/sso";
import { createLogger } from "@sourcebot/logger";
+import { getAuditService } from "@/ee/features/audit/factory";
const logger = createLogger('web-auth-utils');
+const auditService = getAuditService();
export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
+ if (!user.id) {
+ logger.error("User ID is undefined on user creation");
+ await auditService.createAudit({
+ action: "user.creation_failed",
+ actor: {
+ id: "undefined",
+ type: "user"
+ },
+ target: {
+ id: "undefined",
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID, // TODO(mt)
+ metadata: {
+ message: "User ID is undefined on user creation"
+ }
+ });
+ throw new Error("User ID is undefined on user creation");
+ }
+
// In single-tenant mode, we assign the first user to sign
// up as the owner of the default org.
- if (
- env.SOURCEBOT_TENANCY_MODE === 'single'
- ) {
+ if (env.SOURCEBOT_TENANCY_MODE === 'single') {
const defaultOrg = await prisma.org.findUnique({
where: {
id: SINGLE_TENANT_ORG_ID,
@@ -33,7 +53,22 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
}
});
- if (!defaultOrg) {
+ if (defaultOrg === null) {
+ await auditService.createAudit({
+ action: "user.creation_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ metadata: {
+ message: "Default org not found on single tenant user creation"
+ }
+ });
throw new Error("Default org not found on single tenant user creation");
}
@@ -68,20 +103,89 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
}
});
});
+
+ await auditService.createAudit({
+ action: "user.owner_created",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ target: {
+ id: SINGLE_TENANT_ORG_ID.toString(),
+ type: "org"
+ }
+ });
} else {
// TODO(auth): handle multi tenant case
if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) {
- const res = await handleJITProvisioning(user.id!, SINGLE_TENANT_ORG_DOMAIN);
+ const res = await handleJITProvisioning(user.id, SINGLE_TENANT_ORG_DOMAIN);
if (isServiceError(res)) {
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
+ await auditService.createAudit({
+ action: "user.jit_provisioning_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: SINGLE_TENANT_ORG_ID.toString(),
+ type: "org"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ metadata: {
+ message: `Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`
+ }
+ });
throw new ServiceErrorException(res);
}
+
+ await auditService.createAudit({
+ action: "user.jit_provisioned",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: SINGLE_TENANT_ORG_ID.toString(),
+ type: "org"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ });
} else {
- const res = await createAccountRequest(user.id!, SINGLE_TENANT_ORG_DOMAIN);
+ const res = await createAccountRequest(user.id, SINGLE_TENANT_ORG_DOMAIN);
if (isServiceError(res)) {
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
+ await auditService.createAudit({
+ action: "user.join_request_creation_failed",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ target: {
+ id: SINGLE_TENANT_ORG_ID.toString(),
+ type: "org"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ metadata: {
+ message: res.message
+ }
+ });
throw new ServiceErrorException(res);
}
+
+ await auditService.createAudit({
+ action: "user.join_requested",
+ actor: {
+ id: user.id,
+ type: "user"
+ },
+ orgId: SINGLE_TENANT_ORG_ID,
+ target: {
+ id: SINGLE_TENANT_ORG_ID.toString(),
+ type: "org"
+ },
+ });
}
}
}