Skip to content

Commit 8fcb046

Browse files
committed
updated
2 parents 46d8d0f + cffe34b commit 8fcb046

33 files changed

Lines changed: 1848 additions & 120 deletions

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ credentials/
2626
jest.config*
2727
tsconfig*.json
2828
!tsconfig.build.json
29+
!tsconfig.json
2930

3031
# ── Dependencies (rebuilt inside image) ───────────────────────────────────────
3132
node_modules/

.github/workflows/security.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ jobs:
3434
dependency-scan:
3535
name: Dependency Scan
3636
runs-on: ubuntu-latest
37-
if: hashFiles('package.json', 'pnpm-lock.yaml') != ''
3837

3938
steps:
4039
- name: Checkout code
@@ -55,7 +54,7 @@ jobs:
5554
run: pnpm install --frozen-lockfile
5655

5756
- name: Audit dependencies (fail only on critical)
58-
run: pnpm audit --audit-level=critical
57+
run: pnpm audit --audit-level=critical --prod
5958

6059
secrets-scan:
6160
name: Secrets Scan (Gitleaks)
@@ -67,13 +66,20 @@ jobs:
6766
with:
6867
fetch-depth: 0
6968

69+
- name: Install Gitleaks
70+
run: |
71+
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
72+
| tar -xz gitleaks
73+
sudo mv gitleaks /usr/local/bin/gitleaks
74+
7075
- name: Run Gitleaks
71-
uses: gitleaks/gitleaks-action@v2
76+
run: |
77+
gitleaks detect --source=. --exit-code 1 \
78+
--log-opts="origin/${{ github.base_ref }}..HEAD"
7279
7380
container-scan:
7481
name: Container Scan (Trivy)
7582
runs-on: ubuntu-latest
76-
if: hashFiles('Dockerfile') != ''
7783

7884
steps:
7985
- name: Checkout code

PR_DESCRIPTION.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# PR Title
2+
3+
fix(gdpr): revoke active sessions during user erasure
4+
5+
## Summary
6+
7+
Fixes #821.
8+
9+
GDPR erasure previously anonymized the user profile but left Redis-backed sessions and refresh-token material intact. This allowed an erased user to continue authenticating with previously issued session state.
10+
11+
## What changed
12+
13+
- Added session revocation during GDPR erasure by deleting all Redis sessions belonging to the user.
14+
- Cleared the user's refresh token during erasure so old refresh-token-based flows are invalidated.
15+
- Added regression tests covering both:
16+
- GDPR erasure invoking session cleanup, and
17+
- session service removal of all sessions for a specific user.
18+
19+
## Why
20+
21+
This brings the erasure flow into compliance with GDPR data-erasure expectations by ensuring previously valid session state is invalidated immediately when a user is erased.
22+
23+
## Testing
24+
25+
Verified locally with:
26+
27+
```bash
28+
cd /home/gift/teachLink_backend && npx jest --runInBand src/modules/gdpr/tests/gdpr.service.spec.ts src/session/session.service.spec.ts
29+
```
30+
31+
Result:
32+
- 2/2 test suites passed
33+
- 17/17 tests passed

pnpm-lock.yaml

Lines changed: 4 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ allowBuilds:
77
msgpackr-extract: true
88
protobufjs: true
99
sharp: true
10+
11+
overrides:
12+
protobufjs: '>=7.5.5'

src/app.module.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Module } from '@nestjs/common';
1+
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
22
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
33
import { ConfigModule } from '@nestjs/config';
44
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -21,6 +21,8 @@ import { IncidentManagementModule } from './incident-management/incident-managem
2121
import { MonitoringModule } from './monitoring/monitoring.module';
2222
import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor';
2323
import { GlobalExceptionFilter } from './common/interceptors/global-exception.filter';
24+
import { RoleVisibilityInterceptor } from './common/interceptors/role-visibility.interceptor';
25+
import { ApiVersionMiddleware } from './common/middleware/api-version.middleware';
2426
import { DeepLinkModule } from './deep-link/deep-link.module';
2527
import { InvoicesModule } from './payments/invoices/invoices.module';
2628
import { ReportingModule } from './payments/reporting/reporting.module';
@@ -32,6 +34,7 @@ import { CachingModule } from './caching/caching.module';
3234
import { CoursesModule } from './courses/courses.module';
3335
import { AuthModule } from './auth/auth.module';
3436
import { CohortsModule } from './cohorts/cohorts.module';
37+
import { FeatureFlagAuditModule } from './config/feature-flag-audit.module';
3538

3639
const featureFlags = loadFeatureFlags();
3740

@@ -67,12 +70,20 @@ const featureFlags = loadFeatureFlags();
6770
// ✅ courses module with enrollment and prerequisite enforcement
6871
CoursesModule,
6972
CohortsModule,
73+
74+
// Feature flag audit trail and admin management endpoints
75+
FeatureFlagAuditModule,
7076
],
7177
controllers: [AppController],
7278
providers: [
7379
...(featureFlags.ENABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []),
7480
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
81+
{ provide: APP_INTERCEPTOR, useClass: RoleVisibilityInterceptor },
7582
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
7683
],
7784
})
78-
export class AppModule {}
85+
export class AppModule implements NestModule {
86+
configure(consumer: MiddlewareConsumer): void {
87+
consumer.apply(ApiVersionMiddleware).forRoutes({ path: 'v*', method: RequestMethod.ALL });
88+
}
89+
}

src/caching/caching.service.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ describe('CachingService', () => {
1919
clear: jest.fn().mockResolvedValue(undefined),
2020
};
2121
metrics = { updateCacheHitRate: jest.fn() };
22+
(cacheManager as any).store = {
23+
keys: jest.fn().mockResolvedValue(['cache:test:1', 'cache:test:2']),
24+
};
2225
service = new CachingService(
2326
cacheManager as never,
2427
metrics as unknown as MetricsCollectionService,
@@ -51,6 +54,15 @@ describe('CachingService', () => {
5154
});
5255
});
5356

57+
describe('deleteByPattern', () => {
58+
it('uses store.keys to delete matching keys when client scan is unavailable', async () => {
59+
await service.deleteByPattern('cache:test:*');
60+
expect((cacheManager as any).store.keys).toHaveBeenCalledWith('cache:test:*');
61+
expect(cacheManager.del).toHaveBeenCalledWith('cache:test:1');
62+
expect(cacheManager.del).toHaveBeenCalledWith('cache:test:2');
63+
});
64+
});
65+
5466
describe('hit rate metrics', () => {
5567
it('calculates hit rate and publishes to metrics', async () => {
5668
cacheManager.get.mockResolvedValueOnce('cached').mockResolvedValueOnce(undefined);

src/caching/caching.service.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,39 @@ export class CachingService {
6060
}
6161
}
6262

63+
async deleteByPattern(pattern: string): Promise<void> {
64+
try {
65+
const store = (this.cacheManager as any).store;
66+
67+
if (store && store.client && typeof store.client.scan === 'function') {
68+
let cursor = '0';
69+
do {
70+
const result = await store.client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
71+
cursor = result[0];
72+
const keys = result[1];
73+
if (keys && keys.length > 0) {
74+
await store.client.del(...keys);
75+
}
76+
} while (cursor !== '0');
77+
return;
78+
}
79+
80+
if (store && typeof store.keys === 'function') {
81+
const keys = await store.keys(pattern);
82+
if (keys && keys.length > 0) {
83+
await this.deleteMany(keys);
84+
}
85+
return;
86+
}
87+
88+
this.logger.warn(
89+
`Pattern deletion not supported by current cache store for pattern: ${pattern}`,
90+
);
91+
} catch (error: any) {
92+
this.logger.error(`Failed to delete by pattern ${pattern}: ${error.message}`, error.stack);
93+
}
94+
}
95+
6396
getStats(): CacheStats {
6497
const total = this.hits + this.misses;
6598
return {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import 'reflect-metadata';
2+
import { UserRole } from '../../users/entities/user.entity';
3+
4+
/**
5+
* Metadata key used to store {@link VisibleTo} role lists on entity properties.
6+
* @internal
7+
*/
8+
export const VISIBLE_TO_METADATA_KEY = 'visibleTo:roles';
9+
10+
/**
11+
* Marks an entity field as visible only to the specified roles.
12+
*
13+
* When a response is serialised by {@link RoleVisibilityInterceptor}, any
14+
* field decorated with `@VisibleTo` that is **not** in the viewer's role list
15+
* is deleted from the outgoing object before it reaches the client.
16+
*
17+
* Placing `@VisibleTo` on a new field is sufficient to enforce visibility —
18+
* no additional per-route configuration is needed.
19+
*
20+
* @example
21+
* ```ts
22+
* \@VisibleTo(UserRole.ADMIN)
23+
* refreshToken?: string;
24+
*
25+
* \@VisibleTo(UserRole.ADMIN, UserRole.MODERATOR)
26+
* sensitiveScore?: number;
27+
* ```
28+
*/
29+
export function VisibleTo(...roles: UserRole[]): PropertyDecorator {
30+
return (target: object, propertyKey: string | symbol): void => {
31+
// Accumulate existing metadata so multiple decorators on the same class
32+
// don't overwrite each other.
33+
const existing: Map<string | symbol, UserRole[]> =
34+
Reflect.getOwnMetadata(VISIBLE_TO_METADATA_KEY, target.constructor) ?? new Map();
35+
existing.set(propertyKey, roles);
36+
Reflect.defineMetadata(VISIBLE_TO_METADATA_KEY, existing, target.constructor);
37+
};
38+
}
39+
40+
/**
41+
* Returns the `@VisibleTo` role map for a given constructor, or `null` when
42+
* the class has no `@VisibleTo` annotations.
43+
*
44+
* @internal Used by {@link RoleVisibilityInterceptor}.
45+
*/
46+
export function getVisibilityMap(
47+
ctor: new (...args: unknown[]) => unknown,
48+
): Map<string | symbol, UserRole[]> | null {
49+
return Reflect.getOwnMetadata(VISIBLE_TO_METADATA_KEY, ctor) ?? null;
50+
}

0 commit comments

Comments
 (0)