Skip to content

Commit 70bc7a8

Browse files
authored
feat(api): store access token information in audit log (#6738)
1 parent 63705c4 commit 70bc7a8

File tree

11 files changed

+200
-59
lines changed

11 files changed

+200
-59
lines changed

integration-tests/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@types/dockerode": "3.3.34",
2929
"async-retry": "1.3.3",
3030
"bcryptjs": "2.4.3",
31+
"csv-parse": "5.6.0",
3132
"date-fns": "4.1.0",
3233
"dockerode": "4.0.4",
3334
"dotenv": "16.4.7",

integration-tests/testkit/seed.ts

+10-8
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@ import {
5252
import * as GraphQLSchema from './gql/graphql';
5353
import {
5454
BreakingChangeFormula,
55-
OrganizationAccessScope,
56-
ProjectAccessScope,
5755
ProjectType,
5856
SchemaPolicyInput,
5957
TargetAccessScope,
@@ -111,10 +109,14 @@ export function initSeed() {
111109

112110
return {
113111
organization,
114-
async createOrganizationAccessToken(args: {
115-
permissions: Array<string>;
116-
resources: GraphQLSchema.ResourceAssignmentInput;
117-
}) {
112+
async createOrganizationAccessToken(
113+
args: {
114+
permissions: Array<string>;
115+
resources: GraphQLSchema.ResourceAssignmentInput;
116+
},
117+
/** Override the used access token. */
118+
accessToken?: string,
119+
) {
118120
const result = await createOrganizationAccessToken(
119121
{
120122
title: 'A Access Token',
@@ -125,14 +127,14 @@ export function initSeed() {
125127
permissions: args.permissions,
126128
resources: args.resources,
127129
},
128-
ownerToken,
130+
accessToken ?? ownerToken,
129131
).then(r => r.expectNoGraphQLErrors());
130132

131133
if (result.createOrganizationAccessToken.error) {
132134
throw new Error(result.createOrganizationAccessToken.error.message);
133135
}
134136

135-
return result.createOrganizationAccessToken.ok!.privateAccessKey;
137+
return result.createOrganizationAccessToken.ok!;
136138
},
137139
async setFeatureFlag(name: string, value: boolean | string[]) {
138140
const pool = await createConnectionPool();

integration-tests/tests/api/audit-logs/audit-log-record.spec.ts

+115-20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import * as sp from 'node:stream/promises';
2+
import * as csvp from 'csv-parse';
13
import { endOfDay, startOfDay } from 'date-fns';
24
import { graphql } from 'testkit/gql';
3-
import { ProjectType, RuleInstanceSeverityLevel } from 'testkit/gql/graphql';
5+
import * as GraphQLSchema from 'testkit/gql/graphql';
46
import { execute } from 'testkit/graphql';
57
import { initSeed } from 'testkit/seed';
68
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
@@ -15,6 +17,52 @@ const s3Client = new S3Client({
1517
forcePathStyle: true,
1618
});
1719

20+
/** Utility function for getting the s3 report without hazzle */
21+
async function fetchAuditLogFromS3Bucket(url: string): Promise<string> {
22+
const parsedUrl = new URL(url);
23+
const pathParts = parsedUrl.pathname.split('/');
24+
const bucketName = pathParts[1];
25+
const key = pathParts.slice(2).join('/');
26+
const getObjectCommand = new GetObjectCommand({
27+
Bucket: bucketName,
28+
Key: key,
29+
});
30+
const result = await s3Client.send(getObjectCommand);
31+
const body = await result.Body?.transformToString();
32+
if (!body) {
33+
throw new Error('Body is empty lol.');
34+
}
35+
return body;
36+
}
37+
38+
/** Parse the audit log into a json object */
39+
async function parseAuditLog(contents: string): Promise<Array<any>> {
40+
const parser = csvp.parse();
41+
parser.write(contents);
42+
parser.end();
43+
44+
const d: any = [];
45+
46+
let headerMapping: Array<[string, number]> | null = null;
47+
48+
parser.on('data', (chunk: Array<string>) => {
49+
if (!headerMapping) {
50+
headerMapping = chunk.map((key, index) => [key, index] as const);
51+
} else {
52+
d.push(
53+
Object.fromEntries(
54+
headerMapping.map(([key, index]) => [
55+
key,
56+
key === 'metadata' ? JSON.parse(chunk[index]) : chunk[index],
57+
]),
58+
),
59+
);
60+
}
61+
});
62+
await sp.finished(parser);
63+
return d;
64+
}
65+
1866
const ExportAllAuditLogs = graphql(`
1967
mutation exportAllAuditLogs($input: ExportOrganizationAuditLogInput!) {
2068
exportOrganizationAuditLog(input: $input) {
@@ -37,7 +85,7 @@ test.concurrent(
3785
async () => {
3886
const { createOrg } = await initSeed().createOwner();
3987
const { createProject, organization } = await createOrg();
40-
await createProject(ProjectType.Single);
88+
await createProject(GraphQLSchema.ProjectType.Single);
4189
const secondOrg = await initSeed().createOwner();
4290
const secondToken = secondOrg.ownerToken;
4391

@@ -62,7 +110,7 @@ test.concurrent(
62110
test.concurrent('Try to export Audit Logs from an Organization with authorized user', async () => {
63111
const { createOrg, ownerToken } = await initSeed().createOwner();
64112
const { createProject, organization } = await createOrg();
65-
await createProject(ProjectType.Single);
113+
await createProject(GraphQLSchema.ProjectType.Single);
66114

67115
const exportAuditLogs = await execute({
68116
document: ExportAllAuditLogs,
@@ -81,22 +129,19 @@ test.concurrent('Try to export Audit Logs from an Organization with authorized u
81129
});
82130
expect(exportAuditLogs.rawBody.data?.exportOrganizationAuditLog.error).toBeNull();
83131
const url = exportAuditLogs.rawBody.data?.exportOrganizationAuditLog.ok?.url;
84-
const parsedUrl = new URL(String(url));
85-
const pathParts = parsedUrl.pathname.split('/');
86-
const bucketName = pathParts[1];
87-
const key = pathParts.slice(2).join('/');
88-
const getObjectCommand = new GetObjectCommand({
89-
Bucket: bucketName,
90-
Key: key,
91-
});
92-
const result = await s3Client.send(getObjectCommand);
93-
const bodyStream = await result.Body?.transformToString();
94-
expect(bodyStream).toBeDefined();
95-
96-
const rows = bodyStream?.split('\n');
97-
expect(rows?.length).toBeGreaterThan(1); // At least header and one row
132+
const bodyStream = await fetchAuditLogFromS3Bucket(String(url));
133+
const rows = bodyStream.split('\n');
134+
expect(rows.length).toBeGreaterThan(1); // At least header and one row
98135
const header = rows?.[0].split(',');
99-
const expectedHeader = ['id', 'created_at', 'event_type', 'user_id', 'user_email', 'metadata'];
136+
const expectedHeader = [
137+
'id',
138+
'created_at',
139+
'event_type',
140+
'user_id',
141+
'user_email',
142+
'access_token_id',
143+
'metadata',
144+
];
100145
expect(header).toEqual(expectedHeader);
101146
// Sometimes the order of the rows is not guaranteed, so we need to check if the expected rows are present
102147
expect(rows?.find(row => row.includes('ORGANIZATION_CREATED'))).toBeDefined();
@@ -107,14 +152,14 @@ test.concurrent('Try to export Audit Logs from an Organization with authorized u
107152
test.concurrent('export audit log for schema policy', async () => {
108153
const { createOrg, ownerToken } = await initSeed().createOwner();
109154
const { createProject, setOrganizationSchemaPolicy, organization } = await createOrg();
110-
await createProject(ProjectType.Single);
155+
await createProject(GraphQLSchema.ProjectType.Single);
111156
await setOrganizationSchemaPolicy(
112157
{
113158
rules: [
114159
{
115160
configuration: { definitions: false },
116161
ruleId: 'alphabetize',
117-
severity: RuleInstanceSeverityLevel.Warning,
162+
severity: GraphQLSchema.RuleInstanceSeverityLevel.Warning,
118163
},
119164
],
120165
},
@@ -137,3 +182,53 @@ test.concurrent('export audit log for schema policy', async () => {
137182
token: ownerToken,
138183
}).then(res => res.expectNoGraphQLErrors());
139184
});
185+
186+
test.concurrent('access token actions are stored within the audit log', async () => {
187+
const { createOrg, ownerToken } = await initSeed().createOwner();
188+
const { organization, createOrganizationAccessToken } = await createOrg();
189+
// First we create an access token with the organization owner token
190+
const accessToken = await createOrganizationAccessToken({
191+
permissions: ['organization:describe', 'project:describe', 'accessToken:modify'],
192+
resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All },
193+
});
194+
195+
// Now we create a new one using the access token
196+
await createOrganizationAccessToken(
197+
{
198+
permissions: ['organization:describe', 'project:describe'],
199+
resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All },
200+
},
201+
accessToken.privateAccessKey,
202+
);
203+
204+
const exportAuditLogs = await execute({
205+
document: ExportAllAuditLogs,
206+
variables: {
207+
input: {
208+
selector: {
209+
organizationSlug: organization.slug,
210+
},
211+
filter: {
212+
startDate: lastYear.toISOString(),
213+
endDate: today.toISOString(),
214+
},
215+
},
216+
},
217+
token: ownerToken,
218+
}).then(res => res.expectNoGraphQLErrors());
219+
220+
const url = exportAuditLogs?.exportOrganizationAuditLog.ok?.url;
221+
const contents = await fetchAuditLogFromS3Bucket(String(url));
222+
const logs = await parseAuditLog(contents);
223+
/** Find the log entry of the access token we used */
224+
const logEntries = logs.filter(
225+
log => log.access_token_id === accessToken.createdOrganizationAccessToken.id,
226+
);
227+
228+
expect(logEntries.length).toEqual(1);
229+
const [logEntry] = logEntries;
230+
expect(logEntry.event_type).toEqual('ORGANIZATION_ACCESS_TOKEN_CREATED');
231+
expect(
232+
accessToken.privateAccessKey.startsWith(logEntry.metadata.accessToken.firstCharacters),
233+
).toEqual(true);
234+
});

integration-tests/tests/cli/schema.spec.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ test('schema:check without `--target` flag fails for organization access token',
767767
}) => {
768768
const { createOrg } = await initSeed().createOwner();
769769
const { createOrganizationAccessToken } = await createOrg();
770-
const privateKey = await createOrganizationAccessToken({
770+
const { privateAccessKey } = await createOrganizationAccessToken({
771771
permissions: ['schemaCheck:create', 'project:describe'],
772772
resources: {
773773
mode: GraphQLSchema.ResourceAssignmentModeType.All,
@@ -777,7 +777,7 @@ test('schema:check without `--target` flag fails for organization access token',
777777
await expect(
778778
schemaCheck([
779779
'--registry.accessToken',
780-
privateKey,
780+
privateAccessKey,
781781
'--author',
782782
'Kamil',
783783
'fixtures/init-schema.graphql',
@@ -806,7 +806,7 @@ test('schema:check with `--target` flag succeeds for organization access token',
806806
const { createOrg } = await initSeed().createOwner();
807807
const { createOrganizationAccessToken, createProject, organization } = await createOrg();
808808
const { project, target } = await createProject();
809-
const privateKey = await createOrganizationAccessToken({
809+
const { privateAccessKey: privateKey } = await createOrganizationAccessToken({
810810
permissions: ['schemaCheck:create', 'project:describe'],
811811
resources: {
812812
mode: GraphQLSchema.ResourceAssignmentModeType.All,
@@ -838,7 +838,7 @@ test('schema:publish without `--target` flag fails for organization access token
838838
}) => {
839839
const { createOrg } = await initSeed().createOwner();
840840
const { createOrganizationAccessToken } = await createOrg();
841-
const privateKey = await createOrganizationAccessToken({
841+
const { privateAccessKey: privateKey } = await createOrganizationAccessToken({
842842
permissions: ['project:describe', 'schemaVersion:publish'],
843843
resources: {
844844
mode: GraphQLSchema.ResourceAssignmentModeType.All,
@@ -878,7 +878,7 @@ test('schema:publish with `--target` flag succeeds for organization access token
878878
const { createOrg } = await initSeed().createOwner();
879879
const { createOrganizationAccessToken, organization, createProject } = await createOrg();
880880
const { project, target } = await createProject();
881-
const privateKey = await createOrganizationAccessToken({
881+
const { privateAccessKey: privateKey } = await createOrganizationAccessToken({
882882
permissions: ['project:describe', 'schemaVersion:publish'],
883883
resources: {
884884
mode: GraphQLSchema.ResourceAssignmentModeType.All,

integration-tests/tests/usage-reporting/organization-access-token.spec.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ test('/:targetId > operation is accepted with wildcard access token', async () =
99

1010
const usageAddress = await getServiceHost('usage', 8081);
1111

12-
const accessToken = await createOrganizationAccessToken({
12+
const { privateAccessKey: accessToken } = await createOrganizationAccessToken({
1313
permissions: ['usage:report'],
1414
resources: {
1515
mode: ResourceAssignmentModeType.All,
@@ -68,7 +68,7 @@ test('/:targetId > operation is denied without access to target', async () => {
6868

6969
const usageAddress = await getServiceHost('usage', 8081);
7070

71-
const accessToken = await createOrganizationAccessToken({
71+
const { privateAccessKey: accessToken } = await createOrganizationAccessToken({
7272
permissions: ['usage:report'],
7373
resources: {
7474
mode: ResourceAssignmentModeType.Granular,
@@ -130,7 +130,7 @@ test('/:targetId > operation is accepted with specific access to target', async
130130

131131
const usageAddress = await getServiceHost('usage', 8081);
132132

133-
const accessToken = await createOrganizationAccessToken({
133+
const { privateAccessKey: accessToken } = await createOrganizationAccessToken({
134134
permissions: ['usage:report'],
135135
resources: {
136136
mode: ResourceAssignmentModeType.Granular,
@@ -210,7 +210,7 @@ test('/:orgSlug/:projectSlug/:targetSlug > operation is accepted with wildcard a
210210

211211
const usageAddress = await getServiceHost('usage', 8081);
212212

213-
const accessToken = await createOrganizationAccessToken({
213+
const { privateAccessKey: accessToken } = await createOrganizationAccessToken({
214214
permissions: ['usage:report'],
215215
resources: {
216216
mode: ResourceAssignmentModeType.All,
@@ -272,7 +272,7 @@ test('/:orgSlug/:projectSlug/:targetSlug > operation is denied without access to
272272

273273
const usageAddress = await getServiceHost('usage', 8081);
274274

275-
const accessToken = await createOrganizationAccessToken({
275+
const { privateAccessKey: accessToken } = await createOrganizationAccessToken({
276276
permissions: ['usage:report'],
277277
resources: {
278278
mode: ResourceAssignmentModeType.Granular,
@@ -337,7 +337,7 @@ test('/:orgSlug/:projectSlug/:targetSlug > operation is accepted with specific a
337337

338338
const usageAddress = await getServiceHost('usage', 8081);
339339

340-
const accessToken = await createOrganizationAccessToken({
340+
const { privateAccessKey: accessToken } = await createOrganizationAccessToken({
341341
permissions: ['usage:report'],
342342
resources: {
343343
mode: ResourceAssignmentModeType.Granular,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Action } from '../clickhouse';
2+
3+
export const action: Action = async exec => {
4+
await exec(`
5+
ALTER TABLE "audit_logs"
6+
ADD COLUMN IF NOT EXISTS
7+
"access_token_id" String CODEC(ZSTD(1))
8+
;
9+
`);
10+
};

packages/migrations/src/clickhouse.ts

+1
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export async function migrateClickHouse(
174174
import('./clickhouse-actions/011-audit-logs'),
175175
import('./clickhouse-actions/012-coordinates-typename-index'),
176176
import('./clickhouse-actions/013-apply-ttl'),
177+
import('./clickhouse-actions/014-audit-logs-access-token'),
177178
]);
178179

179180
async function actionRunner(action: Action, index: number) {

0 commit comments

Comments
 (0)