Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { IncidentManagementModule } from './incident-management/incident-managem
import { MonitoringModule } from './monitoring/monitoring.module';
import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor';
import { GlobalExceptionFilter } from './common/interceptors/global-exception.filter';
import { RoleVisibilityInterceptor } from './common/interceptors/role-visibility.interceptor';
import { DeepLinkModule } from './deep-link/deep-link.module';
import { InvoicesModule } from './payments/invoices/invoices.module';
import { ReportingModule } from './payments/reporting/reporting.module';
Expand Down Expand Up @@ -72,6 +73,7 @@ const featureFlags = loadFeatureFlags();
providers: [
...(featureFlags.ENABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []),
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
{ provide: APP_INTERCEPTOR, useClass: RoleVisibilityInterceptor },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
})
Expand Down
50 changes: 50 additions & 0 deletions src/common/decorators/visible-to.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import 'reflect-metadata';
import { UserRole } from '../../users/entities/user.entity';

/**
* Metadata key used to store {@link VisibleTo} role lists on entity properties.
* @internal
*/
export const VISIBLE_TO_METADATA_KEY = 'visibleTo:roles';

/**
* Marks an entity field as visible only to the specified roles.
*
* When a response is serialised by {@link RoleVisibilityInterceptor}, any
* field decorated with `@VisibleTo` that is **not** in the viewer's role list
* is deleted from the outgoing object before it reaches the client.
*
* Placing `@VisibleTo` on a new field is sufficient to enforce visibility —
* no additional per-route configuration is needed.
*
* @example
* ```ts
* \@VisibleTo(UserRole.ADMIN)
* refreshToken?: string;
*
* \@VisibleTo(UserRole.ADMIN, UserRole.MODERATOR)
* sensitiveScore?: number;
* ```
*/
export function VisibleTo(...roles: UserRole[]): PropertyDecorator {
return (target: object, propertyKey: string | symbol): void => {
// Accumulate existing metadata so multiple decorators on the same class
// don't overwrite each other.
const existing: Map<string | symbol, UserRole[]> =
Reflect.getOwnMetadata(VISIBLE_TO_METADATA_KEY, target.constructor) ?? new Map();
existing.set(propertyKey, roles);
Reflect.defineMetadata(VISIBLE_TO_METADATA_KEY, existing, target.constructor);
};
}

/**
* Returns the `@VisibleTo` role map for a given constructor, or `null` when
* the class has no `@VisibleTo` annotations.
*
* @internal Used by {@link RoleVisibilityInterceptor}.
*/
export function getVisibilityMap(
ctor: new (...args: unknown[]) => unknown,
): Map<string | symbol, UserRole[]> | null {
return Reflect.getOwnMetadata(VISIBLE_TO_METADATA_KEY, ctor) ?? null;
}
171 changes: 171 additions & 0 deletions src/common/interceptors/role-visibility.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import 'reflect-metadata';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';
import { lastValueFrom } from 'rxjs';

Check failure on line 4 in src/common/interceptors/role-visibility.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / validate

'rxjs' import is duplicated
import { RoleVisibilityInterceptor } from './role-visibility.interceptor';
import { VisibleTo } from '../decorators/visible-to.decorator';
import { UserRole } from '../../users/entities/user.entity';

// ─── Test entity ─────────────────────────────────────────────────────────────

class SensitiveResource {
id: string = 'r1';
name: string = 'Public Name';

@VisibleTo(UserRole.ADMIN)
secretToken: string = 'tok-secret';

@VisibleTo(UserRole.ADMIN, UserRole.MODERATOR)
internalScore: number = 99;
}

// ─── Test helpers ─────────────────────────────────────────────────────────────

function buildContext(role?: UserRole | null): ExecutionContext {
const user = role === null ? undefined : { role: role ?? UserRole.STUDENT };
return {
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as unknown as ExecutionContext;
}

function buildHandler(value: unknown): CallHandler {
return { handle: () => of(value) } as CallHandler;
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('RoleVisibilityInterceptor', () => {
let interceptor: RoleVisibilityInterceptor;

beforeEach(() => {
interceptor = new RoleVisibilityInterceptor();
});

describe('unauthenticated requests', () => {
it('passes the response through unchanged when there is no user', async () => {
const resource = new SensitiveResource();
const result = await lastValueFrom(
interceptor.intercept(buildContext(null), buildHandler(resource)),
);
expect(result).toBe(resource);
});
});

describe('ADMIN role', () => {
it('returns all fields including @VisibleTo(ADMIN) ones', async () => {
const resource = new SensitiveResource();
const result = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.ADMIN), buildHandler(resource)),
)) as Record<string, unknown>;

expect(result['secretToken']).toBe('tok-secret');
expect(result['internalScore']).toBe(99);
});
});

describe('STUDENT role', () => {
it('strips @VisibleTo(ADMIN) fields', async () => {
const resource = new SensitiveResource();
const result = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(resource)),
)) as Record<string, unknown>;

expect('secretToken' in result).toBe(false);
});

it('strips @VisibleTo(ADMIN, MODERATOR) fields', async () => {
const resource = new SensitiveResource();
const result = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(resource)),
)) as Record<string, unknown>;

expect('internalScore' in result).toBe(false);
});

it('preserves non-annotated public fields', async () => {
const resource = new SensitiveResource();
const result = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(resource)),
)) as Record<string, unknown>;

expect(result['id']).toBe('r1');
expect(result['name']).toBe('Public Name');
});
});

describe('MODERATOR role', () => {
it('strips @VisibleTo(ADMIN) fields but keeps @VisibleTo(ADMIN, MODERATOR) fields', async () => {
const resource = new SensitiveResource();
const result = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.MODERATOR), buildHandler(resource)),
)) as Record<string, unknown>;

expect('secretToken' in result).toBe(false);
expect(result['internalScore']).toBe(99);
});
});

describe('array responses', () => {
it('strips restricted fields from every element', async () => {
const resources = [new SensitiveResource(), new SensitiveResource()];
const results = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(resources)),
)) as Record<string, unknown>[];

expect(results).toHaveLength(2);
results.forEach((r) => {
expect('secretToken' in r).toBe(false);
expect(r['id']).toBe('r1');
});
});
});

describe('paginated responses', () => {
it('strips fields from items inside { data: [...] }', async () => {
const payload = { data: [new SensitiveResource()], total: 1 };
const result = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(payload)),
)) as { data: Record<string, unknown>[] };

expect('secretToken' in result.data[0]).toBe(false);
expect(result.data[0]['id']).toBe('r1');
});

it('strips fields from items inside { items: [...] }', async () => {
const payload = { items: [new SensitiveResource()], total: 1 };
const result = (await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(payload)),
)) as { items: Record<string, unknown>[] };

expect('secretToken' in result.items[0]).toBe(false);
});
});

describe('primitive and null values', () => {
it('returns primitive values unchanged', async () => {
const result = await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(42)),
);
expect(result).toBe(42);
});

it('returns null unchanged', async () => {
const result = await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(null)),
);
expect(result).toBeNull();
});
});

describe('plain objects without @VisibleTo annotations', () => {
it('returns all fields for plain objects', async () => {
const plain = { a: 1, b: 2 };
const result = await lastValueFrom(
interceptor.intercept(buildContext(UserRole.STUDENT), buildHandler(plain)),
);
expect(result).toEqual({ a: 1, b: 2 });
});
});
});
110 changes: 110 additions & 0 deletions src/common/interceptors/role-visibility.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Request } from 'express';
import { UserRole } from '../../users/entities/user.entity';
import { getVisibilityMap } from '../decorators/visible-to.decorator';

interface JwtUser {
userId?: string;
role?: UserRole;
roles?: string[];
}

/**
* Global interceptor that enforces `@VisibleTo` field-level visibility.
*
* For every outgoing response it:
* 1. Determines the viewer's role from `request.user`.
* 2. Inspects each plain-object value in the response recursively.
* 3. Looks up the `@VisibleTo` metadata on the value's constructor.
* 4. Deletes any field that the viewer's role is not permitted to see.
*
* Fields without a `@VisibleTo` annotation are always returned as-is.
* Unauthenticated requests are not affected (auth guards handle access).
*
* Supports single objects, arrays, and paginated responses shaped as
* `{ data: [...], ... }` or `{ items: [...], ... }`.
*/
@Injectable()
export class RoleVisibilityInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request & { user?: JwtUser }>();
const viewer = req.user;

// Skip unauthenticated requests — auth guards control access.
if (!viewer) {
return next.handle();
}

const viewerRole = this.resolveRole(viewer);

return next.handle().pipe(map((data) => this.strip(data, viewerRole)));
}

// ─── Private helpers ───────────────────────────────────────────────────────

private resolveRole(user: JwtUser): UserRole {
if (user.role) return user.role;
// JWT payloads may carry roles as a string array
if (user.roles && user.roles.length > 0) {
return user.roles[0] as UserRole;
}
return UserRole.STUDENT;
}

/**
* Recursively strips restricted fields from `data`.
* Returns primitive values and nulls unchanged.
*/
private strip(data: unknown, role: UserRole): unknown {
if (data === null || data === undefined) return data;
if (typeof data !== 'object') return data;

if (Array.isArray(data)) {
return data.map((item) => this.strip(item, role));
}

const record = data as Record<string, unknown>;

// Paginated response shapes — recurse into the items array.
if (Array.isArray(record['data'])) {
return { ...record, data: (record['data'] as unknown[]).map((i) => this.strip(i, role)) };
}
if (Array.isArray(record['items'])) {
return { ...record, items: (record['items'] as unknown[]).map((i) => this.strip(i, role)) };
}

return this.stripFields(record, role);
}

/**
* Removes fields whose `@VisibleTo` annotation excludes the viewer's role.
*/
private stripFields(obj: Record<string, unknown>, role: UserRole): Record<string, unknown> {
// Only inspect objects that came from a decorated class.
const ctor = Object.getPrototypeOf(obj)?.constructor as
| (new (...args: unknown[]) => unknown)
| undefined;

const visibilityMap = ctor ? getVisibilityMap(ctor) : null;

if (!visibilityMap) {
// No @VisibleTo annotations on this class — return a shallow copy as-is.
return { ...obj };
}

const result: Record<string, unknown> = { ...obj };

for (const [field, allowedRoles] of visibilityMap.entries()) {
const key = String(field);
if (!(key in result)) continue;

if (!allowedRoles.includes(role)) {
delete result[key];
}
}

return result;
}
}
5 changes: 5 additions & 0 deletions src/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { Course } from '../../courses/entities/course.entity';
import { Enrollment } from '../../courses/entities/enrollment.entity';
import { Role } from '../../rbac/entities/role.entity';
import { VisibleTo } from '../../common/decorators/visible-to.decorator';

export enum UserRole {
STUDENT = 'student',
Expand Down Expand Up @@ -58,9 +59,11 @@ export class User {
@Index()
providerId: string | null;

@VisibleTo(UserRole.ADMIN)
@Column({ nullable: true })
providerAccessToken: string | null;

@VisibleTo(UserRole.ADMIN)
@Column({ nullable: true })
providerRefreshToken: string | null;

Expand Down Expand Up @@ -99,9 +102,11 @@ export class User {
@Column({ type: 'timestamp', nullable: true })
passwordResetExpires?: Date;

@VisibleTo(UserRole.ADMIN)
@Column({ nullable: true })
refreshToken?: string;

@VisibleTo(UserRole.ADMIN)
@Column('text', { array: true, default: [] })
passwordHistory: string[];

Expand Down
Loading