Skip to content
Merged
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
33 changes: 33 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# PR Title

fix(gdpr): revoke active sessions during user erasure

## Summary

Fixes #821.

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.

## What changed

- Added session revocation during GDPR erasure by deleting all Redis sessions belonging to the user.
- Cleared the user's refresh token during erasure so old refresh-token-based flows are invalidated.
- Added regression tests covering both:
- GDPR erasure invoking session cleanup, and
- session service removal of all sessions for a specific user.

## Why

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.

## Testing

Verified locally with:

```bash
cd /home/gift/teachLink_backend && npx jest --runInBand src/modules/gdpr/tests/gdpr.service.spec.ts src/session/session.service.spec.ts
```

Result:
- 2/2 test suites passed
- 17/17 tests passed
3 changes: 2 additions & 1 deletion src/modules/gdpr/gdpr.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { SessionModule } from '../../session/session.module';

@Module({
imports: [SessionModule],
controllers: [GdprController],

providers: [GdprService],
})
export class GdprModule {}
6 changes: 6 additions & 0 deletions src/modules/gdpr/gdpr.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { plainToInstance, instanceToPlain } from 'class-transformer';
import { UserConsent } from './entities/user-consent.entity';
import { ConsentDto } from './dto/consent.dto';
import { GdprExportDto } from './dto/gdpr-export.dto';
import { SessionService } from '../../session/session.service';

@Injectable()
export class GdprService {
Expand All @@ -17,6 +18,8 @@ export class GdprService {

@InjectRepository(UserConsent)
private readonly consentRepository: Repository<UserConsent>,

private readonly sessionService: SessionService,
) {}

async exportUserData(userId: string) {
Expand Down Expand Up @@ -50,13 +53,16 @@ export class GdprService {
throw new NotFoundException('User not found');
}

await this.sessionService.deleteAllSessionsForUser(userId);

await this.usersService.update(userId, {
email: null,
firstName: '[DELETED]',
lastName: '[DELETED]',
phone: null,
address: null,
deletedAt: new Date(),
refreshToken: null,
});

await this.auditService.log('GDPR_ERASURE', userId);
Expand Down
22 changes: 21 additions & 1 deletion src/modules/gdpr/tests/gdpr.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { GdprService } from '../gdpr.service';
import { UserConsent } from '../entities/user-consent.entity';
import { SessionService } from '../../../session/session.service';

const mockUsersService = {
findById: jest.fn().mockResolvedValue({
Expand All @@ -22,6 +23,10 @@ const mockAuditService = {
log: jest.fn().mockResolvedValue(undefined),
};

const mockSessionService = {
deleteAllSessionsForUser: jest.fn().mockResolvedValue(undefined),
};

const mockConsentRepository = {
find: jest.fn().mockResolvedValue([]),
create: jest.fn((dto) => ({ ...dto, id: 'consent-1' })),
Expand All @@ -37,6 +42,7 @@ describe('GdprService', () => {
GdprService,
{ provide: 'UsersService', useValue: mockUsersService },
{ provide: 'AuditService', useValue: mockAuditService },
{ provide: SessionService, useValue: mockSessionService },
{ provide: getRepositoryToken(UserConsent), useValue: mockConsentRepository },
],
}).compile();
Expand All @@ -62,9 +68,23 @@ describe('GdprService', () => {
expect(result.profile.lastName).toBe('Doe');
});

it('erases user data', async () => {
it('erases user data and invalidates sessions', async () => {
const result = await service.eraseUserData('user-1');

expect(result.success).toBe(true);
expect(mockSessionService.deleteAllSessionsForUser).toHaveBeenCalledWith('user-1');
expect(mockUsersService.update).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({
email: null,
firstName: '[DELETED]',
lastName: '[DELETED]',
phone: null,
address: null,
deletedAt: expect.any(Date),
refreshToken: null,
}),
);
});

it('stores consent changes', async () => {
Expand Down
17 changes: 17 additions & 0 deletions src/session/session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const mockRedis = {
zadd: jest.fn().mockResolvedValue(1),
zrem: jest.fn().mockResolvedValue(1),
zrange: jest.fn().mockResolvedValue([]),
scan: jest.fn(),
status: 'ready',
quit: jest.fn(),
};
Expand Down Expand Up @@ -170,6 +171,22 @@ describe('SessionService', () => {
});
});

describe('deleteAllSessionsForUser', () => {
it('should remove all Redis sessions for a user', async () => {
mockRedis.scan.mockResolvedValueOnce(['0', ['auth:sess:one', 'auth:sess:two']]);
mockRedis.get
.mockResolvedValueOnce(JSON.stringify({ sid: 'one', userId: 'user-123' }))
.mockResolvedValueOnce(JSON.stringify({ sid: 'two', userId: 'user-999' }));
mockRedis.del.mockResolvedValue(1);

const deletedCount = await service.deleteAllSessionsForUser('user-123');

expect(deletedCount).toBe(1);
expect(mockRedis.del).toHaveBeenCalledWith('auth:sess:one');
expect(mockRedis.zrem).toHaveBeenCalledWith('user:sessions:user-123', 'one');
});
});

describe('migrateSession', () => {
it('should migrate session to new sid and delete old one', async () => {
const sessionData = {
Expand Down
31 changes: 31 additions & 0 deletions src/session/session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,37 @@ export class SessionService implements OnModuleDestroy {
}
}

async deleteAllSessionsForUser(userId: string): Promise<number> {
const pattern = `${this.sessionPrefix}*`;
let cursor = '0';
let deletedCount = 0;

do {
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;

for (const key of keys) {
const sessionData = await this.redis.get(key);
if (!sessionData) {
continue;
}

try {
const session = JSON.parse(sessionData) as ISessionRecord;
if (session.userId === userId) {
await this.redis.del(key);
await this.removeSessionFromUserIndex(userId, session.sid);
deletedCount += 1;
}
} catch {
this.logger.warn(`Invalid session payload for key=${key}`);
}
}
} while (cursor !== '0');

return deletedCount;
}

async addSessionToUserIndex(userId: string, sid: string): Promise<void> {
await this.redis.zadd(`user:sessions:${userId}`, Date.now(), sid);
}
Expand Down
Loading