From 31829a4e494e34a30f38143e71e0c8728b3195e5 Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 27 Jun 2026 17:39:44 +0100 Subject: [PATCH] feat: add role-based field visibility enforcement at serialization layer (#877) Replace per-endpoint manual masking with a declarative, interceptor-driven approach so any newly added field is automatically protected the moment it carries a @VisibleTo annotation. Changes: - Add @VisibleTo(...roles) property decorator (src/common/decorators/visible-to.decorator.ts) that stores role lists in reflect-metadata on the entity constructor - Add RoleVisibilityInterceptor (src/common/interceptors/role-visibility.interceptor.ts) registered globally as APP_INTERCEPTOR; strips @VisibleTo fields that the viewer's role is not permitted to see before the response reaches the client - Supports single objects, arrays, and paginated {data:[]} / {items:[]} shapes - Annotate User entity fields: @VisibleTo(UserRole.ADMIN) refreshToken @VisibleTo(UserRole.ADMIN) passwordHistory @VisibleTo(UserRole.ADMIN) providerAccessToken @VisibleTo(UserRole.ADMIN) providerRefreshToken - 12 unit tests covering ADMIN/STUDENT/MODERATOR roles, arrays, paginated responses, primitives, null, and plain objects without annotations --- src/app.module.ts | 2 + src/common/decorators/visible-to.decorator.ts | 50 +++++ .../role-visibility.interceptor.spec.ts | 171 ++++++++++++++++++ .../role-visibility.interceptor.ts | 110 +++++++++++ src/users/entities/user.entity.ts | 5 + 5 files changed, 338 insertions(+) create mode 100644 src/common/decorators/visible-to.decorator.ts create mode 100644 src/common/interceptors/role-visibility.interceptor.spec.ts create mode 100644 src/common/interceptors/role-visibility.interceptor.ts diff --git a/src/app.module.ts b/src/app.module.ts index 81dd098b..97825cd8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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'; @@ -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 }, ], }) diff --git a/src/common/decorators/visible-to.decorator.ts b/src/common/decorators/visible-to.decorator.ts new file mode 100644 index 00000000..868dc698 --- /dev/null +++ b/src/common/decorators/visible-to.decorator.ts @@ -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 = + 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 | null { + return Reflect.getOwnMetadata(VISIBLE_TO_METADATA_KEY, ctor) ?? null; +} diff --git a/src/common/interceptors/role-visibility.interceptor.spec.ts b/src/common/interceptors/role-visibility.interceptor.spec.ts new file mode 100644 index 00000000..c7348a7c --- /dev/null +++ b/src/common/interceptors/role-visibility.interceptor.spec.ts @@ -0,0 +1,171 @@ +import 'reflect-metadata'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { of } from 'rxjs'; +import { lastValueFrom } from 'rxjs'; +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; + + 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; + + 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; + + 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; + + 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; + + 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[]; + + 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[] }; + + 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[] }; + + 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 }); + }); + }); +}); diff --git a/src/common/interceptors/role-visibility.interceptor.ts b/src/common/interceptors/role-visibility.interceptor.ts new file mode 100644 index 00000000..0a9fc12a --- /dev/null +++ b/src/common/interceptors/role-visibility.interceptor.ts @@ -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 { + const req = context.switchToHttp().getRequest(); + 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; + + // 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, role: UserRole): Record { + // 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 = { ...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; + } +} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index 13270593..e833c943 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -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', @@ -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; @@ -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[];