@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
|
@@ -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
|
+
}
|