Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: organization access tokens #6493

Open
wants to merge 37 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
35cfca6
feat: organization access tokens
n1ru4l Feb 10, 2025
83daaeb
more wip
n1ru4l Feb 10, 2025
cfb17b7
w
n1ru4l Feb 10, 2025
d68fd71
missing key in index
n1ru4l Feb 10, 2025
1e73473
missing tests
n1ru4l Feb 10, 2025
563c69b
fixtures
n1ru4l Feb 10, 2025
0adf265
access token strategy
n1ru4l Feb 10, 2025
5ad7426
more?
n1ru4l Feb 11, 2025
3deed76
permission validation
n1ru4l Feb 11, 2025
66165f3
change permissions
n1ru4l Feb 12, 2025
faa9452
dup
n1ru4l Feb 12, 2025
68cf46e
oops
n1ru4l Feb 12, 2025
440121f
fix type
n1ru4l Feb 12, 2025
1587b4c
first integration tests
n1ru4l Feb 12, 2025
58fc5bf
integration tests
n1ru4l Feb 12, 2025
1ff1081
fix
n1ru4l Feb 12, 2025
60ab85b
pagination
n1ru4l Feb 13, 2025
acb9e30
not yet
n1ru4l Feb 13, 2025
90c68ca
no any
n1ru4l Feb 13, 2025
fa7e965
ooops
n1ru4l Feb 13, 2025
90434ed
types
n1ru4l Feb 13, 2025
fae1421
update migration date
n1ru4l Feb 13, 2025
1b17d26
Update packages/migrations/src/actions/2025.02.20T00-00-00.organizati…
n1ru4l Feb 13, 2025
8f6096a
nullabilityy
n1ru4l Feb 13, 2025
222491b
fixtures
n1ru4l Feb 13, 2025
f3ee9f3
audit log
n1ru4l Feb 13, 2025
67da178
coderabbit trolled me
n1ru4l Feb 13, 2025
766102f
a bit more comments
n1ru4l Feb 13, 2025
6109e97
a bit more context
n1ru4l Feb 13, 2025
cc7c340
cache access key validation
n1ru4l Feb 13, 2025
8c38327
store resource assignments and permissions in audit log
n1ru4l Feb 13, 2025
b98959d
unused import
n1ru4l Feb 13, 2025
96abc14
too early
n1ru4l Feb 13, 2025
1a28781
typo
n1ru4l Feb 19, 2025
346f2d2
Merge remote-tracking branch 'origin/main' into wip-organization-acce…
n1ru4l Feb 19, 2025
e9959eb
fix circular dependency
n1ru4l Feb 19, 2025
f4a8679
feat: prometheus instrumentation
n1ru4l Feb 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
413 changes: 413 additions & 0 deletions integration-tests/tests/api/organization-access-tokens.spec.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@
"countup.js": "patches/countup.js.patch",
"@oclif/[email protected]": "patches/@[email protected]",
"@fastify/vite": "patches/@fastify__vite.patch",
"[email protected]": "patches/[email protected]"
"[email protected]": "patches/[email protected]",
"bentocache": "patches/bentocache.patch"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type MigrationExecutor } from '../pg-migrator';

export default {
name: '2025.02.20T00-00-00.organization-access-tokens.ts',
run: ({ sql }) => sql`
CREATE TABLE IF NOT EXISTS "organization_access_tokens" (
"id" UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4()
, "organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE
, "created_at" timestamptz NOT NULL DEFAULT now()
, "title" text NOT NULL
, "description" text NOT NULL
, "permissions" text[] NOT NULL
, "assigned_resources" jsonb
, "hash" text NOT NULL
, "first_characters" text NOT NULL
);

CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" (
"organization_id"
, "created_at" DESC
, "id" DESC
);
`,
} satisfies MigrationExecutor;
1 change: 1 addition & 0 deletions packages/migrations/src/run-pg-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,5 +158,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
await import('./actions/2025.01.17T10-08-00.drop-activities'),
await import('./actions/2025.01.20T00-00-00.legacy-registry-model-removal'),
await import('./actions/2025.01.30T00-00-00.granular-member-role-permissions'),
await import('./actions/2025.02.20T00-00-00.organization-access-tokens'),
],
});
2 changes: 2 additions & 0 deletions packages/services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"devDependencies": {
"@aws-sdk/client-s3": "3.723.0",
"@aws-sdk/s3-request-presigner": "3.723.0",
"@bentocache/plugin-prometheus": "0.2.0",
"@date-fns/utc": "2.1.0",
"@graphql-hive/core": "workspace:*",
"@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a",
Expand Down Expand Up @@ -44,6 +45,7 @@
"@types/object-hash": "3.0.6",
"agentkeepalive": "4.6.0",
"bcryptjs": "2.4.3",
"bentocache": "1.1.0",
"csv-stringify": "6.5.2",
"dataloader": "2.2.3",
"date-fns": "4.1.0",
Expand Down
9 changes: 9 additions & 0 deletions packages/services/api/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { IdTranslator } from './modules/shared/providers/id-translator';
import { Logger } from './modules/shared/providers/logger';
import { Mutex } from './modules/shared/providers/mutex';
import { PG_POOL_CONFIG } from './modules/shared/providers/pg-pool';
import { PrometheusConfig } from './modules/shared/providers/prometheus-config';
import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub';
import { REDIS_INSTANCE } from './modules/shared/providers/redis';
import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config';
Expand Down Expand Up @@ -119,6 +120,7 @@ export function createRegistry({
organizationOIDC,
pubSub,
appDeploymentsEnabled,
prometheus,
}: {
logger: Logger;
storage: Storage;
Expand Down Expand Up @@ -164,6 +166,7 @@ export function createRegistry({
organizationOIDC: boolean;
pubSub: HivePubSub;
appDeploymentsEnabled: boolean;
prometheus: null | Record<string, unknown>;
}) {
const s3Config: S3Config = [
{
Expand Down Expand Up @@ -323,6 +326,12 @@ export function createRegistry({
scope: Scope.Operation,
deps: [CONTEXT],
},
{
provide: PrometheusConfig,
useFactory() {
return new PrometheusConfig(!!prometheus);
},
},
];

if (emailsEndpoint) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { ResourceAssignmentModel } from '../../organization/lib/resource-assignment-model';

export const AuditLogModel = z.union([
z.object({
Expand Down Expand Up @@ -327,6 +328,20 @@ export const AuditLogModel = z.union([
scriptContents: z.string(),
}),
}),
z.object({
eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_CREATED'),
metadata: z.object({
organizationAccessTokenId: z.string().uuid(),
permissions: z.array(z.string()),
assignedResources: ResourceAssignmentModel,
}),
}),
z.object({
eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_DELETED'),
metadata: z.object({
organizationAccessTokenId: z.string().uuid(),
}),
}),
]);

export type AuditLogSchemaEvent = z.infer<typeof AuditLogModel>;
Expand Down
2 changes: 2 additions & 0 deletions packages/services/api/src/modules/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { OrganizationAccessTokenValidationCache } from './providers/organization-access-token-validation-cache';
import { ProjectAccess } from './providers/project-access';
import { TargetAccess } from './providers/target-access';
import { UserManager } from './providers/user-manager';
Expand All @@ -20,5 +21,6 @@ export const authModule = createModule({
ProjectAccess,
TargetAccess,
AuditLogManager,
OrganizationAccessTokenValidationCache,
],
});
2 changes: 1 addition & 1 deletion packages/services/api/src/modules/auth/lib/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ const permissionsByLevel = {
z.literal('project:create'),
z.literal('schemaLinting:modifyOrganizationRules'),
z.literal('auditLog:export'),
z.literal('accessToken:modify'),
],
project: [
z.literal('project:describe'),
Expand All @@ -366,7 +367,6 @@ const permissionsByLevel = {
z.literal('laboratory:describe'),
z.literal('laboratory:modify'),
z.literal('laboratory:modifyPreflightScript'),
z.literal('schema:loadFromRegistry'),
z.literal('schema:compose'),
],
service: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as crypto from 'node:crypto';
import { type FastifyReply, type FastifyRequest } from '@hive/service-common';
import * as OrganizationAccessKey from '../../organization/lib/organization-access-key';
import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache';
import { Logger } from '../../shared/providers/logger';
import { OrganizationAccessTokenValidationCache } from '../providers/organization-access-token-validation-cache';
import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz';

function hashToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}

export class OrganizationAccessTokenSession extends Session {
public readonly organizationId: string;
private policies: Array<AuthorizationPolicyStatement>;

constructor(
args: {
organizationId: string;
policies: Array<AuthorizationPolicyStatement>;
},
deps: {
logger: Logger;
},
) {
super({ logger: deps.logger });
this.organizationId = args.organizationId;
this.policies = args.policies;
}

protected loadPolicyStatementsForOrganization(
_: string,
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> {
return this.policies;
}
}

export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationAccessTokenSession> {
private logger: Logger;

private organizationAccessTokenCache: OrganizationAccessTokensCache;
private organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache;

constructor(deps: {
logger: Logger;
organizationAccessTokensCache: OrganizationAccessTokensCache;
organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache;
}) {
super();
this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' });
this.organizationAccessTokenCache = deps.organizationAccessTokensCache;
this.organizationAccessTokenValidationCache = deps.organizationAccessTokenValidationCache;
}

async parse(args: {
req: FastifyRequest;
reply: FastifyReply;
}): Promise<OrganizationAccessTokenSession | null> {
this.logger.debug('Attempt to resolve an API token from headers');
let value: string | null = null;
for (const headerName in args.req.headers) {
if (headerName.toLowerCase() !== 'authorization') {
continue;
}
const values = args.req.headers[headerName];
value = (Array.isArray(values) ? values.at(0) : values) ?? null;
}

if (!value) {
this.logger.debug('No access token header found.');
return null;
}

if (!value.startsWith('Bearer ')) {
this.logger.debug('Access token does not start with "Bearer ".');
return null;
}

const accessToken = value.replace('Bearer ', '');
const result = OrganizationAccessKey.decode(accessToken);
if (result.type === 'error') {
this.logger.debug(result.reason);
return null;
}

const organizationAccessToken = await this.organizationAccessTokenCache.get(
result.accessKey.id,
);
if (!organizationAccessToken) {
return null;
}

// let's hash it so we do not store the plain private key in memory
const key = hashToken(accessToken);
const isHashMatch = await this.organizationAccessTokenValidationCache.getOrSetForever({
factory: () =>
OrganizationAccessKey.verify(result.accessKey.privateKey, organizationAccessToken.hash),
key,
});

if (!isHashMatch) {
this.logger.debug('Provided private key does not match hash.');
Copy link
Collaborator

@jdolle jdolle Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of returning null, do we want to capture this case to return more specific error messages?

Copy link
Contributor Author

@n1ru4l n1ru4l Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think we do not really want to tell a potential brute-forcer that he correctly guessed the access key id, but not the private key part?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. I picked a condition at random to add this comment to. But more generically for any of these null cases -- would it be worth providing info to the user? E.g. Malformed token if it doesnt start with Bearer?

I suspect still the answer is no, but wanted to make sure i wasnt missing something.

return null;
}

return new OrganizationAccessTokenSession(
{
organizationId: organizationAccessToken.organizationId,
policies: organizationAccessToken.authorizationPolicyStatements,
},
{
logger: args.req.log,
},
);
}
}
Loading
Loading