@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/assess/lsa/knowledge/bfla-patterns.md +8 -0
  3. package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
  4. package/assess/lsa/tests/EVAL-RESULTS.md +25 -0
  5. package/assess/lsa/tests/fixtures/dotnet-legacy/Web/Controllers/CustomerController.cs +1 -1
  6. package/assess/lsa/tests/fixtures/java-legacy/src/com/acme/web/AccountController.java +1 -1
  7. package/assess/lsa/tests/fixtures/php-plain/register.php +3 -3
  8. package/assess/lsa/tests/fixtures/python-flask/profile.py +2 -2
  9. package/operate/ai/client.py +69 -1
  10. package/operate/ai/runner.py +61 -15
  11. package/operate/ai/verdicts.py +113 -0
  12. package/operate/cli/__init__.py +1 -1
  13. package/operate/tests/test_ai_verdicts.py +200 -0
  14. package/package.json +1 -1
  15. package/remediate/authz/README.md +122 -1
  16. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/KomaguRoleOptions.cs +20 -0
  17. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RequireRoleAttribute.cs +34 -0
  18. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleAuthorizationFilter.cs +114 -0
  19. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleDenyResponse.cs +17 -0
  20. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/ServiceCollectionExtensions.cs +16 -0
  21. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/MassAssignmentDtoTests.cs +76 -0
  22. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/RoleAuthorizationFilterTests.cs +233 -0
  23. package/remediate/authz/golang/massassign.go +16 -0
  24. package/remediate/authz/golang/massassign_test.go +135 -0
  25. package/remediate/authz/golang/roles.go +168 -0
  26. package/remediate/authz/golang/roles_test.go +158 -0
  27. package/remediate/authz/nodejs/src/express/role-middleware.d.ts +14 -0
  28. package/remediate/authz/nodejs/src/express/role-middleware.js +52 -0
  29. package/remediate/authz/nodejs/src/index.d.ts +2 -0
  30. package/remediate/authz/nodejs/src/index.js +17 -2
  31. package/remediate/authz/nodejs/src/interfaces.d.ts +19 -4
  32. package/remediate/authz/nodejs/src/interfaces.js +10 -0
  33. package/remediate/authz/nodejs/src/mass-assignment.d.ts +8 -0
  34. package/remediate/authz/nodejs/src/mass-assignment.js +40 -0
  35. package/remediate/authz/nodejs/src/nestjs/decorators.d.ts +16 -0
  36. package/remediate/authz/nodejs/src/nestjs/decorators.js +27 -0
  37. package/remediate/authz/nodejs/src/nestjs/guards.d.ts +5 -0
  38. package/remediate/authz/nodejs/src/nestjs/guards.js +69 -2
  39. package/remediate/authz/nodejs/src/roles.js +90 -0
  40. package/remediate/authz/nodejs/src/service.d.ts +6 -0
  41. package/remediate/authz/nodejs/src/service.js +30 -0
  42. package/remediate/authz/nodejs/tests/test-authz.js +4 -0
  43. package/remediate/authz/nodejs/tests/test-roles.js +278 -0
  44. package/remediate/authz/spring/build.gradle.kts +5 -0
  45. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/SecurityAuditEvent.kt +10 -2
  46. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/data/MassAssignment.kt +18 -0
  47. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/AuthenticationRequiredException.kt +14 -0
  48. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/KomaguSecurityAutoConfiguration.kt +20 -0
  49. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RequireRole.kt +34 -0
  50. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleAspect.kt +97 -0
  51. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleDeniedException.kt +14 -0
  52. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/KomaguSecurityAutoConfigurationTest.kt +34 -0
  53. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/MassAssignmentTest.kt +81 -0
  54. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/RoleAspectTest.kt +196 -0
@@ -0,0 +1,233 @@
1
+ namespace Komagu.Security.Authorization.Tests;
2
+
3
+ using System.Security.Claims;
4
+ using Komagu.Security.AspNetCore;
5
+ using Komagu.Security.Authorization;
6
+ using Microsoft.AspNetCore.Http;
7
+ using Microsoft.AspNetCore.Mvc;
8
+ using Microsoft.AspNetCore.Mvc.Abstractions;
9
+ using Microsoft.AspNetCore.Mvc.Filters;
10
+ using Microsoft.AspNetCore.Routing;
11
+ using Microsoft.Extensions.DependencyInjection;
12
+ using Microsoft.Extensions.Options;
13
+ using Microsoft.Extensions.Primitives;
14
+ using Moq;
15
+ using Xunit;
16
+
17
+ public class RoleAuthorizationFilterTests
18
+ {
19
+ private static ClaimsPrincipal Authenticated(string roleClaimType = ClaimTypes.Role, params string[] roles)
20
+ {
21
+ var claims = new List<Claim> { new("sub", "user-1"), new("tenant_id", "tenant-1") };
22
+ claims.AddRange(roles.Select(r => new Claim(roleClaimType, r)));
23
+ return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
24
+ }
25
+
26
+ private static AuthorizationFilterContext CreateContext(
27
+ ClaimsPrincipal user,
28
+ Dictionary<string, object?>? routeValues = null,
29
+ Action<HttpRequest>? configureRequest = null)
30
+ {
31
+ var httpContext = new DefaultHttpContext { User = user };
32
+ configureRequest?.Invoke(httpContext.Request);
33
+
34
+ var routeData = new RouteData();
35
+ foreach (var (k, v) in routeValues ?? new Dictionary<string, object?>())
36
+ {
37
+ routeData.Values[k] = v;
38
+ }
39
+
40
+ var actionContext = new ActionContext(httpContext, routeData, new ActionDescriptor { DisplayName = "AdminController.DeleteUser" });
41
+ return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
42
+ }
43
+
44
+ private static RoleAuthorizationFilter CreateFilter(
45
+ string[] roles,
46
+ ISecurityAuditSink? sink = null,
47
+ KomaguRoleOptions? options = null)
48
+ {
49
+ sink ??= Mock.Of<ISecurityAuditSink>();
50
+ return new RoleAuthorizationFilter(
51
+ roles,
52
+ sink,
53
+ new DefaultKomaguAuthorizationService(Mock.Of<ISecurityAuditSink>()),
54
+ Options.Create(options ?? new KomaguRoleOptions()));
55
+ }
56
+
57
+ [Fact]
58
+ public async Task Filter_WhenCallerHasOneOfRequiredRoles_AllowsRequest()
59
+ {
60
+ var context = CreateContext(Authenticated(ClaimTypes.Role, "support"));
61
+ var filter = CreateFilter(["admin", "support"]);
62
+
63
+ await filter.OnAuthorizationAsync(context);
64
+
65
+ Assert.Null(context.Result);
66
+ }
67
+
68
+ [Fact]
69
+ public async Task Filter_WhenCallerLacksRole_ReturnsHttp403()
70
+ {
71
+ var context = CreateContext(Authenticated(ClaimTypes.Role, "customer"));
72
+ var filter = CreateFilter(["admin"]);
73
+
74
+ await filter.OnAuthorizationAsync(context);
75
+
76
+ var result = Assert.IsType<StatusCodeResult>(context.Result);
77
+ Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
78
+ }
79
+
80
+ [Fact]
81
+ public async Task Filter_RoleMatchIsExactAndOrdinal()
82
+ {
83
+ var context = CreateContext(Authenticated(ClaimTypes.Role, "Admin", "administrator", " admin"));
84
+ var filter = CreateFilter(["admin"]);
85
+
86
+ await filter.OnAuthorizationAsync(context);
87
+
88
+ Assert.IsType<StatusCodeResult>(context.Result);
89
+ }
90
+
91
+ [Fact]
92
+ public async Task Filter_WhenDenyWithNotFound_ReturnsHttp404()
93
+ {
94
+ var context = CreateContext(Authenticated(ClaimTypes.Role, "customer"));
95
+ var filter = CreateFilter(["admin"], options: new KomaguRoleOptions { DenyWith = RoleDenyResponse.NotFound });
96
+
97
+ await filter.OnAuthorizationAsync(context);
98
+
99
+ Assert.IsType<NotFoundResult>(context.Result);
100
+ }
101
+
102
+ [Fact]
103
+ public async Task Filter_WhenCallerIsAnonymous_ReturnsHttp401()
104
+ {
105
+ var context = CreateContext(new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Role, "admin")])));
106
+ var filter = CreateFilter(["admin"]);
107
+
108
+ await filter.OnAuthorizationAsync(context);
109
+
110
+ Assert.IsType<UnauthorizedResult>(context.Result);
111
+ }
112
+
113
+ [Fact]
114
+ public async Task Filter_IgnoresRoleSuppliedInQueryFormRouteAndHeader()
115
+ {
116
+ // Invariant: role=admin in the query string, form, route values or an X-Role header never grants the role
117
+ var context = CreateContext(
118
+ Authenticated(ClaimTypes.Role, "customer"),
119
+ routeValues: new Dictionary<string, object?> { ["role"] = "admin" },
120
+ configureRequest: request =>
121
+ {
122
+ request.QueryString = new QueryString("?role=admin&isAdmin=true");
123
+ request.ContentType = "application/x-www-form-urlencoded";
124
+ request.Form = new FormCollection(new Dictionary<string, StringValues> { ["role"] = "admin" });
125
+ request.Headers["X-Role"] = "admin";
126
+ request.Headers["X-User-Role"] = "admin";
127
+ });
128
+ var filter = CreateFilter(["admin"]);
129
+
130
+ await filter.OnAuthorizationAsync(context);
131
+
132
+ var result = Assert.IsType<StatusCodeResult>(context.Result);
133
+ Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
134
+ }
135
+
136
+ [Fact]
137
+ public async Task Filter_UsesConfiguredRoleClaimType()
138
+ {
139
+ var jwtUser = Authenticated("role", "admin");
140
+ var options = new KomaguRoleOptions { RoleClaimType = "role" };
141
+
142
+ var allowed = CreateContext(jwtUser);
143
+ await CreateFilter(["admin"], options: options).OnAuthorizationAsync(allowed);
144
+ Assert.Null(allowed.Result);
145
+
146
+ // With the default ClaimTypes.Role the raw "role" claim is not consulted
147
+ var denied = CreateContext(jwtUser);
148
+ await CreateFilter(["admin"]).OnAuthorizationAsync(denied);
149
+ Assert.IsType<StatusCodeResult>(denied.Result);
150
+ }
151
+
152
+ [Fact]
153
+ public async Task Filter_OnDenial_EmitsRoleDeniedAuditEvent()
154
+ {
155
+ SecurityAuditEvent? captured = null;
156
+ var sink = new Mock<ISecurityAuditSink>();
157
+ sink.Setup(s => s.EmitAsync(It.IsAny<SecurityAuditEvent>(), It.IsAny<CancellationToken>()))
158
+ .Callback<SecurityAuditEvent, CancellationToken>((e, _) => captured = e)
159
+ .Returns(ValueTask.CompletedTask);
160
+
161
+ var context = CreateContext(Authenticated(ClaimTypes.Role, "customer"));
162
+ await CreateFilter(["admin", "auditor"], sink.Object).OnAuthorizationAsync(context);
163
+
164
+ sink.Verify(s => s.EmitAsync(It.IsAny<SecurityAuditEvent>(), It.IsAny<CancellationToken>()), Times.Once);
165
+ Assert.NotNull(captured);
166
+ Assert.Equal("Komagu.Authorization.RoleDenied", captured!.EventName);
167
+ Assert.Equal(RoleAuthorizationFilter.RoleDeniedEventName, captured.EventName);
168
+ Assert.Equal("user-1", captured.ActorUserId);
169
+ Assert.Equal("tenant-1", captured.ActorTenantId);
170
+ Assert.Equal("AdminController.DeleteUser", captured.ResourceId);
171
+ Assert.Contains("admin", captured.Reason, StringComparison.Ordinal);
172
+ Assert.Contains("auditor", captured.Reason, StringComparison.Ordinal);
173
+ }
174
+
175
+ [Fact]
176
+ public async Task Filter_OnAllow_DoesNotEmitAuditEvent()
177
+ {
178
+ var sink = new Mock<ISecurityAuditSink>();
179
+ var context = CreateContext(Authenticated(ClaimTypes.Role, "admin"));
180
+
181
+ await CreateFilter(["admin"], sink.Object).OnAuthorizationAsync(context);
182
+
183
+ sink.Verify(s => s.EmitAsync(It.IsAny<SecurityAuditEvent>(), It.IsAny<CancellationToken>()), Times.Never);
184
+ }
185
+
186
+ [Fact]
187
+ public async Task Filter_WhenAuditSinkThrows_StillDenies()
188
+ {
189
+ var sink = new Mock<ISecurityAuditSink>();
190
+ sink.Setup(s => s.EmitAsync(It.IsAny<SecurityAuditEvent>(), It.IsAny<CancellationToken>()))
191
+ .Throws(new InvalidOperationException("sink down"));
192
+ var context = CreateContext(Authenticated(ClaimTypes.Role, "customer"));
193
+
194
+ await CreateFilter(["admin"], sink.Object).OnAuthorizationAsync(context);
195
+
196
+ Assert.IsType<StatusCodeResult>(context.Result);
197
+ }
198
+
199
+ [Fact]
200
+ public void Attribute_WithNoRoles_ThrowsArgumentException()
201
+ {
202
+ Assert.Throws<ArgumentException>(() => new RequireRoleAttribute());
203
+ Assert.Throws<ArgumentException>(() => new RequireRoleAttribute(Array.Empty<string>()));
204
+ Assert.Throws<ArgumentException>(() => new RequireRoleAttribute("admin", " "));
205
+ }
206
+
207
+ [Fact]
208
+ public async Task Attribute_CreatesFilterFromDi_WithConfiguredOptions()
209
+ {
210
+ var services = new ServiceCollection();
211
+ services.AddLogging();
212
+ services.AddKomaguAuthorization();
213
+ services.ConfigureKomaguRoles(o =>
214
+ {
215
+ o.RoleClaimType = "role";
216
+ o.DenyWith = RoleDenyResponse.NotFound;
217
+ });
218
+ using var provider = services.BuildServiceProvider();
219
+ using var scope = provider.CreateScope();
220
+
221
+ var attribute = new RequireRoleAttribute("admin");
222
+ Assert.Equal(new[] { "admin" }, attribute.Roles);
223
+ var filter = Assert.IsType<RoleAuthorizationFilter>(attribute.CreateInstance(scope.ServiceProvider));
224
+
225
+ var allowed = CreateContext(Authenticated("role", "admin"));
226
+ await filter.OnAuthorizationAsync(allowed);
227
+ Assert.Null(allowed.Result);
228
+
229
+ var denied = CreateContext(Authenticated("role", "customer"));
230
+ await filter.OnAuthorizationAsync(denied);
231
+ Assert.IsType<NotFoundResult>(denied.Result);
232
+ }
233
+ }
@@ -0,0 +1,16 @@
1
+ package golang
2
+
3
+ // Mass-assignment defense: copy only allow-listed fields from client input.
4
+
5
+ // AllowFields returns a new map holding only the allowed keys that are present
6
+ // in src. src is never modified; a nil src yields an empty, non-nil map.
7
+ // Values are copied shallowly (nested maps and slices are shared with src).
8
+ func AllowFields(src map[string]any, allowed ...string) map[string]any {
9
+ out := make(map[string]any, len(allowed))
10
+ for _, key := range allowed {
11
+ if val, ok := src[key]; ok {
12
+ out[key] = val
13
+ }
14
+ }
15
+ return out
16
+ }
@@ -0,0 +1,135 @@
1
+ package golang
2
+
3
+ import (
4
+ "encoding/json"
5
+ "reflect"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestAllowFields(t *testing.T) {
11
+ cases := []struct {
12
+ name string
13
+ src map[string]any
14
+ allowed []string
15
+ want map[string]any
16
+ }{
17
+ {
18
+ name: "picks allowed keys only",
19
+ src: map[string]any{"name": "Ann", "email": "a@x.io", "role": "admin", "is_admin": true},
20
+ allowed: []string{"name", "email"},
21
+ want: map[string]any{"name": "Ann", "email": "a@x.io"},
22
+ },
23
+ {
24
+ name: "skips allowed keys missing from src",
25
+ src: map[string]any{"name": "Ann"},
26
+ allowed: []string{"name", "email"},
27
+ want: map[string]any{"name": "Ann"},
28
+ },
29
+ {
30
+ name: "keys are case-sensitive",
31
+ src: map[string]any{"Name": "Ann"},
32
+ allowed: []string{"name"},
33
+ want: map[string]any{},
34
+ },
35
+ {name: "nil src returns empty map", src: nil, allowed: []string{"name"}, want: map[string]any{}},
36
+ {name: "no allowed keys returns empty map", src: map[string]any{"name": "Ann"}, want: map[string]any{}},
37
+ }
38
+ for _, tc := range cases {
39
+ t.Run(tc.name, func(t *testing.T) {
40
+ got := AllowFields(tc.src, tc.allowed...)
41
+ if got == nil {
42
+ t.Fatalf("expected non-nil map")
43
+ }
44
+ if !reflect.DeepEqual(got, tc.want) {
45
+ t.Fatalf("expected %v, got %v", tc.want, got)
46
+ }
47
+ })
48
+ }
49
+ }
50
+
51
+ func TestAllowFields_DoesNotMutateSource(t *testing.T) {
52
+ src := map[string]any{"name": "Ann", "role": "admin"}
53
+ out := AllowFields(src, "name")
54
+ out["name"] = "changed"
55
+ out["extra"] = 1
56
+
57
+ want := map[string]any{"name": "Ann", "role": "admin"}
58
+ if !reflect.DeepEqual(src, want) {
59
+ t.Fatalf("src was mutated: %v", src)
60
+ }
61
+ }
62
+
63
+ // --- DTO allow-list example -------------------------------------------------
64
+
65
+ // User is the domain model; Role and IsAdmin must never be set from client input.
66
+ type User struct {
67
+ ID string
68
+ Name string
69
+ Email string
70
+ Role string
71
+ IsAdmin bool
72
+ }
73
+
74
+ // ProfileUpdate is the narrow request DTO: only the fields a user may change.
75
+ type ProfileUpdate struct {
76
+ Name string `json:"name"`
77
+ Email string `json:"email"`
78
+ }
79
+
80
+ // decodeProfileUpdate rejects any field outside the DTO (strict mode).
81
+ func decodeProfileUpdate(body string) (ProfileUpdate, error) {
82
+ var dto ProfileUpdate
83
+ dec := json.NewDecoder(strings.NewReader(body))
84
+ dec.DisallowUnknownFields()
85
+ if err := dec.Decode(&dto); err != nil {
86
+ return ProfileUpdate{}, err
87
+ }
88
+ return dto, nil
89
+ }
90
+
91
+ // applyProfileUpdate copies DTO fields explicitly and returns a new User.
92
+ func applyProfileUpdate(u User, dto ProfileUpdate) User {
93
+ u.Name = dto.Name
94
+ u.Email = dto.Email
95
+ return u
96
+ }
97
+
98
+ func TestDTOAllowList_StrictDecodeRejectsPrivilegedFields(t *testing.T) {
99
+ for _, body := range []string{
100
+ `{"name":"Ann","role":"admin"}`,
101
+ `{"name":"Ann","is_admin":true}`,
102
+ `{"name":"Ann","IsAdmin":true}`,
103
+ } {
104
+ if _, err := decodeProfileUpdate(body); err == nil {
105
+ t.Fatalf("expected strict decode to reject %s", body)
106
+ }
107
+ }
108
+ }
109
+
110
+ func TestDTOAllowList_ExplicitCopyCannotEscalate(t *testing.T) {
111
+ current := User{ID: "u1", Name: "Old", Email: "old@x.io", Role: "user", IsAdmin: false}
112
+ body := `{"name":"Ann","email":"ann@x.io","role":"admin","is_admin":true}`
113
+
114
+ // Lenient decode into the narrow DTO: unknown fields are dropped, not applied.
115
+ var dto ProfileUpdate
116
+ if err := json.Unmarshal([]byte(body), &dto); err != nil {
117
+ t.Fatalf("decode: %v", err)
118
+ }
119
+ updated := applyProfileUpdate(current, dto)
120
+
121
+ if updated.Role != "user" || updated.IsAdmin {
122
+ t.Fatalf("privileged fields reached the domain model: %+v", updated)
123
+ }
124
+ if updated.Name != "Ann" || updated.Email != "ann@x.io" {
125
+ t.Fatalf("allowed fields not applied: %+v", updated)
126
+ }
127
+ if current.Name != "Old" {
128
+ t.Fatalf("original user was mutated: %+v", current)
129
+ }
130
+
131
+ // Strict decode of the same body fails outright.
132
+ if _, err := decodeProfileUpdate(body); err == nil {
133
+ t.Fatalf("expected strict decode to reject privileged fields")
134
+ }
135
+ }
@@ -0,0 +1,168 @@
1
+ package golang
2
+
3
+ // Komagu Function-Level Authorization (BFLA defense) for Go.
4
+ //
5
+ // RequireRole guards an http.Handler so that only callers holding at least
6
+ // one of the required roles reach it. Caller identity and roles are read
7
+ // ONLY from the trusted Identity that the authentication layer placed in
8
+ // the request context via WithIdentity. Roles are never read from the
9
+ // request body, query string, path or headers.
10
+
11
+ import (
12
+ "context"
13
+ "fmt"
14
+ "net/http"
15
+ "strings"
16
+ "time"
17
+ )
18
+
19
+ // RoleDeniedEventName is the audit event name emitted when RequireRole denies a caller.
20
+ const RoleDeniedEventName = "Komagu.Authorization.RoleDenied"
21
+
22
+ // Identity is the verified caller identity produced by authentication.
23
+ type Identity struct {
24
+ UserID string
25
+ TenantID string
26
+ Roles []string
27
+ }
28
+
29
+ // HasAnyRole reports whether the identity holds at least one of roles.
30
+ // Matching is exact and case-sensitive.
31
+ func (id Identity) HasAnyRole(roles ...string) bool {
32
+ for _, held := range id.Roles {
33
+ for _, want := range roles {
34
+ if held == want {
35
+ return true
36
+ }
37
+ }
38
+ }
39
+ return false
40
+ }
41
+
42
+ const identityContextKey contextKey = "komaguIdentity"
43
+
44
+ // WithIdentity returns a copy of ctx carrying id. Call it from authentication
45
+ // middleware after the token or session has been verified. The Roles slice is
46
+ // copied so later changes by the caller do not affect the stored identity.
47
+ func WithIdentity(ctx context.Context, id Identity) context.Context {
48
+ stored := Identity{
49
+ UserID: id.UserID,
50
+ TenantID: id.TenantID,
51
+ Roles: append([]string(nil), id.Roles...),
52
+ }
53
+ return context.WithValue(ctx, identityContextKey, stored)
54
+ }
55
+
56
+ // IdentityFromContext returns the identity stored by WithIdentity. The second
57
+ // result is false when no identity is present or its UserID is blank.
58
+ func IdentityFromContext(ctx context.Context) (Identity, bool) {
59
+ id, ok := ctx.Value(identityContextKey).(Identity)
60
+ if !ok || strings.TrimSpace(id.UserID) == "" {
61
+ return Identity{}, false
62
+ }
63
+ id.Roles = append([]string(nil), id.Roles...)
64
+ return id, true
65
+ }
66
+
67
+ type roleConfig struct {
68
+ denyStatus int
69
+ sink AuditSink
70
+ }
71
+
72
+ // RoleOption configures RequireRole.
73
+ type RoleOption func(*roleConfig)
74
+
75
+ // WithDenyStatus sets the status returned when the caller lacks the role.
76
+ // Only http.StatusForbidden (default) and http.StatusNotFound (hides the
77
+ // endpoint) are accepted; any other value makes RequireRole panic.
78
+ func WithDenyStatus(status int) RoleOption {
79
+ return func(c *roleConfig) { c.denyStatus = status }
80
+ }
81
+
82
+ // WithRoleAuditSink sets the sink that receives RoleDenied events.
83
+ // A nil sink falls back to LoggingAuditSink.
84
+ func WithRoleAuditSink(sink AuditSink) RoleOption {
85
+ return func(c *roleConfig) { c.sink = sink }
86
+ }
87
+
88
+ // RequireRole returns net/http middleware that allows the request only when
89
+ // the context Identity holds at least one of roles (exact, case-sensitive).
90
+ //
91
+ // - no Identity in context -> 401 {"error":"unauthorized"}
92
+ // - Identity without a required role -> 403 {"error":"forbidden"}, or
93
+ // 404 {"error":"resource not found"} with WithDenyStatus(http.StatusNotFound),
94
+ // and a RoleDeniedEventName audit event.
95
+ //
96
+ // RequireRole panics at construction (route registration) when roles is
97
+ // empty, contains a blank role, or WithDenyStatus is not 403 or 404.
98
+ func RequireRole(roles []string, opts ...RoleOption) func(http.Handler) http.Handler {
99
+ required := validateRoles(roles)
100
+ cfg := roleConfig{denyStatus: http.StatusForbidden}
101
+ for _, opt := range opts {
102
+ if opt != nil {
103
+ opt(&cfg)
104
+ }
105
+ }
106
+ if cfg.denyStatus != http.StatusForbidden && cfg.denyStatus != http.StatusNotFound {
107
+ panic(fmt.Sprintf("komagu: RequireRole deny status must be 403 or 404, got %d", cfg.denyStatus))
108
+ }
109
+ if cfg.sink == nil {
110
+ cfg.sink = &LoggingAuditSink{}
111
+ }
112
+
113
+ return func(next http.Handler) http.Handler {
114
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115
+ id, ok := IdentityFromContext(r.Context())
116
+ if !ok {
117
+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
118
+ return
119
+ }
120
+ if !id.HasAnyRole(required...) {
121
+ emitRoleDenial(cfg.sink, id, r, required)
122
+ writeRoleDenial(w, cfg.denyStatus)
123
+ return
124
+ }
125
+ next.ServeHTTP(w, r)
126
+ })
127
+ }
128
+ }
129
+
130
+ // RequireRole is RequireRole using the Authorizer's audit sink. An explicit
131
+ // WithRoleAuditSink option still takes precedence.
132
+ func (a *Authorizer) RequireRole(roles []string, opts ...RoleOption) func(http.Handler) http.Handler {
133
+ all := append([]RoleOption{WithRoleAuditSink(a.Sink)}, opts...)
134
+ return RequireRole(roles, all...)
135
+ }
136
+
137
+ func validateRoles(roles []string) []string {
138
+ if len(roles) == 0 {
139
+ panic("komagu: RequireRole needs at least one role")
140
+ }
141
+ for _, role := range roles {
142
+ if strings.TrimSpace(role) == "" {
143
+ panic("komagu: RequireRole roles must not be blank")
144
+ }
145
+ }
146
+ return append([]string(nil), roles...)
147
+ }
148
+
149
+ func writeRoleDenial(w http.ResponseWriter, status int) {
150
+ if status == http.StatusNotFound {
151
+ http.Error(w, `{"error":"resource not found"}`, http.StatusNotFound)
152
+ return
153
+ }
154
+ http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
155
+ }
156
+
157
+ func emitRoleDenial(sink AuditSink, id Identity, r *http.Request, required []string) {
158
+ sink.Emit(SecurityAuditEvent{
159
+ Timestamp: time.Now().UTC(),
160
+ EventName: RoleDeniedEventName,
161
+ ActorUserID: id.UserID,
162
+ ActorTenantID: id.TenantID,
163
+ ResourceType: "HTTPEndpoint",
164
+ ResourceID: r.URL.Path,
165
+ Operation: ResourceOperation(r.Method),
166
+ Reason: fmt.Sprintf("Caller lacks required role; requires any of [%s]", strings.Join(required, ", ")),
167
+ })
168
+ }