Skip to content

Commit 198b2df

Browse files
committed
implement missing resolvers
1 parent fcbde5b commit 198b2df

File tree

5 files changed

+180
-101
lines changed

5 files changed

+180
-101
lines changed

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -138,25 +138,23 @@ export default gql`
138138
title: String!
139139
description: String
140140
"""
141+
Whether the permissions of this access token are inherited from the access token owner.
142+
"""
143+
hasAllPermissionsFromOwner: Boolean!
144+
"""
141145
The currently valid permissions for the personal access token.
146+
147+
Resolves to the access token owners permissions in case no restriction was specified during the creation of the access token.
142148
"""
143149
permissions: [String!]!
144150
"""
145151
The currently valid resources for the personal access token.
152+
153+
Resolves to the access token owners resources in case no restriction was specified during the creation of the access token.
146154
"""
147155
resources: ResourceAssignment!
148156
firstCharacters: String!
149157
createdAt: DateTime!
150-
"""
151-
The permissions that were originally assigned to the access token.
152-
They can differ from 'PersonalAccessToken.permissions' as the permissions of the users role can change.
153-
"""
154-
assignedPermissions: [String!]!
155-
"""
156-
The resources that were originally assigned to the access token.
157-
They can differ from 'PersonalAccessToken.resources' as the permissions of the users role can change.
158-
"""
159-
assignedResources: [String!]!
160158
}
161159
162160
type CreatePersonalAccessTokenResultOk {
@@ -810,6 +808,10 @@ export default gql`
810808
"""
811809
personalAccessTokens(first: Int, after: String): PersonalAccessTokenConnection
812810
"""
811+
Retrieve a personal access token by it's id.
812+
"""
813+
personalAccessToken(id: ID!): PersonalAccessToken
814+
"""
813815
Permission groups the member is allowed to assign to personal access tokens.
814816
"""
815817
availablePersonalAccessTokenPermissionGroups: [PermissionGroup!]!

packages/services/api/src/modules/organization/providers/personal-access-tokens-cache.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,22 @@ import { Inject, Injectable, Scope } from 'graphql-modules';
55
import type Redis from 'ioredis';
66
import type { DatabasePool } from 'slonik';
77
import { prometheusPlugin } from '@bentocache/plugin-prometheus';
8+
import type { AuthorizationPolicyStatement } from '../../auth/lib/authz';
89
import { Logger } from '../../shared/providers/logger';
910
import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool';
1011
import { PrometheusConfig } from '../../shared/providers/prometheus-config';
1112
import { REDIS_INSTANCE } from '../../shared/providers/redis';
1213
import { OrganizationMembers, type OrganizationMembership } from './organization-members';
1314
import { PersonalAccessTokens, type PersonalAccessToken } from './personal-access-tokens';
1415

15-
export type CachedPersonalAccessToken = Omit<
16+
export type CachedPersonalAccessToken = Pick<
1617
PersonalAccessToken,
17-
'resolveAuthorizationPolicyStatements'
18+
'id' | 'userId' | 'organizationId' | 'hash'
1819
> & {
19-
authorizationPolicyStatements: ReturnType<
20-
PersonalAccessToken['resolveAuthorizationPolicyStatements']
21-
>;
20+
authorizationPolicyStatements: Array<AuthorizationPolicyStatement>;
2221
};
2322

24-
type PersonalAccessTokeDeleteInput = Pick<PersonalAccessToken, 'id'>;
23+
type PersonalAccessTokeDeleteInput = Pick<CachedPersonalAccessToken, 'id'>;
2524

2625
/**
2726
* Cache for performant PersonalAccessToken lookups.
@@ -65,13 +64,16 @@ export class PersonalAccessTokensCache {
6564
private makeCachedPersonalAccessToken(
6665
personalAccessToken: PersonalAccessToken,
6766
membership: OrganizationMembership,
68-
) {
69-
const authorizationPolicyStatements =
70-
personalAccessToken.resolveAuthorizationPolicyStatements(membership);
71-
67+
): CachedPersonalAccessToken {
7268
return {
73-
...personalAccessToken,
74-
authorizationPolicyStatements,
69+
id: personalAccessToken.id,
70+
userId: personalAccessToken.userId,
71+
organizationId: personalAccessToken.organizationId,
72+
hash: personalAccessToken.hash,
73+
authorizationPolicyStatements: PersonalAccessTokens.computeAuthorizationStatements(
74+
personalAccessToken,
75+
membership,
76+
),
7577
};
7678
}
7779

packages/services/api/src/modules/organization/providers/personal-access-tokens.ts

Lines changed: 134 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Inject, Injectable, Scope } from 'graphql-modules';
22
import { sql, type CommonQueryMethods, type DatabasePool } from 'slonik';
33
import z from 'zod';
4+
import { cache } from '@hive/api/shared/helpers';
45
import {
56
decodeCreatedAtAndUUIDIdBasedCursor,
67
encodeCreatedAtAndUUIDIdBasedCursor,
@@ -38,7 +39,6 @@ import {
3839
})
3940
export class PersonalAccessTokens {
4041
logger: Logger;
41-
private findById: ReturnType<typeof PersonalAccessTokens.findById>;
4242

4343
constructor(
4444
@Inject(PG_POOL_CONFIG) private pool: DatabasePool,
@@ -54,7 +54,6 @@ export class PersonalAccessTokens {
5454
this.logger = logger.child({
5555
source: 'OrganizationAccessTokens',
5656
});
57-
this.findById = PersonalAccessTokens.findById({ logger: this.logger, pool });
5857
}
5958

6059
async create(args: {
@@ -183,18 +182,25 @@ export class PersonalAccessTokens {
183182
async delete(args: { personalAccessTokenId: string }) {
184183
const viewer = await this.session.getViewer();
185184

186-
const record = await this.findById(args.personalAccessTokenId);
185+
const record = await PersonalAccessTokens.findById({
186+
pool: this.pool,
187+
logger: this.logger,
188+
})(args.personalAccessTokenId);
187189

188-
if (record === null || record.userId !== viewer.id) {
189-
this.session.raise('accessToken:modify');
190+
if (!record) {
191+
return null;
190192
}
191193

192194
await this.session.assertPerformAction({
193-
action: 'personalAccessToken:modify',
194195
organizationId: record.organizationId,
195196
params: { organizationId: record.organizationId },
197+
action: 'personalAccessToken:modify',
196198
});
197199

200+
if (record.userId !== viewer.id) {
201+
return null;
202+
}
203+
198204
await this.pool.query(sql`
199205
DELETE
200206
FROM
@@ -220,11 +226,13 @@ export class PersonalAccessTokens {
220226
};
221227
}
222228

223-
async getPaginated(args: { organizationId: string; first: number | null; after: string | null }) {
224-
const viewer = await this.session.getViewer();
229+
async getPaginatedForMembership(
230+
membership: OrganizationMembership,
231+
args: { first: number | null; after: string | null },
232+
) {
225233
await this.session.assertPerformAction({
226-
organizationId: args.organizationId,
227-
params: { organizationId: args.organizationId },
234+
organizationId: membership.organizationId,
235+
params: { organizationId: membership.organizationId },
228236
action: 'personalAccessToken:modify',
229237
});
230238

@@ -245,8 +253,8 @@ export class PersonalAccessTokens {
245253
FROM
246254
"personal_access_tokens"
247255
WHERE
248-
"organization_id" = ${args.organizationId}
249-
AND "user_id" = ${viewer.id}
256+
"organization_id" = ${membership.organizationId}
257+
AND "user_id" = ${membership.userId}
250258
${
251259
cursor
252260
? sql`
@@ -298,6 +306,25 @@ export class PersonalAccessTokens {
298306
};
299307
}
300308

309+
async findByIdForMembership(membership: OrganizationMembership, accessTokenId: string) {
310+
await this.session.assertPerformAction({
311+
organizationId: membership.organizationId,
312+
params: { organizationId: membership.organizationId },
313+
action: 'personalAccessToken:modify',
314+
});
315+
316+
const accessToken = await PersonalAccessTokens.findById({
317+
logger: this.logger,
318+
pool: this.pool,
319+
})(accessTokenId);
320+
321+
if (!accessToken || accessToken.userId !== viewer.id) {
322+
return null;
323+
}
324+
325+
return accessToken;
326+
}
327+
301328
/**
302329
* Implementation for finding a personal access token from the PG database.
303330
* It is a static function, so we can use it for the personal access tokens cache.
@@ -345,6 +372,87 @@ export class PersonalAccessTokens {
345372
return result;
346373
};
347374
}
375+
376+
static computeResources(
377+
personalAccessToken: PersonalAccessToken,
378+
membership: OrganizationMembership,
379+
) {
380+
const legitAssignedResources = intersectResourceAssignments(
381+
membership.assignedRole.resources,
382+
personalAccessToken.assignedResources,
383+
);
384+
385+
return legitAssignedResources;
386+
}
387+
388+
static computePermissions(
389+
personalAccessToken: PersonalAccessToken,
390+
membership: OrganizationMembership,
391+
) {
392+
// If the access token specifies no permissions, we use the permissions of the member role
393+
if (personalAccessToken.permissions === null) {
394+
return membership.assignedRole.role.allPermissions;
395+
}
396+
397+
// The roles permission could have been updated.
398+
// Because of that we always need to filter this list based on the role.
399+
return intersection(
400+
new Set(personalAccessToken.permissions),
401+
membership.assignedRole.role.allPermissions,
402+
);
403+
}
404+
405+
static computeAuthorizationStatements(
406+
personalAccessToken: PersonalAccessToken,
407+
membership: OrganizationMembership,
408+
) {
409+
const permissions = PersonalAccessTokens.computePermissions(personalAccessToken, membership);
410+
const resources = PersonalAccessTokens.computeResources(personalAccessToken, membership);
411+
412+
const permissionsPerLevel = permissionsToPermissionsPerResourceLevelAssignment(permissions);
413+
const resolvedResources = resolveResourceAssignment({
414+
organizationId: personalAccessToken.organizationId,
415+
projects: resources,
416+
});
417+
418+
return translateResolvedResourcesToAuthorizationPolicyStatements(
419+
personalAccessToken.organizationId,
420+
permissionsPerLevel,
421+
resolvedResources,
422+
);
423+
}
424+
425+
@cache((...values: Array<string>) => values.join('|'))
426+
async getMembership(organizationId: string, userId: string) {
427+
const organization = await this.storage.getOrganization({ organizationId });
428+
const membership = await this.organizationMembers.findOrganizationMembership({
429+
organization,
430+
userId,
431+
});
432+
433+
if (!membership) {
434+
throw new Error('Should be able to find membership.');
435+
}
436+
437+
return membership;
438+
}
439+
440+
async getResourcesForPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
441+
const membership = await this.getMembership(
442+
personalAccessToken.organizationId,
443+
personalAccessToken.userId,
444+
);
445+
446+
return PersonalAccessTokens.computeResources(personalAccessToken, membership);
447+
}
448+
449+
async getPermissionsForPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
450+
const membership = await this.getMembership(
451+
personalAccessToken.organizationId,
452+
personalAccessToken.userId,
453+
);
454+
return PersonalAccessTokens.computePermissions(personalAccessToken, membership);
455+
}
348456
}
349457

350458
const personalAccessTokenFields = sql`
@@ -360,58 +468,20 @@ const personalAccessTokenFields = sql`
360468
, "hash"
361469
`;
362470

363-
const PersonalAccessTokenModel = z
364-
.object({
365-
id: z.string().uuid(),
366-
organizationId: z.string().uuid(),
367-
userId: z.string().uuid(),
368-
createdAt: z.string(),
369-
title: z.string(),
370-
description: z.string(),
371-
permissions: z.array(PermissionsModel).nullable(),
372-
assignedResources: ResourceAssignmentModel.nullable().transform(
373-
value => value ?? { mode: '*' as const, projects: [] },
374-
),
375-
firstCharacters: z.string(),
376-
hash: z.string(),
377-
})
378-
.transform(record => ({
379-
...record,
380-
// Only used in the context of authorization, we do not need
381-
// to compute when querying a list of organization access tokens via the GraphQL API.
382-
// Compared to organization access tokens, we also need to filter down the permissions based on the membership
383-
resolveAuthorizationPolicyStatements(organizationMembership: OrganizationMembership) {
384-
const legitPermissions =
385-
// If the access token specifies no permissions, we use the permissions of the member role
386-
record.permissions === null
387-
? organizationMembership.assignedRole.role.allPermissions
388-
: // The roles permission could have been updated.
389-
// Because of that we always need to filter this list based on the role.
390-
intersection(
391-
new Set(record.permissions),
392-
organizationMembership.assignedRole.role.allPermissions,
393-
);
394-
395-
// The membership resources could have been updated.
396-
// Because of that we always need to filter this list based on the role.
397-
const legitAssignedResources = intersectResourceAssignments(
398-
organizationMembership.assignedRole.resources,
399-
record.assignedResources,
400-
);
401-
402-
const permissions = permissionsToPermissionsPerResourceLevelAssignment(legitPermissions);
403-
const resolvedResources = resolveResourceAssignment({
404-
organizationId: record.organizationId,
405-
projects: legitAssignedResources,
406-
});
407-
408-
return translateResolvedResourcesToAuthorizationPolicyStatements(
409-
record.organizationId,
410-
permissions,
411-
resolvedResources,
412-
);
413-
},
414-
}));
471+
const PersonalAccessTokenModel = z.object({
472+
id: z.string().uuid(),
473+
organizationId: z.string().uuid(),
474+
userId: z.string().uuid(),
475+
createdAt: z.string(),
476+
title: z.string(),
477+
description: z.string(),
478+
permissions: z.array(PermissionsModel).nullable(),
479+
assignedResources: ResourceAssignmentModel.nullable().transform(
480+
value => value ?? { mode: '*' as const, projects: [] },
481+
),
482+
firstCharacters: z.string(),
483+
hash: z.string(),
484+
});
415485

416486
export type PersonalAccessToken = z.TypeOf<typeof PersonalAccessTokenModel>;
417487

packages/services/api/src/modules/organization/resolvers/Member.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ export const Member: MemberResolvers = {
4545
});
4646
},
4747
personalAccessTokens: async (member, args, { injector }) => {
48-
return injector.get(PersonalAccessTokens).getPaginated({
49-
organizationId: member.organizationId,
48+
return injector.get(PersonalAccessTokens).getPaginatedForMembership(member, {
5049
first: args.first ?? null,
5150
after: args.after ?? null,
5251
});
@@ -58,4 +57,7 @@ export const Member: MemberResolvers = {
5857
member.assignedRole.role.allPermissions,
5958
);
6059
},
60+
personalAccessToken: async (member, args, { injector }) => {
61+
return injector.get(PersonalAccessTokens).findByIdForMembership(member, args.id);
62+
},
6163
};

0 commit comments

Comments
 (0)