Skip to content

Commit 7322787

Browse files
hard: role-gated endpoints trust stale JWT role claims
1 parent ca2f4a7 commit 7322787

15 files changed

Lines changed: 440 additions & 46 deletions

context/progress-tracker.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
2626
- Tests: refresh-family rotation, replay → family-wide revocation + audit
2727
event, blocked-user denial within TTL bound, cache expiry re-query,
2828
cleanup job deletes-only-expired.
29+
<<<<<<< Updated upstream
30+
=======
31+
=======
32+
## 2026-08-26
33+
34+
- Centralized role authorization on server truth: updated `UserStatusService` to cache user status and role with a 30s staleness bound (`USER_STATUS_CACHE_TTL_MS = 30_000`), and updated `RolesGuard` to enforce datastore roles instead of relying on un-enforced JWT role claims. Stale-token attacks are now rejected with 403 `AUTH_ROLE_FORBIDDEN`.
35+
- Added admin-only role-management endpoint `POST /admin/users/:wallet/role/reset` in `AdminRolesController`, guarded by `JwtAuthGuard` and `AdminGuard`, and audited via `@AuditAction('admin_users', 'RESET_USER_ROLE')` and `AuditInterceptor`.
36+
- Wired cache invalidation (`userStatusService.invalidate(wallet)`) into `setRole` and admin role reset, ensuring role changes take effect immediately on local server instance and within 30s across instances.
37+
- Added unit tests for `RolesGuard`, `AdminRolesController`, `UserStatusService`, and `UsersService.setRole`.
38+
- Fixed registration race conditions in `AuthService.register()` by eliminating application-side pre-checks (`findByWallet`, `checkUsernameExists`) and relying directly on DB-level UNIQUE constraints (`users.wallet_address`, `users.username`).
39+
- Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`.
40+
- Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`).
41+
- Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records.
42+
>>>>>>> Stashed changes
43+
>>>>>>> Stashed changes
2944
3045
## 2026-07-23
3146

src/auth/guards/roles.guard.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import {
44
ExecutionContext,
55
ForbiddenException,
66
SetMetadata,
7+
Optional,
78
} from '@nestjs/common';
89
import { Reflector } from '@nestjs/core';
10+
import { UserStatusService } from '../../modules/auth/user-status.service';
911

1012
export const ROLES_KEY = 'roles';
1113

@@ -19,18 +21,22 @@ export const ROLES_KEY = 'roles';
1921
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
2022

2123
/**
22-
* Enforces the role claim carried in the JWT (set by JwtStrategy.validate).
24+
* Enforces user role authorization based on server truth in the datastore
25+
* (resolved via short-TTL cached UserStatusService).
2326
*
2427
* - Routes without @Roles metadata are unaffected.
25-
* - Tokens without a role claim (role not chosen yet, or token issued
26-
* before the role was set) are rejected with 403; the client must call
27-
* POST /auth/refresh after setting a role to obtain the claim.
28+
* - The JWT role claim is treated as a hint only; live datastore role is enforced.
29+
* - Roles revoked or changed server-side take effect within USER_STATUS_CACHE_TTL_MS (30s)
30+
* or immediately upon cache invalidation.
2831
*/
2932
@Injectable()
3033
export class RolesGuard implements CanActivate {
31-
constructor(private readonly reflector: Reflector) {}
34+
constructor(
35+
private readonly reflector: Reflector,
36+
@Optional() private readonly userStatusService?: UserStatusService,
37+
) {}
3238

33-
canActivate(context: ExecutionContext): boolean {
39+
async canActivate(context: ExecutionContext): Promise<boolean> {
3440
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
3541
context.getHandler(),
3642
context.getClass(),
@@ -41,7 +47,21 @@ export class RolesGuard implements CanActivate {
4147
.switchToHttp()
4248
.getRequest<{ user?: { wallet: string; role?: string | null } }>();
4349

44-
if (!user?.role || !requiredRoles.includes(user.role)) {
50+
if (!user) {
51+
throw new ForbiddenException({
52+
code: 'AUTH_ROLE_FORBIDDEN',
53+
message: `This action requires one of the following roles: ${requiredRoles.join(', ')}.`,
54+
});
55+
}
56+
57+
let currentRole: string | null = null;
58+
if (this.userStatusService && user.wallet) {
59+
currentRole = await this.userStatusService.getRole(user.wallet);
60+
} else {
61+
currentRole = user.role ?? null;
62+
}
63+
64+
if (!currentRole || !requiredRoles.includes(currentRole)) {
4565
throw new ForbiddenException({
4666
code: 'AUTH_ROLE_FORBIDDEN',
4767
message: `This action requires one of the following roles: ${requiredRoles.join(', ')}. If you just selected your role, refresh your access token.`,
@@ -50,3 +70,5 @@ export class RolesGuard implements CanActivate {
5070
return true;
5171
}
5272
}
73+
74+

src/database/repositories/users.repository.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,31 @@ export class UsersRepository {
226226
return (data as { wallet_address: string; role: UserRole } | null) ?? null;
227227
}
228228

229+
/**
230+
* Admin override: sets or resets the user's role regardless of whether a role was set previously.
231+
* Returns the updated user row, or null if the user does not exist.
232+
*/
233+
async forceSetRole(
234+
wallet: string,
235+
role: UserRole | null,
236+
): Promise<{ wallet_address: string; role: UserRole | null } | null> {
237+
const { data, error } = await this.supabaseService
238+
.getServiceRoleClient()
239+
.from('users')
240+
.update({ role })
241+
.eq('wallet_address', wallet)
242+
.select('wallet_address, role')
243+
.maybeSingle();
244+
245+
if (error) {
246+
throw new InternalServerErrorException({
247+
code: 'DATABASE_ROLE_UPDATE_FAILED',
248+
message: 'Failed to update user role.',
249+
});
250+
}
251+
return (data as { wallet_address: string; role: UserRole | null } | null) ?? null;
252+
}
253+
229254
// --- REGISTRATION METHODS ---
230255

231256
async checkUsernameExists(username: string): Promise<boolean> {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
Controller,
3+
Post,
4+
Param,
5+
Body,
6+
NotFoundException,
7+
UseGuards,
8+
UseInterceptors,
9+
HttpCode,
10+
HttpStatus,
11+
} from '@nestjs/common';
12+
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
13+
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
14+
import { AdminGuard } from '../../auth/guards/admin.guard';
15+
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
16+
import { AuditAction } from '../../common/decorators/audit-action.decorator';
17+
import { UsersRepository } from '../../database/repositories/users.repository';
18+
import { UserStatusService } from '../auth/user-status.service';
19+
import { AdminResetRoleDto } from './dto/admin-reset-role.dto';
20+
21+
@ApiTags('admin')
22+
@Controller('admin')
23+
@UseGuards(JwtAuthGuard, AdminGuard)
24+
@ApiBearerAuth()
25+
@UseInterceptors(AuditInterceptor)
26+
export class AdminRolesController {
27+
constructor(
28+
private readonly usersRepository: UsersRepository,
29+
private readonly userStatusService: UserStatusService,
30+
) {}
31+
32+
@Post('users/:wallet/role/reset')
33+
@HttpCode(HttpStatus.OK)
34+
@AuditAction('admin_users', 'RESET_USER_ROLE')
35+
@ApiOperation({
36+
summary: 'Reset or override user role (Admin only)',
37+
description:
38+
'Allows admins to reset a user\'s permanent role back to null or override it with a specific role. ' +
39+
'Immediately invalidates the user\'s server-side status cache and logs an audit event.',
40+
})
41+
@ApiParam({ name: 'wallet', description: 'Target user wallet address' })
42+
@ApiResponse({ status: 200, description: 'Role reset/updated successfully' })
43+
@ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' })
44+
@ApiResponse({ status: 403, description: 'Forbidden — wallet is not in ADMIN_WALLETS' })
45+
@ApiResponse({ status: 404, description: 'User not found' })
46+
async resetUserRole(
47+
@Param('wallet') wallet: string,
48+
@Body() dto?: AdminResetRoleDto,
49+
) {
50+
const targetRole = dto?.role ?? null;
51+
const updated = await this.usersRepository.forceSetRole(wallet, targetRole);
52+
53+
if (!updated) {
54+
throw new NotFoundException({
55+
code: 'USERS_NOT_FOUND',
56+
message: 'User not found.',
57+
});
58+
}
59+
60+
this.userStatusService.invalidate(wallet);
61+
62+
return {
63+
success: true,
64+
data: {
65+
wallet: updated.wallet_address,
66+
role: updated.role,
67+
},
68+
message: 'User role updated successfully.',
69+
};
70+
}
71+
}

src/modules/admin/admin.module.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { Module } from '@nestjs/common';
22
import { AuditController } from './audit.controller';
33
import { AuditService } from './audit.service';
4+
import { AdminRolesController } from './admin-roles.controller';
45
import { SupabaseService } from '../../database/supabase.client';
6+
import { UsersRepository } from '../../database/repositories/users.repository';
7+
import { UserStatusService } from '../auth/user-status.service';
8+
import { AdminGuard } from '../../auth/guards/admin.guard';
59

610
@Module({
7-
controllers: [AuditController],
8-
providers: [AuditService, SupabaseService],
9-
exports: [AuditService],
11+
controllers: [AuditController, AdminRolesController],
12+
providers: [AuditService, SupabaseService, UsersRepository, UserStatusService, AdminGuard],
13+
exports: [AuditService, UserStatusService],
1014
})
1115
export class AdminModule {}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsIn, IsOptional } from 'class-validator';
3+
import { UserRole } from '../../../database/repositories/users.repository';
4+
5+
export class AdminResetRoleDto {
6+
@ApiPropertyOptional({
7+
description: 'New role to assign (sponsor | vendor | mentor), or null to reset/remove the role',
8+
enum: ['sponsor', 'vendor', 'mentor'],
9+
nullable: true,
10+
})
11+
@IsOptional()
12+
@IsIn(['sponsor', 'vendor', 'mentor', null])
13+
role?: UserRole | null;
14+
}

src/modules/auth/auth.module.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { UsersRepository } from '../../database/repositories/users.repository';
1212
import { getJwtConfig } from '../../config/jwt.config';
1313
import { AdminModule } from '../admin/admin.module';
1414

15+
import { RolesGuard } from '../../auth/guards/roles.guard';
16+
1517
@Module({
1618
imports: [
1719
PassportModule.register({ defaultStrategy: 'jwt' }),
@@ -23,7 +25,7 @@ import { AdminModule } from '../admin/admin.module';
2325
AdminModule,
2426
],
2527
controllers: [AuthController],
26-
providers: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository],
27-
exports: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, PassportModule],
28+
providers: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, RolesGuard, SupabaseService, ConfigService, UsersRepository],
29+
exports: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, RolesGuard, PassportModule],
2830
})
2931
export class AuthModule {}

src/modules/auth/user-status.service.ts

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,67 +2,79 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
22
import { SupabaseService } from '../../database/supabase.client';
33

44
/**
5-
* How long a user's status may be served from cache before re-checking the
6-
* database. This is the documented staleness bound for blocking enforcement:
7-
* a blocked wallet can keep using valid access tokens for AT MOST this many
8-
* seconds (plus the remaining lifetime of its current access token is NOT
9-
* granted — requests within this window are the only grace period).
5+
* How long a user's status and role may be served from cache before re-checking
6+
* the database. This is the documented staleness bound for server-truth enforcement:
7+
* role changes or blocked wallets take effect within AT MOST this many seconds
8+
* (or immediately when invalidate() is called after role/status changes).
109
*/
1110
export const USER_STATUS_CACHE_TTL_MS = 30_000;
1211

13-
interface CachedStatus {
12+
interface CachedUserState {
1413
status: string;
14+
role: string | null;
1515
expiresAt: number;
1616
}
1717

1818
/**
19-
* Short-TTL in-memory cache of user account status, consulted on every
20-
* authenticated request by JwtStrategy so that blocked wallets lose API
21-
* access within USER_STATUS_CACHE_TTL_MS instead of waiting for their
22-
* access token to expire naturally.
19+
* Short-TTL in-memory cache of user account status and role, consulted on every
20+
* authenticated request by JwtStrategy and RolesGuard so that authorization decisions
21+
* rely on server truth rather than un-enforced JWT claims.
2322
*
24-
* A local in-memory Map is used deliberately instead of Redis: the check
25-
* runs on every request, one Redis round trip per request would double
26-
* auth latency, and a 30s staleness bound does not justify shared state.
27-
* On multi-instance deployments each instance maintains its own cache with
28-
* the same bound.
23+
* A local in-memory Map is used deliberately instead of Redis: checks run on every
24+
* request, one Redis round trip per request would double auth latency, and a 30s
25+
* staleness bound does not justify shared state. On multi-instance deployments each
26+
* instance maintains its own cache with the same bound.
2927
*/
3028
@Injectable()
3129
export class UserStatusService {
3230
private readonly logger = new Logger(UserStatusService.name);
33-
private readonly cache = new Map<string, CachedStatus>();
31+
private readonly cache = new Map<string, CachedUserState>();
3432

3533
constructor(private readonly supabaseService: SupabaseService) {}
3634

3735
/**
38-
* Returns the user's status ('active', 'blocked', ...), serving from the
39-
* cache when fresh. Never throws for DB errors — fails open so a database
40-
* blip cannot lock out every authenticated user; the failure is logged.
36+
* Returns the user's current status and role, serving from cache when fresh.
37+
* Never throws for DB errors — fails open so a database blip cannot lock out
38+
* every authenticated user; the failure is logged.
4139
*/
42-
async getStatus(wallet: string): Promise<string> {
40+
async getUserState(wallet: string): Promise<{ status: string; role: string | null }> {
4341
const cached = this.cache.get(wallet);
4442
if (cached && cached.expiresAt > Date.now()) {
45-
return cached.status;
43+
return { status: cached.status, role: cached.role };
4644
}
4745
let status = 'active';
46+
let role: string | null = null;
4847
try {
4948
const client = this.supabaseService.getServiceRoleClient();
5049
const { data, error } = await client
5150
.from('users')
52-
.select('status')
51+
.select('status, role')
5352
.eq('wallet_address', wallet)
5453
.maybeSingle();
55-
if (!error && data?.status) {
56-
status = data.status;
54+
if (!error && data) {
55+
if (data.status) status = data.status;
56+
if (data.role !== undefined) role = data.role ?? null;
5757
}
5858
if (error) {
59-
this.logger.error(`Failed to read status for ${wallet}: ${error.message}`);
59+
this.logger.error(`Failed to read user state for ${wallet}: ${error.message}`);
6060
}
6161
} catch (err) {
62-
this.logger.error(`User status lookup failed for ${wallet}`, err);
62+
this.logger.error(`User state lookup failed for ${wallet}`, err);
6363
}
64-
this.cache.set(wallet, { status, expiresAt: Date.now() + USER_STATUS_CACHE_TTL_MS });
65-
return status;
64+
this.cache.set(wallet, { status, role, expiresAt: Date.now() + USER_STATUS_CACHE_TTL_MS });
65+
return { status, role };
66+
}
67+
68+
/** Returns the user's status ('active', 'blocked', ...), serving from cache when fresh. */
69+
async getStatus(wallet: string): Promise<string> {
70+
const state = await this.getUserState(wallet);
71+
return state.status;
72+
}
73+
74+
/** Returns the user's current role ('sponsor', 'vendor', 'mentor', null), serving from cache when fresh. */
75+
async getRole(wallet: string): Promise<string | null> {
76+
const state = await this.getUserState(wallet);
77+
return state.role;
6678
}
6779

6880
/** Throws AUTH_USER_BLOCKED when the wallet's account is suspended. */
@@ -73,8 +85,9 @@ export class UserStatusService {
7385
}
7486
}
7587

76-
/** Test/admin helper: drops cached status so the next check hits the DB. */
88+
/** Test/admin helper: drops cached state so the next check hits the DB. */
7789
invalidate(wallet: string): void {
7890
this.cache.delete(wallet);
7991
}
8092
}
93+

src/modules/users/users.module.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@ import { UsersController } from './users.controller';
33
import { UsersService } from './users.service';
44
import { UsersRepository } from '../../database/repositories/users.repository';
55
import { SupabaseService } from '../../database/supabase.client';
6+
import { AuthModule } from '../auth/auth.module';
67

78
/**
89
* Users feature module.
9-
*
10-
* Note: JwtAuthGuard is NOT listed as a provider here — it lives in AuthModule
11-
* (created in API-03) and is resolved from there by NestJS's DI container.
1210
*/
1311
@Module({
12+
imports: [AuthModule],
1413
controllers: [UsersController],
1514
providers: [UsersService, UsersRepository, SupabaseService],
16-
exports: [UsersService],
15+
exports: [UsersService, UsersRepository],
1716
})
1817
export class UsersModule { }

0 commit comments

Comments
 (0)