mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +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]
|
## [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. -->
|
<!-- @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
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"docs/configuration/transactional-emails",
|
"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
|
### Enterprise Environment Variables
|
||||||
| Variable | Default | Description |
|
| 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_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_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> |
|
| `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:
|
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">
|
<Step title="Create a config.json">
|
||||||
Create a `config.json` file that tells Sourcebot which repositories to sync and index:
|
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
|
touch config.json
|
||||||
echo '{
|
echo '{
|
||||||
"$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json",
|
"$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:
|
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 \
|
docker run \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
--pull=always \
|
--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
|
/// List of pending invites to this organization
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
||||||
|
audits Audit[]
|
||||||
|
|
||||||
accountRequests AccountRequest[]
|
accountRequests AccountRequest[]
|
||||||
|
|
||||||
searchContexts SearchContext[]
|
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
|
// @see : https://authjs.dev/concepts/database-models#user
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,16 @@ const entitlements = [
|
||||||
"public-access",
|
"public-access",
|
||||||
"multi-tenancy",
|
"multi-tenancy",
|
||||||
"sso",
|
"sso",
|
||||||
"code-nav"
|
"code-nav",
|
||||||
|
"audit"
|
||||||
] as const;
|
] as const;
|
||||||
export type Entitlement = (typeof entitlements)[number];
|
export type Entitlement = (typeof entitlements)[number];
|
||||||
|
|
||||||
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
|
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
|
||||||
oss: [],
|
oss: [],
|
||||||
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
|
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
|
||||||
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav"],
|
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit"],
|
||||||
"self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav"],
|
"self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit"],
|
||||||
// Special entitlement for https://demo.sourcebot.dev
|
// Special entitlement for https://demo.sourcebot.dev
|
||||||
"cloud:demo": ["public-access", "code-nav", "search-contexts"],
|
"cloud:demo": ["public-access", "code-nav", "search-contexts"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,14 @@ import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess";
|
||||||
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
||||||
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logger = createLogger('web-actions');
|
const logger = createLogger('web-actions');
|
||||||
|
const auditService = getAuditService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Service Error Wrapper".
|
* "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();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
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 (
|
} else if (
|
||||||
env.SOURCEBOT_TENANCY_MODE === 'single' &&
|
env.SOURCEBOT_TENANCY_MODE === 'single' &&
|
||||||
allowSingleTenantUnauthedAccess &&
|
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
|
// 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 notAuthenticated();
|
||||||
}
|
}
|
||||||
return fn(session.user.id);
|
return fn(session.user.id, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const orgHasAvailability = async (domain: string): Promise<boolean> => {
|
export const orgHasAvailability = async (domain: string): Promise<boolean> => {
|
||||||
|
|
@ -460,6 +462,22 @@ export const createApiKey = async (name: string, domain: string): Promise<{ key:
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingApiKey) {
|
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 {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.API_KEY_ALREADY_EXISTS,
|
errorCode: ErrorCode.API_KEY_ALREADY_EXISTS,
|
||||||
|
|
@ -468,7 +486,7 @@ export const createApiKey = async (name: string, domain: string): Promise<{ key:
|
||||||
}
|
}
|
||||||
|
|
||||||
const { key, hash } = generateApiKey();
|
const { key, hash } = generateApiKey();
|
||||||
await prisma.apiKey.create({
|
const apiKey = await prisma.apiKey.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
hash,
|
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 {
|
return {
|
||||||
key,
|
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(() =>
|
export const deleteApiKey = async (name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
withOrgMembership(userId, domain, async () => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
const apiKey = await prisma.apiKey.findFirst({
|
const apiKey = await prisma.apiKey.findFirst({
|
||||||
where: {
|
where: {
|
||||||
name,
|
name,
|
||||||
|
|
@ -493,6 +524,22 @@ export const deleteApiKey = async (name: string, domain: string): Promise<{ succ
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!apiKey) {
|
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 {
|
return {
|
||||||
statusCode: StatusCodes.NOT_FOUND,
|
statusCode: StatusCodes.NOT_FOUND,
|
||||||
errorCode: ErrorCode.API_KEY_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 {
|
return {
|
||||||
success: true,
|
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(() =>
|
export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
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();
|
const user = await getMe();
|
||||||
if (isServiceError(user)) {
|
if (isServiceError(user)) {
|
||||||
throw new ServiceErrorException(user);
|
throw new ServiceErrorException(user);
|
||||||
|
|
@ -911,6 +992,22 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
|
||||||
|
|
||||||
const hasAvailability = await orgHasAvailability(domain);
|
const hasAvailability = await orgHasAvailability(domain);
|
||||||
if (!hasAvailability) {
|
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 {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
||||||
|
|
@ -929,6 +1026,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingInvites.length > 0) {
|
if (existingInvites.length > 0) {
|
||||||
|
await failAuditCallback("A pending invite already exists for one or more of the provided emails");
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.INVALID_INVITE,
|
errorCode: ErrorCode.INVALID_INVITE,
|
||||||
|
|
@ -949,6 +1047,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingMembers.length > 0) {
|
if (existingMembers.length > 0) {
|
||||||
|
await failAuditCallback("One or more of the provided emails are already members of this org");
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.INVALID_INVITE,
|
errorCode: ErrorCode.INVALID_INVITE,
|
||||||
|
|
@ -956,15 +1055,6 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
|
||||||
} satisfies ServiceError;
|
} satisfies ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.invite.createMany({
|
|
||||||
data: emails.map((email) => ({
|
|
||||||
recipientEmail: email,
|
|
||||||
hostUserId: userId,
|
|
||||||
orgId: org.id,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send invites to recipients
|
// Send invites to recipients
|
||||||
if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) {
|
if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) {
|
||||||
const origin = (await headers()).get('origin')!;
|
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(", ")}`);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
}
|
}
|
||||||
|
|
@ -1090,6 +1195,11 @@ export const getMe = async () => sew(() =>
|
||||||
|
|
||||||
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
||||||
withAuth(async () => {
|
withAuth(async () => {
|
||||||
|
const user = await getMe();
|
||||||
|
if (isServiceError(user)) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
const invite = await prisma.invite.findUnique({
|
const invite = await prisma.invite.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: inviteId,
|
id: inviteId,
|
||||||
|
|
@ -1103,13 +1213,28 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getMe();
|
const failAuditCallback = async (error: string) => {
|
||||||
if (isServiceError(user)) {
|
await auditService.createAudit({
|
||||||
return user;
|
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);
|
const hasAvailability = await orgHasAvailability(invite.org.domain);
|
||||||
if (!hasAvailability) {
|
if (!hasAvailability) {
|
||||||
|
await failAuditCallback("Organization is at max capacity");
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
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
|
// Check if the user is the recipient of the invite
|
||||||
if (user.email !== invite.recipientEmail) {
|
if (user.email !== invite.recipientEmail) {
|
||||||
|
await failAuditCallback("User is not the recipient of the invite");
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1158,6 +1284,19 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
||||||
|
|
||||||
if (accountRequest) {
|
if (accountRequest) {
|
||||||
logger.info(`Deleting account request ${accountRequest.id} for user ${user.id} since they've redeemed an invite`);
|
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({
|
await tx.accountRequest.delete({
|
||||||
where: {
|
where: {
|
||||||
id: accountRequest.id,
|
id: accountRequest.id,
|
||||||
|
|
@ -1174,9 +1313,23 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isServiceError(res)) {
|
if (isServiceError(res)) {
|
||||||
|
await failAuditCallback(res.message);
|
||||||
return res;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
}
|
}
|
||||||
|
|
@ -1229,7 +1382,25 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
const currentUserId = userId;
|
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) {
|
if (newOwnerId === currentUserId) {
|
||||||
|
await failAuditCallback("User is already the owner of this org");
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
|
|
@ -1247,6 +1418,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!newOwner) {
|
if (!newOwner) {
|
||||||
|
await failAuditCallback("The user you're trying to make the owner doesn't exist");
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
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 {
|
return {
|
||||||
success: true,
|
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) =>
|
withAuth(async (userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
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({
|
const request = await prisma.accountRequest.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
|
|
@ -1592,11 +1798,13 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!request || request.orgId !== org.id) {
|
if (!request || request.orgId !== org.id) {
|
||||||
|
await failAuditCallback("Request not found");
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAvailability = await orgHasAvailability(domain);
|
const hasAvailability = await orgHasAvailability(domain);
|
||||||
if (!hasAvailability) {
|
if (!hasAvailability) {
|
||||||
|
await failAuditCallback("Organization is at max capacity");
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED,
|
||||||
|
|
@ -1646,6 +1854,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) =
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isServiceError(res)) {
|
if (isServiceError(res)) {
|
||||||
|
await failAuditCallback(res.message);
|
||||||
return res;
|
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}`);
|
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 {
|
return {
|
||||||
success: true,
|
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 {
|
return {
|
||||||
success: true,
|
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 { render } from '@react-email/render';
|
||||||
import MagicLinkEmail from './emails/magicLinkEmail';
|
import MagicLinkEmail from './emails/magicLinkEmail';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { getSSOProviders } from '@/ee/sso/sso';
|
import { getSSOProviders } from '@/ee/features/sso/sso';
|
||||||
import { hasEntitlement } from '@sourcebot/shared';
|
import { hasEntitlement } from '@sourcebot/shared';
|
||||||
import { onCreateUser } from '@/lib/authUtils';
|
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';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
|
@ -137,6 +141,39 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
events: {
|
events: {
|
||||||
createUser: onCreateUser,
|
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: {
|
callbacks: {
|
||||||
async jwt({ token, user: _user }) {
|
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>;
|
||||||
|
}
|
||||||
|
|
@ -248,4 +248,3 @@ export const handleJITProvisioning = async (userId: string, domain: string): Pro
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,6 +85,7 @@ export const env = createEnv({
|
||||||
|
|
||||||
// EE License
|
// EE License
|
||||||
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
|
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
|
||||||
|
SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('false'),
|
||||||
|
|
||||||
// GitHub app for review agent
|
// GitHub app for review agent
|
||||||
GITHUB_APP_ID: z.string().optional(),
|
GITHUB_APP_ID: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@ import { isServiceError } from "../../lib/utils";
|
||||||
import { search } from "./searchApi";
|
import { search } from "./searchApi";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { OrgRole } from "@sourcebot/db";
|
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.
|
// @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
|
// This will allow us to support permalinks to files at a specific revision that may not be indexed
|
||||||
// by zoekt.
|
// by zoekt.
|
||||||
|
|
||||||
|
const auditService = getAuditService();
|
||||||
|
|
||||||
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId, apiKeyHash) =>
|
||||||
withOrgMembership(userId, domain, async () => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
const escapedFileName = escapeStringRegexp(fileName);
|
const escapedFileName = escapeStringRegexp(fileName);
|
||||||
const escapedRepository = escapeStringRegexp(repository);
|
const escapedRepository = escapeStringRegexp(repository);
|
||||||
|
|
||||||
|
|
@ -40,10 +44,24 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const source = file.content ?? '';
|
const source = file.content ?? '';
|
||||||
const language = file.language;
|
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 {
|
return {
|
||||||
source,
|
source,
|
||||||
language,
|
language,
|
||||||
webUrl: file.webUrl,
|
webUrl: file.webUrl,
|
||||||
} satisfies FileSourceResponse;
|
} satisfies FileSourceResponse;
|
||||||
|
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@ import { ListRepositoriesResponse } from "./types";
|
||||||
import { zoektFetch } from "./zoektClient";
|
import { zoektFetch } from "./zoektClient";
|
||||||
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
|
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
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(() =>
|
export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise<ListRepositoriesResponse | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId, apiKeyHash) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
opts: {
|
opts: {
|
||||||
|
|
@ -42,6 +45,24 @@ export const listRepositories = async (domain: string, apiKey: string | undefine
|
||||||
}))
|
}))
|
||||||
} satisfies ListRepositoriesResponse));
|
} 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)
|
}, /* 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 * as Sentry from "@sentry/nextjs";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { base64Decode } from "@sourcebot/shared";
|
import { base64Decode } from "@sourcebot/shared";
|
||||||
|
import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
|
|
||||||
|
const auditService = getAuditService();
|
||||||
|
|
||||||
// List of supported query prefixes in zoekt.
|
// List of supported query prefixes in zoekt.
|
||||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
// @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(() =>
|
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 }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
const transformedQuery = await transformZoektQuery(query, org.id);
|
const transformedQuery = await transformZoektQuery(query, org.id);
|
||||||
if (isServiceError(transformedQuery)) {
|
if (isServiceError(transformedQuery)) {
|
||||||
|
|
@ -178,7 +181,6 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
|
||||||
const searchBody = await searchResponse.json();
|
const searchBody = await searchResponse.json();
|
||||||
|
|
||||||
const parser = zoektSearchResponseSchema.transform(async ({ Result }) => {
|
const parser = zoektSearchResponseSchema.transform(async ({ Result }) => {
|
||||||
|
|
||||||
// @note (2025-05-12): in zoekt, repositories are identified by the `RepositoryID` field
|
// @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
|
// 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
|
// 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) ?? [];
|
}).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 {
|
return {
|
||||||
zoektStats: {
|
zoektStats: {
|
||||||
duration: Result.Duration,
|
duration: Result.Duration,
|
||||||
|
|
@ -347,4 +365,4 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
|
||||||
|
|
||||||
return parser.parseAsync(searchBody);
|
return parser.parseAsync(searchBody);
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@ const syncDeclarativeConfig = async (configPath: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPublicAccessEntitlement) {
|
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}`);
|
logger.info(`Setting public access status to ${!!enablePublicAccess} for org ${SINGLE_TENANT_ORG_DOMAIN}`);
|
||||||
const res = await setPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN, !!enablePublicAccess);
|
const res = await setPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN, !!enablePublicAccess);
|
||||||
if (isServiceError(res)) {
|
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 () => {
|
const initSingleTenancy = async () => {
|
||||||
await prisma.org.upsert({
|
await prisma.org.upsert({
|
||||||
where: {
|
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
|
// To keep things simple, we'll just delete the old guest user if it exists in the DB
|
||||||
await pruneOldGuestUser();
|
await pruneOldGuestUser();
|
||||||
|
|
||||||
|
// Startup time entitlement/environment variable validation
|
||||||
|
validateEntitlements();
|
||||||
|
|
||||||
const hasPublicAccessEntitlement = hasEntitlement("public-access");
|
const hasPublicAccessEntitlement = hasEntitlement("public-access");
|
||||||
if (hasPublicAccessEntitlement) {
|
if (hasPublicAccessEntitlement) {
|
||||||
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);
|
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,37 @@ import { hasEntitlement } from "@sourcebot/shared";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { createAccountRequest } from "@/actions";
|
import { createAccountRequest } from "@/actions";
|
||||||
import { handleJITProvisioning } from "@/ee/sso/sso";
|
import { handleJITProvisioning } from "@/ee/features/sso/sso";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
|
|
||||||
const logger = createLogger('web-auth-utils');
|
const logger = createLogger('web-auth-utils');
|
||||||
|
const auditService = getAuditService();
|
||||||
|
|
||||||
export const onCreateUser = async ({ user }: { user: AuthJsUser }) => {
|
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
|
// In single-tenant mode, we assign the first user to sign
|
||||||
// up as the owner of the default org.
|
// up as the owner of the default org.
|
||||||
if (
|
if (env.SOURCEBOT_TENANCY_MODE === 'single') {
|
||||||
env.SOURCEBOT_TENANCY_MODE === 'single'
|
|
||||||
) {
|
|
||||||
const defaultOrg = await prisma.org.findUnique({
|
const defaultOrg = await prisma.org.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
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");
|
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 {
|
} else {
|
||||||
// TODO(auth): handle multi tenant case
|
// TODO(auth): handle multi tenant case
|
||||||
if (env.AUTH_EE_ENABLE_JIT_PROVISIONING === 'true' && hasEntitlement("sso")) {
|
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)) {
|
if (isServiceError(res)) {
|
||||||
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
|
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);
|
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 {
|
} else {
|
||||||
const res = await createAccountRequest(user.id!, SINGLE_TENANT_ORG_DOMAIN);
|
const res = await createAccountRequest(user.id, SINGLE_TENANT_ORG_DOMAIN);
|
||||||
if (isServiceError(res)) {
|
if (isServiceError(res)) {
|
||||||
logger.error(`Failed to provision user ${user.id} for org ${SINGLE_TENANT_ORG_DOMAIN}: ${res.message}`);
|
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);
|
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