Skip to content

Commit 7e1b38e

Browse files
committed
personal access token can grant everything the user is allowed to do
1 parent 91061aa commit 7e1b38e

File tree

12 files changed

+348
-46
lines changed

12 files changed

+348
-46
lines changed

packages/migrations/src/actions/2025.10.17T00-00-00.personal-access-tokens.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default {
1010
, "created_at" timestamptz NOT NULL DEFAULT now()
1111
, "title" text NOT NULL
1212
, "description" text NOT NULL
13-
, "permissions" text[] NOT NULL
13+
, "permissions" text[]
1414
, "assigned_resources" jsonb
1515
, "hash" text NOT NULL
1616
, "first_characters" text NOT NULL

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
@@ -347,7 +347,7 @@ export const AuditLogModel = z.union([
347347
metadata: z.object({
348348
organizationAccessTokenId: z.string().uuid(),
349349
userId: z.string().uuid(),
350-
permissions: z.array(z.string()),
350+
permissions: z.array(z.string()).nullable(),
351351
assignedResources: ResourceAssignmentModel,
352352
}),
353353
}),

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ export const permissionGroups: Array<PermissionGroup> = [
147147
title: 'Publish app deployment',
148148
description: 'Grant access to publishing app deployments.',
149149
},
150+
{
151+
id: 'appDeployment:retire',
152+
title: 'Retire app deployment',
153+
description: 'Grant access to retring app deployments.',
154+
},
150155
],
151156
},
152157
];

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

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,64 @@ export const permissionGroups: Array<PermissionGroup> = [
241241
},
242242
],
243243
},
244+
{
245+
id: 'api-cli-actions',
246+
title: 'CLI/API Actions',
247+
permissions: [
248+
{
249+
id: 'schema:compose',
250+
title: 'Compose schema',
251+
description: 'Allow using "hive dev" command for local composition.',
252+
dependsOn: 'project:describe',
253+
},
254+
{
255+
id: 'schemaCheck:create',
256+
title: 'Check schema/service/subgraph',
257+
description: 'Allow usage of the "hive schema:check" command.',
258+
dependsOn: 'project:describe',
259+
},
260+
{
261+
id: 'schemaVersion:publish',
262+
title: 'Publish schema/service/subgraph',
263+
description: 'Allow usage of the "hive schema:publish" command.',
264+
dependsOn: 'project:describe',
265+
},
266+
{
267+
id: 'schemaVersion:deleteService',
268+
title: 'Delete service',
269+
description: 'Allow usage of the "hive schema:delete" command.',
270+
dependsOn: 'project:describe',
271+
},
272+
{
273+
id: 'appDeployment:create',
274+
title: 'Create app deployment',
275+
description: 'Grant access to creating app deployments.',
276+
dependsOn: 'project:describe',
277+
},
278+
{
279+
id: 'appDeployment:publish',
280+
title: 'Publish app deployment',
281+
description: 'Grant access to publishing app deployments.',
282+
dependsOn: 'project:describe',
283+
},
284+
{
285+
id: 'appDeployment:retire',
286+
title: 'Retire app deployment',
287+
description: 'Grant access to retring app deployments.',
288+
dependsOn: 'project:describe',
289+
},
290+
{
291+
id: 'usage:report',
292+
title: 'Report usage data',
293+
description: 'Grant access to report usage data.',
294+
},
295+
{
296+
id: 'traces:report',
297+
title: 'Report OTEL traces',
298+
description: 'Grant access to reporting traces.',
299+
},
300+
],
301+
},
244302
] as const;
245303

246304
function assertAllRulesAreAssigned(excluded: Array<Permission>) {
@@ -267,18 +325,7 @@ function assertAllRulesAreAssigned(excluded: Array<Permission>) {
267325
* This seems like the easiest way to make sure that all the permissions we have are
268326
* assignable and exposed via our API.
269327
*/
270-
assertAllRulesAreAssigned([
271-
/** These are CLI only actions for now. */
272-
'schema:compose',
273-
'schemaCheck:create',
274-
'schemaVersion:publish',
275-
'schemaVersion:deleteService',
276-
'appDeployment:create',
277-
'appDeployment:publish',
278-
'appDeployment:retire',
279-
'usage:report',
280-
'traces:report',
281-
]);
328+
assertAllRulesAreAssigned([]);
282329

283330
/**
284331
* List of permissions that are assignable

packages/services/api/src/modules/organization/lib/permissions.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,58 @@ export type PermissionGroup = {
1414
title: string;
1515
permissions: Array<PermissionRecord>;
1616
};
17+
18+
/**
19+
* Utility to verify that all the permissions in one permission group are also available in the other permission group.
20+
*/
21+
export function assertPermissionGroupsIsSubset(
22+
sourceGroups: Array<PermissionGroup>,
23+
subsetGroups: Array<PermissionGroup>,
24+
) {
25+
const permissionsInSource = new Set(
26+
sourceGroups.flatMap(group => group.permissions.map(permission => permission.id)),
27+
);
28+
const missing = new Array<string>();
29+
30+
subsetGroups.forEach(group =>
31+
group.permissions.forEach(permission => {
32+
if (!permissionsInSource.has(permission.id)) {
33+
missing.push(permission.id);
34+
}
35+
}),
36+
);
37+
38+
if (missing.length) {
39+
throw new Error(
40+
'The following permissions are missing in the main group.\n- ' + missing.join('\n- '),
41+
);
42+
}
43+
}
44+
45+
/**
46+
* Folter down a permission group based on a set of input permissions.
47+
* E.g. for only showing a list of permissions to assign based on the viewers permissions.
48+
*/
49+
export function filterDownPermissionGroups(
50+
sourceGroups: Array<PermissionGroup>,
51+
permissions: Set<string>,
52+
) {
53+
return sourceGroups
54+
.map(group => {
55+
const newPermissions = group.permissions.filter(permission => permissions.has(permission.id));
56+
57+
if (newPermissions.length === group.permissions.length) {
58+
return group;
59+
}
60+
61+
if (newPermissions.length === 0) {
62+
return null;
63+
}
64+
65+
return {
66+
...group,
67+
permissions: newPermissions,
68+
};
69+
})
70+
.filter((p): p is NonNullable<typeof p> => p !== null);
71+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import * as OrganizationMemberPermissions from './organization-member-permissions';
2+
import { assertPermissionGroupsIsSubset, PermissionGroup } from './permissions';
3+
4+
export const permissionGroups: Array<PermissionGroup> = [
5+
{
6+
id: 'organization',
7+
title: 'Organization',
8+
permissions: [
9+
{
10+
id: 'organization:describe',
11+
title: 'Describe organization',
12+
description: 'Fetch information about the specified organization.',
13+
},
14+
],
15+
},
16+
{
17+
id: 'project',
18+
title: 'Project',
19+
permissions: [
20+
{
21+
id: 'project:describe',
22+
title: 'Describe project',
23+
description: 'Fetch information about the specified projects.',
24+
},
25+
],
26+
},
27+
{
28+
id: 'cli-actions-schema-registry',
29+
title: 'CLI/API Actions for Schema Registry',
30+
permissions: [
31+
{
32+
id: 'schema:compose',
33+
title: 'Compose schema',
34+
description: 'Allow using "hive dev" command for local composition.',
35+
dependsOn: 'project:describe',
36+
},
37+
{
38+
id: 'schemaCheck:create',
39+
title: 'Check schema/service/subgraph',
40+
description: 'Allow usage of the "hive schema:check" command.',
41+
dependsOn: 'project:describe',
42+
},
43+
{
44+
id: 'schemaVersion:publish',
45+
title: 'Publish schema/service/subgraph',
46+
description: 'Allow usage of the "hive schema:publish" command.',
47+
dependsOn: 'project:describe',
48+
},
49+
{
50+
id: 'schemaVersion:deleteService',
51+
title: 'Delete service',
52+
description: 'Allow usage of the "hive schema:delete" command.',
53+
dependsOn: 'project:describe',
54+
},
55+
],
56+
},
57+
{
58+
id: 'cli-actions-app-deployments',
59+
title: 'CLI/API Actions for App Deployments',
60+
permissions: [
61+
{
62+
id: 'appDeployment:create',
63+
title: 'Create app deployment',
64+
description: 'Grant access to creating app deployments.',
65+
},
66+
{
67+
id: 'appDeployment:publish',
68+
title: 'Publish app deployment',
69+
description: 'Grant access to publishing app deployments.',
70+
},
71+
{
72+
id: 'appDeployment:retire',
73+
title: 'Retire app deployment',
74+
description: 'Grant access to retring app deployments.',
75+
},
76+
],
77+
},
78+
{
79+
id: 'api-actions-reporting',
80+
title: 'API Reporting',
81+
permissions: [
82+
{
83+
id: 'usage:report',
84+
title: 'Report usage data',
85+
description: 'Grant access to report usage data.',
86+
},
87+
{
88+
id: 'traces:report',
89+
title: 'Report OTEL traces',
90+
description: 'Grant access to reporting traces.',
91+
},
92+
],
93+
},
94+
];
95+
96+
/**
97+
* Make sure that the personal access token permissions is always a subset of the
98+
* organization member permissions.
99+
*
100+
* We need to do this because the organization member needs the permissions assigned in order
101+
* to assign them to the personal access token.
102+
*
103+
* If that is not possible we have a inconsistency.
104+
*/
105+
assertPermissionGroupsIsSubset(OrganizationMemberPermissions.permissionGroups, permissionGroups);

packages/services/api/src/modules/organization/module.graphql.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,17 @@ export default gql`
8686
"""
8787
List of permissions that are assigned to the access token.
8888
A list of available permissions can be retrieved via the 'Organization.availableOrganizationAccessTokenPermissionGroups' field.
89+
90+
If omitted (or set to null), the personal access token will be granted all permissions of the members role.
8991
"""
90-
permissions: [String!]!
92+
permissions: [String!]
9193
"""
9294
Resources on which the permissions should be granted (project, target, service, and app deployments).
9395
Permissions are inherited by sub-resources.
96+
97+
If omitted (or set to null), the personal access token will be granted all permissions of the members role.
9498
"""
95-
resources: ResourceAssignmentInput!
99+
resources: ResourceAssignmentInput
96100
}
97101
98102
input DeletePersonalAccessTokenInput {
@@ -120,13 +124,13 @@ export default gql`
120124
}
121125
122126
interface AccessToken {
123-
id: ID! @tag(name: "public")
124-
title: String! @tag(name: "public")
125-
description: String @tag(name: "public")
126-
permissions: [String!]! @tag(name: "public")
127-
resources: ResourceAssignment! @tag(name: "public")
128-
firstCharacters: String! @tag(name: "public")
129-
createdAt: DateTime! @tag(name: "public")
127+
id: ID!
128+
title: String!
129+
description: String
130+
permissions: [String!]!
131+
resources: ResourceAssignment!
132+
firstCharacters: String!
133+
createdAt: DateTime!
130134
}
131135
132136
type PersonalAccessToken implements AccessToken {
@@ -514,6 +518,10 @@ export default gql`
514518
"""
515519
viewerCanManageAccessTokens: Boolean!
516520
"""
521+
Whether the viewer can manage personal access tokens.
522+
"""
523+
viewerCanManagePersonalAccessTokens: Boolean!
524+
"""
517525
Paginated organization access tokens.
518526
"""
519527
accessTokens(
@@ -526,12 +534,22 @@ export default gql`
526534
accessToken(id: ID! @tag(name: "public")): OrganizationAccessToken @tag(name: "public")
527535
}
528536
529-
type OrganizationAccessTokenEdge {
537+
interface AccessTokenEdge {
538+
node: AccessToken!
539+
cursor: String!
540+
}
541+
542+
interface AccessTokenConnection {
543+
pageInfo: PageInfo!
544+
edges: [AccessTokenEdge!]!
545+
}
546+
547+
type OrganizationAccessTokenEdge implements AccessTokenEdge {
530548
node: OrganizationAccessToken! @tag(name: "public")
531549
cursor: String! @tag(name: "public")
532550
}
533551
534-
type OrganizationAccessTokenConnection {
552+
type OrganizationAccessTokenConnection implements AccessTokenConnection {
535553
pageInfo: PageInfo! @tag(name: "public")
536554
edges: [OrganizationAccessTokenEdge!]! @tag(name: "public")
537555
}
@@ -766,6 +784,16 @@ export default gql`
766784
error: AssignMemberRoleResultError @tag(name: "public")
767785
}
768786
787+
type PersonalAccessTokenConnection implements AccessTokenConnection {
788+
pageInfo: PageInfo!
789+
edges: [PersonalAccessTokenEdge!]!
790+
}
791+
792+
type PersonalAccessTokenEdge implements AccessTokenEdge {
793+
node: PersonalAccessToken!
794+
cursor: String!
795+
}
796+
769797
type Member {
770798
id: ID!
771799
user: User! @tag(name: "public")
@@ -777,6 +805,14 @@ export default gql`
777805
Whether the viewer can remove this member from the organization.
778806
"""
779807
viewerCanRemove: Boolean!
808+
"""
809+
Paginated list of personal access tokens for the user.
810+
"""
811+
personalAccessTokens(first: Int, after: String): PersonalAccessTokenConnection
812+
"""
813+
Permission groups the member is allowed to assign to personal access tokens.
814+
"""
815+
availablePersonalAccessTokenPermissionGroups: [PermissionGroup!]!
780816
}
781817
782818
enum ResourceAssignmentModeType {

0 commit comments

Comments
 (0)