Skip to content

Commit 6e1ff11

Browse files
committed
wip implementation
1 parent 9626a63 commit 6e1ff11

File tree

11 files changed

+201
-11
lines changed

11 files changed

+201
-11
lines changed

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,22 @@ export const AuditLogModel = z.union([
342342
organizationAccessTokenId: z.string().uuid(),
343343
}),
344344
}),
345+
z.object({
346+
eventType: z.literal('PERSONAL_ACCESS_TOKEN_CREATED'),
347+
metadata: z.object({
348+
organizationAccessTokenId: z.string().uuid(),
349+
userId: z.string().uuid(),
350+
permissions: z.array(z.string()),
351+
assignedResources: ResourceAssignmentModel,
352+
}),
353+
}),
354+
z.object({
355+
eventType: z.literal('PERSONAL_ACCESS_TOKEN_DELETED'),
356+
metadata: z.object({
357+
organizationAccessTokenId: z.string().uuid(),
358+
userId: z.string().uuid(),
359+
}),
360+
}),
345361
]);
346362

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

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AccessError } from '../../../shared/errors';
66
import { objectEntries, objectFromEntries } from '../../../shared/helpers';
77
import { isUUID } from '../../../shared/is-uuid';
88
import type { OrganizationAccessToken } from '../../organization/providers/organization-access-tokens';
9+
import type { CachedPersonalAccessToken } from '../../organization/providers/personal-access-tokens-cache';
910
import { Logger } from '../../shared/providers/logger';
1011

1112
export type AuthorizationPolicyStatement = {
@@ -44,7 +45,6 @@ function parseResourceIdentifier(resource: string) {
4445
}
4546

4647
// TODO: maybe some stricter validation of the resource id characters
47-
4848
return { organizationId, resourceId: parts[2] };
4949
}
5050

@@ -58,7 +58,12 @@ export type OrganizationAccessTokenActor = {
5858
organizationAccessToken: OrganizationAccessToken;
5959
};
6060

61-
type Actor = UserActor | OrganizationAccessTokenActor;
61+
export type PersonalAccessTokenActor = {
62+
type: 'personalAccessToken';
63+
personalAccessToken: CachedPersonalAccessToken;
64+
};
65+
66+
type Actor = UserActor | OrganizationAccessTokenActor | PersonalAccessTokenActor;
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'),
@@ -485,7 +491,7 @@ export function getPermissionGroup(permission: Permission): ResourceLevel {
485491
* Transforms a flat permission array into an object that groups the permissions per resource level.
486492
*/
487493
export function permissionsToPermissionsPerResourceLevelAssignment(
488-
permissions: Array<Permission>,
494+
permissions: Iterable<Permission>,
489495
): PermissionsPerResourceLevelAssignment {
490496
const assignment: PermissionsPerResourceLevelAssignment = {
491497
organization: new Set(),

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { OrganizationAccessTokensCache } from './providers/organization-access-t
44
import { OrganizationManager } from './providers/organization-manager';
55
import { OrganizationMemberRoles } from './providers/organization-member-roles';
66
import { OrganizationMembers } from './providers/organization-members';
7+
import { PersonalAccessTokens } from './providers/personal-access-tokens';
8+
import { PersonalAccessTokensCache } from './providers/personal-access-tokens-cache';
79
import { ResourceAssignments } from './providers/resource-assignments';
810
import { resolvers } from './resolvers.generated';
911
import typeDefs from './module.graphql';
@@ -18,7 +20,9 @@ export const organizationModule = createModule({
1820
OrganizationMembers,
1921
OrganizationManager,
2022
OrganizationAccessTokens,
23+
PersonalAccessTokens,
2124
ResourceAssignments,
2225
OrganizationAccessTokensCache,
26+
PersonalAccessTokensCache,
2327
],
2428
});

packages/services/api/src/modules/organization/lib/resource-assignment-model.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const AssignedServicesModel = z.union([
2626
WildcardAssignmentMode,
2727
]);
2828

29+
type AssignedServices = z.TypeOf<typeof AssignedServicesModel>;
30+
2931
const AssignedAppDeploymentsModel = z.union([
3032
z.object({
3133
mode: GranularAssignmentModeModel,
@@ -34,6 +36,8 @@ const AssignedAppDeploymentsModel = z.union([
3436
WildcardAssignmentMode,
3537
]);
3638

39+
type AssignedAppDeployments = z.TypeOf<typeof AssignedAppDeploymentsModel>;
40+
3741
export const TargetAssignmentModel = z.object({
3842
type: z.literal('target'),
3943
id: z.string().uuid(),
@@ -49,6 +53,8 @@ const AssignedTargetsModel = z.union([
4953
WildcardAssignmentMode,
5054
]);
5155

56+
type AssignedTargets = z.TypeOf<typeof AssignedTargetsModel>;
57+
5258
const ProjectAssignmentModel = z.object({
5359
type: z.literal('project'),
5460
id: z.string().uuid(),
@@ -79,3 +85,111 @@ export const ResourceAssignmentModel = z.union([
7985
*/
8086
export type ResourceAssignmentGroup = z.TypeOf<typeof ResourceAssignmentModel>;
8187
export type GranularAssignedProjects = z.TypeOf<typeof GranularAssignedProjectsModel>;
88+
89+
/**
90+
* Get the intersection of two resource assignments
91+
*/
92+
export function intersectResourceAssignments(
93+
a: ResourceAssignmentGroup,
94+
b: ResourceAssignmentGroup,
95+
): ResourceAssignmentGroup {
96+
if (a.mode === '*' && b.mode === '*') {
97+
return { mode: '*' };
98+
}
99+
100+
if (a.mode === '*') {
101+
return b;
102+
}
103+
104+
if (b.mode === '*') {
105+
return a;
106+
}
107+
108+
return {
109+
mode: 'granular',
110+
projects: a.projects
111+
.map(projectA => {
112+
const projectB = b.projects.find(p => p.id === projectA.id);
113+
if (!projectB) {
114+
return null;
115+
}
116+
117+
const intersectedTargets = intersectTargets(projectA.targets, projectB.targets);
118+
119+
return {
120+
...projectA,
121+
targets: intersectedTargets,
122+
};
123+
})
124+
.filter((p): p is NonNullable<typeof p> => p !== null),
125+
};
126+
}
127+
128+
function intersectTargets(a: AssignedTargets, b: AssignedTargets): AssignedTargets {
129+
if (a.mode === '*' && b.mode === '*') {
130+
return { mode: '*' };
131+
}
132+
133+
if (a.mode === '*') {
134+
return b;
135+
}
136+
137+
if (b.mode === '*') {
138+
return a;
139+
}
140+
141+
const targets = a.targets
142+
.map(targetA => {
143+
const targetB = b.targets.find(t => t.id === targetA.id);
144+
if (!targetB) return null;
145+
146+
return {
147+
...targetA,
148+
services: intersectServices(targetA.services, targetB.services),
149+
appDeployments: intersectAppDeployments(targetA.appDeployments, targetB.appDeployments),
150+
};
151+
})
152+
.filter(t => t !== null);
153+
154+
return { mode: 'granular', targets };
155+
}
156+
157+
function intersectServices(a: AssignedServices, b: AssignedServices): AssignedServices {
158+
if (a.mode === '*' && b.mode === '*') {
159+
return { mode: '*' };
160+
}
161+
if (a.mode === '*') {
162+
return b;
163+
}
164+
if (b.mode === '*') {
165+
return a;
166+
}
167+
168+
// Both granular
169+
const services = a.services.filter(s => b.services.some(sb => sb.serviceName === s.serviceName));
170+
return { mode: 'granular', services };
171+
}
172+
173+
function intersectAppDeployments(
174+
a: AssignedAppDeployments,
175+
b: AssignedAppDeployments,
176+
): AssignedAppDeployments {
177+
if (a.mode === '*' && b.mode === '*') {
178+
return { mode: '*' };
179+
}
180+
181+
if (a.mode === '*') {
182+
return b;
183+
}
184+
185+
if (b.mode === '*') {
186+
return a;
187+
}
188+
189+
// Both granular
190+
const appDeployments = a.appDeployments.filter(ad =>
191+
b.appDeployments.some(bd => bd.type === ad.type && bd.appName === ad.appName),
192+
);
193+
194+
return { mode: 'granular', appDeployments };
195+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ import {
2929
translateResolvedResourcesToAuthorizationPolicyStatements,
3030
} from './resource-assignments';
3131

32-
const TitleInputModel = z
32+
export const TitleInputModel = z
3333
.string()
3434
.trim()
3535
.regex(/^[ a-zA-Z0-9_-]+$/, 'Can only contain letters, numbers, " ", "_", and "-".')
3636
.min(2, 'Minimum length is 2 characters.')
3737
.max(100, 'Maximum length is 100 characters.');
3838

39-
const DescriptionInputModel = z
39+
export const DescriptionInputModel = z
4040
.string()
4141
.trim()
4242
.max(248, 'Maximum length is 248 characters.')

packages/services/api/src/modules/organization/providers/organization-member-roles.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ const MemberRoleModel = z
6767
return {
6868
...omit(record, 'legacyScopes'),
6969
permissions,
70+
get allPermissions() {
71+
const allPermissions = new Set<Permission>();
72+
Object.values(permissions).forEach(set => {
73+
set.forEach(permission => {
74+
allPermissions.add(permission);
75+
});
76+
});
77+
return allPermissions;
78+
},
7079
};
7180
});
7281

packages/services/api/src/modules/organization/providers/organization-members.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export type OrganizationMembership = {
5959
createdAt: string;
6060
};
6161

62+
/** Properties needed from organization. */
63+
type OrganizationMemberPartial = Pick<Organization, 'id' | 'ownerId'>;
64+
6265
@Injectable({
6366
scope: Scope.Operation,
6467
global: true,
@@ -99,7 +102,7 @@ export class OrganizationMembers {
99102
* them into resource based role assignments.
100103
*/
101104
private async resolveMemberships(
102-
organization: Organization,
105+
organization: OrganizationMemberPartial,
103106
organizationMembers: Array<z.TypeOf<typeof RawOrganizationMembershipModel>>,
104107
) {
105108
const organizationMembershipByUserId = new Map</* userId */ string, OrganizationMembership>();
@@ -164,7 +167,7 @@ export class OrganizationMembers {
164167
}
165168

166169
async getPaginatedOrganizationMembersForOrganization(
167-
organization: Organization,
170+
organization: OrganizationMemberPartial,
168171
args: { first: number | null; after: string | null },
169172
) {
170173
this.logger.debug(
@@ -245,7 +248,7 @@ export class OrganizationMembers {
245248
* Batched loader function for a organization membership.
246249
*/
247250
findOrganizationMembership = batchBy(
248-
(args: { organization: Organization; userId: string }) => args.organization.id,
251+
(args: { organization: OrganizationMemberPartial; userId: string }) => args.organization.id,
249252
async args => {
250253
const organization = args[0].organization;
251254
const userIds = args.map(arg => arg.userId);
@@ -271,7 +274,7 @@ export class OrganizationMembers {
271274
}
272275

273276
async findOrganizationMembershipByEmail(
274-
organization: Organization,
277+
organization: OrganizationMemberPartial,
275278
email: string,
276279
): Promise<OrganizationMembership | null> {
277280
this.logger.debug(

packages/services/api/src/modules/shared/providers/in-memory-rate-limiter.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ export class InMemoryRateLimiter {
6565
const limiter = this.store.ensureLimiter(action, windowSizeInMs, maxActions);
6666

6767
if (
68-
!limiter.isAllowed(actor.type === 'user' ? actor.user.id : actor.organizationAccessToken.id)
68+
!limiter.isAllowed(
69+
actor.type === 'user'
70+
? actor.user.id
71+
: actor.type === 'organizationAccessToken'
72+
? actor.organizationAccessToken.id
73+
: actor.personalAccessToken.id,
74+
)
6975
) {
7076
throw new HiveError(message);
7177
}

packages/services/server/src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
OrganizationMemberRoles,
2424
OrganizationMembers,
2525
} from '@hive/api';
26+
import { PersonalAccessTokenSessionStrategy } from '@hive/api/modules/auth/lib/personal-access-token-strategy';
27+
import { PersonalAccessTokensCache } from '@hive/api/modules/organization/providers/personal-access-tokens-cache';
2628
import { HivePubSub } from '@hive/api/modules/shared/providers/pub-sub';
2729
import { createRedisClient } from '@hive/api/modules/shared/providers/redis';
2830
import { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache';
@@ -413,6 +415,13 @@ export async function main() {
413415
accessTokenValidationCache: registry.injector.get(AccessTokenValidationCache),
414416
});
415417

418+
const personalAccessTokenStrategy = (logger: Logger) =>
419+
new PersonalAccessTokenSessionStrategy(
420+
logger,
421+
registry.injector.get(PersonalAccessTokensCache),
422+
registry.injector.get(AccessTokenValidationCache),
423+
);
424+
416425
const graphqlPath = '/graphql';
417426
const port = env.http.port;
418427
const signature = Math.random().toString(16).substr(2);
@@ -443,6 +452,7 @@ export async function main() {
443452
),
444453
}),
445454
organizationAccessTokenStrategy,
455+
personalAccessTokenStrategy,
446456
(logger: Logger) =>
447457
new TargetAccessTokenStrategy({
448458
logger,
@@ -461,7 +471,7 @@ export async function main() {
461471
});
462472

463473
const authN = new AuthN({
464-
strategies: [organizationAccessTokenStrategy],
474+
strategies: [organizationAccessTokenStrategy, personalAccessTokenStrategy],
465475
});
466476

467477
server.route({

0 commit comments

Comments
 (0)