Skip to content

Commit 3043d4e

Browse files
feat(api): organization access tokens (#6493)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent aca2fc0 commit 3043d4e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2351
-620
lines changed

integration-tests/tests/api/organization-access-tokens.spec.ts

Lines changed: 413 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@
132132
"countup.js": "patches/countup.js.patch",
133133
"@oclif/[email protected]": "patches/@[email protected]",
134134
"@fastify/vite": "patches/@fastify__vite.patch",
135-
135+
136+
"bentocache": "patches/bentocache.patch"
136137
}
137138
}
138139
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { type MigrationExecutor } from '../pg-migrator';
2+
3+
export default {
4+
name: '2025.02.20T00-00-00.organization-access-tokens.ts',
5+
run: ({ sql }) => sql`
6+
CREATE TABLE IF NOT EXISTS "organization_access_tokens" (
7+
"id" UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4()
8+
, "organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE
9+
, "created_at" timestamptz NOT NULL DEFAULT now()
10+
, "title" text NOT NULL
11+
, "description" text NOT NULL
12+
, "permissions" text[] NOT NULL
13+
, "assigned_resources" jsonb
14+
, "hash" text NOT NULL
15+
, "first_characters" text NOT NULL
16+
);
17+
18+
CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" (
19+
"organization_id"
20+
, "created_at" DESC
21+
, "id" DESC
22+
);
23+
`,
24+
} satisfies MigrationExecutor;

packages/migrations/src/run-pg-migrations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,5 +158,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
158158
await import('./actions/2025.01.17T10-08-00.drop-activities'),
159159
await import('./actions/2025.01.20T00-00-00.legacy-registry-model-removal'),
160160
await import('./actions/2025.01.30T00-00-00.granular-member-role-permissions'),
161+
await import('./actions/2025.02.20T00-00-00.organization-access-tokens'),
161162
],
162163
});

packages/services/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"devDependencies": {
1414
"@aws-sdk/client-s3": "3.723.0",
1515
"@aws-sdk/s3-request-presigner": "3.723.0",
16+
"@bentocache/plugin-prometheus": "0.2.0",
1617
"@date-fns/utc": "2.1.0",
1718
"@graphql-hive/core": "workspace:*",
1819
"@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a",
@@ -44,6 +45,7 @@
4445
"@types/object-hash": "3.0.6",
4546
"agentkeepalive": "4.6.0",
4647
"bcryptjs": "2.4.3",
48+
"bentocache": "1.1.0",
4749
"csv-stringify": "6.5.2",
4850
"dataloader": "2.2.3",
4951
"date-fns": "4.1.0",

packages/services/api/src/create.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
import { Logger } from './modules/shared/providers/logger';
5757
import { Mutex } from './modules/shared/providers/mutex';
5858
import { PG_POOL_CONFIG } from './modules/shared/providers/pg-pool';
59+
import { PrometheusConfig } from './modules/shared/providers/prometheus-config';
5960
import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub';
6061
import { REDIS_INSTANCE } from './modules/shared/providers/redis';
6162
import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config';
@@ -112,6 +113,7 @@ export function createRegistry({
112113
organizationOIDC,
113114
pubSub,
114115
appDeploymentsEnabled,
116+
prometheus,
115117
}: {
116118
logger: Logger;
117119
storage: Storage;
@@ -155,6 +157,7 @@ export function createRegistry({
155157
organizationOIDC: boolean;
156158
pubSub: HivePubSub;
157159
appDeploymentsEnabled: boolean;
160+
prometheus: null | Record<string, unknown>;
158161
}) {
159162
const s3Config: S3Config = [
160163
{
@@ -303,6 +306,12 @@ export function createRegistry({
303306
scope: Scope.Operation,
304307
deps: [CONTEXT],
305308
},
309+
{
310+
provide: PrometheusConfig,
311+
useFactory() {
312+
return new PrometheusConfig(!!prometheus);
313+
},
314+
},
306315
];
307316

308317
if (emailsEndpoint) {

packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { ResourceAssignmentModel } from '../../organization/lib/resource-assignment-model';
23

34
export const AuditLogModel = z.union([
45
z.object({
@@ -327,6 +328,20 @@ export const AuditLogModel = z.union([
327328
scriptContents: z.string(),
328329
}),
329330
}),
331+
z.object({
332+
eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_CREATED'),
333+
metadata: z.object({
334+
organizationAccessTokenId: z.string().uuid(),
335+
permissions: z.array(z.string()),
336+
assignedResources: ResourceAssignmentModel,
337+
}),
338+
}),
339+
z.object({
340+
eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_DELETED'),
341+
metadata: z.object({
342+
organizationAccessTokenId: z.string().uuid(),
343+
}),
344+
}),
330345
]);
331346

332347
export type AuditLogSchemaEvent = z.infer<typeof AuditLogModel>;

packages/services/api/src/modules/auth/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createModule } from 'graphql-modules';
22
import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager';
33
import { AuthManager } from './providers/auth-manager';
44
import { OrganizationAccess } from './providers/organization-access';
5+
import { OrganizationAccessTokenValidationCache } from './providers/organization-access-token-validation-cache';
56
import { ProjectAccess } from './providers/project-access';
67
import { TargetAccess } from './providers/target-access';
78
import { UserManager } from './providers/user-manager';
@@ -20,5 +21,6 @@ export const authModule = createModule({
2021
ProjectAccess,
2122
TargetAccess,
2223
AuditLogManager,
24+
OrganizationAccessTokenValidationCache,
2325
],
2426
});

packages/services/api/src/modules/auth/lib/authz.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ const permissionsByLevel = {
349349
z.literal('project:create'),
350350
z.literal('schemaLinting:modifyOrganizationRules'),
351351
z.literal('auditLog:export'),
352+
z.literal('accessToken:modify'),
352353
],
353354
project: [
354355
z.literal('project:describe'),
@@ -366,7 +367,6 @@ const permissionsByLevel = {
366367
z.literal('laboratory:describe'),
367368
z.literal('laboratory:modify'),
368369
z.literal('laboratory:modifyPreflightScript'),
369-
z.literal('schema:loadFromRegistry'),
370370
z.literal('schema:compose'),
371371
],
372372
service: [
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as crypto from 'node:crypto';
2+
import { type FastifyReply, type FastifyRequest } from '@hive/service-common';
3+
import * as OrganizationAccessKey from '../../organization/lib/organization-access-key';
4+
import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache';
5+
import { Logger } from '../../shared/providers/logger';
6+
import { OrganizationAccessTokenValidationCache } from '../providers/organization-access-token-validation-cache';
7+
import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz';
8+
9+
function hashToken(token: string) {
10+
return crypto.createHash('sha256').update(token).digest('hex');
11+
}
12+
13+
export class OrganizationAccessTokenSession extends Session {
14+
public readonly organizationId: string;
15+
private policies: Array<AuthorizationPolicyStatement>;
16+
17+
constructor(
18+
args: {
19+
organizationId: string;
20+
policies: Array<AuthorizationPolicyStatement>;
21+
},
22+
deps: {
23+
logger: Logger;
24+
},
25+
) {
26+
super({ logger: deps.logger });
27+
this.organizationId = args.organizationId;
28+
this.policies = args.policies;
29+
}
30+
31+
protected loadPolicyStatementsForOrganization(
32+
_: string,
33+
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> {
34+
return this.policies;
35+
}
36+
}
37+
38+
export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationAccessTokenSession> {
39+
private logger: Logger;
40+
41+
private organizationAccessTokenCache: OrganizationAccessTokensCache;
42+
private organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache;
43+
44+
constructor(deps: {
45+
logger: Logger;
46+
organizationAccessTokensCache: OrganizationAccessTokensCache;
47+
organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache;
48+
}) {
49+
super();
50+
this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' });
51+
this.organizationAccessTokenCache = deps.organizationAccessTokensCache;
52+
this.organizationAccessTokenValidationCache = deps.organizationAccessTokenValidationCache;
53+
}
54+
55+
async parse(args: {
56+
req: FastifyRequest;
57+
reply: FastifyReply;
58+
}): Promise<OrganizationAccessTokenSession | null> {
59+
this.logger.debug('Attempt to resolve an API token from headers');
60+
let value: string | null = null;
61+
for (const headerName in args.req.headers) {
62+
if (headerName.toLowerCase() !== 'authorization') {
63+
continue;
64+
}
65+
const values = args.req.headers[headerName];
66+
value = (Array.isArray(values) ? values.at(0) : values) ?? null;
67+
}
68+
69+
if (!value) {
70+
this.logger.debug('No access token header found.');
71+
return null;
72+
}
73+
74+
if (!value.startsWith('Bearer ')) {
75+
this.logger.debug('Access token does not start with "Bearer ".');
76+
return null;
77+
}
78+
79+
const accessToken = value.replace('Bearer ', '');
80+
const result = OrganizationAccessKey.decode(accessToken);
81+
if (result.type === 'error') {
82+
this.logger.debug(result.reason);
83+
return null;
84+
}
85+
86+
const organizationAccessToken = await this.organizationAccessTokenCache.get(
87+
result.accessKey.id,
88+
);
89+
if (!organizationAccessToken) {
90+
return null;
91+
}
92+
93+
// let's hash it so we do not store the plain private key in memory
94+
const key = hashToken(accessToken);
95+
const isHashMatch = await this.organizationAccessTokenValidationCache.getOrSetForever({
96+
factory: () =>
97+
OrganizationAccessKey.verify(result.accessKey.privateKey, organizationAccessToken.hash),
98+
key,
99+
});
100+
101+
if (!isHashMatch) {
102+
this.logger.debug('Provided private key does not match hash.');
103+
return null;
104+
}
105+
106+
return new OrganizationAccessTokenSession(
107+
{
108+
organizationId: organizationAccessToken.organizationId,
109+
policies: organizationAccessToken.authorizationPolicyStatements,
110+
},
111+
{
112+
logger: args.req.log,
113+
},
114+
);
115+
}
116+
}

0 commit comments

Comments
 (0)