diff --git a/.changeset/fair-donuts-cough.md b/.changeset/fair-donuts-cough.md new file mode 100644 index 0000000000..f7d09cb312 --- /dev/null +++ b/.changeset/fair-donuts-cough.md @@ -0,0 +1,10 @@ +--- +'hive': minor +--- + +Add organization audit log. + +Each organization now has an audit log of all user actions that can be exported by admins. +Exported audit logs are stored on the pre-configured S3 storage. + +In case you want to store exported audit logs on a separate S3 bucket, you can use the `S3_AUDIT_LOG` prefixed environment variables for the configuration. diff --git a/deployment/index.ts b/deployment/index.ts index 3ac1dbd509..ac0c224192 100644 --- a/deployment/index.ts +++ b/deployment/index.ts @@ -19,7 +19,7 @@ import { deployPostgres } from './services/postgres'; import { deployProxy } from './services/proxy'; import { deployRateLimit } from './services/rate-limit'; import { deployRedis } from './services/redis'; -import { deployS3, deployS3Mirror } from './services/s3'; +import { deployS3, deployS3AuditLog, deployS3Mirror } from './services/s3'; import { deploySchema } from './services/schema'; import { configureSentry } from './services/sentry'; import { deploySentryEventsMonitor } from './services/sentry-events'; @@ -84,6 +84,7 @@ const redis = deployRedis({ environment }); const kafka = deployKafka(); const s3 = deployS3(); const s3Mirror = deployS3Mirror(); +const s3AuditLog = deployS3AuditLog(); const cdn = deployCFCDN({ s3, @@ -246,6 +247,7 @@ const graphql = deployGraphQL({ supertokens, s3, s3Mirror, + s3AuditLog, zendesk, githubApp, sentry, diff --git a/deployment/services/graphql.ts b/deployment/services/graphql.ts index 8eeb4d8887..7b8adb89b6 100644 --- a/deployment/services/graphql.ts +++ b/deployment/services/graphql.ts @@ -51,6 +51,7 @@ export function deployGraphQL({ supertokens, s3, s3Mirror, + s3AuditLog, zendesk, docker, postgres, @@ -72,6 +73,7 @@ export function deployGraphQL({ cdn: CDN; s3: S3; s3Mirror: S3; + s3AuditLog: S3; usage: Usage; usageEstimator: UsageEstimator; dbMigrations: DbMigrations; @@ -201,6 +203,11 @@ export function deployGraphQL({ .withSecret('S3_MIRROR_SECRET_ACCESS_KEY', s3Mirror.secret, 'secretAccessKey') .withSecret('S3_MIRROR_BUCKET_NAME', s3Mirror.secret, 'bucket') .withSecret('S3_MIRROR_ENDPOINT', s3Mirror.secret, 'endpoint') + // S3 Audit Log + .withSecret('S3_AUDIT_LOG_ACCESS_KEY_ID', s3AuditLog.secret, 'accessKeyId') + .withSecret('S3_AUDIT_LOG_SECRET_ACCESS_KEY', s3AuditLog.secret, 'secretAccessKey') + .withSecret('S3_AUDIT_LOG_BUCKET_NAME', s3AuditLog.secret, 'bucket') + .withSecret('S3_AUDIT_LOG_ENDPOINT', s3AuditLog.secret, 'endpoint') // Auth .withSecret('SUPERTOKENS_API_KEY', supertokens.secret, 'apiKey') .withSecret('AUTH_GITHUB_CLIENT_ID', githubOAuthSecret, 'clientId') diff --git a/deployment/services/s3.ts b/deployment/services/s3.ts index 55f832116a..792200b699 100644 --- a/deployment/services/s3.ts +++ b/deployment/services/s3.ts @@ -34,4 +34,17 @@ export function deployS3Mirror() { return { secret }; } +export function deployS3AuditLog() { + const config = new pulumi.Config('audit-log-s3'); + + const secret = new S3Secret('audit-log-s3', { + endpoint: config.require('endpoint'), + bucket: config.require('bucketName'), + accessKeyId: config.requireSecret('accessKeyId'), + secretAccessKey: config.requireSecret('secretAccessKey'), + }); + + return { secret }; +} + export type S3 = ReturnType; diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index 83e49a83b2..7846e4b999 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -135,7 +135,8 @@ services: entrypoint: > /bin/sh -c " /usr/bin/mc alias set myminio http://s3:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}; /usr/bin/mc ls myminio/artifacts >/dev/null 2>&1 || /usr/bin/mc mb - myminio/artifacts; exit 0" + myminio/artifacts; /usr/bin/mc ls myminio/audit-logs >/dev/null 2>&1 || /usr/bin/mc mb + myminio/audit-logs; exit 0" s3_reverse_proxy: image: caddy:2.8.4-alpine @@ -225,6 +226,10 @@ services: S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER} S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} S3_BUCKET_NAME: artifacts + S3_AUDIT_LOG_ENDPOINT: 'http://s3:9000' + S3_AUDIT_LOG_ACCESS_KEY_ID: ${MINIO_ROOT_USER} + S3_AUDIT_LOG_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} + S3_AUDIT_LOG_BUCKET_NAME: audit-logs CDN_AUTH_PRIVATE_KEY: ${CDN_AUTH_PRIVATE_KEY} CDN_API: '1' CDN_API_BASE_URL: 'http://localhost:8082' diff --git a/integration-tests/tests/api/audit-logs/audit-log-record.spec.ts b/integration-tests/tests/api/audit-logs/audit-log-record.spec.ts new file mode 100644 index 0000000000..4a20ccec7c --- /dev/null +++ b/integration-tests/tests/api/audit-logs/audit-log-record.spec.ts @@ -0,0 +1,105 @@ +import { endOfDay, startOfDay } from 'date-fns'; +import { graphql } from 'testkit/gql'; +import { ProjectType } from 'testkit/gql/graphql'; +import { execute } from 'testkit/graphql'; +import { initSeed } from 'testkit/seed'; +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; + +const s3Client = new S3Client({ + endpoint: 'http://127.0.0.1:9000', + region: 'auto', + credentials: { + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + }, + forcePathStyle: true, +}); + +const ExportAllAuditLogs = graphql(` + mutation exportAllAuditLogs($input: ExportOrganizationAuditLogInput!) { + exportOrganizationAuditLog(input: $input) { + ok { + url + } + error { + message + } + __typename + } + } +`); + +const today = endOfDay(new Date()); +const lastYear = startOfDay(new Date(new Date().setFullYear(new Date().getFullYear() - 1))); + +test.concurrent( + 'Try to export Audit Logs from an Organization with unauthorized user - should throw error', + async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, organization } = await createOrg(); + await createProject(ProjectType.Single); + const secondOrg = await initSeed().createOwner(); + const secondToken = secondOrg.ownerToken; + + await execute({ + document: ExportAllAuditLogs, + variables: { + input: { + selector: { + organizationSlug: organization.id, + }, + filter: { + startDate: lastYear.toISOString(), + endDate: today.toISOString(), + }, + }, + }, + token: secondToken, + }).then(r => r.expectGraphQLErrors()); + }, +); + +test.concurrent('Try to export Audit Logs from an Organization with authorized user', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, organization } = await createOrg(); + await createProject(ProjectType.Single); + + const exportAuditLogs = await execute({ + document: ExportAllAuditLogs, + variables: { + input: { + selector: { + organizationSlug: organization.id, + }, + filter: { + startDate: lastYear.toISOString(), + endDate: today.toISOString(), + }, + }, + }, + token: ownerToken, + }); + expect(exportAuditLogs.rawBody.data?.exportOrganizationAuditLog.error).toBeNull(); + const url = exportAuditLogs.rawBody.data?.exportOrganizationAuditLog.ok?.url; + const parsedUrl = new URL(String(url)); + const pathParts = parsedUrl.pathname.split('/'); + const bucketName = pathParts[1]; + const key = pathParts.slice(2).join('/'); + const getObjectCommand = new GetObjectCommand({ + Bucket: bucketName, + Key: key, + }); + const result = await s3Client.send(getObjectCommand); + const bodyStream = await result.Body?.transformToString(); + expect(bodyStream).toBeDefined(); + + const rows = bodyStream?.split('\n'); + expect(rows?.length).toBeGreaterThan(1); // At least header and one row + const header = rows?.[0].split(','); + const expectedHeader = ['id', 'created_at', 'event_type', 'user_id', 'user_email', 'metadata']; + expect(header).toEqual(expectedHeader); + // Sometimes the order of the rows is not guaranteed, so we need to check if the expected rows are present + expect(rows?.find(row => row.includes('ORGANIZATION_CREATED'))).toBeDefined(); + expect(rows?.find(row => row.includes('PROJECT_CREATED'))).toBeDefined(); + expect(rows?.find(row => row.includes('TARGET_CREATED'))).toBeDefined(); +}); diff --git a/packages/migrations/src/clickhouse-actions/011-audit-logs.ts b/packages/migrations/src/clickhouse-actions/011-audit-logs.ts new file mode 100644 index 0000000000..d9d6d09434 --- /dev/null +++ b/packages/migrations/src/clickhouse-actions/011-audit-logs.ts @@ -0,0 +1,22 @@ +import type { Action } from '../clickhouse'; + +export const action: Action = async exec => { + await exec(` + CREATE TABLE IF NOT EXISTS "audit_logs" ( + id String CODEC(ZSTD(1)), + timestamp DateTime('UTC') CODEC(DoubleDelta, LZ4), + organization_id LowCardinality(String) CODEC(ZSTD(1)), + event_action LowCardinality(String) CODEC(ZSTD(1)), + user_id String CODEC(ZSTD(1)), + user_email String CODEC(ZSTD(1)), + metadata String CODEC(ZSTD(1)), + INDEX idx_action event_action TYPE set(0) GRANULARITY 1, + INDEX idx_user_id user_id TYPE bloom_filter(0.001) GRANULARITY 1, + ) + ENGINE = MergeTree + PARTITION BY toYearWeek(timestamp, 1, 'UTC') + ORDER BY (organization_id, timestamp) + TTL timestamp + INTERVAL 1 YEAR + SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1; + `); +}; diff --git a/packages/migrations/src/clickhouse.ts b/packages/migrations/src/clickhouse.ts index 87b21567a3..f635c462ee 100644 --- a/packages/migrations/src/clickhouse.ts +++ b/packages/migrations/src/clickhouse.ts @@ -168,6 +168,7 @@ export async function migrateClickHouse( import('./clickhouse-actions/008-daily-operations-log'), import('./clickhouse-actions/009-ttl-1-year'), import('./clickhouse-actions/010-app-deployment-operations'), + import('./clickhouse-actions/011-audit-logs'), ]); async function actionRunner(action: Action, index: number) { diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 0f85519e12..3bd6aa39cf 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -42,6 +42,7 @@ "@types/object-hash": "3.0.6", "agentkeepalive": "4.5.0", "bcryptjs": "2.4.3", + "csv-stringify": "6.5.2", "dataloader": "2.2.3", "date-fns": "4.1.0", "fast-json-stable-stringify": "2.1.0", diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index 70bef8e2bc..0a39138a6b 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -5,6 +5,9 @@ import { alertsModule } from './modules/alerts'; import { WEBHOOKS_CONFIG, WebhooksConfig } from './modules/alerts/providers/tokens'; import { appDeploymentsModule } from './modules/app-deployments'; import { APP_DEPLOYMENTS_ENABLED } from './modules/app-deployments/providers/app-deployments-enabled-token'; +import { auditLogsModule } from './modules/audit-logs'; +import { AuditLogRecorder } from './modules/audit-logs/providers/audit-log-recorder'; +import { AuditLogS3Config } from './modules/audit-logs/providers/audit-logs-manager'; import { authModule } from './modules/auth'; import { Session } from './modules/auth/lib/authz'; import { billingModule } from './modules/billing'; @@ -89,6 +92,7 @@ const modules = [ schemaPolicyModule, collectionModule, appDeploymentsModule, + auditLogsModule, ]; export function createRegistry({ @@ -107,6 +111,7 @@ export function createRegistry({ cdn, s3, s3Mirror, + s3AuditLogs, encryptionSecret, billing, schemaConfig, @@ -142,6 +147,13 @@ export function createRegistry({ secretAccessKeyId: string; sessionToken?: string; } | null; + s3AuditLogs: { + bucketName: string; + endpoint: string; + accessKeyId: string; + secretAccessKeyId: string; + sessionToken?: string; + } | null; encryptionSecret: string; app: { baseUrl: string; @@ -182,7 +194,21 @@ export function createRegistry({ const artifactStorageWriter = new ArtifactStorageWriter(s3Config, logger); + const auditLogS3Config = s3AuditLogs + ? new AuditLogS3Config( + new AwsClient({ + accessKeyId: s3AuditLogs.accessKeyId, + secretAccessKey: s3AuditLogs.secretAccessKeyId, + sessionToken: s3AuditLogs.sessionToken, + service: 's3', + }), + s3.endpoint, + s3.bucketName, + ) + : new AuditLogS3Config(s3Config[0].client, s3Config[0].endpoint, s3Config[0].bucket); + const providers: Provider[] = [ + AuditLogRecorder, ActivityManager, HttpClient, IdTranslator, @@ -190,6 +216,10 @@ export function createRegistry({ DistributedCache, CryptoProvider, Emails, + { + provide: AuditLogS3Config, + useValue: auditLogS3Config, + }, { provide: ArtifactStorageWriter, useValue: artifactStorageWriter, diff --git a/packages/services/api/src/modules/audit-logs/index.ts b/packages/services/api/src/modules/audit-logs/index.ts new file mode 100644 index 0000000000..29d08b66cc --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/index.ts @@ -0,0 +1,14 @@ +import { createModule } from 'graphql-modules'; +import { ClickHouse } from '../operations/providers/clickhouse-client'; +import { AuditLogRecorder } from './providers/audit-log-recorder'; +import { AuditLogManager } from './providers/audit-logs-manager'; +import { resolvers } from './resolvers.generated'; +import { typeDefs } from './module.graphql'; + +export const auditLogsModule = createModule({ + id: 'audit-logs', + dirname: __dirname, + typeDefs, + resolvers, + providers: [AuditLogManager, AuditLogRecorder, ClickHouse], +}); diff --git a/packages/services/api/src/modules/audit-logs/module.graphql.mappers.ts b/packages/services/api/src/modules/audit-logs/module.graphql.mappers.ts new file mode 100644 index 0000000000..9c9fe5adbf --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/module.graphql.mappers.ts @@ -0,0 +1,8 @@ +import { AuditLogType } from './providers/audit-logs-types'; + +export type AuditLogMapper = AuditLogType; +export type AuditLogIdRecordMapper = { + organizationId: string; + userEmail: string; + userId: string; +}; diff --git a/packages/services/api/src/modules/audit-logs/module.graphql.ts b/packages/services/api/src/modules/audit-logs/module.graphql.ts new file mode 100644 index 0000000000..d042ab5cd1 --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/module.graphql.ts @@ -0,0 +1,32 @@ +import { gql } from 'graphql-modules'; + +export const typeDefs = gql` + extend type Mutation { + exportOrganizationAuditLog( + input: ExportOrganizationAuditLogInput! + ): ExportOrganizationAuditLogResult! + } + + input ExportOrganizationAuditLogInput { + selector: OrganizationSelectorInput! + filter: AuditLogFilter! + } + + input AuditLogFilter { + startDate: DateTime! + endDate: DateTime! + } + + type ExportOrganizationAuditLogError implements Error { + message: String! + } + + type ExportOrganizationAuditLogPayload { + url: String! + } + + type ExportOrganizationAuditLogResult { + ok: ExportOrganizationAuditLogPayload + error: ExportOrganizationAuditLogError + } +`; diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-log-recorder.ts b/packages/services/api/src/modules/audit-logs/providers/audit-log-recorder.ts new file mode 100644 index 0000000000..b43fb71dc4 --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/providers/audit-log-recorder.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto'; +import { Injectable, Scope } from 'graphql-modules'; +import { captureException } from '@sentry/node'; +import { Session } from '../../auth/lib/authz'; +import { ClickHouse, sql } from '../../operations/providers/clickhouse-client'; +import { Logger } from '../../shared/providers/logger'; +import { AuditLogModel, AuditLogSchemaEvent } from './audit-logs-types'; + +@Injectable({ + scope: Scope.Operation, + global: true, +}) +/** + * Responsible for recording audit log events and storing them in ClickHouse + */ +export class AuditLogRecorder { + private logger: Logger; + + constructor( + logger: Logger, + private clickHouse: ClickHouse, + private session: Session, + ) { + this.logger = logger.child({ source: 'AuditLogRecorder' }); + } + + async record(data: AuditLogSchemaEvent & { organizationId: string }): Promise { + try { + const user = await this.session.getViewer(); + const { eventType, organizationId } = data; + this.logger.debug('Creating audit log event', { eventType }); + + const auditLog = AuditLogModel.parse(data); + const eventMetadata = JSON.stringify({ + ...('metadata' in auditLog ? auditLog.metadata : {}), + user: { + fullName: user.fullName, + displayName: user.displayName, + provider: user.provider, + }, + }); + + const eventTime = formatToClickhouseDateTime(new Date()); + const id = randomUUID(); + + await this.clickHouse.query({ + query: sql` + INSERT INTO "audit_logs" ( + "id", + "timestamp", + "organization_id", + "event_action", + "user_id", + "user_email", + "metadata" + ) + VALUES ( + ${id} + , ${eventTime} + , ${organizationId} + , ${eventType} + , ${user.id} + , ${user.email} + , ${eventMetadata} + ) + `, + timeout: 10000, + queryId: 'create-audit-log', + }); + } catch (error) { + this.logger.error('Failed to create audit log event', error); + captureException(error, { + extra: { + data, + }, + }); + } + } +} + +export function formatToClickhouseDateTime(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; +} diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts new file mode 100644 index 0000000000..7460993f5a --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts @@ -0,0 +1,261 @@ +import { stringify } from 'csv-stringify'; +import { endOfDay, startOfDay } from 'date-fns'; +import { Injectable, Scope } from 'graphql-modules'; +import { traceFn } from '@hive/service-common'; +import { captureException } from '@sentry/node'; +import { Session } from '../../auth/lib/authz'; +import { type AwsClient } from '../../cdn/providers/aws'; +import { ClickHouse, sql } from '../../operations/providers/clickhouse-client'; +import { Emails, mjml } from '../../shared/providers/emails'; +import { Logger } from '../../shared/providers/logger'; +import { Storage } from '../../shared/providers/storage'; +import { formatToClickhouseDateTime } from './audit-log-recorder'; +import { AuditLogClickhouseArrayModel, AuditLogType } from './audit-logs-types'; + +export class AuditLogS3Config { + constructor( + public client: AwsClient, + public endpoint: string, + public bucket: string, + ) {} +} + +@Injectable({ + scope: Scope.Operation, +}) +/** + * Responsible for accessing audit logs. + */ +export class AuditLogManager { + private logger: Logger; + + constructor( + logger: Logger, + private clickHouse: ClickHouse, + private s3Config: AuditLogS3Config, + private emailProvider: Emails, + private session: Session, + private storage: Storage, + ) { + this.logger = logger.child({ source: 'AuditLogManager' }); + } + + async getAuditLogsByDateRange( + organizationId: string, + filter: { startDate: Date; endDate: Date }, + ): Promise<{ data: AuditLogType[] }> { + await this.session.assertPerformAction({ + action: 'auditLog:export', + organizationId, + params: { + organizationId, + }, + }); + + this.logger.info('Getting audit logs (organizationId=%s, filter=%o)', organizationId, filter); + + const query = sql` + SELECT + "id" + , "timestamp" + , "organization_id" AS "organizationId" + , "event_action" AS "eventAction" + , "user_id" AS "userId" + , "user_email" AS "userEmail" + , "metadata" + FROM + "audit_logs" + WHERE + "organization_id" = ${organizationId} + AND "timestamp" >= ${formatToClickhouseDateTime(startOfDay(filter.startDate))} + AND "timestamp" <= ${formatToClickhouseDateTime(endOfDay(filter.endDate))} + ORDER BY + "timestamp" DESC + , "id" DESC + `; + + const result = await this.clickHouse.query({ + query, + queryId: 'get-audit-logs', + timeout: 10000, + }); + + const data = AuditLogClickhouseArrayModel.parse(result.data); + + return { + data, + }; + } + + @traceFn('AuditLogsManager.exportAndSendEmail', { + initAttributes: (organizationId, filter) => ({ + 'hive.organization.id': organizationId, + 'input.start-date': filter.startDate.toString(), + 'input.end-date': filter.endDate.toString(), + }), + resultAttributes: result => ({ + 'error.message': result.error?.message, + }), + errorAttributes: error => ({ + 'error.message': error.message, + }), + }) + async exportAndSendEmail( + organizationId: string, + filter: { startDate: Date; endDate: Date }, + ): Promise< + | { + ok: { + url: string; + }; + error?: never; + } + | { + ok?: never; + error: { + message: string; + }; + } + > { + await this.session.assertPerformAction({ + action: 'auditLog:export', + organizationId, + params: { + organizationId, + }, + }); + + const getAllAuditLogs = await this.getAuditLogsByDateRange(organizationId, filter); + + if (!getAllAuditLogs || !getAllAuditLogs.data || getAllAuditLogs.data.length === 0) { + return { + error: { + message: 'No audit logs found for the given date range', + }, + }; + } + + try { + const { email } = await this.session.getViewer(); + const csvData = await new Promise((resolve, reject) => { + stringify( + getAllAuditLogs.data, + { + header: true, + columns: { + id: 'id', + timestamp: 'created_at', + eventAction: 'event_type', + userId: 'user_id', + userEmail: 'user_email', + metadata: 'metadata', + }, + }, + (err, output) => { + if (err) { + reject(err); + } else { + resolve(output); + } + }, + ); + }); + + const { endpoint, bucket, client } = this.s3Config; + const cleanStartDate = filter.startDate.toISOString().split('T')[0]; + const cleanEndDate = filter.endDate.toISOString().split('T')[0]; + const unixTimestampInSeconds = Math.floor(Date.now() / 1000); + const key = `audit-logs/${organizationId}/${unixTimestampInSeconds}-${cleanStartDate}-${cleanEndDate}.csv`; + const uploadResult = await client.fetch([endpoint, bucket, key].join('/'), { + method: 'PUT', + headers: { + 'Content-Type': 'text/csv', + }, + body: csvData, + }); + + if (!uploadResult.ok) { + this.logger.error(`Failed to upload the file: ${uploadResult.url}`); + captureException('Audit log: Failed to upload the file', { + extra: { + organizationId, + filter, + }, + }); + return { + error: { + message: 'Failed to generate the audit logs CSV', + }, + }; + } + + const getPresignedUrl = await client.fetch([endpoint, bucket, key].join('/'), { + method: 'GET', + aws: { + signQuery: true, + }, + }); + + if (!getPresignedUrl.ok) { + this.logger.error(`Failed to get the pre-signed URL: ${getPresignedUrl.url}`); + captureException('Audit log: Failed to get the pre-signed URL', { + extra: { + organizationId, + filter, + }, + }); + return { + error: { + message: 'Failed to generate the audit logs CSV', + }, + }; + } + + const organization = await this.storage.getOrganization({ + organizationId, + }); + const title = `Audit Logs for your organization ${organization.name} from ${cleanStartDate} to ${cleanEndDate}`; + await this.emailProvider.schedule({ + email: email, + subject: 'Hive - Audit Log Report', + body: mjml` + + + + + + + + ${title} + . + + Download Audit Logs CSV + + + + + + `, + }); + + return { + ok: { + url: getPresignedUrl.url, + }, + }; + } catch (error) { + this.logger.error(`Failed to export and send audit logs: ${error}`); + captureException(error, { + extra: { + organizationId, + filter, + }, + }); + return { + error: { + message: 'Failed to generate the audit logs CSV', + }, + }; + } + } +} diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts new file mode 100644 index 0000000000..1e0246aec7 --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts @@ -0,0 +1,342 @@ +import { z } from 'zod'; + +export const AuditLogModel = z.union([ + z.object({ + eventType: z.literal('USER_INVITED'), + metadata: z.object({ + inviteeEmail: z.string(), + roleId: z.string().uuid(), + }), + }), + z.object({ + eventType: z.literal('USER_JOINED'), + metadata: z.object({ + inviteeEmail: z.string(), + }), + }), + z.object({ + eventType: z.literal('USER_REMOVED'), + metadata: z.object({ + removedUserId: z.string().uuid(), + removedUserEmail: z.string(), + }), + }), + z.object({ + eventType: z.literal('USER_SETTINGS_UPDATED'), + metadata: z.object({ + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_POLICY_UPDATED'), + metadata: z.object({ + allowOverrides: z.boolean(), + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_PLAN_UPDATED'), + metadata: z.object({ + previousPlan: z.string(), + newPlan: z.string(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_TRANSFERRED'), + metadata: z.object({ + newOwnerId: z.string().uuid(), + newOwnerEmail: z.string(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_TRANSFERRED_REQUEST'), + metadata: z.object({ + newOwnerId: z.string().uuid(), + newOwnerEmail: z.string().nullable(), + }), + }), + z.object({ + eventType: z.literal('PROJECT_CREATED'), + metadata: z.object({ + projectId: z.string().uuid().nullish(), + projectSlug: z.string(), + projectType: z.string(), + }), + }), + z.object({ + eventType: z.literal('PROJECT_POLICY_UPDATED'), + metadata: z.object({ + projectId: z.string().uuid(), + policy: z.string(), + }), + }), + z.object({ + eventType: z.literal('PROJECT_SLUG_UPDATED'), + metadata: z.object({ + previousSlug: z.string(), + newSlug: z.string(), + }), + }), + z.object({ + eventType: z.literal('PROJECT_DELETED'), + metadata: z.object({ + projectId: z.string().uuid(), + projectSlug: z.string(), + }), + }), + z.object({ + eventType: z.literal('TARGET_CREATED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + targetSlug: z.string(), + }), + }), + z.object({ + eventType: z.literal('TARGET_SLUG_UPDATED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + previousSlug: z.string(), + newSlug: z.string(), + }), + }), + z.object({ + eventType: z.literal('TARGET_GRAPHQL_ENDPOINT_URL_UPDATED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + graphqlEndpointUrl: z.string().nullish(), + }), + }), + z.object({ + eventType: z.literal('TARGET_SCHEMA_COMPOSITION_UPDATED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + nativeComposition: z.boolean(), + }), + }), + z.object({ + eventType: z.literal('TARGET_CDN_ACCESS_TOKEN_CREATED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + alias: z.string(), + token: z.string(), + }), + }), + z.object({ + eventType: z.literal('TARGET_CDN_ACCESS_TOKEN_DELETED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + alias: z.string(), + }), + }), + z.object({ + eventType: z.literal('TARGET_TOKEN_CREATED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + alias: z.string(), + token: z.string(), + }), + }), + z.object({ + eventType: z.literal('TARGET_TOKEN_DELETED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + alias: z.string(), + }), + }), + z.object({ + eventType: z.literal('TARGET_DELETED'), + metadata: z.object({ + projectId: z.string().uuid(), + targetId: z.string().uuid(), + targetSlug: z.string(), + }), + }), + z.object({ + eventType: z.literal('ROLE_CREATED'), + metadata: z.object({ + roleId: z.string().uuid(), + roleName: z.string(), + }), + }), + z.object({ + eventType: z.literal('ROLE_ASSIGNED'), + metadata: z.object({ + roleId: z.string().uuid(), + updatedMember: z.string(), + previousMemberRole: z.string().nullable(), + userIdAssigned: z.string(), + }), + }), + z.object({ + eventType: z.literal('ROLE_DELETED'), + metadata: z.object({ + roleId: z.string().uuid(), + roleName: z.string(), + }), + }), + z.object({ + eventType: z.literal('ROLE_UPDATED'), + metadata: z.object({ + roleId: z.string().uuid(), + roleName: z.string(), + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('SUPPORT_TICKET_CREATED'), + metadata: z.object({ + ticketId: z.string().uuid(), + ticketSubject: z.string(), + ticketDescription: z.string(), + ticketPriority: z.string(), + }), + }), + z.object({ + eventType: z.literal('SUPPORT_TICKET_UPDATED'), + metadata: z.object({ + ticketId: z.string().uuid(), + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('COLLECTION_CREATED'), + metadata: z.object({ + collectionId: z.string().uuid(), + collectionName: z.string(), + targetId: z.string(), + }), + }), + z.object({ + eventType: z.literal('COLLECTION_UPDATED'), + metadata: z.object({ + collectionId: z.string().uuid(), + collectionName: z.string(), + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('COLLECTION_DELETED'), + metadata: z.object({ + collectionId: z.string().uuid(), + collectionName: z.string(), + }), + }), + z.object({ + eventType: z.literal('OPERATION_IN_DOCUMENT_COLLECTION_CREATED'), + metadata: z.object({ + collectionId: z.string().uuid(), + collectionName: z.string(), + targetId: z.string().uuid(), + operationId: z.string().uuid(), + operationQuery: z.string(), + }), + }), + z.object({ + eventType: z.literal('OPERATION_IN_DOCUMENT_COLLECTION_UPDATED'), + metadata: z.object({ + collectionId: z.string().uuid(), + collectionName: z.string(), + operationId: z.string().uuid(), + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('OPERATION_IN_DOCUMENT_COLLECTION_DELETED'), + metadata: z.object({ + collectionId: z.string().uuid(), + collectionName: z.string(), + operationId: z.string().uuid(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_CREATED'), + metadata: z.object({ + organizationSlug: z.string(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_SLUG_UPDATED'), + metadata: z.object({ + previousSlug: z.string(), + newSlug: z.string(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_DELETED'), + }), + z.object({ + eventType: z.literal('ORGANIZATION_UPDATED_INTEGRATION'), + metadata: z.object({ + integrationId: z.string().uuid().nullable(), + integrationType: z.enum(['SLACK', 'GITHUB']), + integrationStatus: z.enum(['ENABLED', 'DISABLED']), + }), + }), + z.object({ + eventType: z.literal('SUBSCRIPTION_CREATED'), + metadata: z.object({ + paymentMethodId: z.string().uuid().nullish(), + operations: z.number(), + previousPlan: z.string(), + newPlan: z.string(), + }), + }), + z.object({ + eventType: z.literal('SUBSCRIPTION_UPDATED'), + metadata: z.object({ + updatedFields: z.string(), + }), + }), + z.object({ + eventType: z.literal('SUBSCRIPTION_CANCELED'), + metadata: z.object({ + previousPlan: z.string(), + newPlan: z.string(), + }), + }), + z.object({ + eventType: z.literal('OIDC_INTEGRATION_CREATED'), + metadata: z.object({ + integrationId: z.string().uuid(), + }), + }), + z.object({ + eventType: z.literal('OIDC_INTEGRATION_DELETED'), + metadata: z.object({ + integrationId: z.string().uuid(), + }), + }), + z.object({ + eventType: z.literal('OIDC_INTEGRATION_UPDATED'), + metadata: z.object({ + integrationId: z.string().uuid(), + updatedFields: z.string(), + }), + }), +]); + +export type AuditLogSchemaEvent = z.infer; + +const auditLogEventTypes = AuditLogModel.options.map(option => option.shape.eventType.value); + +const AuditLogClickhouseObjectModel = z.object({ + id: z.string(), + timestamp: z.string(), + organizationId: z.string(), + eventAction: z.enum(auditLogEventTypes as [string, ...string[]]), + userId: z.string(), + userEmail: z.string(), + metadata: z.string().transform(x => JSON.parse(x)), +}); + +export type AuditLogType = z.infer; + +export const AuditLogClickhouseArrayModel = z.array(AuditLogClickhouseObjectModel); diff --git a/packages/services/api/src/modules/audit-logs/resolvers/Mutation/exportOrganizationAuditLog.ts b/packages/services/api/src/modules/audit-logs/resolvers/Mutation/exportOrganizationAuditLog.ts new file mode 100644 index 0000000000..144288340d --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/resolvers/Mutation/exportOrganizationAuditLog.ts @@ -0,0 +1,30 @@ +import { AuditLogManager } from '../../../audit-logs/providers/audit-logs-manager'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const exportOrganizationAuditLog: NonNullable< + MutationResolvers['exportOrganizationAuditLog'] +> = async (_parent, arg, ctx) => { + const organizationId = arg.input.selector.organizationSlug; + const auditLogManager = ctx.injector.get(AuditLogManager); + + const result = await auditLogManager.exportAndSendEmail(organizationId, { + endDate: arg.input.filter.endDate, + startDate: arg.input.filter.startDate, + }); + + if (result.error) { + return { + error: { + message: result.error.message, + }, + ok: null, + }; + } + + return { + error: null, + ok: { + url: result.ok.url, + }, + }; +}; diff --git a/packages/services/api/src/modules/auth/index.ts b/packages/services/api/src/modules/auth/index.ts index c640d657a2..6b88892c69 100644 --- a/packages/services/api/src/modules/auth/index.ts +++ b/packages/services/api/src/modules/auth/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager'; import { AuthManager } from './providers/auth-manager'; import { OrganizationAccess } from './providers/organization-access'; import { ProjectAccess } from './providers/project-access'; @@ -12,5 +13,12 @@ export const authModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [AuthManager, UserManager, OrganizationAccess, ProjectAccess, TargetAccess], + providers: [ + AuthManager, + UserManager, + OrganizationAccess, + ProjectAccess, + TargetAccess, + AuditLogManager, + ], }); diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index af58d3b3c8..3bcda2600c 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -361,6 +361,7 @@ const actionDefinitions = { 'schemaVersion:deleteService': schemaCheckOrPublishIdentity, 'schema:loadFromRegistry': defaultTargetIdentity, 'schema:compose': defaultTargetIdentity, + 'auditLog:export': defaultOrgIdentity, } satisfies ActionDefinitionMap; type ActionDefinitionMap = { diff --git a/packages/services/api/src/modules/billing/index.ts b/packages/services/api/src/modules/billing/index.ts index 8146b63856..85650ed0dc 100644 --- a/packages/services/api/src/modules/billing/index.ts +++ b/packages/services/api/src/modules/billing/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager'; import { BillingProvider } from './providers/billing.provider'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -8,5 +9,5 @@ export const billingModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [BillingProvider], + providers: [BillingProvider, AuditLogManager], }); diff --git a/packages/services/api/src/modules/billing/providers/billing.provider.ts b/packages/services/api/src/modules/billing/providers/billing.provider.ts index 6c396a6772..e0c63a1fb6 100644 --- a/packages/services/api/src/modules/billing/providers/billing.provider.ts +++ b/packages/services/api/src/modules/billing/providers/billing.provider.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import type { StripeBillingApi, StripeBillingApiInput } from '@hive/stripe-billing'; import { createTRPCProxyClient, httpLink } from '@trpc/client'; import { OrganizationBilling } from '../../../shared/entities'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; @@ -21,6 +22,7 @@ export class BillingProvider { constructor( logger: Logger, + private auditLog: AuditLogRecorder, private storage: Storage, private idTranslator: IdTranslator, private session: Session, @@ -38,13 +40,26 @@ export class BillingProvider { } } - upgradeToPro(input: StripeBillingApiInput['createSubscriptionForOrganization']) { + async upgradeToPro(input: StripeBillingApiInput['createSubscriptionForOrganization']) { this.logger.debug('Upgrading to PRO (input=%o)', input); if (!this.billingService) { throw new Error(`Billing service is not configured!`); } - return this.billingService.createSubscriptionForOrganization.mutate(input); + const result = this.billingService.createSubscriptionForOrganization.mutate(input); + + await this.auditLog.record({ + eventType: 'SUBSCRIPTION_CREATED', + organizationId: input.organizationId, + metadata: { + operations: input.reserved.operations, + paymentMethodId: input.paymentMethodId, + newPlan: 'PRO', + previousPlan: 'HOBBY', + }, + }); + + return result; } syncOrganization(input: StripeBillingApiInput['syncOrganizationToStripe']) { @@ -107,6 +122,15 @@ export class BillingProvider { throw new Error(`Billing service is not configured!`); } + await this.auditLog.record({ + eventType: 'SUBSCRIPTION_CANCELED', + organizationId: input.organizationId, + metadata: { + newPlan: 'HOBBY', + previousPlan: 'PRO', + }, + }); + return await this.billingService.cancelSubscriptionForOrganization.mutate(input); } diff --git a/packages/services/api/src/modules/cdn/index.ts b/packages/services/api/src/modules/cdn/index.ts index f5503fb552..dd3a423fd7 100644 --- a/packages/services/api/src/modules/cdn/index.ts +++ b/packages/services/api/src/modules/cdn/index.ts @@ -1,4 +1,6 @@ import { createModule } from 'graphql-modules'; +import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager'; +import { ClickHouse } from '../operations/providers/clickhouse-client'; import { CdnProvider } from './providers/cdn.provider'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -8,5 +10,5 @@ export const cdnModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [CdnProvider], + providers: [CdnProvider, AuditLogManager, ClickHouse], }); diff --git a/packages/services/api/src/modules/cdn/providers/cdn.provider.ts b/packages/services/api/src/modules/cdn/providers/cdn.provider.ts index d5b91e8552..a845435067 100644 --- a/packages/services/api/src/modules/cdn/providers/cdn.provider.ts +++ b/packages/services/api/src/modules/cdn/providers/cdn.provider.ts @@ -2,8 +2,10 @@ import bcryptjs from 'bcryptjs'; import { Inject, Injectable, Scope } from 'graphql-modules'; import { z } from 'zod'; import { encodeCdnToken, generatePrivateKey } from '@hive/cdn-script/cdn-token'; +import { maskToken } from '@hive/service-common'; import { HiveError } from '../../../shared/errors'; import { isUUID } from '../../../shared/is-uuid'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import type { Contract } from '../../schema/providers/contracts'; import { Logger } from '../../shared/providers/logger'; @@ -23,6 +25,7 @@ export class CdnProvider { constructor( logger: Logger, private session: Session, + private auditLog: AuditLogRecorder, @Inject(CDN_CONFIG) private config: CDNConfig, @Inject(S3_CONFIG) private s3Config: S3Config, @Inject(Storage) private storage: Storage, @@ -222,6 +225,18 @@ export class CdnProvider { cdnAccessTokenRecord.id, ); + const maskedToken = maskToken(cdnAccessToken); + await this.auditLog.record({ + eventType: 'TARGET_CDN_ACCESS_TOKEN_CREATED', + organizationId: args.organizationId, + metadata: { + targetId: args.targetId, + projectId: args.projectId, + alias: args.alias, + token: maskedToken, + }, + }); + return { type: 'success', cdnAccessToken: cdnAccessTokenRecord, @@ -319,6 +334,16 @@ export class CdnProvider { cdnAccessTokenId: args.cdnAccessTokenId, }); + await this.auditLog.record({ + eventType: 'TARGET_CDN_ACCESS_TOKEN_DELETED', + organizationId: args.organizationId, + metadata: { + targetId: args.targetId, + projectId: args.projectId, + alias: record.alias, + }, + }); + return { type: 'success', } as const; diff --git a/packages/services/api/src/modules/collection/index.ts b/packages/services/api/src/modules/collection/index.ts index c1aaf7b2ac..d383366c2d 100644 --- a/packages/services/api/src/modules/collection/index.ts +++ b/packages/services/api/src/modules/collection/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager'; import { CollectionProvider } from './providers/collection.provider'; import { resolvers } from './resolvers.generated'; import { typeDefs } from './module.graphql'; @@ -8,5 +9,5 @@ export const collectionModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [CollectionProvider], + providers: [CollectionProvider, AuditLogManager], }); diff --git a/packages/services/api/src/modules/collection/providers/collection.provider.ts b/packages/services/api/src/modules/collection/providers/collection.provider.ts index 415fc68dac..5c87241656 100644 --- a/packages/services/api/src/modules/collection/providers/collection.provider.ts +++ b/packages/services/api/src/modules/collection/providers/collection.provider.ts @@ -2,6 +2,7 @@ import { Injectable, Scope } from 'graphql-modules'; import * as zod from 'zod'; import { DocumentCollection, DocumentCollectionOperation, Target } from '../../../shared/entities'; import { isUUID } from '../../../shared/is-uuid'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; @@ -19,6 +20,7 @@ export class CollectionProvider { private storage: Storage, private session: Session, private idTranslator: IdTranslator, + private auditLog: AuditLogRecorder, ) { this.logger = logger.child({ source: 'CollectionProvider' }); } @@ -140,7 +142,6 @@ export class CollectionProvider { targetId, }); const currentUser = await this.session.getViewer(); - const collection = await this.storage.createDocumentCollection({ createdByUserId: currentUser.id, title: args.name, @@ -148,6 +149,16 @@ export class CollectionProvider { targetId, }); + await this.auditLog.record({ + eventType: 'COLLECTION_CREATED', + organizationId, + metadata: { + collectionId: collection.id, + collectionName: collection.title, + targetId: target.id, + }, + }); + return { collection, target, @@ -204,6 +215,19 @@ export class CollectionProvider { targetId, }); + await this.auditLog.record({ + eventType: 'COLLECTION_UPDATED', + organizationId, + metadata: { + collectionId: collection.id, + collectionName: collection.title, + updatedFields: JSON.stringify({ + name: args.name, + description: args.description || null, + }), + }, + }); + return { collection, target, @@ -254,6 +278,15 @@ export class CollectionProvider { documentCollectionId: args.collectionId, }); + await this.auditLog.record({ + eventType: 'COLLECTION_DELETED', + organizationId, + metadata: { + collectionId: args.collectionId, + collectionName: collection.title, + }, + }); + return { target, deletedCollectionId: args.collectionId, @@ -343,6 +376,18 @@ export class CollectionProvider { createdByUserId: currentUser.id, }); + await this.auditLog.record({ + eventType: 'OPERATION_IN_DOCUMENT_COLLECTION_CREATED', + organizationId, + metadata: { + collectionId: collection.id, + collectionName: collection.title, + operationId: document.id, + operationQuery: document.contents, + targetId: target.id, + }, + }); + return { type: 'success' as const, target, @@ -445,6 +490,22 @@ export class CollectionProvider { }; } + await this.auditLog.record({ + eventType: 'OPERATION_IN_DOCUMENT_COLLECTION_UPDATED', + organizationId, + metadata: { + collectionId: collection.id, + collectionName: collection.title, + operationId: document.id, + updatedFields: JSON.stringify({ + name: data.name, + query: data.query, + variables: data.variables, + headers: data.headers, + }), + }, + }); + return { type: 'success' as const, target, @@ -521,6 +582,16 @@ export class CollectionProvider { targetId, }); + await this.auditLog.record({ + eventType: 'OPERATION_IN_DOCUMENT_COLLECTION_DELETED', + organizationId, + metadata: { + collectionId: collection.id, + collectionName: collection.title, + operationId: document.id, + }, + }); + return { type: 'success' as const, target, diff --git a/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts b/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts index 1b943c98a1..9dd1940c33 100644 --- a/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts +++ b/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts @@ -5,6 +5,7 @@ import { retry } from '@octokit/plugin-retry'; import { RequestError } from '@octokit/request-error'; import type { GitHubIntegration } from '../../../__generated__/types'; import { HiveError } from '../../../shared/errors'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { OrganizationSelector, ProjectSelector, Storage } from '../../shared/providers/storage'; @@ -34,6 +35,7 @@ export class GitHubIntegrationManager { logger: Logger, private session: Session, private storage: Storage, + private auditLog: AuditLogRecorder, @Inject(GITHUB_APP_CONFIG) private config: GitHubApplicationConfig | null, ) { this.logger = logger.child({ @@ -76,10 +78,22 @@ export class GitHubIntegrationManager { }, }); this.logger.debug('Updating organization'); - await this.storage.addGitHubIntegration({ + const result = await this.storage.addGitHubIntegration({ organizationId: input.organizationId, installationId: input.installationId, }); + + await this.auditLog.record({ + eventType: 'ORGANIZATION_UPDATED_INTEGRATION', + organizationId: input.organizationId, + metadata: { + integrationId: input.installationId, + integrationType: 'GITHUB', + integrationStatus: 'ENABLED', + }, + }); + + return result; } async unregister(input: OrganizationSelector): Promise { @@ -93,9 +107,21 @@ export class GitHubIntegrationManager { }, }); this.logger.debug('Updating organization'); - await this.storage.deleteGitHubIntegration({ + const result = await this.storage.deleteGitHubIntegration({ organizationId: input.organizationId, }); + + await this.auditLog.record({ + eventType: 'ORGANIZATION_UPDATED_INTEGRATION', + organizationId: input.organizationId, + metadata: { + integrationId: input.organizationId, + integrationType: 'GITHUB', + integrationStatus: 'DISABLED', + }, + }); + + return result; } async isAvailable(selector: OrganizationSelector): Promise { diff --git a/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts b/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts index 7660fb0d96..d3de896617 100644 --- a/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts +++ b/packages/services/api/src/modules/integrations/providers/slack-integration-manager.ts @@ -1,5 +1,6 @@ import { Injectable, Scope } from 'graphql-modules'; import { AccessError } from '../../../shared/errors'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { CryptoProvider } from '../../shared/providers/crypto'; import { Logger } from '../../shared/providers/logger'; @@ -23,6 +24,7 @@ export class SlackIntegrationManager { private session: Session, private storage: Storage, private crypto: CryptoProvider, + private auditLog: AuditLogRecorder, ) { this.logger = logger.child({ source: 'SlackIntegrationManager', @@ -43,10 +45,22 @@ export class SlackIntegrationManager { }, }); this.logger.debug('Updating organization'); - await this.storage.addSlackIntegration({ + const result = await this.storage.addSlackIntegration({ organizationId: input.organizationId, token: this.crypto.encrypt(input.token), }); + + await this.auditLog.record({ + eventType: 'ORGANIZATION_UPDATED_INTEGRATION', + organizationId: input.organizationId, + metadata: { + integrationId: input.organizationId, + integrationType: 'SLACK', + integrationStatus: 'ENABLED', + }, + }); + + return result; } async unregister(input: OrganizationSelector): Promise { @@ -59,9 +73,21 @@ export class SlackIntegrationManager { }, }); this.logger.debug('Updating organization'); - await this.storage.deleteSlackIntegration({ + const result = await this.storage.deleteSlackIntegration({ organizationId: input.organizationId, }); + + await this.auditLog.record({ + eventType: 'ORGANIZATION_UPDATED_INTEGRATION', + organizationId: input.organizationId, + metadata: { + integrationId: input.organizationId, + integrationType: 'GITHUB', + integrationStatus: 'DISABLED', + }, + }); + + return result; } async isAvailable(selector: OrganizationSelector): Promise { diff --git a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts index a480e17931..e83ba1689d 100644 --- a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts +++ b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts @@ -1,7 +1,9 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import zod from 'zod'; +import { maskToken } from '@hive/service-common'; import { OIDCIntegration } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { CryptoProvider } from '../../shared/providers/crypto'; import { Logger } from '../../shared/providers/logger'; @@ -20,6 +22,7 @@ export class OIDCIntegrationsProvider { logger: Logger, private storage: Storage, private crypto: CryptoProvider, + private auditLog: AuditLogRecorder, @Inject(PUB_SUB_CONFIG) private pubSub: HivePubSub, @Inject(OIDC_INTEGRATIONS_ENABLED) private enabled: boolean, private session: Session, @@ -129,6 +132,14 @@ export class OIDCIntegrationsProvider { }); if (creationResult.type === 'ok') { + await this.auditLog.record({ + eventType: 'OIDC_INTEGRATION_CREATED', + organizationId: args.organizationId, + metadata: { + integrationId: creationResult.oidcIntegration.id, + }, + }); + return creationResult; } @@ -231,6 +242,24 @@ export class OIDCIntegrationsProvider { authorizationEndpoint: authorizationEndpointResult.data, }); + const redactedClientSecret = maskToken(oidcIntegration.clientId); + const redactedTokenEndpoint = maskToken(oidcIntegration.tokenEndpoint); + await this.auditLog.record({ + eventType: 'OIDC_INTEGRATION_UPDATED', + organizationId: integration.linkedOrganizationId, + metadata: { + updatedFields: JSON.stringify({ + updateOIDCIntegration: true, + clientId: args.clientId, + clientSecret: redactedClientSecret, + tokenEndpoint: redactedTokenEndpoint, + userinfoEndpoint: args.userinfoEndpoint, + authorizationEndpoint: args.authorizationEndpoint, + }), + integrationId: args.oidcIntegrationId, + }, + }); + return { type: 'ok', oidcIntegration, @@ -287,6 +316,14 @@ export class OIDCIntegrationsProvider { await this.storage.deleteOIDCIntegration(args); + await this.auditLog.record({ + eventType: 'OIDC_INTEGRATION_DELETED', + organizationId: integration.linkedOrganizationId, + metadata: { + integrationId: args.oidcIntegrationId, + }, + }); + return { type: 'ok', organizationId: integration.linkedOrganizationId, diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 590a9cfb12..dcba3e9a06 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -258,6 +258,10 @@ export default gql` Whether the viewer can migrate the legacy member roles """ viewerCanMigrateLegacyMemberRoles: Boolean! + """ + The organization's audit logs. This field is only available to members with the Admin role. + """ + viewerCanExportAuditLogs: Boolean! } type OrganizationConnection { diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index 21c89a8f54..c774202cf1 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -3,6 +3,7 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { Organization, OrganizationMemberRole } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; import { cache, diffArrays, share } from '../../../shared/helpers'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { AuthManager } from '../../auth/providers/auth-manager'; import { OrganizationAccessScope } from '../../auth/providers/organization-access'; @@ -61,8 +62,9 @@ export class OrganizationManager { constructor( logger: Logger, private storage: Storage, - private authManager: AuthManager, private session: Session, + private authManager: AuthManager, + private auditLog: AuditLogRecorder, private tokenStorage: TokenStorage, private activityManager: ActivityManager, private billingProvider: BillingProvider, @@ -336,13 +338,22 @@ export class OrganizationManager { }); if (result.ok) { - await this.activityManager.create({ - type: 'ORGANIZATION_CREATED', - selector: { + await Promise.all([ + this.activityManager.create({ + type: 'ORGANIZATION_CREATED', + selector: { + organizationId: result.organization.id, + }, + user, + }), + this.auditLog.record({ + eventType: 'ORGANIZATION_CREATED', organizationId: result.organization.id, - }, - user, - }); + metadata: { + organizationSlug: result.organization.id, + }, + }), + ]); } return result; @@ -372,6 +383,11 @@ export class OrganizationManager { this.authManager.resetAccessCache(); this.session.reset(); + await this.auditLog.record({ + eventType: 'ORGANIZATION_DELETED', + organizationId: organization.id, + }); + return deletedOrganization; } @@ -410,6 +426,15 @@ export class OrganizationManager { }, }); + await this.auditLog.record({ + eventType: 'ORGANIZATION_PLAN_UPDATED', + organizationId: organization.id, + metadata: { + newPlan: plan, + previousPlan: organization.billingPlan, + }, + }); + return result; } @@ -444,6 +469,19 @@ export class OrganizationManager { }); } + await this.auditLog.record({ + eventType: 'SUBSCRIPTION_UPDATED', + organizationId: organization.id, + metadata: { + updatedFields: JSON.stringify({ + monthlyRateLimit: { + retentionInDays: monthlyRateLimit.retentionInDays, + operations: monthlyRateLimit.operations, + }, + }), + }, + }); + return result; } @@ -483,6 +521,15 @@ export class OrganizationManager { reservedSlugs: reservedOrganizationSlugs, }); + await this.auditLog.record({ + eventType: 'ORGANIZATION_SLUG_UPDATED', + organizationId: organization.id, + metadata: { + previousSlug: organization.slug, + newSlug: slug, + }, + }); + if (result.ok) { await this.activityManager.create({ type: 'ORGANIZATION_ID_UPDATED', @@ -494,7 +541,6 @@ export class OrganizationManager { }, }); } - return result; } @@ -621,6 +667,15 @@ export class OrganizationManager { }), ]); + await this.auditLog.record({ + eventType: 'USER_INVITED', + organizationId: organization.id, + metadata: { + inviteeEmail: email, + roleId: role.id, + }, + }); + return { ok: invitation, }; @@ -684,6 +739,14 @@ export class OrganizationManager { }), ]); + await this.auditLog.record({ + eventType: 'USER_JOINED', + organizationId: organization.id, + metadata: { + inviteeEmail: user.email, + }, + }); + return organization; } @@ -749,6 +812,15 @@ export class OrganizationManager { `, }); + await this.auditLog.record({ + eventType: 'ORGANIZATION_TRANSFERRED_REQUEST', + organizationId: organization.id, + metadata: { + newOwnerEmail: member.user.email, + newOwnerId: member.user.id, + }, + }); + return { ok: { email: member.user.email, @@ -793,6 +865,15 @@ export class OrganizationManager { }); const currentUser = await this.session.getViewer(); + await this.auditLog.record({ + eventType: 'ORGANIZATION_TRANSFERRED', + organizationId: input.organizationId, + metadata: { + newOwnerEmail: currentUser.email, + newOwnerId: currentUser.id, + }, + }); + await this.storage.answerOrganizationTransferRequest({ organizationId: input.organizationId, code: input.code, @@ -875,6 +956,15 @@ export class OrganizationManager { this.authManager.resetAccessCache(); this.session.reset(); + await this.auditLog.record({ + eventType: 'USER_REMOVED', + organizationId: organization, + metadata: { + removedUserEmail: member.user.email, + removedUserId: member.user.id, + }, + }); + return this.storage.getOrganization({ organizationId: organization, }); @@ -1021,6 +1111,15 @@ export class OrganizationManager { scopes, }); + await this.auditLog.record({ + eventType: 'ROLE_CREATED', + organizationId: input.organizationId, + metadata: { + roleId: role.id, + roleName: role.name, + }, + }); + return { ok: { updatedOrganization: await this.storage.getOrganization({ @@ -1075,6 +1174,15 @@ export class OrganizationManager { roleId: input.roleId, }); + await this.auditLog.record({ + eventType: 'ROLE_DELETED', + organizationId: input.organizationId, + metadata: { + roleId: role.id, + roleName: role.name, + }, + }); + return { ok: { updatedOrganization: await this.storage.getOrganization({ @@ -1183,14 +1291,29 @@ export class OrganizationManager { this.authManager.resetAccessCache(); this.session.reset(); + const result = { + updatedMember: await this.getOrganizationMember({ + organizationId: input.organizationId, + userId: input.userId, + }), + previousMemberRole: member.role, + }; + + if (result) { + await this.auditLog.record({ + eventType: 'ROLE_ASSIGNED', + organizationId: input.organizationId, + metadata: { + previousMemberRole: member.role ? member.role.name : null, + roleId: newRole.id, + updatedMember: member.user.email, + userIdAssigned: input.userId, + }, + }); + } + return { - ok: { - updatedMember: await this.getOrganizationMember({ - organizationId: input.organizationId, - userId: input.userId, - }), - previousMemberRole: member.role, - }, + ok: result, }; } @@ -1317,6 +1440,19 @@ export class OrganizationManager { this.authManager.resetAccessCache(); this.session.reset(); + await this.auditLog.record({ + eventType: 'ROLE_UPDATED', + organizationId: input.organizationId, + metadata: { + roleId: updatedRole.id, + roleName: updatedRole.name, + updatedFields: JSON.stringify({ + name: roleName, + description: input.description, + scopes: newScopes, + }), + }, + }); return { ok: { updatedRole, diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index d08a70975b..e174aa9882 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -25,6 +25,7 @@ export const Organization: Pick< | 'viewerCanAccessSettings' | 'viewerCanAssignUserRoles' | 'viewerCanDelete' + | 'viewerCanExportAuditLogs' | 'viewerCanManageInvitations' | 'viewerCanManageRoles' | 'viewerCanMigrateLegacyMemberRoles' @@ -227,4 +228,13 @@ export const Organization: Pick< const viewer = await session.getViewer(); return viewer.id === owner.id; }, + viewerCanExportAuditLogs: async (organization, _arg, { session }) => { + return session.canPerformAction({ + action: 'auditLog:export', + organizationId: organization.id, + params: { + organizationId: organization.id, + }, + }); + }, }; diff --git a/packages/services/api/src/modules/policy/providers/schema-policy.provider.ts b/packages/services/api/src/modules/policy/providers/schema-policy.provider.ts index 57752c0e2d..ed61db1853 100644 --- a/packages/services/api/src/modules/policy/providers/schema-policy.provider.ts +++ b/packages/services/api/src/modules/policy/providers/schema-policy.provider.ts @@ -1,6 +1,7 @@ import { Injectable, Scope } from 'graphql-modules'; import type { CheckPolicyResponse, PolicyConfigurationObject } from '@hive/policy'; import { SchemaPolicy } from '../../../shared/entities'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { @@ -22,6 +23,7 @@ export class SchemaPolicyProvider { rootLogger: Logger, private storage: Storage, private session: Session, + private auditLog: AuditLogRecorder, private api: SchemaPolicyApiProvider, ) { this.logger = rootLogger.child({ service: 'SchemaPolicyProvider' }); @@ -133,11 +135,24 @@ export class SchemaPolicyProvider { }, }); - return await this.storage.setSchemaPolicyForOrganization({ + const result = await this.storage.setSchemaPolicyForOrganization({ organizationId: selector.organizationId, policy, allowOverrides, }); + + await this.auditLog.record({ + eventType: 'ORGANIZATION_POLICY_UPDATED', + organizationId: selector.organizationId, + metadata: { + allowOverrides: allowOverrides, + updatedFields: JSON.stringify({ + policy: policy, + }), + }, + }); + + return result; } async setProjectPolicy(selector: ProjectSelector, policy: any) { @@ -150,10 +165,21 @@ export class SchemaPolicyProvider { }, }); - return await this.storage.setSchemaPolicyForProject({ + const result = await this.storage.setSchemaPolicyForProject({ projectId: selector.projectId, policy, }); + + await this.auditLog.record({ + eventType: 'PROJECT_POLICY_UPDATED', + organizationId: selector.organizationId, + metadata: { + projectId: selector.projectId, + policy: JSON.stringify(policy), + }, + }); + + return result; } async getOrganizationPolicy(selector: OrganizationSelector) { diff --git a/packages/services/api/src/modules/project/providers/project-manager.ts b/packages/services/api/src/modules/project/providers/project-manager.ts index 0fc64a8326..f5ddd25eeb 100644 --- a/packages/services/api/src/modules/project/providers/project-manager.ts +++ b/packages/services/api/src/modules/project/providers/project-manager.ts @@ -1,6 +1,7 @@ import { Injectable, Scope } from 'graphql-modules'; import type { Organization, Project, ProjectType } from '../../../shared/entities'; import { share } from '../../../shared/helpers'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { ActivityManager } from '../../shared/providers/activity-manager'; import { Logger } from '../../shared/providers/logger'; @@ -26,6 +27,7 @@ export class ProjectManager { private session: Session, private tokenStorage: TokenStorage, private activityManager: ActivityManager, + private auditLog: AuditLogRecorder, ) { this.logger = logger.child({ source: 'ProjectManager' }); } @@ -54,7 +56,6 @@ export class ProjectManager { }; } - // create project const result = await this.storage.createProject({ slug, type, @@ -77,6 +78,15 @@ export class ProjectManager { projectType: type, }, }), + this.auditLog.record({ + eventType: 'PROJECT_CREATED', + organizationId: organization, + metadata: { + projectId: result.project.id, + projectType: type, + projectSlug: slug, + }, + }), ]); } @@ -101,7 +111,14 @@ export class ProjectManager { projectId: project, organizationId: organization, }); - + await this.auditLog.record({ + eventType: 'PROJECT_DELETED', + organizationId: organization, + metadata: { + projectId: deletedProject.id, + projectSlug: deletedProject.slug, + }, + }); await this.tokenStorage.invalidateTokens(deletedProject.tokens); await this.activityManager.create({ @@ -243,6 +260,14 @@ export class ProjectManager { value: slug, }, }); + await this.auditLog.record({ + eventType: 'PROJECT_SLUG_UPDATED', + organizationId: organization, + metadata: { + previousSlug: slug, + newSlug: result.project.slug, + }, + }); } return result; diff --git a/packages/services/api/src/modules/support/providers/support-manager.ts b/packages/services/api/src/modules/support/providers/support-manager.ts index bbe44ece34..53b3068bff 100644 --- a/packages/services/api/src/modules/support/providers/support-manager.ts +++ b/packages/services/api/src/modules/support/providers/support-manager.ts @@ -3,6 +3,7 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { z } from 'zod'; import { Organization, SupportTicketPriority, SupportTicketStatus } from '../../../shared/entities'; import { atomic } from '../../../shared/helpers'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { HttpClient } from '../../shared/providers/http-client'; import { Logger } from '../../shared/providers/logger'; @@ -136,6 +137,7 @@ export class SupportManager { private organizationManager: OrganizationManager, private storage: Storage, private session: Session, + private auditLog: AuditLogRecorder, ) { this.logger = logger.child({ service: 'SupportManager' }); } @@ -584,6 +586,17 @@ export class SupportManager { }), ); + await this.auditLog.record({ + eventType: 'SUPPORT_TICKET_CREATED', + organizationId: input.organizationId, + metadata: { + ticketDescription: input.description, + ticketPriority: input.priority, + ticketId: String(response.ticket.id), + ticketSubject: input.subject, + }, + }); + return { ok: { supportTicketId: String(response.ticket.id), @@ -675,6 +688,19 @@ export class SupportManager { }), ); + await this.auditLog.record({ + eventType: 'SUPPORT_TICKET_UPDATED', + organizationId: input.organizationId, + metadata: { + ticketId: input.ticketId, + updatedFields: JSON.stringify({ + comment: request.data.body, + authorId: internalUserId, + public: true, + }), + }, + }); + return { ok: { supportTicketId: String(response.ticket.id), diff --git a/packages/services/api/src/modules/target/providers/target-manager.ts b/packages/services/api/src/modules/target/providers/target-manager.ts index 9709099a1a..dcb4a198b4 100644 --- a/packages/services/api/src/modules/target/providers/target-manager.ts +++ b/packages/services/api/src/modules/target/providers/target-manager.ts @@ -2,6 +2,7 @@ import { Injectable, Scope } from 'graphql-modules'; import * as zod from 'zod'; import type { Project, Target, TargetSettings } from '../../../shared/entities'; import { share } from '../../../shared/helpers'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { ActivityManager } from '../../shared/providers/activity-manager'; import { IdTranslator } from '../../shared/providers/id-translator'; @@ -30,6 +31,7 @@ export class TargetManager { private session: Session, private activityManager: ActivityManager, private idTranslator: IdTranslator, + private auditLog: AuditLogRecorder, ) { this.logger = logger.child({ source: 'TargetManager' }); } @@ -88,6 +90,16 @@ export class TargetManager { targetId: result.target.id, }, }); + + await this.auditLog.record({ + eventType: 'TARGET_CREATED', + organizationId: result.target.orgId, + metadata: { + projectId: result.target.projectId, + targetId: result.target.id, + targetSlug: result.target.slug, + }, + }); } return result; @@ -133,6 +145,16 @@ export class TargetManager { }, }); + await this.auditLog.record({ + eventType: 'TARGET_DELETED', + organizationId: organization, + metadata: { + targetId: target, + targetSlug: deletedTarget.slug, + projectId: project, + }, + }); + return deletedTarget; } @@ -306,6 +328,17 @@ export class TargetManager { value: slug, }, }); + + await this.auditLog.record({ + eventType: 'TARGET_SLUG_UPDATED', + organizationId: organization, + metadata: { + projectId: project, + targetId: target, + newSlug: result.target.slug, + previousSlug: slug, + }, + }); } return result; @@ -349,6 +382,16 @@ export class TargetManager { } as const; } + await this.auditLog.record({ + eventType: 'TARGET_GRAPHQL_ENDPOINT_URL_UPDATED', + organizationId: args.organizationId, + metadata: { + projectId: args.projectId, + targetId: args.targetId, + graphqlEndpointUrl: args.graphqlEndpointUrl, + }, + }); + return { type: 'ok', target, @@ -408,6 +451,16 @@ export class TargetManager { nativeComposition: args.nativeComposition, }); + await this.auditLog.record({ + eventType: 'TARGET_SCHEMA_COMPOSITION_UPDATED', + organizationId: args.organizationId, + metadata: { + projectId: args.projectId, + targetId: args.targetId, + nativeComposition: args.nativeComposition, + }, + }); + return target; } } diff --git a/packages/services/api/src/modules/token/providers/token-manager.ts b/packages/services/api/src/modules/token/providers/token-manager.ts index e1bdd4829c..d98689b863 100644 --- a/packages/services/api/src/modules/token/providers/token-manager.ts +++ b/packages/services/api/src/modules/token/providers/token-manager.ts @@ -1,7 +1,9 @@ import { Injectable, Scope } from 'graphql-modules'; +import { maskToken } from '@hive/service-common'; import type { Token } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; import { diffArrays, pushIfMissing } from '../../../shared/helpers'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { OrganizationAccessScope } from '../../auth/providers/organization-access'; import { ProjectAccessScope } from '../../auth/providers/project-access'; @@ -33,6 +35,7 @@ export class TokenManager { private session: Session, private tokenStorage: TokenStorage, private storage: Storage, + private auditLog: AuditLogRecorder, logger: Logger, ) { this.logger = logger.child({ @@ -82,13 +85,27 @@ export class TokenManager { pushIfMissing(scopes, ProjectAccessScope.READ); pushIfMissing(scopes, OrganizationAccessScope.READ); - return this.tokenStorage.createToken({ + const result = await this.tokenStorage.createToken({ organizationId: input.organizationId, projectId: input.projectId, targetId: input.targetId, name: input.name, scopes, }); + + const maskedToken = maskToken(result.token); + await this.auditLog.record({ + eventType: 'TARGET_TOKEN_CREATED', + organizationId: input.organizationId, + metadata: { + targetId: input.targetId, + projectId: input.projectId, + alias: input.name, + token: maskedToken, + }, + }); + + return result; } async deleteTokens(input: { @@ -107,7 +124,19 @@ export class TokenManager { }, }); - return this.tokenStorage.deleteTokens(input); + const result = this.tokenStorage.deleteTokens(input); + + await this.auditLog.record({ + eventType: 'TARGET_TOKEN_DELETED', + organizationId: input.organizationId, + metadata: { + targetId: input.targetId, + projectId: input.projectId, + alias: input.tokenIds.join(', '), + }, + }); + + return result; } async getTokens(selector: TargetSelector): Promise { diff --git a/packages/services/server/README.md b/packages/services/server/README.md index 74b0e978fb..56b1a70b74 100644 --- a/packages/services/server/README.md +++ b/packages/services/server/README.md @@ -4,79 +4,91 @@ The GraphQL API for GraphQL Hive. ## Configuration -| Name | Required | Description | Example Value | -| ------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| `PORT` | **Yes** | The port this service is running on. | `4013` | -| `ENCRYPTION_SECRET` | **Yes** | Secret for encrypting stuff. | `8ebe95cg21c1fee355e9fa32c8c33141` | -| `WEB_APP_URL` | **Yes** | The url of the web app. | `http://127.0.0.1:3000` | -| `GRAPHQL_PUBLIC_ORIGIN` | **Yes** | The origin of the GraphQL server. | `http://127.0.0.1:4013` | -| `RATE_LIMIT_ENDPOINT` | **Yes** | The endpoint of the rate limiting service. | `http://127.0.0.1:4012` | -| `EMAILS_ENDPOINT` | **Yes** | The endpoint of the GraphQL Hive Email service. | `http://127.0.0.1:6260` | -| `TOKENS_ENDPOINT` | **Yes** | The endpoint of the tokens service. | `http://127.0.0.1:6001` | -| `WEBHOOKS_ENDPOINT` | **Yes** | The endpoint of the webhooks service. | `http://127.0.0.1:6250` | -| `SCHEMA_ENDPOINT` | **Yes** | The endpoint of the schema service. | `http://127.0.0.1:6500` | -| `SCHEMA_POLICY_ENDPOINT` | **No** | The endpoint of the schema policy service. | `http://127.0.0.1:6600` | -| `POSTGRES_SSL` | No | Whether the postgres connection should be established via SSL. | `1` (enabled) or `0` (disabled) | -| `POSTGRES_HOST` | **Yes** | Host of the postgres database | `127.0.0.1` | -| `POSTGRES_PORT` | **Yes** | Port of the postgres database | `5432` | -| `POSTGRES_DB` | **Yes** | Name of the postgres database. | `registry` | -| `POSTGRES_USER` | **Yes** | User name for accessing the postgres database. | `postgres` | -| `POSTGRES_PASSWORD` | **Yes** | Password for accessing the postgres database. | `postgres` | -| `CLICKHOUSE_PROTOCOL` | **Yes** | The clickhouse protocol for connecting to the clickhouse instance. | `http` | -| `CLICKHOUSE_HOST` | **Yes** | The host of the clickhouse instance. | `127.0.0.1` | -| `CLICKHOUSE_PORT` | **Yes** | The port of the clickhouse instance | `8123` | -| `CLICKHOUSE_USERNAME` | **Yes** | The username for accessing the clickhouse instance. | `test` | -| `CLICKHOUSE_PASSWORD` | **Yes** | The password for accessing the clickhouse instance. | `test` | -| `CLICKHOUSE_REQUEST_TIMEOUT` | No | Force a request timeout value for ClickHouse operations (in ms) | `30000` | -| `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` | -| `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` | -| `REDIS_PASSWORD` | **Yes** | The password of your redis instance. | `"apollorocks"` | -| `REDIS_TLS_ENABLED` | **No** | Enable TLS for redis connection (rediss://). | `"0"` | -| `S3_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | -| `S3_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | -| `S3_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | -| `S3_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` | -| `S3_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | -| `S3_MIRROR` | No | Whether S3 mirror is enabled | `1` (enabled) or `0` (disabled) | -| `S3_MIRROR_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | -| `S3_MIRROR_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | -| `S3_MIRROR_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | -| `S3_MIRROR_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` | -| `S3_MIRROR_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | -| `S3_MIRROR_PUBLIC_URL` | No | The public URL of the S3, in case it differs from the `S3_ENDPOINT`. | `http://localhost:8083` | -| `CDN_API` | No | Whether the CDN exposed via API is enabled. | `1` (enabled) or `0` (disabled) | -| `CDN_API_BASE_URL` | No (Yes if `CDN_API` is set to `1`) | The public base url of the API service. | `http://localhost:8082` | -| `SUPERTOKENS_CONNECTION_URI` | **Yes** | The URI of the SuperTokens instance. | `http://127.0.0.1:3567` | -| `SUPERTOKENS_API_KEY` | **Yes** | The API KEY of the SuperTokens instance. | `iliketurtlesandicannotlie` | -| `AUTH_GITHUB` | No | Whether login via GitHub should be allowed | `1` (enabled) or `0` (disabled) | -| `AUTH_GITHUB_CLIENT_ID` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client ID. | `g6aff8102efda5e1d12e` | -| `AUTH_GITHUB_CLIENT_SECRET` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | -| `AUTH_GOOGLE` | No | Whether login via Google should be allowed | `1` (enabled) or `0` (disabled) | -| `AUTH_GOOGLE_CLIENT_ID` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client ID. | `g6aff8102efda5e1d12e` | -| `AUTH_GOOGLE_CLIENT_SECRET` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | -| `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) | -| `AUTH_OKTA` | No | Whether login via Okta should be allowed | `1` (enabled) or `0` (disabled) | -| `AUTH_OKTA_CLIENT_ENDPOINT` | No (**Yes** if `AUTH_OKTA` is set) | The Okta endpoint. | `https://dev-1234567.okta.com` | -| `AUTH_OKTA_HIDDEN` | No | Whether the Okta login button should be hidden. (Default: `0`) | `1` (enabled) or `0` (disabled) | -| `AUTH_OKTA_CLIENT_ID` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client ID. | `g6aff8102efda5e1d12e` | -| `AUTH_OKTA_CLIENT_SECRET` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | -| `AUTH_REQUIRE_EMAIL_VERIFICATION` | No | Whether verifying the email address is mandatory. | `1` (enabled) or `0` (disabled) | -| `INTEGRATION_GITHUB` | No | Whether the GitHub integration is enabled | `1` (enabled) or `0` (disabled) | -| `INTEGRATION_GITHUB_GITHUB_APP_ID` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app id. | `123` | -| `INTEGRATION_GITHUB_GITHUB_APP_PRIVATE_KEY` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app private key. | `letmein1` | -| `FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED` | No | Whether app deployments should be enabled for every organization. | `1` (enabled) or `0` (disabled) | -| `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` | -| `SENTRY` | No | Whether Sentry error reporting should be enabled. | `1` (enabled) or `0` (disabled) | -| `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` | -| `PROMETHEUS_METRICS` | No | Whether Prometheus metrics should be enabled | `1` (enabled) or `0` (disabled) | -| `PROMETHEUS_METRICS_LABEL_INSTANCE` | No | The instance label added for the prometheus metrics. | `server` | -| `PROMETHEUS_METRICS_PORT` | No | Port on which prometheus metrics are exposed | Defaults to `10254` | -| `REQUEST_LOGGING` | No | Log http requests | `1` (enabled) or `0` (disabled) | -| `HIVE_PERSISTED_DOCUMENTS` | No | Whether persisted documents should be enabled or disabled | `1` (enabled) or `0` (disabled) | -| `HIVE_PERSISTED_DOCUMENTS_CDN_ENDPOINT` | No (Yes if `HIVE_PERSISTED_DOCUMENTS` is set to `1`) | The endpoint for the Hive persisted documents CDN. | `https://cdn.graphql-hive.com/artifacts/v1/` | -| `HIVE_PERSISTED_DOCUMENTS_CDN_ACCESS_KEY` | No (Yes if `HIVE_PERSISTED_DOCUMENTS` is set to `1`) | The access token key for the Hive CDN. | `hv2abcdefg` | -| `LOG_LEVEL` | No | The verbosity of the service logs. One of `trace`, `debug`, `info`, `warn` ,`error`, `fatal` or `silent` | `info` (default) | -| `OPENTELEMETRY_COLLECTOR_ENDPOINT` | No | OpenTelemetry Collector endpoint. The expected traces transport is HTTP (port `4318`). | `http://localhost:4318/v1/traces` | +| Name | Required | Description | Example Value | +| ---------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------- | --- | +| `PORT` | **Yes** | The port this service is running on. | `4013` | +| `ENCRYPTION_SECRET` | **Yes** | Secret for encrypting stuff. | `8ebe95cg21c1fee355e9fa32c8c33141` | +| `WEB_APP_URL` | **Yes** | The url of the web app. | `http://127.0.0.1:3000` | +| `GRAPHQL_PUBLIC_ORIGIN` | **Yes** | The origin of the GraphQL server. | `http://127.0.0.1:4013` | +| `RATE_LIMIT_ENDPOINT` | **Yes** | The endpoint of the rate limiting service. | `http://127.0.0.1:4012` | +| `EMAILS_ENDPOINT` | **Yes** | The endpoint of the GraphQL Hive Email service. | `http://127.0.0.1:6260` | +| `TOKENS_ENDPOINT` | **Yes** | The endpoint of the tokens service. | `http://127.0.0.1:6001` | +| `WEBHOOKS_ENDPOINT` | **Yes** | The endpoint of the webhooks service. | `http://127.0.0.1:6250` | +| `SCHEMA_ENDPOINT` | **Yes** | The endpoint of the schema service. | `http://127.0.0.1:6500` | +| `SCHEMA_POLICY_ENDPOINT` | **No** | The endpoint of the schema policy service. | `http://127.0.0.1:6600` | +| `POSTGRES_SSL` | No | Whether the postgres connection should be established via SSL. | `1` (enabled) or `0` (disabled) | +| `POSTGRES_HOST` | **Yes** | Host of the postgres database | `127.0.0.1` | +| `POSTGRES_PORT` | **Yes** | Port of the postgres database | `5432` | +| `POSTGRES_DB` | **Yes** | Name of the postgres database. | `registry` | +| `POSTGRES_USER` | **Yes** | User name for accessing the postgres database. | `postgres` | +| `POSTGRES_PASSWORD` | **Yes** | Password for accessing the postgres database. | `postgres` | +| `CLICKHOUSE_PROTOCOL` | **Yes** | The clickhouse protocol for connecting to the clickhouse instance. | `http` | +| `CLICKHOUSE_HOST` | **Yes** | The host of the clickhouse instance. | `127.0.0.1` | +| `CLICKHOUSE_PORT` | **Yes** | The port of the clickhouse instance | `8123` | +| `CLICKHOUSE_USERNAME` | **Yes** | The username for accessing the clickhouse instance. | `test` | +| `CLICKHOUSE_PASSWORD` | **Yes** | The password for accessing the clickhouse instance. | `test` | +| `CLICKHOUSE_REQUEST_TIMEOUT` | No | Force a request timeout value for ClickHouse operations (in ms) | `30000` | +| `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` | +| `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` | +| `REDIS_PASSWORD` | **Yes** | The password of your redis instance. | `"apollorocks"` | +| `REDIS_TLS_ENABLED` | **No** | Enable TLS for redis connection (rediss://). | `"0"` | +| `S3_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | +| `S3_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | +| `S3_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | +| `S3_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` | +| `S3_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | +| `S3_MIRROR` | No | Whether S3 mirror is enabled | `1` (enabled) or `0` (disabled) | +| `S3_MIRROR_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | +| `S3_MIRROR_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | +| `S3_MIRROR_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | +| `S3_MIRROR_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` | +| `S3_MIRROR_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | +| `S3_MIRROR_PUBLIC_URL` | No | The public URL of the S3, in case it differs from the `S3_ENDPOINT`. | `http://localhost:8083` | +| `CDN_API` | No | Whether the CDN exposed via API is enabled. | `1` (enabled) or `0` (disabled) | +| `CDN_API_BASE_URL` | No (Yes if `CDN_API` is set to `1`) | The public base url of the API service. | `http://localhost:8082` | +| `SUPERTOKENS_CONNECTION_URI` | **Yes** | The URI of the SuperTokens instance. | `http://127.0.0.1:3567` | +| `SUPERTOKENS_API_KEY` | **Yes** | The API KEY of the SuperTokens instance. | `iliketurtlesandicannotlie` | +| `AUTH_GITHUB` | No | Whether login via GitHub should be allowed | `1` (enabled) or `0` (disabled) | +| `AUTH_GITHUB_CLIENT_ID` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client ID. | `g6aff8102efda5e1d12e` | +| `AUTH_GITHUB_CLIENT_SECRET` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | +| `AUTH_GOOGLE` | No | Whether login via Google should be allowed | `1` (enabled) or `0` (disabled) | +| `AUTH_GOOGLE_CLIENT_ID` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client ID. | `g6aff8102efda5e1d12e` | +| `AUTH_GOOGLE_CLIENT_SECRET` | No (**Yes** if `AUTH_GOOGLE` is set) | The Google client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | +| `AUTH_ORGANIZATION_OIDC` | No | Whether linking a Hive organization to an Open ID Connect provider is allowed. (Default: `0`) | `1` (enabled) or `0` (disabled) | +| `AUTH_OKTA` | No | Whether login via Okta should be allowed | `1` (enabled) or `0` (disabled) | +| `AUTH_OKTA_CLIENT_ENDPOINT` | No (**Yes** if `AUTH_OKTA` is set) | The Okta endpoint. | `https://dev-1234567.okta.com` | +| `AUTH_OKTA_HIDDEN` | No | Whether the Okta login button should be hidden. (Default: `0`) | `1` (enabled) or `0` (disabled) | +| `AUTH_OKTA_CLIENT_ID` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client ID. | `g6aff8102efda5e1d12e` | +| `AUTH_OKTA_CLIENT_SECRET` | No (**Yes** if `AUTH_OKTA` is set) | The Okta client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` | +| `AUTH_REQUIRE_EMAIL_VERIFICATION` | No | Whether verifying the email address is mandatory. | `1` (enabled) or `0` (disabled) | +| `INTEGRATION_GITHUB` | No | Whether the GitHub integration is enabled | `1` (enabled) or `0` (disabled) | +| `INTEGRATION_GITHUB_GITHUB_APP_ID` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app id. | `123` | +| `INTEGRATION_GITHUB_GITHUB_APP_PRIVATE_KEY` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app private key. | `letmein1` | +| `FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED` | No | Whether app deployments should be enabled for every organization. | `1` (enabled) or `0` (disabled) | +| `S3_AUDIT_LOG` | No (audit log uses default S3 if not configured) | Whether audit logs should be stored on another S3 bucket than the artifacts. | `1` (enabled) or `0` (disabled) | +| `S3_AUDIT_LOG_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | +| `S3_AUDIT_LOG_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | +| `S3_AUDIT_LOG_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | +| `S3_AUDIT_LOG_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` | +| `S3_AUDIT_LOG_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | +| `S3_AUDIT_LOG_PUBLIC_URL` | No | The public URL of the S3, in case it differs from the `S3_ENDPOINT`. | `http://localhost:8083` | +| `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry | +| reporting.) | `staging` | | `SENTRY` | No | Whether Sentry error reporting should be enabled. | `1` | +| (enabled) or `0` (disabled) | | `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | +| `https://dooobars@o557896.ingest.sentry.io/12121212` | | `PROMETHEUS_METRICS` | No | Whether | +| Prometheus metrics should be enabled | `1` (enabled) or `0` (disabled) | | +| `PROMETHEUS_METRICS_LABEL_INSTANCE` | No | The instance label added for the prometheus metrics. | +| `server` | | `PROMETHEUS_METRICS_PORT` | No | Port on which prometheus metrics are exposed | +| Defaults to `10254` | | `REQUEST_LOGGING` | No | Log http requests | `1` (enabled) or `0` (disabled) | +| | `HIVE_PERSISTED_DOCUMENTS` | No | Whether persisted documents should be enabled or disabled | +| `1` (enabled) or `0` (disabled) | | `HIVE_PERSISTED_DOCUMENTS_CDN_ENDPOINT` | No (Yes if | +| `HIVE_PERSISTED_DOCUMENTS` is set to `1`) | The endpoint for the Hive persisted documents CDN. | +| `https://cdn.graphql-hive.com/artifacts/v1/` | | `HIVE_PERSISTED_DOCUMENTS_CDN_ACCESS_KEY` | No | +| (Yes if `HIVE_PERSISTED_DOCUMENTS` is set to `1`) | The access token key for the Hive CDN. | +| `hv2abcdefg` | | `LOG_LEVEL` | No | The verbosity of the service logs. One of `trace`, `debug`, | +| `info`, `warn` ,`error`, `fatal` or `silent` | `info` (default) | | +| `OPENTELEMETRY_COLLECTOR_ENDPOINT` | No | OpenTelemetry Collector endpoint. The expected traces | +| transport is HTTP (port `4318`). | `http://localhost:4318/v1/traces` | ## Hive Cloud Configuration diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 986ce944e3..9756947f22 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -173,6 +173,21 @@ const S3MirrorModel = zod.union([ }), ]); +const S3AuditLogModel = zod.union([ + zod.object({ + S3_AUDIT_LOG: zod.union([zod.void(), zod.literal('0'), zod.literal('')]), + }), + zod.object({ + S3_AUDIT_LOG: zod.literal('1'), + S3_AUDIT_LOG_ENDPOINT: zod.string().url(), + S3_AUDIT_LOG_ACCESS_KEY_ID: zod.string(), + S3_AUDIT_LOG_SECRET_ACCESS_KEY: zod.string(), + S3_AUDIT_LOG_SESSION_TOKEN: emptyString(zod.string().optional()), + S3_AUDIT_LOG_BUCKET_NAME: zod.string(), + S3_AUDIT_LOG_PUBLIC_URL: emptyString(zod.string().url().optional()), + }), +]); + const AuthGitHubConfigSchema = zod.union([ zod.object({ AUTH_GITHUB: zod.union([zod.void(), zod.literal('0'), zod.literal('')]), @@ -263,6 +278,7 @@ const configs = { hive: HiveModel.safeParse(processEnv), s3: S3Model.safeParse(processEnv), s3Mirror: S3MirrorModel.safeParse(processEnv), + s3AuditLog: S3AuditLogModel.safeParse(processEnv), log: LogModel.safeParse(processEnv), zendeskSupport: ZendeskSupportModel.safeParse(processEnv), tracing: OpenTelemetryConfigurationModel.safeParse(processEnv), @@ -308,6 +324,7 @@ const cdnApi = extractConfig(configs.cdnApi); const hive = extractConfig(configs.hive); const s3 = extractConfig(configs.s3); const s3Mirror = extractConfig(configs.s3Mirror); +const s3AuditLog = extractConfig(configs.s3AuditLog); const zendeskSupport = extractConfig(configs.zendeskSupport); const tracing = extractConfig(configs.tracing); const hivePersistedDocuments = extractConfig(configs.hivePersistedDocuments); @@ -471,6 +488,19 @@ export const env = { }, } : null, + s3AuditLogs: + s3AuditLog.S3_AUDIT_LOG === '1' + ? { + bucketName: s3AuditLog.S3_AUDIT_LOG_BUCKET_NAME, + endpoint: s3AuditLog.S3_AUDIT_LOG_ENDPOINT, + publicUrl: s3AuditLog.S3_AUDIT_LOG_PUBLIC_URL ?? null, + credentials: { + accessKeyId: s3AuditLog.S3_AUDIT_LOG_ACCESS_KEY_ID, + secretAccessKey: s3AuditLog.S3_AUDIT_LOG_SECRET_ACCESS_KEY, + sessionToken: s3AuditLog.S3_AUDIT_LOG_SESSION_TOKEN, + }, + } + : null, organizationOIDC: base.AUTH_ORGANIZATION_OIDC === '1', sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, log: { diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 8fa615b281..b4e6627314 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -364,6 +364,15 @@ export async function main() { endpoint: env.s3Mirror.endpoint, } : null, + s3AuditLogs: env.s3AuditLogs + ? { + accessKeyId: env.s3AuditLogs.credentials.accessKeyId, + secretAccessKeyId: env.s3AuditLogs.credentials.secretAccessKey, + sessionToken: env.s3AuditLogs.credentials.sessionToken, + bucketName: env.s3AuditLogs.bucketName, + endpoint: env.s3AuditLogs.endpoint, + } + : null, encryptionSecret: env.encryptionSecret, schemaConfig: env.hiveServices.webApp ? { diff --git a/packages/web/app/src/components/ui/toast.tsx b/packages/web/app/src/components/ui/toast.tsx index d507e47bc0..6f2f54e096 100644 --- a/packages/web/app/src/components/ui/toast.tsx +++ b/packages/web/app/src/components/ui/toast.tsx @@ -30,6 +30,7 @@ const toastVariants = cva( default: 'border bg-background text-foreground', destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground', + warning: 'group border-orange-900 bg-orange-600 text-black', }, }, defaultVariants: { diff --git a/packages/web/app/src/pages/organization-settings.tsx b/packages/web/app/src/pages/organization-settings.tsx index 321a07fa54..bc9bb3a150 100644 --- a/packages/web/app/src/pages/organization-settings.tsx +++ b/packages/web/app/src/pages/organization-settings.tsx @@ -22,7 +22,14 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { DocsLink } from '@/components/ui/docs-note'; -import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; import { GitHubIcon, SlackIcon } from '@/components/ui/icon'; import { Input } from '@/components/ui/input'; import { Meta } from '@/components/ui/meta'; @@ -177,6 +184,7 @@ const SettingsPageRenderer_OrganizationFragment = graphql(` viewerCanManageOIDCIntegration viewerCanModifySlackIntegration viewerCanModifyGitHubIntegration + viewerCanExportAuditLogs ...OIDCIntegrationSection_OrganizationFragment ...TransferOrganizationOwnershipModal_OrganizationFragment ...GitHubIntegrationSection_OrganizationFragment @@ -204,6 +212,7 @@ const SettingsPageRenderer = (props: { const router = useRouter(); const [isDeleteModalOpen, toggleDeleteModalOpen] = useToggle(); const [isTransferModalOpen, toggleTransferModalOpen] = useToggle(); + const [isAuditLogsModalOpen, toggleAuditLogsModalOpen] = useToggle(); const { toast } = useToast(); const [_slugMutation, slugMutate] = useMutation(UpdateOrganizationSlugMutation); @@ -443,6 +452,31 @@ const SettingsPageRenderer = (props: { )} + + {organization.viewerCanExportAuditLogs && ( + + + Audit Logs + + View a history of changes made to the organization settings. + + + +
+
+ + +
+
+
+
+ )} ); @@ -609,3 +643,136 @@ export function DeleteOrganizationModalContent(props: { ); } + +const AuditLogsOrganizationSettingsPageMutation = graphql(` + mutation AuditLogsOrganizationSettingsPageMutation($input: ExportOrganizationAuditLogInput!) { + exportOrganizationAuditLog(input: $input) { + ok { + url + } + error { + message + } + } + } +`); + +const AuditLogsSchema = z.object({ + startDate: z.string(), + endDate: z.string(), + userId: z.string().optional(), +}); + +function AuditLogsOrganizationModal(props: { + isOpen: boolean; + toggleModalOpen: () => void; + organization: string; +}) { + const { organization } = props; + const { toast } = useToast(); + const [, exportAuditLogs] = useMutation(AuditLogsOrganizationSettingsPageMutation); + + const today = new Date().toISOString().split('T')[0]; + const lastYear = new Date(new Date().setFullYear(new Date().getFullYear() - 1)) + .toISOString() + .split('T')[0]; + + const form = useForm>({ + mode: 'onSubmit', + resolver: zodResolver(AuditLogsSchema), + defaultValues: { + startDate: lastYear, + endDate: today, + }, + }); + + async function onSubmit(data: z.infer) { + const formattedStartDate = new Date(data.startDate).toISOString(); + const formattedEndDate = new Date(data.endDate).toISOString(); + + const result = await exportAuditLogs({ + input: { + selector: { + organizationSlug: organization, + }, + filter: { + startDate: formattedStartDate, + endDate: formattedEndDate, + }, + }, + }); + + if (result.data?.exportOrganizationAuditLog.error) { + toast({ + title: 'Failed to start audit logs report', + description: result.data.exportOrganizationAuditLog.error.message, + variant: 'destructive', + }); + return; + } + + props.toggleModalOpen(); + form.reset(); + toast({ + variant: 'warning', + title: 'Audit logs report started', + description: 'Your audit logs report is being generated. You will receive an email shortly.', + }); + } + + return ( + + +
+ + + Audit Logs + + Select a date range to generate an audit logs report. + + +
+ ( + + Start Date + + + + + + )} + /> +
+
+ ( + + End Date + + + + + + )} + /> +
+ + + +
+ +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52f028633b..c416f7ba8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -740,6 +740,9 @@ importers: bcryptjs: specifier: 2.4.3 version: 2.4.3 + csv-stringify: + specifier: 6.5.2 + version: 6.5.2 dataloader: specifier: 2.2.3 version: 2.2.3 @@ -9288,6 +9291,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csv-stringify@6.5.2: + resolution: {integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==} + cypress@13.17.0: resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} @@ -16567,30 +16573,30 @@ snapshots: '@aws-sdk/util-endpoints': 3.587.0 '@aws-sdk/util-user-agent-browser': 3.577.0 '@aws-sdk/util-user-agent-node': 3.587.0 - '@smithy/config-resolver': 3.0.12 - '@smithy/core': 2.5.3 + '@smithy/config-resolver': 3.0.13 + '@smithy/core': 2.5.6 '@smithy/fetch-http-handler': 3.2.9 - '@smithy/hash-node': 3.0.10 - '@smithy/invalid-dependency': 3.0.10 - '@smithy/middleware-content-length': 3.0.12 - '@smithy/middleware-endpoint': 3.2.3 - '@smithy/middleware-retry': 3.0.27 - '@smithy/middleware-serde': 3.0.10 - '@smithy/middleware-stack': 3.0.10 - '@smithy/node-config-provider': 3.1.11 - '@smithy/node-http-handler': 3.3.1 - '@smithy/protocol-http': 4.1.7 - '@smithy/smithy-client': 3.4.4 - '@smithy/types': 3.7.1 - '@smithy/url-parser': 3.0.10 + '@smithy/hash-node': 3.0.11 + '@smithy/invalid-dependency': 3.0.11 + '@smithy/middleware-content-length': 3.0.13 + '@smithy/middleware-endpoint': 3.2.7 + '@smithy/middleware-retry': 3.0.32 + '@smithy/middleware-serde': 3.0.11 + '@smithy/middleware-stack': 3.0.11 + '@smithy/node-config-provider': 3.1.12 + '@smithy/node-http-handler': 3.3.3 + '@smithy/protocol-http': 4.1.8 + '@smithy/smithy-client': 3.5.2 + '@smithy/types': 3.7.2 + '@smithy/url-parser': 3.0.11 '@smithy/util-base64': 3.0.0 '@smithy/util-body-length-browser': 3.0.0 '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.27 - '@smithy/util-defaults-mode-node': 3.0.27 - '@smithy/util-endpoints': 2.1.6 - '@smithy/util-middleware': 3.0.10 - '@smithy/util-retry': 3.0.10 + '@smithy/util-defaults-mode-browser': 3.0.32 + '@smithy/util-defaults-mode-node': 3.0.32 + '@smithy/util-endpoints': 2.1.7 + '@smithy/util-middleware': 3.0.11 + '@smithy/util-retry': 3.0.11 '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: @@ -16744,30 +16750,30 @@ snapshots: '@aws-sdk/util-endpoints': 3.587.0 '@aws-sdk/util-user-agent-browser': 3.577.0 '@aws-sdk/util-user-agent-node': 3.587.0 - '@smithy/config-resolver': 3.0.12 - '@smithy/core': 2.5.3 + '@smithy/config-resolver': 3.0.13 + '@smithy/core': 2.5.6 '@smithy/fetch-http-handler': 3.2.9 - '@smithy/hash-node': 3.0.10 - '@smithy/invalid-dependency': 3.0.10 - '@smithy/middleware-content-length': 3.0.12 - '@smithy/middleware-endpoint': 3.2.3 - '@smithy/middleware-retry': 3.0.27 - '@smithy/middleware-serde': 3.0.10 - '@smithy/middleware-stack': 3.0.10 - '@smithy/node-config-provider': 3.1.11 - '@smithy/node-http-handler': 3.3.1 - '@smithy/protocol-http': 4.1.7 - '@smithy/smithy-client': 3.4.4 - '@smithy/types': 3.7.1 - '@smithy/url-parser': 3.0.10 + '@smithy/hash-node': 3.0.11 + '@smithy/invalid-dependency': 3.0.11 + '@smithy/middleware-content-length': 3.0.13 + '@smithy/middleware-endpoint': 3.2.7 + '@smithy/middleware-retry': 3.0.32 + '@smithy/middleware-serde': 3.0.11 + '@smithy/middleware-stack': 3.0.11 + '@smithy/node-config-provider': 3.1.12 + '@smithy/node-http-handler': 3.3.3 + '@smithy/protocol-http': 4.1.8 + '@smithy/smithy-client': 3.5.2 + '@smithy/types': 3.7.2 + '@smithy/url-parser': 3.0.11 '@smithy/util-base64': 3.0.0 '@smithy/util-body-length-browser': 3.0.0 '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.27 - '@smithy/util-defaults-mode-node': 3.0.27 - '@smithy/util-endpoints': 2.1.6 - '@smithy/util-middleware': 3.0.10 - '@smithy/util-retry': 3.0.10 + '@smithy/util-defaults-mode-browser': 3.0.32 + '@smithy/util-defaults-mode-node': 3.0.32 + '@smithy/util-endpoints': 2.1.7 + '@smithy/util-middleware': 3.0.11 + '@smithy/util-retry': 3.0.11 '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: @@ -25690,6 +25696,8 @@ snapshots: csstype@3.1.3: {} + csv-stringify@6.5.2: {} + cypress@13.17.0: dependencies: '@cypress/request': 3.0.6