Skip to content

Commit 035ea42

Browse files
authored
Merge pull request #77 from clintjeff2/Add-role-hierarchy-and-temporary-assignments-#55
Add role hierarchy and temporary assignments #55
2 parents 848fc89 + 8ab535a commit 035ea42

5 files changed

Lines changed: 212 additions & 137 deletions

File tree

apps/access-api/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ model RoleAssignment {
7979
role Role
8080
source RoleSource
8181
active Boolean @default(true)
82+
expiresAt DateTime?
8283
createdAt DateTime @default(now())
8384
member Member @relation(fields: [memberId], references: [id])
8485
}

apps/access-api/src/services/memberService.ts

Lines changed: 117 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import {
44
AccessDecision,
55
Role,
66
RoleContext,
7+
AssignRoleInput,
8+
RemoveRoleInput,
9+
RoleMutationResult,
710
} from "@guildpass/shared-types";
811
import { evaluate } from "@guildpass/policy-engine";
912
import { logEvent } from "./auditService";
@@ -18,6 +21,16 @@ function normaliseWallet(wallet: string): string {
1821
return wallet.toLowerCase();
1922
}
2023

24+
function getNormalizedMembershipState(
25+
state: string,
26+
expiresAt?: Date | null,
27+
): string {
28+
if (expiresAt && new Date(expiresAt) < new Date()) {
29+
return "expired";
30+
}
31+
return state;
32+
}
33+
2134
function accessDecisionCacheKey({
2235
communityId,
2336
wallet,
@@ -121,7 +134,6 @@ export function getMemberService(prismaClient: PrismaClient) {
121134
afterState: { evaluation: input.details ?? null },
122135
});
123136
} catch (err) {
124-
// Never fail access because audit failed.
125137
// eslint-disable-next-line no-console
126138
console.error("Failed to log access audit event:", err);
127139
}
@@ -140,8 +152,8 @@ export function getMemberService(prismaClient: PrismaClient) {
140152
...versions,
141153
});
142154

143-
const cached = await cacheService.getJSON<AccessDecision>(cacheKey);
144-
if (cached) return cached;
155+
const cached = await cacheService.getJSON<any>(cacheKey);
156+
if (cached) return cached as unknown as AccessDecision;
145157

146158
const w = await prismaClient.wallet.findUnique({
147159
where: { address: wallet },
@@ -203,13 +215,19 @@ export function getMemberService(prismaClient: PrismaClient) {
203215

204216
const ruleType = policy ? policy.ruleType : "MEMBERS_ONLY";
205217

218+
const effectiveState = getNormalizedMembershipState(
219+
(member.membership?.state as any) ?? "invited",
220+
member.membership?.expiresAt,
221+
);
222+
206223
const ctx: RoleContext = {
207224
assignments: member.roles.map((r) => ({
208225
role: r.role as any,
209226
source: r.source as any,
210227
active: r.active,
228+
expiresAt: r.expiresAt,
211229
})),
212-
membershipState: (member.membership?.state as any) ?? "invited",
230+
membershipState: effectiveState as any,
213231
};
214232

215233
const decision = evaluate(
@@ -259,11 +277,12 @@ export function getMemberService(prismaClient: PrismaClient) {
259277
),
260278
expiresAt: m.membership?.expiresAt?.toISOString() ?? null,
261279
}));
262-
return { wallet: normalizedWallet, communities };
280+
return { wallet: normaliseWallet(wallet), communities };
263281
},
264282
async getProfileByWallet(wallet: string) {
283+
const normalised = normaliseWallet(wallet);
265284
const w = await prismaClient.wallet.findUnique({
266-
where: { address: normaliseWallet(wallet) },
285+
where: { address: normalised },
267286
});
268287
if (!w) return null;
269288
const m = await prismaClient.member.findFirst({
@@ -272,7 +291,7 @@ export function getMemberService(prismaClient: PrismaClient) {
272291
});
273292
if (!m) return null;
274293
return {
275-
wallet: normalizedWallet,
294+
wallet: normalised,
276295
communityId: m.communityId,
277296
profile: {
278297
id: m.profile?.id ?? "",
@@ -290,77 +309,13 @@ export function getMemberService(prismaClient: PrismaClient) {
290309
};
291310
},
292311

293-
async checkAccess(input: AccessCheckInput): Promise<AccessDecision> {
294-
const wallet = input.wallet.toLowerCase();
295-
const w = await db.wallet.findUnique({ where: { address: wallet } });
296-
if (!w) {
297-
return {
298-
allowed: false,
299-
code: "DENY",
300-
reasons: [{ code: "NO_WALLET", message: "Wallet not known" }],
301-
membershipState: "invited",
302-
effectiveRoles: [],
303-
};
304-
}
305-
const member = await db.member.findFirst({
306-
where: { walletId: w.id, communityId: input.communityId },
307-
include: { roles: true, membership: true },
308-
});
309-
if (!member) {
310-
return {
311-
allowed: false,
312-
code: "DENY",
313-
reasons: [
314-
{
315-
code: "NOT_MEMBER",
316-
message: "Wallet is not a member of community",
317-
},
318-
],
319-
membershipState: "invited",
320-
effectiveRoles: [],
321-
};
322-
}
323-
const policy = await db.accessPolicy.findFirst({
324-
where: { communityId: input.communityId, resource: input.resource },
325-
});
326-
const ruleType = policy ? policy.ruleType : "MEMBERS_ONLY";
327-
const effectiveState = getNormalizedMembershipState(
328-
member.membership?.state ?? "invited",
329-
member.membership?.expiresAt,
330-
);
331-
const ctx: RoleContext = {
332-
assignments: member.roles.map((r: any) => ({
333-
role: r.role as any,
334-
source: r.source as any,
335-
active: r.active,
336-
})),
337-
membershipState: effectiveState as any,
338-
};
339-
const decision = evaluate(
340-
{
341-
id: policy?.id ?? "default",
342-
communityId: input.communityId,
343-
resource: input.resource,
344-
ruleType: ruleType,
345-
params: policy?.params as Record<string, any> | undefined,
346-
},
347-
ctx,
348-
);
349-
return decision;
350-
},
312+
checkAccess,
313+
351314
async listMembersForAdmin(
352315
communityId: string,
353-
role?: "admin" | "member" | "contributor",
316+
role?: Role,
354317
) {
355-
// NOTE: list endpoint is intended for community admins.
356-
// Enforcing requester-admin auth requires requester wallet identity, which is not provided here.
357-
// This endpoint is for admin listing only; enforce auth here.
358-
// NOTE: This service method receives only communityId + optional role, so
359-
// requester auth is expected to be enforced by the route via a wrapper.
360-
// (We keep listing open at service-layer to avoid breaking existing API.)
361-
const members = await db.member.findMany({
362-
363-
318+
const members = await prismaClient.member.findMany({
364319
where: { communityId },
365320
include: { wallet: true, membership: true, roles: true, profile: true },
366321
});
@@ -383,7 +338,93 @@ export function getMemberService(prismaClient: PrismaClient) {
383338
return { communityId, members: list };
384339
},
385340

386-
// Invalidation hooks (call from mutation/event handlers)
341+
async assignMemberRole(input: AssignRoleInput): Promise<RoleMutationResult> {
342+
const { requesterWallet, communityId, targetWallet, role } = input;
343+
const validRoles: Role[] = ["admin", "member", "contributor"];
344+
if (!validRoles.includes(role)) {
345+
throw { statusCode: 400, message: "Invalid role" };
346+
}
347+
348+
const requester = await prismaClient.wallet.findUnique({
349+
where: { address: normaliseWallet(requesterWallet) },
350+
});
351+
if (!requester) throw { statusCode: 403, message: "Requester not found" };
352+
353+
const requesterMember = await prismaClient.member.findFirst({
354+
where: { walletId: requester.id, communityId },
355+
include: { roles: true },
356+
});
357+
const isRequesterAdmin = requesterMember?.roles.some(
358+
(r) => r.role === "admin" && r.active,
359+
);
360+
if (!isRequesterAdmin) throw { statusCode: 403, message: "Not authorized" };
361+
362+
const target = await prismaClient.wallet.findUnique({
363+
where: { address: normaliseWallet(targetWallet) },
364+
});
365+
if (!target) throw { statusCode: 404, message: "Target wallet not found" };
366+
367+
const targetMember = await prismaClient.member.findFirst({
368+
where: { walletId: target.id, communityId },
369+
});
370+
if (!targetMember) throw { statusCode: 404, message: "Target not a member" };
371+
372+
const existing = await prismaClient.roleAssignment.findFirst({
373+
where: { memberId: targetMember.id, role, active: true },
374+
});
375+
if (existing) {
376+
return { communityId, wallet: targetWallet, role, assigned: false, removed: false, message: "Role already assigned" };
377+
}
378+
379+
await prismaClient.roleAssignment.create({
380+
data: {
381+
memberId: targetMember.id,
382+
role,
383+
source: "manual",
384+
active: true,
385+
},
386+
});
387+
388+
await bumpRoleVersion(communityId);
389+
return { communityId, wallet: targetWallet, role, assigned: true, removed: false };
390+
},
391+
392+
async removeMemberRole(input: RemoveRoleInput): Promise<RoleMutationResult> {
393+
const { requesterWallet, communityId, targetWallet, role } = input;
394+
395+
const requester = await prismaClient.wallet.findUnique({
396+
where: { address: normaliseWallet(requesterWallet) },
397+
});
398+
if (!requester) throw { statusCode: 403, message: "Requester not found" };
399+
400+
const requesterMember = await prismaClient.member.findFirst({
401+
where: { walletId: requester.id, communityId },
402+
include: { roles: true },
403+
});
404+
const isRequesterAdmin = requesterMember?.roles.some(
405+
(r) => r.role === "admin" && r.active,
406+
);
407+
if (!isRequesterAdmin) throw { statusCode: 403, message: "Not authorized" };
408+
409+
const target = await prismaClient.wallet.findUnique({
410+
where: { address: normaliseWallet(targetWallet) },
411+
});
412+
if (!target) throw { statusCode: 404, message: "Target wallet not found" };
413+
414+
const targetMember = await prismaClient.member.findFirst({
415+
where: { walletId: target.id, communityId },
416+
});
417+
if (!targetMember) throw { statusCode: 404, message: "Target not a member" };
418+
419+
await prismaClient.roleAssignment.updateMany({
420+
where: { memberId: targetMember.id, role, active: true },
421+
data: { active: false },
422+
});
423+
424+
await bumpRoleVersion(communityId);
425+
return { communityId, wallet: targetWallet, role, assigned: false, removed: true };
426+
},
427+
387428
bumpMembershipVersion,
388429
bumpRoleVersion,
389430
bumpPolicyVersion,
@@ -393,11 +434,7 @@ export function getMemberService(prismaClient: PrismaClient) {
393434

394435
export const memberService = getMemberService(prisma);
395436

396-
// Backwards-compatible re-export of the invalidation hooks.
397-
// These are intended to be called by membership/role/policy mutation handlers.
398437
export const bumpMembershipVersion = memberService.bumpMembershipVersion;
399438
export const bumpRoleVersion = memberService.bumpRoleVersion;
400439
export const bumpPolicyVersion = memberService.bumpPolicyVersion;
401440
export const bumpResourceVersion = memberService.bumpResourceVersion;
402-
403-

packages/policy-engine/src/index.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,33 @@ function validatePolicy(
4242

4343
export function resolveEffectiveRoles(ctx: RoleContext): Role[] {
4444
const roles: Role[] = [];
45+
const now = new Date();
46+
4547
for (const a of ctx.assignments) {
46-
if (a.active) roles.push(a.role);
48+
if (!a.active) continue;
49+
if (a.expiresAt) {
50+
const expiry = new Date(a.expiresAt);
51+
if (expiry < now) continue;
52+
}
53+
roles.push(a.role);
4754
}
55+
4856
if (ctx.membershipState === "active") {
4957
roles.push("member");
5058
}
51-
return unique(roles);
59+
60+
// Role hierarchy implementation:
61+
// admin -> contributor -> member
62+
const effective: Role[] = [...roles];
63+
if (roles.includes("admin")) {
64+
effective.push("contributor");
65+
effective.push("member");
66+
}
67+
if (roles.includes("contributor")) {
68+
effective.push("member");
69+
}
70+
71+
return unique(effective);
5272
}
5373

5474
export function evaluate(

0 commit comments

Comments
 (0)