feat(audit-logging): Adds audit logging support (#355)

* add audit factory skeleton

* add additional audit events

* add more audit logs

* delete account join request when redeeming an invite

* add audit event for account request removed

* wip api to fetch audits

* add check for audit with public access and entitlement

* fix issues with merge

* add docs for audit logs

* add proper audit log for audit fetch and proper handling of api key hash in audit

* format nit

* feedback
This commit is contained in:
Michael Sukkarieh 2025-06-18 10:50:36 -07:00 committed by GitHub
parent 1d95e82b95
commit 5438298d61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 932 additions and 43 deletions

View file

@ -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)
<!-- @NOTE: this release includes a API change that affects the MCP package (@sourcebot/mcp). On release, bump the MCP package's version and delete this message. -->
### Fixed

View file

@ -75,7 +75,8 @@
]
},
"docs/configuration/transactional-emails",
"docs/configuration/structured-logging"
"docs/configuration/structured-logging",
"docs/configuration/audit-logs"
]
},
{

View file

@ -0,0 +1,212 @@
---
title: Audit Logs
sidebarTitle: Audit logs
---
import LicenseKeyRequired from '/snippets/license-key-required.mdx'
<LicenseKeyRequired />
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
}
}
```

View file

@ -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` | <p>Enables/disables audit logging</p> |
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> |
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |

View file

@ -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:

View file

@ -33,7 +33,7 @@ Watch this 1:51 minute video to get a quick overview of how to deploy Sourcebot
<Step title="Create a config.json">
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 \

View file

@ -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;

View file

@ -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())

View file

@ -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<Plan, Entitlement[]> = {
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;

View file

@ -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 <T>(fn: () => Promise<T>): Promise<T | ServiceError> =>
}
}
export const withAuth = async <T>(fn: (userId: string) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => {
const session = await auth();
if (!session) {
@ -93,7 +95,7 @@ export const withAuth = async <T>(fn: (userId: string) => Promise<T>, 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 <T>(fn: (userId: string) => Promise<T>, 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<boolean> => {
@ -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<OrgRole | Serv
export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => 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,
}

View file

@ -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);
};

View file

@ -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 }) {

View file

@ -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)
);

View file

@ -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<AuditEvent, 'sourcebotVersion'>): Promise<Audit | null> {
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;
}
}
}

View file

@ -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;
}

View file

@ -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<AuditEvent, 'sourcebotVersion'>): Promise<Audit | null> {
return null;
}
}

View file

@ -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<typeof auditActorSchema>;
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<typeof auditTargetSchema>;
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<typeof auditMetadataSchema>;
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<typeof auditEventSchema>;
export interface IAuditService {
createAudit(event: Omit<AuditEvent, 'sourcebotVersion'>): Promise<Audit | null>;
}

View file

@ -248,4 +248,3 @@ export const handleJITProvisioning = async (userId: string, domain: string): Pro
return true;
});

View file

@ -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(),

View file

@ -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 <hash>:<path>` 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<FileSourceResponse | ServiceError> => 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)
);

View file

@ -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<ListRepositoriesResponse | ServiceError> => 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)
);

View file

@ -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)
)
);

View file

@ -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);

View file

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