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" + }, + }); } } }