Skip to content

Commit 543cfae

Browse files
committed
feat(api): project and personal scoped access tokens
1 parent 4b2a248 commit 543cfae

30 files changed

+1712
-187
lines changed

packages/libraries/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"@oclif/plugin-help": "6.0.22",
6262
"@oclif/plugin-update": "4.2.13",
6363
"@theguild/federation-composition": "0.20.2",
64+
"cli-table3": "0.6.5",
6465
"colors": "1.4.0",
6566
"env-ci": "7.3.0",
6667
"graphql": "^16.8.1",

packages/libraries/cli/src/commands/whoami.ts

Lines changed: 56 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Table from 'cli-table3';
12
import { Flags } from '@oclif/core';
23
import Command from '../base-command';
34
import { graphql } from '../gql';
@@ -6,33 +7,28 @@ import {
67
InvalidRegistryTokenError,
78
MissingEndpointError,
89
MissingRegistryTokenError,
9-
UnexpectedError,
1010
} from '../helpers/errors';
1111
import { Texture } from '../helpers/texture/texture';
1212

1313
const myTokenInfoQuery = graphql(/* GraphQL */ `
14-
query myTokenInfo {
15-
tokenInfo {
16-
__typename
17-
... on TokenInfo {
18-
token {
19-
name
14+
query myTokenInfo($showAll: Boolean!) {
15+
whoAmI {
16+
title
17+
resolvedPermissions(includeAll: $showAll) {
18+
level
19+
resolvedResourceIds
20+
title
21+
groups {
22+
title
23+
permissions {
24+
isGranted
25+
permission {
26+
id
27+
title
28+
description
29+
}
30+
}
2031
}
21-
organization {
22-
slug
23-
}
24-
project {
25-
type
26-
slug
27-
}
28-
target {
29-
slug
30-
}
31-
canPublishSchema: hasTargetScope(scope: REGISTRY_WRITE)
32-
canCheckSchema: hasTargetScope(scope: REGISTRY_READ)
33-
}
34-
... on TokenNotFoundError {
35-
message
3632
}
3733
}
3834
}
@@ -63,6 +59,10 @@ export default class WhoAmI extends Command<typeof WhoAmI> {
6359
version: '0.21.0',
6460
},
6561
}),
62+
all: Flags.boolean({
63+
description: 'Also show non-granted permissions.',
64+
default: false,
65+
}),
6666
};
6767

6868
async run() {
@@ -95,43 +95,44 @@ export default class WhoAmI extends Command<typeof WhoAmI> {
9595

9696
const result = await this.registryApi(registry, token).request({
9797
operation: myTokenInfoQuery,
98+
variables: {
99+
showAll: flags.all,
100+
},
98101
});
99102

100-
if (result.tokenInfo.__typename === 'TokenInfo') {
101-
const { tokenInfo } = result;
102-
const { organization, project, target } = tokenInfo;
103-
104-
const organizationUrl = `https://app.graphql-hive.com/${organization.slug}`;
105-
const projectUrl = `${organizationUrl}/${project.slug}`;
106-
const targetUrl = `${projectUrl}/${target.slug}`;
107-
108-
const access = {
109-
yes: Texture.colors.green('Yes'),
110-
not: Texture.colors.red('No access'),
111-
};
112-
113-
const print = createPrinter({
114-
'Token name:': [Texture.colors.bold(tokenInfo.token.name)],
115-
' ': [''],
116-
'Organization:': [
117-
Texture.colors.bold(organization.slug),
118-
Texture.colors.dim(organizationUrl),
119-
],
120-
'Project:': [Texture.colors.bold(project.slug), Texture.colors.dim(projectUrl)],
121-
'Target:': [Texture.colors.bold(target.slug), Texture.colors.dim(targetUrl)],
122-
' ': [''],
123-
'Access to schema:publish': [tokenInfo.canPublishSchema ? access.yes : access.not],
124-
'Access to schema:check': [tokenInfo.canCheckSchema ? access.yes : access.not],
103+
if (result.whoAmI == null) {
104+
throw new InvalidRegistryTokenError();
105+
}
106+
107+
const data = result.whoAmI;
108+
109+
// Print header
110+
this.log(`\n=== ${data.title} ===\n`);
111+
112+
// Iterate and display each permission group
113+
for (const permLevel of data.resolvedPermissions) {
114+
this.log(`Level: ${permLevel.level}`);
115+
this.log(`Resources: ${permLevel.resolvedResourceIds?.join(', ') ?? '<none>'}`);
116+
117+
const table = new Table({
118+
head: ['Group', 'Permission ID', 'Title', 'Granted', 'Description'],
119+
wordWrap: true,
120+
style: { head: ['cyan'] },
125121
});
126122

127-
this.log(print());
128-
} else if (result.tokenInfo.__typename === 'TokenNotFoundError') {
129-
this.debug(result.tokenInfo.message);
130-
throw new InvalidRegistryTokenError();
131-
} else {
132-
throw new UnexpectedError(
133-
`Token response got an unsupported type: ${(result.tokenInfo as any).__typename}`,
134-
);
123+
for (const group of permLevel.groups) {
124+
for (const perm of group.permissions) {
125+
table.push([
126+
group.title,
127+
perm.permission.id,
128+
perm.permission.title,
129+
perm.isGranted ? '✓' : '❌',
130+
perm.permission.description,
131+
]);
132+
}
133+
}
134+
135+
this.log(table.toString());
135136
}
136137
}
137138
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type MigrationExecutor } from '../pg-migrator';
2+
3+
export default {
4+
name: '2025.10.17T00-00-00.organization-access-tokens-project-scope.ts',
5+
noTransaction: true,
6+
run: ({ sql }) => [
7+
{
8+
name: 'add new columns to "organization_access_tokens"',
9+
query: sql`
10+
ALTER TABLE "organization_access_tokens"
11+
ADD COLUMN IF NOT EXISTS "project_id" UUID REFERENCES "projects" ("id") ON DELETE CASCADE
12+
, ADD COLUMN IF NOT EXISTS "user_id" UUID REFERENCES "users" ("id") ON DELETE CASCADE
13+
, ALTER COLUMN "permissions" DROP NOT NULL;
14+
`,
15+
},
16+
{
17+
name: 'add index "organization_access_tokens_pagination_target"',
18+
query: sql`
19+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "organization_access_tokens_pagination_target" ON "organization_access_tokens" (
20+
"project_id"
21+
, "created_at" DESC
22+
, "id" DESC
23+
)
24+
`,
25+
},
26+
{
27+
name: 'add index "organization_access_tokens_pagination_user"',
28+
query: sql`
29+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "organization_access_tokens_pagination_user" ON "organization_access_tokens" (
30+
"user_id"
31+
, "created_at" DESC
32+
, "id" DESC
33+
)
34+
`,
35+
},
36+
],
37+
} satisfies MigrationExecutor;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
168168
await import('./actions/2025.05.15T00-00-01.organization-member-pagination'),
169169
await import('./actions/2025.05.28T00-00-00.schema-log-by-ids'),
170170
await import('./actions/2025.10.16T00-00-00.schema-log-by-commit-ordered'),
171+
await import('./actions/2025.10.17T00-00-00.project-access-tokens'),
171172
],
172173
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ export const AuditLogModel = z.union([
332332
eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_CREATED'),
333333
metadata: z.object({
334334
organizationAccessTokenId: z.string().uuid(),
335-
permissions: z.array(z.string()),
335+
permissions: z.array(z.string()).nullable(),
336336
assignedResources: ResourceAssignmentModel,
337337
}),
338338
}),

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import type { User } from '../../../shared/entities';
55
import { AccessError } from '../../../shared/errors';
66
import { objectEntries, objectFromEntries } from '../../../shared/helpers';
77
import { isUUID } from '../../../shared/is-uuid';
8-
import type { OrganizationAccessToken } from '../../organization/providers/organization-access-tokens';
8+
import { CachedAccessToken } from '../../organization/providers/organization-access-tokens-cache';
99
import { Logger } from '../../shared/providers/logger';
10+
import { TargetAccessTokenStrategy } from './target-access-token-strategy';
1011

1112
export type AuthorizationPolicyStatement = {
1213
effect: 'allow' | 'deny';
@@ -55,10 +56,14 @@ export type UserActor = {
5556

5657
export type OrganizationAccessTokenActor = {
5758
type: 'organizationAccessToken';
58-
organizationAccessToken: OrganizationAccessToken;
59+
organizationAccessToken: CachedAccessToken;
5960
};
6061

61-
type Actor = UserActor | OrganizationAccessTokenActor;
62+
export type LegacyTargetAccessTokenActor = {
63+
type: 'legacyTargetAccessToken';
64+
};
65+
66+
type Actor = UserActor | OrganizationAccessTokenActor | LegacyTargetAccessTokenActor;
6267

6368
/**
6469
* Abstract session class that is implemented by various ways to identify a session.
@@ -393,6 +398,7 @@ const permissionsByLevel = {
393398
z.literal('schemaLinting:modifyOrganizationRules'),
394399
z.literal('auditLog:export'),
395400
z.literal('accessToken:modify'),
401+
z.literal('personalAccessToken:modify'),
396402
],
397403
project: [
398404
z.literal('project:describe'),
@@ -401,6 +407,7 @@ const permissionsByLevel = {
401407
z.literal('alert:modify'),
402408
z.literal('schemaLinting:modifyProjectRules'),
403409
z.literal('target:create'),
410+
z.literal('projectAccessToken:modify'),
404411
],
405412
target: [
406413
z.literal('targetAccessToken:modify'),

packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import * as crypto from 'node:crypto';
22
import { type FastifyReply, type FastifyRequest } from '@hive/service-common';
33
import * as OrganizationAccessKey from '../../organization/lib/organization-access-key';
4-
import type { OrganizationAccessToken } from '../../organization/providers/organization-access-tokens';
5-
import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache';
4+
import {
5+
CachedAccessToken,
6+
OrganizationAccessTokensCache,
7+
} from '../../organization/providers/organization-access-tokens-cache';
68
import { Logger } from '../../shared/providers/logger';
79
import { OrganizationAccessTokenValidationCache } from '../providers/organization-access-token-validation-cache';
810
import {
@@ -19,15 +21,15 @@ function hashToken(token: string) {
1921
export class OrganizationAccessTokenSession extends Session {
2022
public readonly organizationId: string;
2123
private policies: Array<AuthorizationPolicyStatement>;
22-
private organizationAccessToken: OrganizationAccessToken;
24+
private organizationAccessToken: CachedAccessToken;
2325
readonly id: string;
2426

2527
constructor(
2628
args: {
2729
id: string;
2830
organizationId: string;
2931
policies: Array<AuthorizationPolicyStatement>;
30-
organizationAccessToken: OrganizationAccessToken;
32+
organizationAccessToken: CachedAccessToken;
3133
},
3234
deps: {
3335
logger: Logger;

packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
ProjectAccessScope,
99
TargetAccessScope,
1010
} from '../providers/scopes';
11-
import { AuthNStrategy, Session, type AuthorizationPolicyStatement } from './authz';
11+
import { AuthNStrategy, Permission, Session, type AuthorizationPolicyStatement } from './authz';
1212

1313
export class TargetAccessTokenSession extends Session {
1414
public readonly organizationId: string;
@@ -57,6 +57,11 @@ export class TargetAccessTokenSession extends Session {
5757
};
5858
}
5959

60+
get allowedPermissions(): Array<Permission> {
61+
// Since the list is static and computed below, we can safely hard-cast it and treat all policy statements as "allow"
62+
return this.policies.map(policy => policy.action) as Array<Permission>;
63+
}
64+
6065
public async getActor(): Promise<never> {
6166
throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED');
6267
}

packages/services/api/src/modules/auth/resolvers/Permission.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const Permission: PermissionResolvers = {
2525
},
2626
};
2727

28-
function resourceLevelToResourceLevelType(resourceLevel: ResourceLevel) {
28+
export function resourceLevelToResourceLevelType(resourceLevel: ResourceLevel) {
2929
switch (resourceLevel) {
3030
case 'target':
3131
return 'TARGET' as const;

packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ export const permissionGroups: Array<PermissionGroup> = [
116116
id: 'services',
117117
title: 'Schema Registry',
118118
permissions: [
119+
{
120+
id: 'schema:compose',
121+
title: 'Compose schema',
122+
description: 'Allow using "hive dev" command for local composition.',
123+
},
119124
{
120125
id: 'schemaCheck:create',
121126
title: 'Check schema/service/subgraph',
@@ -147,6 +152,11 @@ export const permissionGroups: Array<PermissionGroup> = [
147152
title: 'Publish app deployment',
148153
description: 'Grant access to publishing app deployments.',
149154
},
155+
{
156+
id: 'appDeployment:retire',
157+
title: 'Retire app deployment',
158+
description: 'Grant access to retring app deployments.',
159+
},
150160
],
151161
},
152162
];

0 commit comments

Comments
 (0)