Skip to content

Commit

Permalink
feat: audit log for organizations (#5530)
Browse files Browse the repository at this point in the history
Co-authored-by: Laurin Quast <[email protected]>
Co-authored-by: Dotan Simha <[email protected]>
  • Loading branch information
3 people authored Dec 27, 2024
1 parent 1932427 commit 38c14e2
Show file tree
Hide file tree
Showing 42 changed files with 1,872 additions and 148 deletions.
10 changes: 10 additions & 0 deletions .changeset/fair-donuts-cough.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion deployment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -84,6 +84,7 @@ const redis = deployRedis({ environment });
const kafka = deployKafka();
const s3 = deployS3();
const s3Mirror = deployS3Mirror();
const s3AuditLog = deployS3AuditLog();

const cdn = deployCFCDN({
s3,
Expand Down Expand Up @@ -246,6 +247,7 @@ const graphql = deployGraphQL({
supertokens,
s3,
s3Mirror,
s3AuditLog,
zendesk,
githubApp,
sentry,
Expand Down
7 changes: 7 additions & 0 deletions deployment/services/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function deployGraphQL({
supertokens,
s3,
s3Mirror,
s3AuditLog,
zendesk,
docker,
postgres,
Expand All @@ -72,6 +73,7 @@ export function deployGraphQL({
cdn: CDN;
s3: S3;
s3Mirror: S3;
s3AuditLog: S3;
usage: Usage;
usageEstimator: UsageEstimator;
dbMigrations: DbMigrations;
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 13 additions & 0 deletions deployment/services/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof deployS3>;
7 changes: 6 additions & 1 deletion docker/docker-compose.community.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
105 changes: 105 additions & 0 deletions integration-tests/tests/api/audit-logs/audit-log-record.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
22 changes: 22 additions & 0 deletions packages/migrations/src/clickhouse-actions/011-audit-logs.ts
Original file line number Diff line number Diff line change
@@ -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;
`);
};
1 change: 1 addition & 0 deletions packages/migrations/src/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions packages/services/api/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -89,6 +92,7 @@ const modules = [
schemaPolicyModule,
collectionModule,
appDeploymentsModule,
auditLogsModule,
];

export function createRegistry({
Expand All @@ -107,6 +111,7 @@ export function createRegistry({
cdn,
s3,
s3Mirror,
s3AuditLogs,
encryptionSecret,
billing,
schemaConfig,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -182,14 +194,32 @@ 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,
Mutex,
DistributedCache,
CryptoProvider,
Emails,
{
provide: AuditLogS3Config,
useValue: auditLogS3Config,
},
{
provide: ArtifactStorageWriter,
useValue: artifactStorageWriter,
Expand Down
14 changes: 14 additions & 0 deletions packages/services/api/src/modules/audit-logs/index.ts
Original file line number Diff line number Diff line change
@@ -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],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { AuditLogType } from './providers/audit-logs-types';

export type AuditLogMapper = AuditLogType;
export type AuditLogIdRecordMapper = {
organizationId: string;
userEmail: string;
userId: string;
};
32 changes: 32 additions & 0 deletions packages/services/api/src/modules/audit-logs/module.graphql.ts
Original file line number Diff line number Diff line change
@@ -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
}
`;
Loading

0 comments on commit 38c14e2

Please sign in to comment.