Skip to content

Commit e168d0d

Browse files
authored
Merge pull request #542 from dzekojohn4/fix/issue-511-admin-role-header-bypass
fix(auth): remove X-Roles admin bypass and gate admin endpoints behind AdminGuard
2 parents 2016d2f + 1d4dbcf commit e168d0d

27 files changed

Lines changed: 704 additions & 90 deletions

api/.env.example

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,6 @@ SWAGGER_ENABLED=false
9090
# Leave empty for single-replica local development.
9191
REDIS_URL=
9292

93-
# Dev-only convenience: allow roles to be supplied via request headers so auth
94-
# flows can be exercised locally without minting tokens. Production deployments
95-
# MUST set this to 0 to disable the fallback.
96-
ALLOW_HEADER_ROLES=1
97-
9893
# ──────────────────────────────────────────────────────────────────────────────
9994
# OpenTelemetry distributed tracing (issue #346)
10095
#
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import { Controller, Get, Query, UseGuards } from "@nestjs/common"
2-
import { AuditService } from "./audit.service"
2+
3+
import { AuditService } from "../audit/audit.service"
4+
import { AdminGuard } from "../common/auth/admin.guard"
5+
import { Roles } from "../common/auth/roles.guard"
36
import { PaginationQueryDto } from "../common/dto/pagination.dto"
4-
import { Roles, RolesGuard } from "../common/auth/roles.guard"
57

8+
/**
9+
* Admin-only audit log reader.
10+
*
11+
* Guarded by the same {@link AdminGuard} composition as `AdminController`
12+
* (issue #511): authentication runs first, then the `admin` role is
13+
* enforced from the JWT's `isAdmin` claim. A bare `X-Roles: admin`
14+
* header is never honored.
15+
*/
616
@Controller("admin/audit-logs")
7-
@UseGuards(RolesGuard)
17+
@UseGuards(AdminGuard)
818
@Roles("admin")
919
export class AdminAuditController {
1020
constructor(private readonly auditService: AuditService) {}
1121

12-
//get function
1322
@Get()
1423
async findAll(@Query() query: PaginationQueryDto) {
1524
const page = query.page ?? 1
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* Integration tests for the admin guard composition (issue #511).
3+
*
4+
* Proves the security contract end-to-end through real HTTP:
5+
* - No bearer token → 401 on both admin endpoints, even when the
6+
* `X-Roles: admin` header is present (the header path is gone).
7+
* - Authenticated non-admin → 403.
8+
* - Authenticated admin → 200.
9+
*
10+
* The harness follows api/src/auth/auth-rate-limit.integration.spec.ts:
11+
* a real Nest application with the real guard chain and mocked
12+
* services, exercised via supertest.
13+
*/
14+
import { CACHE_MANAGER } from "@nestjs/cache-manager"
15+
import { INestApplication, UnauthorizedException } from "@nestjs/common"
16+
import { Test, TestingModule } from "@nestjs/testing"
17+
import request from "supertest"
18+
19+
import { AdminAuditController } from "./admin-audit.controller"
20+
import { AdminStatsService } from "./admin-stats.service"
21+
import { AdminController } from "./admin.controller"
22+
import { AuditService } from "../audit/audit.service"
23+
import { AdminGuard } from "../common/auth/admin.guard"
24+
import { RolesGuard } from "../common/auth/roles.guard"
25+
import { AuthGuard } from "../common/guards/auth.guard"
26+
import { JwtExtractorService } from "../common/guards/jwt-extractor.service"
27+
28+
describe("Admin endpoints — guard composition (Integration)", () => {
29+
let app: INestApplication
30+
31+
const mockJwtExtractor = {
32+
authenticate: jest.fn(),
33+
extractBearerToken: jest.fn(),
34+
}
35+
const mockAuditService = { findAll: jest.fn() }
36+
const mockStatsService = { compute: jest.fn() }
37+
const mockCache = { get: jest.fn(), set: jest.fn() }
38+
39+
beforeAll(async () => {
40+
const moduleFixture: TestingModule = await Test.createTestingModule({
41+
controllers: [AdminController, AdminAuditController],
42+
providers: [
43+
{ provide: JwtExtractorService, useValue: mockJwtExtractor },
44+
{ provide: AuditService, useValue: mockAuditService },
45+
{ provide: AdminStatsService, useValue: mockStatsService },
46+
{ provide: CACHE_MANAGER, useValue: mockCache },
47+
AuthGuard,
48+
RolesGuard,
49+
AdminGuard,
50+
],
51+
}).compile()
52+
53+
app = moduleFixture.createNestApplication()
54+
await app.init()
55+
})
56+
57+
afterAll(async () => {
58+
await app.close()
59+
})
60+
61+
beforeEach(() => {
62+
jest.clearAllMocks()
63+
mockCache.get.mockResolvedValue(null)
64+
mockStatsService.compute.mockResolvedValue({
65+
totalUsers: 3,
66+
totalStreams: 5,
67+
activeStreams: 2,
68+
eventsLast24h: 10,
69+
generatedAt: "2026-08-24T00:00:00.000Z",
70+
})
71+
mockAuditService.findAll.mockResolvedValue({
72+
data: [{ id: 1, action: "AUTH_LOGIN_SUCCESS" }],
73+
total: 1,
74+
page: 1,
75+
limit: 20,
76+
})
77+
})
78+
79+
describe("GET /admin/audit-logs", () => {
80+
it("returns 401 without a bearer token", async () => {
81+
mockJwtExtractor.authenticate.mockRejectedValue(
82+
new UnauthorizedException(
83+
"Authorization header must contain a Bearer token",
84+
),
85+
)
86+
87+
const res = await request(app.getHttpServer()).get("/admin/audit-logs")
88+
89+
expect(res.status).toBe(401)
90+
expect(mockAuditService.findAll).not.toHaveBeenCalled()
91+
})
92+
93+
it("returns 401 for X-Roles: admin with no bearer token — the header grants nothing", async () => {
94+
mockJwtExtractor.authenticate.mockRejectedValue(
95+
new UnauthorizedException(
96+
"Authorization header must contain a Bearer token",
97+
),
98+
)
99+
100+
const res = await request(app.getHttpServer())
101+
.get("/admin/audit-logs")
102+
.set("X-Roles", "admin")
103+
104+
expect(res.status).toBe(401)
105+
expect(mockAuditService.findAll).not.toHaveBeenCalled()
106+
})
107+
108+
it("returns 403 for an authenticated non-admin user", async () => {
109+
mockJwtExtractor.authenticate.mockResolvedValue({
110+
userId: 1,
111+
isAdmin: false,
112+
})
113+
114+
const res = await request(app.getHttpServer())
115+
.get("/admin/audit-logs")
116+
.set("Authorization", "Bearer non-admin-token")
117+
118+
expect(res.status).toBe(403)
119+
expect(mockAuditService.findAll).not.toHaveBeenCalled()
120+
})
121+
122+
it("returns 403 for a non-admin user even when they send X-Roles: admin", async () => {
123+
mockJwtExtractor.authenticate.mockResolvedValue({
124+
userId: 1,
125+
isAdmin: false,
126+
})
127+
128+
const res = await request(app.getHttpServer())
129+
.get("/admin/audit-logs")
130+
.set("Authorization", "Bearer non-admin-token")
131+
.set("X-Roles", "admin")
132+
133+
expect(res.status).toBe(403)
134+
expect(mockAuditService.findAll).not.toHaveBeenCalled()
135+
})
136+
137+
it("returns 200 for an authenticated admin user", async () => {
138+
mockJwtExtractor.authenticate.mockResolvedValue({
139+
userId: 1,
140+
isAdmin: true,
141+
})
142+
143+
const res = await request(app.getHttpServer())
144+
.get("/admin/audit-logs")
145+
.set("Authorization", "Bearer admin-token")
146+
147+
expect(res.status).toBe(200)
148+
expect(mockAuditService.findAll).toHaveBeenCalledWith(1, 20)
149+
expect(res.body.data).toHaveLength(1)
150+
})
151+
})
152+
153+
describe("GET /admin/stats", () => {
154+
it("returns 401 without a bearer token", async () => {
155+
mockJwtExtractor.authenticate.mockRejectedValue(
156+
new UnauthorizedException(
157+
"Authorization header must contain a Bearer token",
158+
),
159+
)
160+
161+
const res = await request(app.getHttpServer()).get("/admin/stats")
162+
163+
expect(res.status).toBe(401)
164+
expect(mockStatsService.compute).not.toHaveBeenCalled()
165+
})
166+
167+
it("returns 401 for X-Roles: admin with no bearer token", async () => {
168+
mockJwtExtractor.authenticate.mockRejectedValue(
169+
new UnauthorizedException(
170+
"Authorization header must contain a Bearer token",
171+
),
172+
)
173+
174+
const res = await request(app.getHttpServer())
175+
.get("/admin/stats")
176+
.set("X-Roles", "admin")
177+
178+
expect(res.status).toBe(401)
179+
expect(mockStatsService.compute).not.toHaveBeenCalled()
180+
})
181+
182+
it("returns 403 for an authenticated non-admin user", async () => {
183+
mockJwtExtractor.authenticate.mockResolvedValue({
184+
userId: 1,
185+
isAdmin: false,
186+
})
187+
188+
const res = await request(app.getHttpServer())
189+
.get("/admin/stats")
190+
.set("Authorization", "Bearer non-admin-token")
191+
192+
expect(res.status).toBe(403)
193+
expect(mockStatsService.compute).not.toHaveBeenCalled()
194+
})
195+
196+
it("returns 200 with a snapshot for an authenticated admin user", async () => {
197+
mockJwtExtractor.authenticate.mockResolvedValue({
198+
userId: 1,
199+
isAdmin: true,
200+
})
201+
202+
const res = await request(app.getHttpServer())
203+
.get("/admin/stats")
204+
.set("Authorization", "Bearer admin-token")
205+
206+
expect(res.status).toBe(200)
207+
expect(res.body.totalUsers).toBe(3)
208+
})
209+
})
210+
})

api/src/admin/admin.controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@ import {
1515
ApiUnauthorizedResponse,
1616
} from "@nestjs/swagger"
1717
import { Cache } from "cache-manager"
18-
import { AuthGuard } from "../common/guards/auth.guard"
19-
import { Roles, RolesGuard } from "../common/auth/roles.guard"
18+
2019
import { AdminStats, AdminStatsService } from "./admin-stats.service"
20+
import { AdminGuard } from "../common/auth/admin.guard"
21+
import { Roles } from "../common/auth/roles.guard"
2122

2223
const STATS_CACHE_TTL_MS = 60_000
2324

2425
@ApiTags("admin")
2526
@Controller("admin")
26-
@UseGuards(AuthGuard, RolesGuard)
27+
@UseGuards(AdminGuard)
2728
@Roles("admin")
2829
export class AdminController {
2930
constructor(

api/src/admin/admin.module.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import { CacheModule } from "@nestjs/cache-manager"
22
import { Module } from "@nestjs/common"
3+
4+
import { AdminAuditController } from "./admin-audit.controller"
5+
import { AdminStatsService } from "./admin-stats.service"
6+
import { AdminController } from "./admin.controller"
7+
import { AuditModule } from "../audit/audit.module"
38
import { AuthModule } from "../auth/auth.module"
4-
import { adminCacheConfig } from "../config/cache.config"
9+
import { AdminGuard } from "../common/auth/admin.guard"
510
import { RolesGuard } from "../common/auth/roles.guard"
611
import { AuthGuard } from "../common/guards/auth.guard"
7-
import { AdminStatsService } from "./admin-stats.service"
8-
import { AdminController } from "./admin.controller"
12+
import { adminCacheConfig } from "../config/cache.config"
913

1014
@Module({
1115
imports: [
1216
CacheModule.register(adminCacheConfig()),
1317
AuthModule,
18+
AuditModule,
1419
],
15-
controllers: [AdminController],
16-
providers: [AdminStatsService, RolesGuard, AuthGuard],
20+
controllers: [AdminController, AdminAuditController],
21+
providers: [AdminStatsService, AdminGuard, RolesGuard, AuthGuard],
1722
})
18-
export class AdminModule {}
23+
export class AdminModule {}

api/src/audit/audit.module.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { Module } from "@nestjs/common"
22
import { APP_INTERCEPTOR } from "@nestjs/core"
3-
import { AuditService } from "./audit.service"
3+
44
import { AuditInterceptor } from "./audit.interceptor"
5-
import { AdminAuditController } from "./admin-audit.controller"
5+
import { AuditService } from "./audit.service"
66

77
@Module({
8-
controllers: [AdminAuditController],
98
providers: [
109
AuditService,
1110
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },

0 commit comments

Comments
 (0)