Skip to content

Commit 79d2a99

Browse files
authored
Merge pull request #200 from devxsameer/feature/rbac-admin-enforcement
feat: enforce RBAC on admin routes (deny-by-default) with tests Closes #115
2 parents 9fde7ae + 1637127 commit 79d2a99

File tree

3 files changed

+76
-20
lines changed

3 files changed

+76
-20
lines changed

src/middleware/rbac.ts

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,49 @@
11
import { Request, Response, NextFunction } from 'express'
22
import { UserRole } from '../types/user.js'
33

4-
export function requireRole(...allowedRoles: UserRole[]) {
5-
return (req: Request, res: Response, next: NextFunction): void => {
6-
if (!req.user) {
7-
res.status(401).json({ error: 'Unauthenticated' })
8-
return
9-
}
4+
type RBACOptions = {
5+
allow: UserRole[];
6+
};
107

11-
if (!allowedRoles.includes(req.user.role)) {
12-
res.status(403).json({
13-
error: `Forbidden: requires role ${allowedRoles.join(' or ')}, got '${req.user.role}'`,
14-
})
15-
return
16-
}
8+
const logRBACDenied = (req: Request, reason: string) => {
9+
console.warn(
10+
JSON.stringify({
11+
level: "warn",
12+
event: "security.rbac_denied",
13+
service: "disciplr-backend",
14+
userId: req.user?.userId ?? "unknown",
15+
role: req.user?.role ?? "unknown",
16+
path: req.originalUrl,
17+
method: req.method,
18+
reason,
19+
timestamp: new Date().toISOString(),
20+
}),
21+
);
22+
};
1723

18-
next()
19-
}
20-
}
24+
export const enforceRBAC = (options: RBACOptions) => {
25+
return (req: Request, res: Response, next: NextFunction): void => {
26+
// Deny by default
27+
if (!req.user) {
28+
logRBACDenied(req, "missing_user");
29+
res.status(401).json({ error: "Unauthorized" });
30+
return;
31+
}
2132

22-
// Convenience helpers
23-
export const requireUser = requireRole(UserRole.USER, UserRole.VERIFIER, UserRole.ADMIN)
24-
export const requireVerifier = requireRole(UserRole.VERIFIER, UserRole.ADMIN)
25-
export const requireAdmin = requireRole(UserRole.ADMIN)
33+
if (!options.allow.includes(req.user.role)) {
34+
logRBACDenied(req, "insufficient_role");
35+
res.status(403).json({
36+
error: "Forbidden",
37+
message: `Requires role: ${options.allow.join(", ")}`,
38+
});
39+
return;
40+
}
41+
42+
next();
43+
};
44+
};
45+
46+
// Convenience
47+
export const requireAdmin = enforceRBAC({
48+
allow: [UserRole.ADMIN],
49+
});

src/routes/admin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const adminRouter = Router()
1111

1212
// Apply authentication to all admin routes
1313
adminRouter.use(authenticate)
14-
adminRouter.use(authorize([UserRole.ADMIN]))
14+
adminRouter.use(requireAdmin)
1515

1616
/**
1717
* Force-logout a user (Admin only) - Preserve Issue #46 logic

src/tests/admin.rbac.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import request from "supertest";
2+
import { app } from "../app.js";
3+
import jwt from "jsonwebtoken";
4+
5+
const SECRET = process.env.JWT_SECRET || "change-me-in-production";
6+
7+
const makeToken = (role: string) =>
8+
jwt.sign({ userId: "test-user", role }, SECRET);
9+
10+
describe("Admin RBAC", () => {
11+
it("allows ADMIN", async () => {
12+
const res = await request(app)
13+
.get("/api/admin/audit-logs")
14+
.set("Authorization", `Bearer ${makeToken("ADMIN")}`);
15+
16+
expect(res.status).not.toBe(403);
17+
});
18+
19+
it("denies USER", async () => {
20+
const res = await request(app)
21+
.get("/api/admin/audit-logs")
22+
.set("Authorization", `Bearer ${makeToken("USER")}`);
23+
24+
expect(res.status).toBe(403);
25+
});
26+
27+
it("denies unauthenticated", async () => {
28+
const res = await request(app).get("/api/admin/audit-logs");
29+
30+
expect(res.status).toBe(401);
31+
});
32+
});

0 commit comments

Comments
 (0)