Skip to content

Instantly share code, notes, and snippets.

@tengla
Created February 26, 2025 09:57
Show Gist options
  • Save tengla/efcedebdee3153b57aed58b6ff9ec0a2 to your computer and use it in GitHub Desktop.
Save tengla/efcedebdee3153b57aed58b6ff9ec0a2 to your computer and use it in GitHub Desktop.
Separation of concerns
// 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