@hoanghainh/komagu 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/assess/lsa/knowledge/bfla-patterns.md +8 -0
- package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
- package/assess/lsa/tests/EVAL-RESULTS.md +25 -0
- package/assess/lsa/tests/fixtures/dotnet-legacy/Web/Controllers/CustomerController.cs +1 -1
- package/assess/lsa/tests/fixtures/java-legacy/src/com/acme/web/AccountController.java +1 -1
- package/assess/lsa/tests/fixtures/php-plain/register.php +3 -3
- package/assess/lsa/tests/fixtures/python-flask/profile.py +2 -2
- package/operate/ai/client.py +69 -1
- package/operate/ai/runner.py +61 -15
- package/operate/ai/verdicts.py +113 -0
- package/operate/cli/__init__.py +1 -1
- package/operate/tests/test_ai_verdicts.py +200 -0
- package/package.json +1 -1
- package/remediate/authz/README.md +122 -1
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/KomaguRoleOptions.cs +20 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RequireRoleAttribute.cs +34 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleAuthorizationFilter.cs +114 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleDenyResponse.cs +17 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/ServiceCollectionExtensions.cs +16 -0
- package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/MassAssignmentDtoTests.cs +76 -0
- package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/RoleAuthorizationFilterTests.cs +233 -0
- package/remediate/authz/golang/massassign.go +16 -0
- package/remediate/authz/golang/massassign_test.go +135 -0
- package/remediate/authz/golang/roles.go +168 -0
- package/remediate/authz/golang/roles_test.go +158 -0
- package/remediate/authz/nodejs/src/express/role-middleware.d.ts +14 -0
- package/remediate/authz/nodejs/src/express/role-middleware.js +52 -0
- package/remediate/authz/nodejs/src/index.d.ts +2 -0
- package/remediate/authz/nodejs/src/index.js +17 -2
- package/remediate/authz/nodejs/src/interfaces.d.ts +19 -4
- package/remediate/authz/nodejs/src/interfaces.js +10 -0
- package/remediate/authz/nodejs/src/mass-assignment.d.ts +8 -0
- package/remediate/authz/nodejs/src/mass-assignment.js +40 -0
- package/remediate/authz/nodejs/src/nestjs/decorators.d.ts +16 -0
- package/remediate/authz/nodejs/src/nestjs/decorators.js +27 -0
- package/remediate/authz/nodejs/src/nestjs/guards.d.ts +5 -0
- package/remediate/authz/nodejs/src/nestjs/guards.js +69 -2
- package/remediate/authz/nodejs/src/roles.js +90 -0
- package/remediate/authz/nodejs/src/service.d.ts +6 -0
- package/remediate/authz/nodejs/src/service.js +30 -0
- package/remediate/authz/nodejs/tests/test-authz.js +4 -0
- package/remediate/authz/nodejs/tests/test-roles.js +278 -0
- package/remediate/authz/spring/build.gradle.kts +5 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/SecurityAuditEvent.kt +10 -2
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/data/MassAssignment.kt +18 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/AuthenticationRequiredException.kt +14 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/KomaguSecurityAutoConfiguration.kt +20 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RequireRole.kt +34 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleAspect.kt +97 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleDeniedException.kt +14 -0
- package/remediate/authz/spring/src/test/kotlin/io/komagu/security/KomaguSecurityAutoConfigurationTest.kt +34 -0
- package/remediate/authz/spring/src/test/kotlin/io/komagu/security/MassAssignmentTest.kt +81 -0
- package/remediate/authz/spring/src/test/kotlin/io/komagu/security/RoleAspectTest.kt +196 -0
|
@@ -128,6 +128,36 @@ class KomaguSecurityService {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Emit a 'Komagu.Authorization.RoleDenied' audit event (function-level / BFLA denial).
|
|
133
|
+
* Auditing failures never affect the denial outcome.
|
|
134
|
+
*/
|
|
135
|
+
async emitRoleDenial(user, requiredRoles, route, reason = 'Caller lacks required role') {
|
|
136
|
+
try {
|
|
137
|
+
let actorUserId = null;
|
|
138
|
+
let actorTenantId = null;
|
|
139
|
+
|
|
140
|
+
if (user) {
|
|
141
|
+
try { actorUserId = this.getCurrentUserId(user); } catch (_) {}
|
|
142
|
+
try { actorTenantId = this.getCurrentTenantId(user); } catch (_) {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const event = {
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
eventName: 'Komagu.Authorization.RoleDenied',
|
|
148
|
+
actorUserId: actorUserId || undefined,
|
|
149
|
+
actorTenantId: actorTenantId || undefined,
|
|
150
|
+
requiredRoles: Array.isArray(requiredRoles) ? requiredRoles.slice() : [],
|
|
151
|
+
route: route || undefined,
|
|
152
|
+
reason,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await this.auditSink.emit(event);
|
|
156
|
+
} catch (_) {
|
|
157
|
+
// Auditing failures must never affect the denial outcome
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
async _emitDenial(user, resource, operation, reason, resourceId) {
|
|
132
162
|
try {
|
|
133
163
|
let actorUserId = null;
|
|
@@ -253,3 +253,7 @@ test('KomaguSecurityService: claim semantics and edge cases', async () => {
|
|
|
253
253
|
const decision = await service.authorize(userWithId, emptyOwnerRes, ResourceOperation.READ);
|
|
254
254
|
assert.strictEqual(decision.succeeded, false);
|
|
255
255
|
});
|
|
256
|
+
|
|
257
|
+
// Role guard (BFLA) and mass-assignment tests. Loaded from here so the CI step
|
|
258
|
+
// `node --test remediate/authz/nodejs/tests/test-authz.js` also runs them.
|
|
259
|
+
require('./test-roles.js');
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
NotFoundException,
|
|
8
|
+
UnauthorizedException,
|
|
9
|
+
ForbiddenException,
|
|
10
|
+
InMemorySecurityAuditSink,
|
|
11
|
+
KomaguSecurityService,
|
|
12
|
+
RequireRole,
|
|
13
|
+
KomaguRoleGuard,
|
|
14
|
+
requireRole,
|
|
15
|
+
allowFields,
|
|
16
|
+
} = require('../src/index');
|
|
17
|
+
|
|
18
|
+
function mockRes() {
|
|
19
|
+
return {
|
|
20
|
+
statusCode: null,
|
|
21
|
+
body: null,
|
|
22
|
+
status(code) {
|
|
23
|
+
this.statusCode = code;
|
|
24
|
+
return this;
|
|
25
|
+
},
|
|
26
|
+
json(data) {
|
|
27
|
+
this.body = data;
|
|
28
|
+
return this;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runMiddleware(mw, req) {
|
|
34
|
+
const res = mockRes();
|
|
35
|
+
let nextCalled = false;
|
|
36
|
+
await mw(req, res, () => { nextCalled = true; });
|
|
37
|
+
return { res, nextCalled };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const admin = { userId: 'user-admin', tenantId: 'tenant-komagu', roles: ['admin'] };
|
|
41
|
+
const plainUser = { userId: 'user-b', tenantId: 'tenant-komagu', roles: ['user'] };
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Express requireRole
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
test('Express requireRole: allows caller holding a required role (roles array or role string)', async () => {
|
|
48
|
+
const mw = requireRole(['admin', 'ops']);
|
|
49
|
+
|
|
50
|
+
const fromArray = await runMiddleware(mw, { user: admin });
|
|
51
|
+
assert.strictEqual(fromArray.nextCalled, true);
|
|
52
|
+
assert.strictEqual(fromArray.res.statusCode, null);
|
|
53
|
+
|
|
54
|
+
const fromString = await runMiddleware(mw, { user: { userId: 'user-o', tenantId: 't', role: 'ops' } });
|
|
55
|
+
assert.strictEqual(fromString.nextCalled, true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('Express requireRole: denies with 403 Forbidden by default (case-sensitive match)', async () => {
|
|
59
|
+
const mw = requireRole('admin', { securityService: new KomaguSecurityService(new InMemorySecurityAuditSink()) });
|
|
60
|
+
|
|
61
|
+
const denied = await runMiddleware(mw, { user: plainUser });
|
|
62
|
+
assert.strictEqual(denied.nextCalled, false);
|
|
63
|
+
assert.strictEqual(denied.res.statusCode, 403);
|
|
64
|
+
assert.deepStrictEqual(denied.res.body, { message: 'Forbidden' });
|
|
65
|
+
|
|
66
|
+
const wrongCase = await runMiddleware(mw, { user: { userId: 'u', tenantId: 't', roles: ['Admin'] } });
|
|
67
|
+
assert.strictEqual(wrongCase.res.statusCode, 403);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('Express requireRole: denyStatus 404 hides the endpoint', async () => {
|
|
71
|
+
const mw = requireRole('admin', {
|
|
72
|
+
denyStatus: 404,
|
|
73
|
+
securityService: new KomaguSecurityService(new InMemorySecurityAuditSink()),
|
|
74
|
+
});
|
|
75
|
+
const denied = await runMiddleware(mw, { user: plainUser });
|
|
76
|
+
assert.strictEqual(denied.nextCalled, false);
|
|
77
|
+
assert.strictEqual(denied.res.statusCode, 404);
|
|
78
|
+
assert.deepStrictEqual(denied.res.body, { message: 'Resource not found' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('Express requireRole: returns 401 without a principal', async () => {
|
|
82
|
+
const mw = requireRole('admin');
|
|
83
|
+
const result = await runMiddleware(mw, { headers: {} });
|
|
84
|
+
assert.strictEqual(result.nextCalled, false);
|
|
85
|
+
assert.strictEqual(result.res.statusCode, 401);
|
|
86
|
+
assert.deepStrictEqual(result.res.body, { message: 'Unauthorized' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('Express requireRole: role claimed in body, query, params or headers is ignored', async () => {
|
|
90
|
+
const sink = new InMemorySecurityAuditSink();
|
|
91
|
+
const mw = requireRole('admin', { securityService: new KomaguSecurityService(sink) });
|
|
92
|
+
|
|
93
|
+
const req = {
|
|
94
|
+
user: { userId: 'user-b', tenantId: 'tenant-komagu' }, // authenticated, no roles
|
|
95
|
+
body: { role: 'admin', roles: ['admin'], isAdmin: true },
|
|
96
|
+
query: { role: 'admin', roles: ['admin'] },
|
|
97
|
+
params: { role: 'admin' },
|
|
98
|
+
headers: { 'x-role': 'admin', 'x-roles': 'admin' },
|
|
99
|
+
};
|
|
100
|
+
const result = await runMiddleware(mw, req);
|
|
101
|
+
assert.strictEqual(result.nextCalled, false);
|
|
102
|
+
assert.strictEqual(result.res.statusCode, 403);
|
|
103
|
+
assert.strictEqual(sink.events.length, 1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('Express requireRole: emits RoleDenied audit event on denial only', async () => {
|
|
107
|
+
const sink = new InMemorySecurityAuditSink();
|
|
108
|
+
const mw = requireRole(['admin', 'ops'], { securityService: new KomaguSecurityService(sink) });
|
|
109
|
+
|
|
110
|
+
await runMiddleware(mw, { user: admin, method: 'DELETE', baseUrl: '/api', route: { path: '/users/:id' } });
|
|
111
|
+
assert.strictEqual(sink.events.length, 0);
|
|
112
|
+
|
|
113
|
+
await runMiddleware(mw, {
|
|
114
|
+
user: plainUser,
|
|
115
|
+
method: 'DELETE',
|
|
116
|
+
baseUrl: '/api',
|
|
117
|
+
route: { path: '/users/:id' },
|
|
118
|
+
originalUrl: '/api/users/7?token=secret',
|
|
119
|
+
});
|
|
120
|
+
assert.strictEqual(sink.events.length, 1);
|
|
121
|
+
const event = sink.events[0];
|
|
122
|
+
assert.strictEqual(event.eventName, 'Komagu.Authorization.RoleDenied');
|
|
123
|
+
assert.strictEqual(event.actorUserId, 'user-b');
|
|
124
|
+
assert.strictEqual(event.actorTenantId, 'tenant-komagu');
|
|
125
|
+
assert.deepStrictEqual(event.requiredRoles, ['admin', 'ops']);
|
|
126
|
+
assert.strictEqual(event.route, 'DELETE /api/users/:id');
|
|
127
|
+
assert.ok(event.timestamp);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('Express requireRole: audit sink failure does not change the denial', async () => {
|
|
131
|
+
const brokenSink = { emit() { throw new Error('sink down'); } };
|
|
132
|
+
const mw = requireRole('admin', { securityService: new KomaguSecurityService(brokenSink) });
|
|
133
|
+
const result = await runMiddleware(mw, { user: plainUser });
|
|
134
|
+
assert.strictEqual(result.res.statusCode, 403);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('requireRole / RequireRole: invalid arguments throw at construction time', () => {
|
|
138
|
+
for (const bad of [undefined, null, '', ' ', [], [''], ['admin', 42], 42, {}]) {
|
|
139
|
+
assert.throws(() => requireRole(bad), TypeError, `requireRole(${JSON.stringify(bad)})`);
|
|
140
|
+
assert.throws(() => RequireRole(bad), TypeError, `RequireRole(${JSON.stringify(bad)})`);
|
|
141
|
+
}
|
|
142
|
+
for (const badStatus of [401, 500, '404', 0]) {
|
|
143
|
+
assert.throws(() => requireRole('admin', { denyStatus: badStatus }), /denyStatus must be 403 or 404/);
|
|
144
|
+
assert.throws(() => RequireRole('admin', { denyStatus: badStatus }), /denyStatus must be 403 or 404/);
|
|
145
|
+
}
|
|
146
|
+
assert.doesNotThrow(() => requireRole('admin', { denyStatus: 403 }));
|
|
147
|
+
assert.doesNotThrow(() => requireRole('admin', { denyStatus: 404 }));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// NestJS RequireRole + KomaguRoleGuard
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function nestContext(controller, handlerName, req) {
|
|
155
|
+
return {
|
|
156
|
+
getHandler: () => controller.prototype[handlerName],
|
|
157
|
+
getClass: () => controller,
|
|
158
|
+
switchToHttp: () => ({ getRequest: () => req }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function decorate(decorator, controller, handlerName) {
|
|
163
|
+
decorator(controller.prototype, handlerName, Object.getOwnPropertyDescriptor(controller.prototype, handlerName));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test('NestJS KomaguRoleGuard: allows role holder, denies others with 403 and audits', async () => {
|
|
167
|
+
const sink = new InMemorySecurityAuditSink();
|
|
168
|
+
const guard = new KomaguRoleGuard(null, new KomaguSecurityService(sink));
|
|
169
|
+
|
|
170
|
+
class AdminController {
|
|
171
|
+
deleteUser() {}
|
|
172
|
+
publicInfo() {}
|
|
173
|
+
}
|
|
174
|
+
decorate(RequireRole('admin'), AdminController, 'deleteUser');
|
|
175
|
+
|
|
176
|
+
assert.strictEqual(await guard.canActivate(nestContext(AdminController, 'deleteUser', { user: admin })), true);
|
|
177
|
+
|
|
178
|
+
await assert.rejects(
|
|
179
|
+
guard.canActivate(nestContext(AdminController, 'deleteUser', {
|
|
180
|
+
user: plainUser,
|
|
181
|
+
body: { role: 'admin' },
|
|
182
|
+
query: { roles: ['admin'] },
|
|
183
|
+
headers: { 'x-role': 'admin' },
|
|
184
|
+
})),
|
|
185
|
+
(err) => {
|
|
186
|
+
assert(err instanceof ForbiddenException);
|
|
187
|
+
assert.strictEqual(err.getStatus(), 403);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
assert.strictEqual(sink.events.length, 1);
|
|
192
|
+
assert.strictEqual(sink.events[0].eventName, 'Komagu.Authorization.RoleDenied');
|
|
193
|
+
assert.strictEqual(sink.events[0].route, 'AdminController.deleteUser');
|
|
194
|
+
assert.deepStrictEqual(sink.events[0].requiredRoles, ['admin']);
|
|
195
|
+
|
|
196
|
+
// Undecorated handler passes through
|
|
197
|
+
assert.strictEqual(await guard.canActivate(nestContext(AdminController, 'publicInfo', { user: plainUser })), true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('NestJS KomaguRoleGuard: class-level decorator, denyStatus 404 and 401', async () => {
|
|
201
|
+
const guard = new KomaguRoleGuard(null, new KomaguSecurityService(new InMemorySecurityAuditSink()));
|
|
202
|
+
|
|
203
|
+
class OpsController {
|
|
204
|
+
rotateKeys() {}
|
|
205
|
+
}
|
|
206
|
+
RequireRole(['ops'], { denyStatus: 404 })(OpsController);
|
|
207
|
+
|
|
208
|
+
await assert.rejects(
|
|
209
|
+
guard.canActivate(nestContext(OpsController, 'rotateKeys', { user: plainUser })),
|
|
210
|
+
(err) => err instanceof NotFoundException && err.getStatus() === 404
|
|
211
|
+
);
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
guard.canActivate(nestContext(OpsController, 'rotateKeys', {})),
|
|
214
|
+
(err) => err instanceof UnauthorizedException && err.getStatus() === 401
|
|
215
|
+
);
|
|
216
|
+
assert.strictEqual(
|
|
217
|
+
await guard.canActivate(nestContext(OpsController, 'rotateKeys', { user: { userId: 'o', tenantId: 't', role: 'ops' } })),
|
|
218
|
+
true
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// allowFields (mass assignment)
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
test('allowFields: picks allow-listed keys, skips missing, never mutates source', () => {
|
|
227
|
+
const source = { name: 'Bob', email: 'b@example.com', role: 'admin', isAdmin: true };
|
|
228
|
+
const snapshot = JSON.parse(JSON.stringify(source));
|
|
229
|
+
|
|
230
|
+
const picked = allowFields(source, ['name', 'email', 'phone']);
|
|
231
|
+
assert.deepStrictEqual(picked, { name: 'Bob', email: 'b@example.com' });
|
|
232
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(picked, 'phone'), false);
|
|
233
|
+
assert.notStrictEqual(picked, source);
|
|
234
|
+
assert.deepStrictEqual(source, snapshot);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('allowFields: ignores __proto__/constructor/prototype and inherited or non-enumerable keys', () => {
|
|
238
|
+
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}, "constructor": {"x": 1}, "prototype": 1, "name": "x"}');
|
|
239
|
+
const picked = allowFields(malicious, ['__proto__', 'constructor', 'prototype', 'name']);
|
|
240
|
+
assert.deepStrictEqual(Object.keys(picked), ['name']);
|
|
241
|
+
assert.strictEqual(Object.getPrototypeOf(picked), Object.prototype);
|
|
242
|
+
assert.strictEqual(picked.isAdmin, undefined);
|
|
243
|
+
assert.strictEqual({}.isAdmin, undefined);
|
|
244
|
+
|
|
245
|
+
const inherited = Object.create({ role: 'admin' });
|
|
246
|
+
Object.defineProperty(inherited, 'hidden', { value: 'h', enumerable: false });
|
|
247
|
+
assert.deepStrictEqual(allowFields(inherited, ['role', 'hidden']), {});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('allowFields: non-object source yields {}; invalid allow-list throws', () => {
|
|
251
|
+
for (const bad of [undefined, null, 'str', 42, true, ['a']]) {
|
|
252
|
+
assert.deepStrictEqual(allowFields(bad, ['a', '0']), {});
|
|
253
|
+
}
|
|
254
|
+
assert.throws(() => allowFields({ a: 1 }, 'a'), TypeError);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('DTO allow-list example: profile update cannot change role or isAdmin', async () => {
|
|
258
|
+
const usersDb = { 'user-b': { id: 'user-b', name: 'Bob', email: 'bob@old.example', role: 'user', isAdmin: false } };
|
|
259
|
+
|
|
260
|
+
// Example Express handler: PATCH /me
|
|
261
|
+
function updateProfile(req, res) {
|
|
262
|
+
const changes = allowFields(req.body, ['name', 'email']);
|
|
263
|
+
const current = usersDb[req.user.userId];
|
|
264
|
+
usersDb[req.user.userId] = { ...current, ...changes };
|
|
265
|
+
return res.status(200).json(usersDb[req.user.userId]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const res = mockRes();
|
|
269
|
+
updateProfile({
|
|
270
|
+
user: { userId: 'user-b', tenantId: 'tenant-komagu' },
|
|
271
|
+
body: { name: 'Bobby', email: 'bob@new.example', role: 'admin', isAdmin: true, id: 'user-a' },
|
|
272
|
+
}, res);
|
|
273
|
+
|
|
274
|
+
assert.strictEqual(res.statusCode, 200);
|
|
275
|
+
assert.deepStrictEqual(usersDb['user-b'], {
|
|
276
|
+
id: 'user-b', name: 'Bobby', email: 'bob@new.example', role: 'user', isAdmin: false,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -50,6 +50,11 @@ dependencies {
|
|
|
50
50
|
testImplementation("org.springframework.data:spring-data-commons:3.5.13")
|
|
51
51
|
testImplementation("org.springframework:spring-aspects:6.2.19")
|
|
52
52
|
testImplementation("org.aspectj:aspectjweaver:1.9.25.1")
|
|
53
|
+
// Test-only: a real servlet request for the role-from-request tests, and Jackson (the version
|
|
54
|
+
// Spring Boot $springBootVersion manages) for the mass-assignment DTO example.
|
|
55
|
+
testImplementation("jakarta.servlet:jakarta.servlet-api:6.1.0")
|
|
56
|
+
testImplementation("com.fasterxml.jackson.core:jackson-databind:2.21.4")
|
|
57
|
+
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.4")
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
tasks.test {
|
|
@@ -7,7 +7,7 @@ import java.time.Instant
|
|
|
7
7
|
*/
|
|
8
8
|
data class SecurityAuditEvent(
|
|
9
9
|
val timestamp: Instant = Instant.now(),
|
|
10
|
-
val eventName: String =
|
|
10
|
+
val eventName: String = DENIED,
|
|
11
11
|
val traceId: String? = null,
|
|
12
12
|
val actorUserId: String? = null,
|
|
13
13
|
val actorTenantId: String? = null,
|
|
@@ -16,7 +16,15 @@ data class SecurityAuditEvent(
|
|
|
16
16
|
val operation: ResourceOperation = ResourceOperation.READ,
|
|
17
17
|
val clientIp: String? = null,
|
|
18
18
|
val reason: String = ""
|
|
19
|
-
)
|
|
19
|
+
) {
|
|
20
|
+
companion object {
|
|
21
|
+
/** Object-level (ownership / tenant) denial. */
|
|
22
|
+
const val DENIED = "Komagu.Authorization.Denied"
|
|
23
|
+
|
|
24
|
+
/** Function-level denial: the caller lacks every role required by @RequireRole. */
|
|
25
|
+
const val ROLE_DENIED = "Komagu.Authorization.RoleDenied"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
22
30
|
* Sink interface for processing security audit events.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
@file:JvmName("MassAssignment")
|
|
2
|
+
|
|
3
|
+
package io.komagu.security.data
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mass-assignment defense for map-shaped input (e.g. a JSON body bound to `Map<String, Any?>`).
|
|
7
|
+
*
|
|
8
|
+
* Returns a NEW map holding only the entries of [source] whose keys are listed in [allowed]
|
|
9
|
+
* (exact, case-sensitive match, source order kept). [source] is never mutated. Privileged fields such as
|
|
10
|
+
* `role`, `isAdmin` or `balance` are dropped unless explicitly allowed.
|
|
11
|
+
*
|
|
12
|
+
* Prefer a dedicated request DTO that declares only the client-editable fields; use this helper when the
|
|
13
|
+
* input must stay a map (patch endpoints, legacy controllers).
|
|
14
|
+
*/
|
|
15
|
+
fun allowFields(source: Map<String, Any?>, vararg allowed: String): Map<String, Any?> {
|
|
16
|
+
val allowedKeys = allowed.toSet()
|
|
17
|
+
return source.filterKeys { it in allowedKeys }
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package io.komagu.security.spring
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus
|
|
4
|
+
import org.springframework.web.bind.annotation.ResponseStatus
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thrown when a role-protected method is invoked without an authenticated principal.
|
|
8
|
+
* Mapped to HTTP 401 Unauthorized.
|
|
9
|
+
*/
|
|
10
|
+
@ResponseStatus(HttpStatus.UNAUTHORIZED, reason = "Authentication required")
|
|
11
|
+
class AuthenticationRequiredException(
|
|
12
|
+
message: String = "Authentication required",
|
|
13
|
+
cause: Throwable? = null
|
|
14
|
+
) : RuntimeException(message, cause)
|
|
@@ -8,6 +8,7 @@ import org.springframework.beans.factory.ObjectProvider
|
|
|
8
8
|
import org.springframework.boot.autoconfigure.AutoConfiguration
|
|
9
9
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
|
10
10
|
import org.springframework.context.annotation.Bean
|
|
11
|
+
import org.springframework.core.env.Environment
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Spring Boot AutoConfiguration for Komagu Authorization module.
|
|
@@ -37,4 +38,23 @@ class KomaguSecurityAutoConfiguration {
|
|
|
37
38
|
): OwnershipAspect {
|
|
38
39
|
return OwnershipAspect(securityManager, resolvers.orderedStream().toList())
|
|
39
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Role (function-level) aspect for @RequireRole. Set `komagu.security.role.hide-denied-as-not-found=true`
|
|
44
|
+
* to answer role denials with 404 instead of 403 for every annotated endpoint.
|
|
45
|
+
*/
|
|
46
|
+
@Bean
|
|
47
|
+
@ConditionalOnMissingBean(RoleAspect::class)
|
|
48
|
+
fun roleAspect(
|
|
49
|
+
securityManager: KomaguSecurityManager,
|
|
50
|
+
auditSink: SecurityAuditSink,
|
|
51
|
+
environment: Environment
|
|
52
|
+
): RoleAspect {
|
|
53
|
+
val hideAsNotFound = environment.getProperty(HIDE_ROLE_DENIED_PROPERTY, Boolean::class.javaObjectType, false)
|
|
54
|
+
return RoleAspect(securityManager, auditSink, hideAsNotFound)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
companion object {
|
|
58
|
+
const val HIDE_ROLE_DENIED_PROPERTY = "komagu.security.role.hide-denied-as-not-found"
|
|
59
|
+
}
|
|
40
60
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
package io.komagu.security.spring
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declarative function-level authorization (BFLA defense) for controller or service methods and classes.
|
|
5
|
+
*
|
|
6
|
+
* The caller is allowed when the authenticated principal in Spring Security's `SecurityContextHolder`
|
|
7
|
+
* holds at least one of the listed roles. Roles are read only from the principal's granted authorities,
|
|
8
|
+
* never from request parameters, bodies or headers.
|
|
9
|
+
*
|
|
10
|
+
* Role matching rule: a single leading `ROLE_` prefix is stripped from both the granted authority and the
|
|
11
|
+
* required role, then the names are compared exactly and case-sensitively. So `ROLE_admin` and `admin`
|
|
12
|
+
* both satisfy `@RequireRole("admin")` (and `@RequireRole("ROLE_admin")`), while `Admin` and `SCOPE_admin`
|
|
13
|
+
* do not. JWT scopes or custom role claims must be mapped to granted authorities by the application's
|
|
14
|
+
* `JwtAuthenticationConverter`.
|
|
15
|
+
*
|
|
16
|
+
* Outcomes:
|
|
17
|
+
* - not authenticated: [AuthenticationRequiredException] (HTTP 401);
|
|
18
|
+
* - authenticated but no matching role: [RoleDeniedException] (HTTP 403), or [ResourceNotFoundException]
|
|
19
|
+
* (HTTP 404) when [hideAsNotFound] is true or `komagu.security.role.hide-denied-as-not-found=true`;
|
|
20
|
+
* - empty or blank role list: [IllegalStateException] before the method runs (misconfiguration, fail fast).
|
|
21
|
+
*
|
|
22
|
+
* A method-level annotation takes precedence over a class-level one.
|
|
23
|
+
*
|
|
24
|
+
* NOTE ON SPRING PROXY BYPASS (Self-Invocation):
|
|
25
|
+
* Spring AOP proxies intercept calls entering from outside the bean. Calling an annotated method from
|
|
26
|
+
* within the same class (`this.method()`) bypasses the proxy and will NOT enforce role checks.
|
|
27
|
+
*/
|
|
28
|
+
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
|
|
29
|
+
@Retention(AnnotationRetention.RUNTIME)
|
|
30
|
+
@MustBeDocumented
|
|
31
|
+
annotation class RequireRole(
|
|
32
|
+
vararg val value: String,
|
|
33
|
+
val hideAsNotFound: Boolean = false
|
|
34
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package io.komagu.security.spring
|
|
2
|
+
|
|
3
|
+
import io.komagu.security.authorization.KomaguSecurityManager
|
|
4
|
+
import io.komagu.security.authorization.SecurityAuditEvent
|
|
5
|
+
import io.komagu.security.authorization.SecurityAuditSink
|
|
6
|
+
import org.aspectj.lang.ProceedingJoinPoint
|
|
7
|
+
import org.aspectj.lang.annotation.Around
|
|
8
|
+
import org.aspectj.lang.annotation.Aspect
|
|
9
|
+
import org.aspectj.lang.reflect.MethodSignature
|
|
10
|
+
import org.springframework.security.authentication.AnonymousAuthenticationToken
|
|
11
|
+
import org.springframework.security.core.Authentication
|
|
12
|
+
import org.springframework.security.core.context.SecurityContextHolder
|
|
13
|
+
import java.time.Instant
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Spring AOP Aspect enforcing [RequireRole] on annotated methods and classes (BFLA defense).
|
|
17
|
+
*
|
|
18
|
+
* Roles come only from the granted authorities of the authentication in `SecurityContextHolder`;
|
|
19
|
+
* request parameters, bodies and headers are never consulted. See [RequireRole] for the matching rule.
|
|
20
|
+
*
|
|
21
|
+
* NOTE ON PROXY BYPASS (Self-Invocation):
|
|
22
|
+
* Direct calls within the same class bypass the Spring proxy and will NOT trigger this aspect.
|
|
23
|
+
*/
|
|
24
|
+
@Aspect
|
|
25
|
+
class RoleAspect(
|
|
26
|
+
private val securityManager: KomaguSecurityManager,
|
|
27
|
+
private val auditSink: SecurityAuditSink,
|
|
28
|
+
private val hideDeniedAsNotFound: Boolean = false
|
|
29
|
+
) {
|
|
30
|
+
|
|
31
|
+
companion object {
|
|
32
|
+
const val ROLE_PREFIX = "ROLE_"
|
|
33
|
+
|
|
34
|
+
/** Strips one leading `ROLE_` prefix; the remaining name is compared case-sensitively. */
|
|
35
|
+
fun normalizeRole(role: String): String = role.trim().removePrefix(ROLE_PREFIX)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Around("@annotation(io.komagu.security.spring.RequireRole) || @within(io.komagu.security.spring.RequireRole)")
|
|
39
|
+
fun enforceRole(joinPoint: ProceedingJoinPoint): Any? {
|
|
40
|
+
val method = (joinPoint.signature as? MethodSignature)?.method
|
|
41
|
+
val annotation = method?.getAnnotation(RequireRole::class.java)
|
|
42
|
+
?: method?.declaringClass?.getAnnotation(RequireRole::class.java)
|
|
43
|
+
?: joinPoint.target?.javaClass?.getAnnotation(RequireRole::class.java)
|
|
44
|
+
?: return joinPoint.proceed()
|
|
45
|
+
|
|
46
|
+
val target = method?.let { "${it.declaringClass.simpleName}.${it.name}" } ?: "method"
|
|
47
|
+
requireAnyRole(annotation.value.toList(), annotation.hideAsNotFound, target)
|
|
48
|
+
return joinPoint.proceed()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Programmatic check: returns normally when the current principal holds at least one of [roles].
|
|
53
|
+
* Throws [AuthenticationRequiredException] (401), [RoleDeniedException] (403) or, when
|
|
54
|
+
* [hideAsNotFound] (or the configured default) is set, [ResourceNotFoundException] (404).
|
|
55
|
+
*/
|
|
56
|
+
fun requireAnyRole(roles: Collection<String>, hideAsNotFound: Boolean = false, target: String = "method") {
|
|
57
|
+
val required = roles.map { normalizeRole(it) }
|
|
58
|
+
check(required.isNotEmpty() && required.none { it.isEmpty() }) {
|
|
59
|
+
"@RequireRole on '$target' must list at least one non-blank role"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
val authentication = SecurityContextHolder.getContext().authentication
|
|
63
|
+
if (authentication == null || !authentication.isAuthenticated ||
|
|
64
|
+
authentication is AnonymousAuthenticationToken
|
|
65
|
+
) {
|
|
66
|
+
throw AuthenticationRequiredException()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
val granted = authentication.authorities.orEmpty()
|
|
70
|
+
.mapNotNull { it.authority }
|
|
71
|
+
.map { normalizeRole(it) }
|
|
72
|
+
.toSet()
|
|
73
|
+
if (required.any { it in granted }) return
|
|
74
|
+
|
|
75
|
+
val reason = "Caller lacks required role(s) [${required.joinToString(", ")}] for '$target'"
|
|
76
|
+
emitRoleDeniedAudit(authentication, target, reason)
|
|
77
|
+
throw if (hideAsNotFound || hideDeniedAsNotFound) ResourceNotFoundException() else RoleDeniedException()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private fun emitRoleDeniedAudit(authentication: Authentication, target: String, reason: String) {
|
|
81
|
+
try {
|
|
82
|
+
auditSink.emit(
|
|
83
|
+
SecurityAuditEvent(
|
|
84
|
+
timestamp = Instant.now(),
|
|
85
|
+
eventName = SecurityAuditEvent.ROLE_DENIED,
|
|
86
|
+
actorUserId = runCatching { securityManager.getCurrentUserId(authentication) }.getOrNull(),
|
|
87
|
+
actorTenantId = runCatching { securityManager.getCurrentTenantId(authentication) }.getOrNull(),
|
|
88
|
+
resourceType = "Endpoint",
|
|
89
|
+
resourceId = target,
|
|
90
|
+
reason = reason
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
} catch (_: Exception) {
|
|
94
|
+
// Auditing failures must never alter the denial outcome
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleDeniedException.kt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package io.komagu.security.spring
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus
|
|
4
|
+
import org.springframework.web.bind.annotation.ResponseStatus
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thrown when an authenticated caller lacks every role required by [RequireRole].
|
|
8
|
+
* Mapped to HTTP 403 Forbidden. Use `hideAsNotFound` to answer 404 instead and hide the endpoint.
|
|
9
|
+
*/
|
|
10
|
+
@ResponseStatus(HttpStatus.FORBIDDEN, reason = "Forbidden")
|
|
11
|
+
class RoleDeniedException(
|
|
12
|
+
message: String = "Forbidden",
|
|
13
|
+
cause: Throwable? = null
|
|
14
|
+
) : RuntimeException(message, cause)
|
|
@@ -2,7 +2,14 @@ package io.komagu.security
|
|
|
2
2
|
|
|
3
3
|
import io.komagu.security.spring.KomaguSecurityAutoConfiguration
|
|
4
4
|
import io.komagu.security.spring.OwnershipAspect
|
|
5
|
+
import io.komagu.security.spring.ResourceNotFoundException
|
|
5
6
|
import io.komagu.security.spring.ResourceOwnershipResolver
|
|
7
|
+
import io.komagu.security.spring.RoleAspect
|
|
8
|
+
import io.komagu.security.spring.RoleDeniedException
|
|
9
|
+
import org.junit.jupiter.api.AfterEach
|
|
10
|
+
import org.junit.jupiter.api.assertThrows
|
|
11
|
+
import org.springframework.security.authentication.TestingAuthenticationToken
|
|
12
|
+
import org.springframework.security.core.context.SecurityContextHolder
|
|
6
13
|
import org.assertj.core.api.Assertions.assertThat
|
|
7
14
|
import org.junit.jupiter.api.Test
|
|
8
15
|
import org.springframework.boot.autoconfigure.AutoConfigurations
|
|
@@ -35,4 +42,31 @@ class KomaguSecurityAutoConfigurationTest {
|
|
|
35
42
|
assertThat(context).hasSingleBean(OwnershipAspect::class.java)
|
|
36
43
|
}
|
|
37
44
|
}
|
|
45
|
+
|
|
46
|
+
@AfterEach
|
|
47
|
+
fun clear() = SecurityContextHolder.clearContext()
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
fun `role aspect is auto-configured and denies with 403 by default`() {
|
|
51
|
+
contextRunner.run { context ->
|
|
52
|
+
assertThat(context).hasNotFailed()
|
|
53
|
+
assertThat(context).hasSingleBean(RoleAspect::class.java)
|
|
54
|
+
SecurityContextHolder.getContext().authentication = TestingAuthenticationToken("u", null, "ROLE_user")
|
|
55
|
+
assertThrows<RoleDeniedException> {
|
|
56
|
+
context.getBean(RoleAspect::class.java).requireAnyRole(listOf("admin"))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun `role denial maps to 404 when the hide property is set`() {
|
|
63
|
+
contextRunner
|
|
64
|
+
.withPropertyValues("${KomaguSecurityAutoConfiguration.HIDE_ROLE_DENIED_PROPERTY}=true")
|
|
65
|
+
.run { context ->
|
|
66
|
+
SecurityContextHolder.getContext().authentication = TestingAuthenticationToken("u", null, "ROLE_user")
|
|
67
|
+
assertThrows<ResourceNotFoundException> {
|
|
68
|
+
context.getBean(RoleAspect::class.java).requireAnyRole(listOf("admin"))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
38
72
|
}
|