Created
February 26, 2025 09:57
-
-
Save tengla/efcedebdee3153b57aed58b6ff9ec0a2 to your computer and use it in GitHub Desktop.
Separation of concerns
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/index.test.ts | |
import { describe, expect, it } from "bun:test"; | |
import { AppointmentAccessService } from "./index"; | |
describe('AppointmentAccessService', () => { | |
it('should allow user to find own appointments', () => { | |
const service = AppointmentAccessService.withUser({ | |
id: 'jane', roles: ['user'] | |
}); | |
const appointments = service.findAll(); | |
expect(appointments).toEqual([{ | |
id: '2', | |
name: 'Appointment 2', | |
}]); | |
}); | |
it('should allow user to find any appointments', () => { | |
const service = AppointmentAccessService.withUser({ | |
id: 'jane', roles: ['doctor'] | |
}); | |
const appointments = service.findAll(); | |
expect(appointments).toEqual([{ | |
id: '1', | |
name: 'Appointment 1', | |
userid: 'john', | |
}, { | |
id: '2', | |
name: 'Appointment 2', | |
userid: 'jane', | |
}]); | |
}); | |
it('should find own appointment', () => { | |
const service = AppointmentAccessService.withUser({ | |
id: 'jane', roles: ['user'] | |
}); | |
const appointment = service.find('2'); | |
expect(appointment).toEqual({ | |
id: '2', | |
name: 'Appointment 2', | |
}); | |
}); | |
it('should find any appointment', () => { | |
const service = AppointmentAccessService.withUser({ | |
id: 'jane', roles: ['doctor'] | |
}); | |
const appointment = service.find('1'); | |
expect(appointment).toEqual({ | |
id: '1', | |
name: 'Appointment 1', | |
userid: 'john', | |
}); | |
}); | |
}); | |
// src/index.ts | |
import { ForbiddenException, NotFoundException } from "@nestjs/common"; | |
import { AccessControl } from "accesscontrol"; | |
import permissions, { Resources } from "./permissions"; | |
declare module "accesscontrol" { | |
interface Permission { | |
filter<T, R = Partial<T>>(data: T[]): R[]; | |
filter<T, R = Partial<T>>(data: T): R; | |
} | |
} | |
const ac = new AccessControl(permissions).lock(); | |
export class User { | |
constructor(public id: string, public roles: string[]) { } | |
} | |
export interface Repository<T> { | |
find(id: string): T | undefined; | |
findAll(userid?: string): T[]; | |
} | |
function isOwn<T extends { userid: string }>(userid: string, record: T) { | |
return userid === record.userid; | |
} | |
export class AccessControlledRepository< | |
T extends { userid: string } | |
> { | |
constructor( | |
private repository: Repository<T>, | |
private accessControl: AccessControl, | |
private user: User, | |
private resource: keyof typeof Resources | |
) { } | |
findAll() { | |
const grant = this.accessControl.can(this.user.roles); | |
const readAny = grant.readAny(this.resource); | |
const readOwn = grant.readOwn(this.resource); | |
if (readAny.granted) { | |
const records = this.repository.findAll(); | |
return readAny.filter(records); | |
} | |
if (readOwn.granted) { | |
const records = this.repository.findAll(this.user.id) | |
return readOwn.filter(records); | |
} | |
throw new ForbiddenException('Access denied'); | |
} | |
find(id: string) { | |
const record = this.repository.find(id); | |
if (!record) { | |
throw new NotFoundException('Appointment not found'); | |
} | |
const grant = this.accessControl.can(this.user.roles) | |
const readAny = grant.readAny(this.resource); | |
const readOwn = grant.readOwn(this.resource); | |
if (readAny.granted) { | |
return readAny.filter(record); | |
} | |
if (!readOwn.granted) { | |
throw new ForbiddenException('Access denied'); | |
} | |
if (!isOwn(this.user.id, record)) { | |
throw new ForbiddenException('Access denied'); | |
} | |
return readOwn.filter(record); | |
} | |
} | |
export type AppointmentEntity = { id: string, name: string, userid: string }; | |
export class AppointmentRepository implements Repository<AppointmentEntity> { | |
private _appointments = [{ | |
id: "1", | |
name: "Appointment 1", | |
userid: "john", | |
}, { | |
id: "2", | |
name: "Appointment 2", | |
userid: "jane", | |
}]; | |
find(id: string) { | |
const records = this._appointments.find((appointment) => | |
appointment.id === id); | |
return records; | |
} | |
findAll(userid?: string) { | |
if (userid) { | |
const records = this._appointments.filter((appointment) => | |
appointment.userid === userid); | |
return records; | |
} | |
return this._appointments; | |
} | |
} | |
export class AppointmentAccessService extends AccessControlledRepository<AppointmentEntity> { | |
static withUser(user: User) { | |
return new AccessControlledRepository( | |
new AppointmentRepository(), | |
ac, user, 'appointment' | |
); | |
} | |
} | |
// src/permissions.ts | |
export const Resources = { | |
appointment: "appointment", | |
} as const; | |
export const Roles = { | |
doctor: "doctor", | |
nurse: "nurse", | |
anon: "anon", | |
user: "user", | |
} as const; | |
const perms: { | |
[k1 in keyof typeof Roles]: { | |
[k2 in keyof typeof Resources]: { | |
"read:any"?: string[]; | |
"create:own"?: string[]; | |
"delete:own"?: string[]; | |
"read:own"?: string[]; | |
}; | |
} | |
} = { | |
anon: { | |
appointment: {}, | |
}, | |
doctor: { | |
appointment: { | |
"read:any": ["*"], | |
}, | |
}, | |
nurse: { | |
appointment: { | |
"read:any": ["*"], | |
}, | |
}, | |
user: { | |
appointment: { | |
"create:own": ["id", "name"], | |
"delete:own": ["id", "name"], | |
"read:own": ["id", "name"], | |
}, | |
}, | |
} | |
export default perms; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment