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
2 changes: 1 addition & 1 deletion src/modules/admin/admin-roles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AdminGuard } from '../../auth/guards/admin.guard';
import { AdminGuard } from './admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { AuditAction } from '../../common/decorators/audit-action.decorator';
import { UsersRepository } from '../../database/repositories/users.repository';
Expand Down
123 changes: 123 additions & 0 deletions src/modules/admin/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
Logger,
Optional,
} from '@nestjs/common';
import { UserStatusService } from '../auth/user-status.service';
import { AuditService } from './audit.service';
import { SupabaseService } from '../../database/supabase.client';

/**
* Guard that enforces server-truth admin authorization for /admin routes.
*
* Rather than trusting the JWT role claim alone (which remains valid until token expiry),
* this guard queries server truth (via UserStatusService / Supabase datastore) fresh for
* req.user.wallet.
*
* Authorization rules:
* - Unauthenticated / missing wallet => 401 Unauthorized (AUTH_TOKEN_INVALID)
* - Blocked status => 401 Unauthorized (AUTH_USER_BLOCKED)
* - Role is not explicitly 'admin' => 403 Forbidden (ADMIN_FORBIDDEN) & logs audit attempt
*/
@Injectable()
export class AdminGuard implements CanActivate {
private readonly logger = new Logger(AdminGuard.name);

constructor(
@Optional() private readonly userStatusService?: UserStatusService,
@Optional() private readonly auditService?: AuditService,
@Optional() private readonly supabaseService?: SupabaseService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{
user?: { wallet: string; role?: string | null };
url?: string;
method?: string;
}>();

const user = request.user;
if (!user || !user.wallet) {
throw new UnauthorizedException({
code: 'AUTH_TOKEN_INVALID',
message: 'Invalid or missing access token.',
});
}

const wallet = user.wallet;
let status = 'active';
let role: string | null = null;

if (this.userStatusService) {
const state = await this.userStatusService.getUserState(wallet);
status = state.status;
role = state.role;
} else if (this.supabaseService) {
try {
const client = this.supabaseService.getServiceRoleClient();
const { data } = await client
.from('users')
.select('status, role')
.eq('wallet_address', wallet)
.maybeSingle();
if (data) {
status = data.status ?? 'active';
role = data.role ?? null;
}
} catch (err) {
this.logger.error(`Database query for admin check failed for ${wallet}: ${(err as Error).message}`);
}
} else {
role = user.role ?? null;
}

if (status === 'blocked') {
throw new UnauthorizedException({
code: 'AUTH_USER_BLOCKED',
message: 'This account has been suspended.',
});
}

if (role !== 'admin') {
this.logger.warn(
`Admin access denied: wallet ${wallet} (status: ${status}, role: ${role ?? 'none'}) attempted to access ${request.method ?? 'GET'} ${request.url ?? '/admin'}`,
);

if (this.auditService) {
try {
await this.auditService.log({
actor_wallet: wallet,
action: 'ADMIN_ACCESS_DENIED',
resource: 'admin',
resource_id: null,
before_state: null,
after_state: null,
ip_address: null,
user_agent: null,
metadata: {
path: request.url ?? null,
method: request.method ?? null,
attemptedRole: role,
status,
},
});
} catch (auditErr) {
this.logger.error(
`Failed to log denied admin attempt for ${wallet}: ${(auditErr as Error).message}`,
);
}
}

throw new ForbiddenException({
code: 'ADMIN_FORBIDDEN',
message: 'Forbidden. Explicit admin role required.',
});
}

return true;
}
}
4 changes: 2 additions & 2 deletions src/modules/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { AdminRolesController } from './admin-roles.controller';
import { SupabaseService } from '../../database/supabase.client';
import { UsersRepository } from '../../database/repositories/users.repository';
import { UserStatusService } from '../auth/user-status.service';
import { AdminGuard } from '../../auth/guards/admin.guard';
import { AdminGuard } from './admin.guard';

@Module({
controllers: [AuditController, AdminRolesController],
providers: [AuditService, SupabaseService, UsersRepository, UserStatusService, AdminGuard],
exports: [AuditService, UserStatusService],
exports: [AuditService, UserStatusService, AdminGuard],
})
export class AdminModule {}
3 changes: 2 additions & 1 deletion src/modules/admin/audit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AuditService } from './audit.service';
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
import { AuditLogListResponseDto } from './dto/audit-log-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AdminGuard } from '../../auth/guards/admin.guard';
import { AdminGuard } from './admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { AuditAction } from '../../common/decorators/audit-action.decorator';

Expand Down Expand Up @@ -36,6 +36,7 @@ export class AuditController {
type: AuditLogListResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid admin JWT' })
@ApiResponse({ status: 403, description: 'Forbidden - wallet does not have explicit admin role' })
async getAuditLogs(@Query() query: AuditLogQueryDto) {
return this.auditService.findMany(query);
}
Expand Down
113 changes: 113 additions & 0 deletions test/unit/modules/admin/admin.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { AdminGuard } from '../../../../src/modules/admin/admin.guard';
import { UserStatusService } from '../../../../src/modules/auth/user-status.service';
import { AuditService } from '../../../../src/modules/admin/audit.service';

describe('AdminGuard (src/modules/admin/admin.guard.ts)', () => {
let guard: AdminGuard;
let userStatusService: jest.Mocked<UserStatusService>;
let auditService: jest.Mocked<AuditService>;

const ADMIN_WALLET = 'GAQWQJJBC2D5YCR6WUFFZSL6DIFJ5CA4774QB6QWPRNHSUUVRNQ2BHXJ';
const SPONSOR_WALLET = 'GBXH6BL5Z7R5Y6RJSJRMJQH4YZVMYTCX2L4X2L4X2L4X2L4X2L4X2L4X';
const BLOCKED_ADMIN_WALLET = 'GDBLOCKEDADMINWALLET12345678901234567890123456789012345678';

function createMockExecutionContext(user?: { wallet: string; role?: string | null }) {
const request = {
user,
url: '/admin/audit-logs',
method: 'GET',
};
return {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue(request),
}),
} as unknown as ExecutionContext;
}

beforeEach(() => {
userStatusService = {
getUserState: jest.fn(),
getStatus: jest.fn(),
getRole: jest.fn(),
ensureNotBlocked: jest.fn(),
invalidate: jest.fn(),
} as unknown as jest.Mocked<UserStatusService>;

auditService = {
log: jest.fn().mockResolvedValue(undefined),
logWithBeforeAfter: jest.fn().mockResolvedValue(undefined),
findMany: jest.fn(),
} as unknown as jest.Mocked<AuditService>;

guard = new AdminGuard(userStatusService, auditService);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should return true for an active admin wallet', async () => {
userStatusService.getUserState.mockResolvedValue({ status: 'active', role: 'admin' });
const ctx = createMockExecutionContext({ wallet: ADMIN_WALLET });

const result = await guard.canActivate(ctx);

expect(result).toBe(true);
expect(userStatusService.getUserState).toHaveBeenCalledWith(ADMIN_WALLET);
expect(auditService.log).not.toHaveBeenCalled();
});

it('should throw 403 (ADMIN_FORBIDDEN) for a non-admin wallet', async () => {
userStatusService.getUserState.mockResolvedValue({ status: 'active', role: 'sponsor' });
const ctx = createMockExecutionContext({ wallet: SPONSOR_WALLET });

await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(ctx)).rejects.toMatchObject({
response: { code: 'ADMIN_FORBIDDEN' },
});
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
actor_wallet: SPONSOR_WALLET,
action: 'ADMIN_ACCESS_DENIED',
resource: 'admin',
}),
);
});

it('should throw 401 (AUTH_USER_BLOCKED) for a blocked admin wallet', async () => {
userStatusService.getUserState.mockResolvedValue({ status: 'blocked', role: 'admin' });
const ctx = createMockExecutionContext({ wallet: BLOCKED_ADMIN_WALLET });

await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(ctx)).rejects.toMatchObject({
response: { code: 'AUTH_USER_BLOCKED' },
});
});

it('should throw 401 (AUTH_TOKEN_INVALID) when request user or wallet is missing', async () => {
const ctxUnauthenticated = createMockExecutionContext(undefined);

await expect(guard.canActivate(ctxUnauthenticated)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(ctxUnauthenticated)).rejects.toMatchObject({
response: { code: 'AUTH_TOKEN_INVALID' },
});
});

it('should throw 403 (ADMIN_FORBIDDEN) for a stale token with admin role claim when DB role is revoked', async () => {
// JWT claim says 'admin', but server truth in Supabase/UserStatusService is now null / revoked
userStatusService.getUserState.mockResolvedValue({ status: 'active', role: null });
const ctx = createMockExecutionContext({ wallet: SPONSOR_WALLET, role: 'admin' });

await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(ctx)).rejects.toMatchObject({
response: { code: 'ADMIN_FORBIDDEN' },
});
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
actor_wallet: SPONSOR_WALLET,
action: 'ADMIN_ACCESS_DENIED',
}),
);
});
});
47 changes: 47 additions & 0 deletions test/unit/modules/admin/audit.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditController } from '../../../../src/modules/admin/audit.controller';
import { AuditService } from '../../../../src/modules/admin/audit.service';
import { AuditLogQueryDto } from '../../../../src/modules/admin/dto/audit-log-query.dto';

describe('AuditController', () => {
let controller: AuditController;
let auditService: jest.Mocked<AuditService>;

const mockAuditService = {
findMany: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuditController],
providers: [
{ provide: AuditService, useValue: mockAuditService },
],
}).compile();

controller = module.get<AuditController>(AuditController);
auditService = module.get<AuditService>(AuditService) as jest.Mocked<AuditService>;
jest.clearAllMocks();
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

it('should delegate getAuditLogs to auditService.findMany', async () => {
const query: AuditLogQueryDto = { limit: 10, offset: 0 };
const expectedResponse = {
success: true,
data: [],
pagination: { limit: 10, offset: 0, total: 0 },
message: 'Audit logs retrieved successfully',
};

mockAuditService.findMany.mockResolvedValue(expectedResponse);

const result = await controller.getAuditLogs(query);

expect(mockAuditService.findMany).toHaveBeenCalledWith(query);
expect(result).toEqual(expectedResponse);
});
});
Loading