mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
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:
parent
1d95e82b95
commit
5438298d61
24 changed files with 932 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
]
|
||||
},
|
||||
"docs/configuration/transactional-emails",
|
||||
"docs/configuration/structured-logging"
|
||||
"docs/configuration/structured-logging",
|
||||
"docs/configuration/audit-logs"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
212
docs/docs/configuration/audit-logs.mdx
Normal file
212
docs/docs/configuration/audit-logs.mdx
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
|
@ -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> |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
46
packages/web/src/app/api/(server)/ee/audit/route.ts
Normal file
46
packages/web/src/app/api/(server)/ee/audit/route.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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 }) {
|
||||
|
|
|
|||
49
packages/web/src/ee/features/audit/actions.ts
Normal file
49
packages/web/src/ee/features/audit/actions.ts
Normal 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)
|
||||
);
|
||||
32
packages/web/src/ee/features/audit/auditService.ts
Normal file
32
packages/web/src/ee/features/audit/auditService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/web/src/ee/features/audit/factory.ts
Normal file
11
packages/web/src/ee/features/audit/factory.ts
Normal 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;
|
||||
}
|
||||
8
packages/web/src/ee/features/audit/mockAuditService.ts
Normal file
8
packages/web/src/ee/features/audit/mockAuditService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
35
packages/web/src/ee/features/audit/types.ts
Normal file
35
packages/web/src/ee/features/audit/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -247,5 +247,4 @@ export const handleJITProvisioning = async (userId: string, domain: string): Pro
|
|||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue