@hoanghainh/komagu 2.1.2 → 2.2.1

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/assess/lsa/knowledge/bfla-patterns.md +8 -0
  4. package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
  5. package/examples/member-portal/backend-dotnet/MemberPortal.Backend.csproj +2 -2
  6. package/operate/cli/__init__.py +1 -1
  7. package/package.json +1 -1
  8. package/remediate/authz/README.md +122 -1
  9. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/KomaguRoleOptions.cs +20 -0
  10. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RequireRoleAttribute.cs +34 -0
  11. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleAuthorizationFilter.cs +114 -0
  12. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleDenyResponse.cs +17 -0
  13. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/ServiceCollectionExtensions.cs +16 -0
  14. package/remediate/authz/dotnet/src/Komagu.Security.EntityFrameworkCore/Komagu.Security.EntityFrameworkCore.csproj +1 -1
  15. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/Komagu.Security.Authorization.Tests.csproj +1 -1
  16. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/MassAssignmentDtoTests.cs +76 -0
  17. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/RoleAuthorizationFilterTests.cs +233 -0
  18. package/remediate/authz/golang/massassign.go +16 -0
  19. package/remediate/authz/golang/massassign_test.go +135 -0
  20. package/remediate/authz/golang/roles.go +168 -0
  21. package/remediate/authz/golang/roles_test.go +158 -0
  22. package/remediate/authz/nodejs/src/express/role-middleware.d.ts +14 -0
  23. package/remediate/authz/nodejs/src/express/role-middleware.js +52 -0
  24. package/remediate/authz/nodejs/src/index.d.ts +2 -0
  25. package/remediate/authz/nodejs/src/index.js +17 -2
  26. package/remediate/authz/nodejs/src/interfaces.d.ts +19 -4
  27. package/remediate/authz/nodejs/src/interfaces.js +10 -0
  28. package/remediate/authz/nodejs/src/mass-assignment.d.ts +8 -0
  29. package/remediate/authz/nodejs/src/mass-assignment.js +40 -0
  30. package/remediate/authz/nodejs/src/nestjs/decorators.d.ts +16 -0
  31. package/remediate/authz/nodejs/src/nestjs/decorators.js +27 -0
  32. package/remediate/authz/nodejs/src/nestjs/guards.d.ts +5 -0
  33. package/remediate/authz/nodejs/src/nestjs/guards.js +69 -2
  34. package/remediate/authz/nodejs/src/roles.js +90 -0
  35. package/remediate/authz/nodejs/src/service.d.ts +6 -0
  36. package/remediate/authz/nodejs/src/service.js +30 -0
  37. package/remediate/authz/nodejs/tests/test-authz.js +4 -0
  38. package/remediate/authz/nodejs/tests/test-roles.js +278 -0
  39. package/remediate/authz/spring/build.gradle.kts +7 -2
  40. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/SecurityAuditEvent.kt +10 -2
  41. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/data/MassAssignment.kt +18 -0
  42. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/AuthenticationRequiredException.kt +14 -0
  43. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/KomaguSecurityAutoConfiguration.kt +20 -0
  44. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RequireRole.kt +34 -0
  45. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleAspect.kt +97 -0
  46. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleDeniedException.kt +14 -0
  47. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/KomaguSecurityAutoConfigurationTest.kt +34 -0
  48. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/MassAssignmentTest.kt +81 -0
  49. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/RoleAspectTest.kt +196 -0
  50. package/remediate/identity/dotnet/src/Komagu.Security.Identity/Komagu.Security.Identity.csproj +1 -1
  51. package/remediate/identity/dotnet/tests/Komagu.Security.Identity.Tests/Komagu.Security.Identity.Tests.csproj +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
4
4
 
5
+ ## [2.2.1](https://github.com/hoanghainh1188/komagu/compare/v2.2.0...v2.2.1) (2026-11-01)
6
+
7
+
8
+ ### Documentation
9
+
10
+ * **adr:** adopt 100% open source model under Apache-2.0 (ADR-0008) ([#342](https://github.com/hoanghainh1188/komagu/issues/342)) ([4194cda](https://github.com/hoanghainh1188/komagu/commit/4194cda97f334fc97df258daed5183e22d1b9534))
11
+
12
+ ## [2.2.0](https://github.com/hoanghainh1188/komagu/compare/v2.1.2...v2.2.0) (2026-11-01)
13
+
14
+
15
+ ### Features
16
+
17
+ * **remediate:** add role guards and DTO allow-list helpers for BFLA and mass assignment ([#337](https://github.com/hoanghainh1188/komagu/issues/337)) ([e8c0463](https://github.com/hoanghainh1188/komagu/commit/e8c046322fa10c3c29a5d896cdbb41de65282c34))
18
+
5
19
  ## [2.1.2](https://github.com/hoanghainh1188/komagu/compare/v2.1.1...v2.1.2) (2026-11-01)
6
20
 
7
21
 
package/README.md CHANGED
@@ -230,4 +230,4 @@ komagu/
230
230
  ## 👥 Governance & License
231
231
 
232
232
  - **The Two-Person Rule:** Komagu enforces strict governance ([`governance/usage-policy.md`](governance/usage-policy.md)) where automated audits/patches (Person A) must be validated by a human architect/engineer (Person B) prior to production deployment.
233
- - **Open Core Architecture ([ADR-0002](docs/adr/0002-ip-position-open-core.md)):** Core LSA assessment kit, contracts, and remediation interfaces are licensed under **MIT**. Enterprise integrations and managed playbooks are retained for customer engagements.
233
+ - **100% Open Source Architecture ([ADR-0008](docs/adr/0008-transition-to-fully-open-source.md)):** Komagu is fully open source under the **Apache License 2.0**. The assessment kit, WAF edge shields, AST auto-fix generators, and CI cross-user testing engines are completely open and auditable by the global engineering community.
@@ -89,3 +89,11 @@ route named `/orders/close-month` can still be an admin function.
89
89
 
90
90
  Adjust with `severity-and-cwe.md` (multi-tenant impact +1, compensating
91
91
  control outside code −1).
92
+
93
+ ## 7. Recommended fix
94
+
95
+ Check the role on the server, from the authenticated principal only, before the privileged action.
96
+ Komagu ships a drop-in guard per stack (`[RequireRole]` for .NET, `@RequireRole` for Spring Boot,
97
+ `requireRole` / `@RequireRole` for Express and NestJS, `RequireRole` middleware for Go): see
98
+ `remediate/authz/README.md`, section 7. Use it in the finding's `recommendation` when the target stack
99
+ matches; never suggest reading the role from the request.
@@ -75,3 +75,10 @@ Bindings to DTO or form classes that are not entities are not reported.
75
75
  | Only harmless fields reachable | low (or no finding) |
76
76
 
77
77
  Adjust with `severity-and-cwe.md`.
78
+
79
+ ## 6. Recommended fix
80
+
81
+ Bind a DTO with only the editable fields and copy them onto the entity explicitly, or filter map-shaped
82
+ input with an allow-list. Komagu has a tested example per stack and an `allowFields` helper for Spring
83
+ Boot, Express and Go: see `remediate/authz/README.md`, section 7. Use it in the finding's `recommendation`
84
+ when the target stack matches.
@@ -8,8 +8,8 @@
8
8
  </PropertyGroup>
9
9
 
10
10
  <ItemGroup>
11
- <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.32" />
12
- <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.32" />
11
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.33" />
12
+ <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.33" />
13
13
  </ItemGroup>
14
14
 
15
15
  <ItemGroup>
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.1.2" # x-release-please-version
5
+ __version__ = "2.2.1" # x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoanghainh/komagu",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "description": "Security-First Legacy Modernization Platform CLI (LSA Kit, IAM Perimeter, Cross-User Testing)",
5
5
  "main": "remediate/authz/nodejs/src/index.js",
6
6
  "types": "remediate/authz/nodejs/src/index.d.ts",
@@ -276,7 +276,128 @@ func RegisterOrderRoutes(mux *http.ServeMux, authorizer *authz.Authorizer) {
276
276
 
277
277
  ---
278
278
 
279
- ## 7. Test Verification & D5 Runner
279
+ ## 7. Function-Level Authorization (BFLA) and Mass Assignment
280
+
281
+ These helpers fix the two privilege findings of the audit kit: **BFLA** (CWE-285, see
282
+ [`bfla-patterns.md`](../../assess/lsa/knowledge/bfla-patterns.md)) and **mass assignment** (CWE-915, see
283
+ [`mass-assignment-patterns.md`](../../assess/lsa/knowledge/mass-assignment-patterns.md)).
284
+
285
+ The role guard behaves the same in every module:
286
+
287
+ | Rule | Behaviour |
288
+ |---|---|
289
+ | Role source | The authenticated principal only (claims, granted authorities, `req.user`, request context). Never route values, query, form, body or headers |
290
+ | Not authenticated | 401 |
291
+ | Match | The caller needs at least one of the listed roles (exact, case-sensitive) |
292
+ | Denied | 403 by default; a module setting answers 404 to hide the endpoint |
293
+ | Audit | Each denial emits `Komagu.Authorization.RoleDenied` with actor ids and the required roles |
294
+ | Bad configuration | Empty or blank role lists and unsupported deny statuses fail fast |
295
+
296
+ For mass assignment, bind a DTO that lists only the fields the caller may change and copy them onto the
297
+ entity explicitly. Never bind the entity itself or rely on `[Bind(Include = "...")]`. Where the input is a
298
+ map, `allowFields` keeps only the listed keys and never mutates its input.
299
+
300
+ ### .NET 8
301
+
302
+ ```csharp
303
+ // Program.cs
304
+ builder.Services.AddKomaguAuthorization();
305
+ builder.Services.ConfigureKomaguRoles(o =>
306
+ {
307
+ o.RoleClaimType = "role"; // default: ClaimTypes.Role
308
+ o.DenyWith = RoleDenyResponse.NotFound; // default: Forbidden (403)
309
+ });
310
+
311
+ [HttpDelete("admin/users/{id}")]
312
+ [RequireRole("admin", "support")] // any one role; several attributes must all pass
313
+ public IActionResult DeleteUser(int id) => NoContent();
314
+
315
+ public sealed record ProfileUpdateDto(string Name, string Email)
316
+ {
317
+ public void ApplyTo(Customer customer)
318
+ {
319
+ customer.Name = Name;
320
+ customer.Email = Email; // Role / IsAdmin / Balance cannot be set from the request
321
+ }
322
+ }
323
+ ```
324
+
325
+ `[RequireRole]` is an authorization filter, so it runs before model binding.
326
+
327
+ ### Spring Boot 3
328
+
329
+ ```kotlin
330
+ @RestController
331
+ @RequestMapping("/admin")
332
+ @RequireRole("admin") // class-level default
333
+ class AdminController(private val users: UserService) {
334
+ @DeleteMapping("/users/{id}")
335
+ fun delete(@PathVariable id: String) = users.delete(id) // ROLE_admin or admin passes
336
+
337
+ @GetMapping("/audit")
338
+ @RequireRole("admin", "auditor", hideAsNotFound = true) // method-level overrides class-level
339
+ fun audit() = users.auditTrail()
340
+ }
341
+
342
+ data class ProfileUpdate(val name: String, val email: String) {
343
+ fun applyTo(account: UserAccount) = account.copy(name = name, email = email)
344
+ }
345
+ val safe = allowFields(body, "name", "email") // io.komagu.security.data
346
+ ```
347
+
348
+ Roles are the granted authorities of the authentication in `SecurityContextHolder`; one leading `ROLE_` is
349
+ ignored on both sides. Map JWT scopes or role claims to authorities in your `JwtAuthenticationConverter`.
350
+ Set `komagu.security.role.hide-denied-as-not-found=true` to answer every denial with 404. Spring Boot's
351
+ default `ObjectMapper` silently drops unknown fields such as `role`; the explicit copy is what keeps them out.
352
+
353
+ ### Node.js (Express & NestJS)
354
+
355
+ ```js
356
+ const { requireRole, allowFields } = require('@hoanghainh/komagu-authz');
357
+
358
+ app.delete('/admin/users/:id', requireRole('admin'), deleteUser);
359
+ app.post('/ops/rotate-keys', requireRole(['ops', 'admin'], { denyStatus: 404 }), rotateKeys);
360
+
361
+ app.patch('/me', async (req, res) => {
362
+ const changes = allowFields(req.body, ['name', 'email']); // role / isAdmin are dropped
363
+ res.json(await users.update(req.user.userId, changes));
364
+ });
365
+ ```
366
+
367
+ ```ts
368
+ @Controller('admin')
369
+ @UseGuards(KomaguRoleGuard)
370
+ export class AdminController {
371
+ @Delete('users/:id')
372
+ @RequireRole('admin')
373
+ deleteUser(@Param('id') id: string) { /* ... */ }
374
+ }
375
+ ```
376
+
377
+ Roles come from `req.user.roles` and `req.user.role`. `allowFields` ignores `__proto__`, `constructor` and
378
+ `prototype`, and copies values by reference.
379
+
380
+ ### Go
381
+
382
+ ```go
383
+ // After verifying the token, attach the identity in your authentication middleware.
384
+ ctx := authz.WithIdentity(r.Context(), authz.Identity{UserID: claims.Sub, TenantID: claims.TenantID, Roles: claims.Roles})
385
+
386
+ auth := authz.NewAuthorizer(nil)
387
+ mux.Handle("/admin/users", authenticate(auth.RequireRole([]string{"admin"})(adminUsers)))
388
+ mux.Handle("/internal/reindex", authenticate(auth.RequireRole(
389
+ []string{"admin", "ops"}, authz.WithDenyStatus(http.StatusNotFound))(reindex)))
390
+
391
+ patch := authz.AllowFields(body, "name", "email") // or decode into a narrow struct:
392
+ dec := json.NewDecoder(r.Body)
393
+ dec.DisallowUnknownFields() // {"role":"admin"} -> 400
394
+ ```
395
+
396
+ `RequireRole` panics at route registration on an empty role list or a deny status other than 403 / 404.
397
+
398
+ ---
399
+
400
+ ## 8. Test Verification & D5 Runner
280
401
 
281
402
  All multi-language adapters verify identical BOLA defense invariants:
282
403
 
@@ -0,0 +1,20 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ using System.Security.Claims;
4
+
5
+ /// <summary>
6
+ /// Options for <see cref="RequireRoleAttribute"/> / <see cref="RoleAuthorizationFilter"/>.
7
+ /// </summary>
8
+ public sealed class KomaguRoleOptions
9
+ {
10
+ /// <summary>
11
+ /// Claim type read from the authenticated <see cref="ClaimsPrincipal"/> to obtain the caller's roles.
12
+ /// Defaults to <see cref="ClaimTypes.Role"/>; set to <c>"role"</c> or <c>"roles"</c> for raw JWT claims.
13
+ /// </summary>
14
+ public string RoleClaimType { get; set; } = ClaimTypes.Role;
15
+
16
+ /// <summary>
17
+ /// Response returned when an authenticated caller lacks every required role. Defaults to 403 Forbidden.
18
+ /// </summary>
19
+ public RoleDenyResponse DenyWith { get; set; } = RoleDenyResponse.Forbidden;
20
+ }
@@ -0,0 +1,34 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ using Microsoft.AspNetCore.Mvc;
4
+
5
+ /// <summary>
6
+ /// Declarative function-level authorization (BFLA defense): the authenticated caller must hold at least one of the
7
+ /// given roles. Roles are read only from verified <c>HttpContext.User</c> claims, never from the request.
8
+ /// Returns 401 when unauthenticated and 403 (or 404, see <see cref="KomaguRoleOptions.DenyWith"/>) when denied.
9
+ /// </summary>
10
+ [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
11
+ public sealed class RequireRoleAttribute : TypeFilterAttribute
12
+ {
13
+ public RequireRoleAttribute(params string[] roles)
14
+ : base(typeof(RoleAuthorizationFilter))
15
+ {
16
+ if (roles == null || roles.Length == 0)
17
+ {
18
+ throw new ArgumentException("At least one required role must be specified.", nameof(roles));
19
+ }
20
+
21
+ if (roles.Any(string.IsNullOrWhiteSpace))
22
+ {
23
+ throw new ArgumentException("Required roles must not be null, empty or whitespace.", nameof(roles));
24
+ }
25
+
26
+ Roles = roles.ToArray();
27
+ Arguments = new object[] { Roles.ToArray() };
28
+ }
29
+
30
+ /// <summary>
31
+ /// Roles accepted by this guard; holding any one of them (exact, ordinal match) grants access.
32
+ /// </summary>
33
+ public IReadOnlyList<string> Roles { get; }
34
+ }
@@ -0,0 +1,114 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ using System.Diagnostics;
4
+ using Komagu.Security.Authorization;
5
+ using Microsoft.AspNetCore.Http;
6
+ using Microsoft.AspNetCore.Mvc;
7
+ using Microsoft.AspNetCore.Mvc.Filters;
8
+ using Microsoft.Extensions.Options;
9
+
10
+ /// <summary>
11
+ /// Authorization filter enforcing that the authenticated caller holds at least one required role.
12
+ /// Runs before model binding, so request-supplied values (route, query, form, body, headers) can never grant a role.
13
+ /// </summary>
14
+ public sealed class RoleAuthorizationFilter : IAsyncAuthorizationFilter
15
+ {
16
+ /// <summary>
17
+ /// Audit event name emitted on a role denial.
18
+ /// </summary>
19
+ public const string RoleDeniedEventName = "Komagu.Authorization.RoleDenied";
20
+
21
+ private readonly string[] _requiredRoles;
22
+ private readonly ISecurityAuditSink _auditSink;
23
+ private readonly IKomaguAuthorizationService _authorizationService;
24
+ private readonly KomaguRoleOptions _options;
25
+
26
+ public RoleAuthorizationFilter(
27
+ string[] requiredRoles,
28
+ ISecurityAuditSink auditSink,
29
+ IKomaguAuthorizationService authorizationService,
30
+ IOptions<KomaguRoleOptions> options)
31
+ {
32
+ ArgumentNullException.ThrowIfNull(requiredRoles);
33
+ if (requiredRoles.Length == 0 || requiredRoles.Any(string.IsNullOrWhiteSpace))
34
+ {
35
+ throw new ArgumentException("At least one non-empty required role must be specified.", nameof(requiredRoles));
36
+ }
37
+
38
+ _requiredRoles = requiredRoles.ToArray();
39
+ _auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
40
+ _authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService));
41
+ _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
42
+ }
43
+
44
+ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
45
+ {
46
+ ArgumentNullException.ThrowIfNull(context);
47
+
48
+ var user = context.HttpContext.User;
49
+
50
+ // Invariant: Unauthenticated callers get 401 before any role evaluation
51
+ if (user?.Identity?.IsAuthenticated != true)
52
+ {
53
+ context.Result = new UnauthorizedResult();
54
+ return;
55
+ }
56
+
57
+ // Roles come only from the verified principal; exact, ordinal match against any required role
58
+ var roleClaimType = string.IsNullOrWhiteSpace(_options.RoleClaimType)
59
+ ? System.Security.Claims.ClaimTypes.Role
60
+ : _options.RoleClaimType;
61
+
62
+ var hasRole = user.FindAll(roleClaimType)
63
+ .Any(c => _requiredRoles.Contains(c.Value, StringComparer.Ordinal));
64
+
65
+ if (hasRole)
66
+ {
67
+ return;
68
+ }
69
+
70
+ context.Result = _options.DenyWith == RoleDenyResponse.NotFound
71
+ ? new NotFoundResult()
72
+ : new StatusCodeResult(StatusCodes.Status403Forbidden);
73
+
74
+ await EmitDenialAsync(context).ConfigureAwait(false);
75
+ }
76
+
77
+ private async ValueTask EmitDenialAsync(AuthorizationFilterContext context)
78
+ {
79
+ var httpContext = context.HttpContext;
80
+ try
81
+ {
82
+ var auditEvent = new SecurityAuditEvent
83
+ {
84
+ Timestamp = DateTimeOffset.UtcNow,
85
+ EventName = RoleDeniedEventName,
86
+ TraceId = Activity.Current?.Id ?? httpContext.TraceIdentifier,
87
+ ActorUserId = TryRead(() => _authorizationService.GetCurrentUserId(httpContext.User)),
88
+ ActorTenantId = TryRead(() => _authorizationService.GetCurrentTenantId(httpContext.User)),
89
+ ResourceType = "Endpoint",
90
+ ResourceId = context.ActionDescriptor?.DisplayName ?? httpContext.Request.Path.Value,
91
+ ClientIp = httpContext.Connection.RemoteIpAddress?.ToString(),
92
+ Reason = $"Missing required role: caller holds none of [{string.Join(", ", _requiredRoles)}]"
93
+ };
94
+
95
+ await _auditSink.EmitAsync(auditEvent, httpContext.RequestAborted).ConfigureAwait(false);
96
+ }
97
+ catch
98
+ {
99
+ // Auditing failures must not mask or alter the denial outcome
100
+ }
101
+ }
102
+
103
+ private static string? TryRead(Func<string> read)
104
+ {
105
+ try
106
+ {
107
+ return read();
108
+ }
109
+ catch (UnauthorizedAccessException)
110
+ {
111
+ return null;
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,17 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ /// <summary>
4
+ /// HTTP response returned by <see cref="RoleAuthorizationFilter"/> when an authenticated caller lacks every required role.
5
+ /// </summary>
6
+ public enum RoleDenyResponse
7
+ {
8
+ /// <summary>
9
+ /// HTTP 403 Forbidden (default).
10
+ /// </summary>
11
+ Forbidden,
12
+
13
+ /// <summary>
14
+ /// HTTP 404 Not Found, hiding the existence of the privileged endpoint.
15
+ /// </summary>
16
+ NotFound
17
+ }
@@ -16,6 +16,7 @@ public static class ServiceCollectionExtensions
16
16
  {
17
17
  services.TryAddSingleton<ISecurityAuditSink, LoggingSecurityAuditSink>();
18
18
  services.TryAddScoped<IKomaguAuthorizationService, DefaultKomaguAuthorizationService>();
19
+ services.AddOptions<KomaguRoleOptions>();
19
20
  return services;
20
21
  }
21
22
 
@@ -27,6 +28,21 @@ public static class ServiceCollectionExtensions
27
28
  {
28
29
  services.AddSingleton<ISecurityAuditSink, TAuditSink>();
29
30
  services.TryAddScoped<IKomaguAuthorizationService, DefaultKomaguAuthorizationService>();
31
+ services.AddOptions<KomaguRoleOptions>();
32
+ return services;
33
+ }
34
+
35
+ /// <summary>
36
+ /// Configures the <see cref="RequireRoleAttribute"/> guard (role claim type, 403 vs 404 on denial).
37
+ /// Call after <c>AddKomaguAuthorization</c>.
38
+ /// </summary>
39
+ public static IServiceCollection ConfigureKomaguRoles(
40
+ this IServiceCollection services,
41
+ Action<KomaguRoleOptions> configure)
42
+ {
43
+ ArgumentNullException.ThrowIfNull(services);
44
+ ArgumentNullException.ThrowIfNull(configure);
45
+ services.AddOptions<KomaguRoleOptions>().Configure(configure);
30
46
  return services;
31
47
  }
32
48
  }
@@ -13,7 +13,7 @@
13
13
  </PropertyGroup>
14
14
 
15
15
  <ItemGroup>
16
- <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.32" />
16
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.33" />
17
17
  </ItemGroup>
18
18
 
19
19
  <ItemGroup>
@@ -20,7 +20,7 @@
20
20
  <PrivateAssets>all</PrivateAssets>
21
21
  </PackageReference>
22
22
  <PackageReference Include="Moq" Version="4.21.0" />
23
- <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.32" />
23
+ <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.33" />
24
24
  </ItemGroup>
25
25
 
26
26
  <ItemGroup>
@@ -0,0 +1,76 @@
1
+ namespace Komagu.Security.Authorization.Tests;
2
+
3
+ using System.Text.Json;
4
+ using Xunit;
5
+
6
+ /// <summary>
7
+ /// Mass-assignment remediation example: replace whole-entity binding (or <c>[Bind(Include = "...")]</c>) with a
8
+ /// request DTO that declares only the client-editable fields, then copy them onto the tracked entity explicitly.
9
+ /// </summary>
10
+ public class MassAssignmentDtoTests
11
+ {
12
+ /// <summary>Persisted entity: Role, IsAdmin and Balance must never be client-writable.</summary>
13
+ public sealed class Customer
14
+ {
15
+ public int Id { get; set; }
16
+ public string Name { get; set; } = string.Empty;
17
+ public string Email { get; set; } = string.Empty;
18
+ public string Role { get; set; } = "customer";
19
+ public bool IsAdmin { get; set; }
20
+ public decimal Balance { get; set; }
21
+ }
22
+
23
+ /// <summary>Allow-list DTO: only the fields a customer may edit on their own profile.</summary>
24
+ public sealed record ProfileUpdateDto(string Name, string Email)
25
+ {
26
+ /// <summary>Explicit copy: privileged fields are not reachable from the request.</summary>
27
+ public void ApplyTo(Customer customer)
28
+ {
29
+ ArgumentNullException.ThrowIfNull(customer);
30
+ customer.Name = Name;
31
+ customer.Email = Email;
32
+ }
33
+ }
34
+
35
+ private static readonly JsonSerializerOptions WebJson = new(JsonSerializerDefaults.Web);
36
+
37
+ [Fact]
38
+ public void ProfileUpdate_WithPrivilegedFieldsInJson_ChangesOnlyAllowListedFields()
39
+ {
40
+ var customer = new Customer { Id = 7, Name = "Old", Email = "old@example.com", Role = "customer", IsAdmin = false, Balance = 10m };
41
+ const string attackerJson = """
42
+ {"id": 1, "name": "New Name", "email": "new@example.com", "role": "admin", "isAdmin": true, "balance": 999999}
43
+ """;
44
+
45
+ var dto = JsonSerializer.Deserialize<ProfileUpdateDto>(attackerJson, WebJson);
46
+ Assert.NotNull(dto);
47
+ dto!.ApplyTo(customer);
48
+
49
+ Assert.Equal("New Name", customer.Name);
50
+ Assert.Equal("new@example.com", customer.Email);
51
+ Assert.Equal(7, customer.Id);
52
+ Assert.Equal("customer", customer.Role);
53
+ Assert.False(customer.IsAdmin);
54
+ Assert.Equal(10m, customer.Balance);
55
+ }
56
+
57
+ [Fact]
58
+ public void ProfileUpdateDto_ExposesOnlyNameAndEmail()
59
+ {
60
+ var writable = typeof(ProfileUpdateDto).GetProperties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal);
61
+ Assert.Equal(new[] { "Email", "Name" }, writable);
62
+ }
63
+
64
+ [Fact]
65
+ public void WholeEntityBinding_IsTheVulnerablePattern()
66
+ {
67
+ // Contrast: deserializing straight into the entity lets the client set Role/IsAdmin/Balance.
68
+ const string attackerJson = """{"name": "x", "email": "x@example.com", "role": "admin", "isAdmin": true, "balance": 999999}""";
69
+
70
+ var bound = JsonSerializer.Deserialize<Customer>(attackerJson, WebJson)!;
71
+
72
+ Assert.Equal("admin", bound.Role);
73
+ Assert.True(bound.IsAdmin);
74
+ Assert.Equal(999999m, bound.Balance);
75
+ }
76
+ }