@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/assess/lsa/knowledge/bfla-patterns.md +8 -0
- package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
- package/examples/member-portal/backend-dotnet/MemberPortal.Backend.csproj +2 -2
- package/operate/cli/__init__.py +1 -1
- 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/src/Komagu.Security.EntityFrameworkCore/Komagu.Security.EntityFrameworkCore.csproj +1 -1
- package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/Komagu.Security.Authorization.Tests.csproj +1 -1
- 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 +7 -2
- 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
- package/remediate/identity/dotnet/src/Komagu.Security.Identity/Komagu.Security.Identity.csproj +1 -1
- package/remediate/identity/dotnet/tests/Komagu.Security.Identity.Tests/Komagu.Security.Identity.Tests.csproj +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
package golang
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func okHandler() http.Handler {
|
|
11
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
12
|
+
w.WriteHeader(http.StatusOK)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func withCaller(req *http.Request, id Identity) *http.Request {
|
|
17
|
+
return req.WithContext(WithIdentity(req.Context(), id))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var (
|
|
21
|
+
adminCaller = Identity{UserID: "user-admin", TenantID: "tenant-komagu", Roles: []string{"admin"}}
|
|
22
|
+
regularUser = Identity{UserID: "user-b", TenantID: "tenant-komagu", Roles: []string{"user"}}
|
|
23
|
+
upperCaseFan = Identity{UserID: "user-c", TenantID: "tenant-komagu", Roles: []string{"Admin"}}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
func TestRequireRole_Decisions(t *testing.T) {
|
|
27
|
+
cases := []struct {
|
|
28
|
+
name string
|
|
29
|
+
roles []string
|
|
30
|
+
opts []RoleOption
|
|
31
|
+
caller *Identity
|
|
32
|
+
want int
|
|
33
|
+
}{
|
|
34
|
+
{"allowed with required role", []string{"admin"}, nil, &adminCaller, http.StatusOK},
|
|
35
|
+
{"allowed with any of several roles", []string{"ops", "admin"}, nil, &adminCaller, http.StatusOK},
|
|
36
|
+
{"denied 403 by default", []string{"admin"}, nil, ®ularUser, http.StatusForbidden},
|
|
37
|
+
{"denied 404 when hiding endpoint", []string{"admin"}, []RoleOption{WithDenyStatus(http.StatusNotFound)}, ®ularUser, http.StatusNotFound},
|
|
38
|
+
{"role match is case-sensitive", []string{"admin"}, nil, &upperCaseFan, http.StatusForbidden},
|
|
39
|
+
{"unauthenticated 401", []string{"admin"}, nil, nil, http.StatusUnauthorized},
|
|
40
|
+
{"blank user id treated as unauthenticated", []string{"admin"}, nil, &Identity{UserID: " ", Roles: []string{"admin"}}, http.StatusUnauthorized},
|
|
41
|
+
}
|
|
42
|
+
for _, tc := range cases {
|
|
43
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
44
|
+
opts := append([]RoleOption{WithRoleAuditSink(&InMemoryAuditSink{})}, tc.opts...)
|
|
45
|
+
handler := RequireRole(tc.roles, opts...)(okHandler())
|
|
46
|
+
req := httptest.NewRequest(http.MethodDelete, "/admin/users/42", nil)
|
|
47
|
+
if tc.caller != nil {
|
|
48
|
+
req = withCaller(req, *tc.caller)
|
|
49
|
+
}
|
|
50
|
+
rec := httptest.NewRecorder()
|
|
51
|
+
handler.ServeHTTP(rec, req)
|
|
52
|
+
if rec.Code != tc.want {
|
|
53
|
+
t.Fatalf("expected %d, got %d", tc.want, rec.Code)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestRequireRole_IgnoresRoleClaimsFromRequest(t *testing.T) {
|
|
60
|
+
sink := &InMemoryAuditSink{}
|
|
61
|
+
handler := RequireRole([]string{"admin"}, WithRoleAuditSink(sink))(okHandler())
|
|
62
|
+
|
|
63
|
+
body := strings.NewReader(`{"role":"admin","roles":["admin"],"is_admin":true}`)
|
|
64
|
+
req := httptest.NewRequest(http.MethodPost, "/admin/users?role=admin&roles=admin", body)
|
|
65
|
+
req.Header.Set("Content-Type", "application/json")
|
|
66
|
+
req.Header.Set("X-Role", "admin")
|
|
67
|
+
req.Header.Set("X-Roles", "admin")
|
|
68
|
+
req = withCaller(req, regularUser)
|
|
69
|
+
|
|
70
|
+
rec := httptest.NewRecorder()
|
|
71
|
+
handler.ServeHTTP(rec, req)
|
|
72
|
+
if rec.Code != http.StatusForbidden {
|
|
73
|
+
t.Fatalf("expected 403 despite role=admin in query, body and X-Role header, got %d", rec.Code)
|
|
74
|
+
}
|
|
75
|
+
if len(sink.Events) != 1 {
|
|
76
|
+
t.Fatalf("expected 1 audit event, got %d", len(sink.Events))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestRequireRole_EmitsAuditEventOnDenial(t *testing.T) {
|
|
81
|
+
sink := &InMemoryAuditSink{}
|
|
82
|
+
auth := NewAuthorizer(sink)
|
|
83
|
+
handler := auth.RequireRole([]string{"admin", "ops"})(okHandler())
|
|
84
|
+
|
|
85
|
+
req := withCaller(httptest.NewRequest(http.MethodDelete, "/admin/users/42", nil), regularUser)
|
|
86
|
+
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
87
|
+
|
|
88
|
+
if len(sink.Events) != 1 {
|
|
89
|
+
t.Fatalf("expected 1 audit event, got %d", len(sink.Events))
|
|
90
|
+
}
|
|
91
|
+
ev := sink.Events[0]
|
|
92
|
+
if ev.EventName != "Komagu.Authorization.RoleDenied" {
|
|
93
|
+
t.Fatalf("unexpected event name %q", ev.EventName)
|
|
94
|
+
}
|
|
95
|
+
if ev.ActorUserID != "user-b" || ev.ActorTenantID != "tenant-komagu" {
|
|
96
|
+
t.Fatalf("unexpected actor %q/%q", ev.ActorUserID, ev.ActorTenantID)
|
|
97
|
+
}
|
|
98
|
+
if !strings.Contains(ev.Reason, "admin") || !strings.Contains(ev.Reason, "ops") {
|
|
99
|
+
t.Fatalf("reason should name required roles, got %q", ev.Reason)
|
|
100
|
+
}
|
|
101
|
+
if ev.Operation != ResourceOperation(http.MethodDelete) || ev.ResourceID != "/admin/users/42" {
|
|
102
|
+
t.Fatalf("unexpected operation/resource %q %q", ev.Operation, ev.ResourceID)
|
|
103
|
+
}
|
|
104
|
+
if ev.Timestamp.IsZero() {
|
|
105
|
+
t.Fatalf("expected timestamp")
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestRequireRole_NoAuditOnAllowOrUnauthenticated(t *testing.T) {
|
|
110
|
+
sink := &InMemoryAuditSink{}
|
|
111
|
+
handler := RequireRole([]string{"admin"}, WithRoleAuditSink(sink))(okHandler())
|
|
112
|
+
|
|
113
|
+
handler.ServeHTTP(httptest.NewRecorder(), withCaller(httptest.NewRequest(http.MethodGet, "/admin", nil), adminCaller))
|
|
114
|
+
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/admin", nil))
|
|
115
|
+
if len(sink.Events) != 0 {
|
|
116
|
+
t.Fatalf("expected 0 audit events, got %d", len(sink.Events))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func TestRequireRole_InvalidConfigurationPanics(t *testing.T) {
|
|
121
|
+
cases := []struct {
|
|
122
|
+
name string
|
|
123
|
+
roles []string
|
|
124
|
+
opts []RoleOption
|
|
125
|
+
}{
|
|
126
|
+
{"nil roles", nil, nil},
|
|
127
|
+
{"empty roles", []string{}, nil},
|
|
128
|
+
{"blank role", []string{"admin", " "}, nil},
|
|
129
|
+
{"deny status 401", []string{"admin"}, []RoleOption{WithDenyStatus(http.StatusUnauthorized)}},
|
|
130
|
+
{"deny status 200", []string{"admin"}, []RoleOption{WithDenyStatus(http.StatusOK)}},
|
|
131
|
+
}
|
|
132
|
+
for _, tc := range cases {
|
|
133
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
134
|
+
defer func() {
|
|
135
|
+
if recover() == nil {
|
|
136
|
+
t.Fatalf("expected panic")
|
|
137
|
+
}
|
|
138
|
+
}()
|
|
139
|
+
RequireRole(tc.roles, tc.opts...)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func TestIdentity_ContextCopiesRoles(t *testing.T) {
|
|
145
|
+
roles := []string{"user"}
|
|
146
|
+
req := withCaller(httptest.NewRequest(http.MethodGet, "/", nil), Identity{UserID: "u1", Roles: roles})
|
|
147
|
+
roles[0] = "admin" // caller mutates its slice after storing
|
|
148
|
+
|
|
149
|
+
got, ok := IdentityFromContext(req.Context())
|
|
150
|
+
if !ok || got.HasAnyRole("admin") {
|
|
151
|
+
t.Fatalf("stored identity must not change when the original slice is mutated: %+v", got)
|
|
152
|
+
}
|
|
153
|
+
got.Roles[0] = "admin" // reader mutates the returned copy
|
|
154
|
+
again, _ := IdentityFromContext(req.Context())
|
|
155
|
+
if again.HasAnyRole("admin") {
|
|
156
|
+
t.Fatalf("returned identity must be a copy")
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { KomaguSecurityService } from '../service';
|
|
2
|
+
|
|
3
|
+
export type RoleDenyStatus = 403 | 404;
|
|
4
|
+
|
|
5
|
+
export interface RequireRoleOptions {
|
|
6
|
+
/** 403 Forbidden (default) or 404 Resource not found to hide the endpoint. */
|
|
7
|
+
denyStatus?: RoleDenyStatus;
|
|
8
|
+
securityService?: KomaguSecurityService;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export declare function requireRole(
|
|
12
|
+
roles: string | readonly string[],
|
|
13
|
+
options?: RequireRoleOptions
|
|
14
|
+
): (req: any, res: any, next: (err?: any) => void) => Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { KomaguSecurityService } = require('../service');
|
|
4
|
+
const { normalizeRequiredRoles, resolveDenyStatus, hasAnyRole, denyBody } = require('../roles');
|
|
5
|
+
|
|
6
|
+
const defaultService = new KomaguSecurityService();
|
|
7
|
+
|
|
8
|
+
function describeRoute(req) {
|
|
9
|
+
const method = req.method ? `${req.method} ` : '';
|
|
10
|
+
if (req.route && req.route.path) {
|
|
11
|
+
return `${method}${req.baseUrl || ''}${req.route.path}`;
|
|
12
|
+
}
|
|
13
|
+
const raw = req.path || req.originalUrl || req.url;
|
|
14
|
+
if (!raw) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
// Never put the query string into the audit trail
|
|
18
|
+
return `${method}${String(raw).split('?')[0]}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Express middleware enforcing function-level authorization (BFLA).
|
|
23
|
+
* Roles come only from req.user.roles / req.user.role.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|string[]} roles - caller needs at least one of these
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {403|404} [options.denyStatus=403] - 404 hides the endpoint
|
|
28
|
+
* @param {KomaguSecurityService} [options.securityService]
|
|
29
|
+
*/
|
|
30
|
+
function requireRole(roles, options = {}) {
|
|
31
|
+
const requiredRoles = normalizeRequiredRoles(roles);
|
|
32
|
+
const denyStatus = resolveDenyStatus(options);
|
|
33
|
+
const service = options.securityService || defaultService;
|
|
34
|
+
|
|
35
|
+
return async function (req, res, next) {
|
|
36
|
+
const user = req.user;
|
|
37
|
+
if (!user) {
|
|
38
|
+
return res.status(401).json({ message: 'Unauthorized' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (hasAnyRole(user, requiredRoles)) {
|
|
42
|
+
return next();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await service.emitRoleDenial(user, requiredRoles, describeRoute(req));
|
|
46
|
+
return res.status(denyStatus).json(denyBody(denyStatus));
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
requireRole,
|
|
52
|
+
};
|
|
@@ -5,28 +5,43 @@ const {
|
|
|
5
5
|
HttpException,
|
|
6
6
|
NotFoundException,
|
|
7
7
|
UnauthorizedException,
|
|
8
|
+
ForbiddenException,
|
|
8
9
|
LoggingSecurityAuditSink,
|
|
9
10
|
InMemorySecurityAuditSink,
|
|
10
11
|
} = require('./interfaces');
|
|
11
12
|
|
|
12
13
|
const { KomaguSecurityService } = require('./service');
|
|
13
|
-
const {
|
|
14
|
-
|
|
14
|
+
const {
|
|
15
|
+
KOMAGU_REQUIRE_OWNERSHIP_KEY,
|
|
16
|
+
KOMAGU_REQUIRE_ROLE_KEY,
|
|
17
|
+
RequireOwnership,
|
|
18
|
+
RequireRole,
|
|
19
|
+
CurrentUser,
|
|
20
|
+
} = require('./nestjs/decorators');
|
|
21
|
+
const { OwnershipGuard, KomaguRoleGuard } = require('./nestjs/guards');
|
|
15
22
|
const { OwnershipInterceptor } = require('./nestjs/interceptors');
|
|
16
23
|
const { komaguOwnershipMiddleware } = require('./express/middleware');
|
|
24
|
+
const { requireRole } = require('./express/role-middleware');
|
|
25
|
+
const { allowFields } = require('./mass-assignment');
|
|
17
26
|
|
|
18
27
|
module.exports = {
|
|
19
28
|
ResourceOperation,
|
|
20
29
|
HttpException,
|
|
21
30
|
NotFoundException,
|
|
22
31
|
UnauthorizedException,
|
|
32
|
+
ForbiddenException,
|
|
23
33
|
LoggingSecurityAuditSink,
|
|
24
34
|
InMemorySecurityAuditSink,
|
|
25
35
|
KomaguSecurityService,
|
|
26
36
|
KOMAGU_REQUIRE_OWNERSHIP_KEY,
|
|
37
|
+
KOMAGU_REQUIRE_ROLE_KEY,
|
|
27
38
|
RequireOwnership,
|
|
39
|
+
RequireRole,
|
|
28
40
|
CurrentUser,
|
|
29
41
|
OwnershipGuard,
|
|
42
|
+
KomaguRoleGuard,
|
|
30
43
|
OwnershipInterceptor,
|
|
31
44
|
komaguOwnershipMiddleware,
|
|
45
|
+
requireRole,
|
|
46
|
+
allowFields,
|
|
32
47
|
};
|
|
@@ -25,8 +25,18 @@ export interface SecurityAuditEvent {
|
|
|
25
25
|
reason: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface RoleDeniedAuditEvent {
|
|
29
|
+
timestamp: string;
|
|
30
|
+
eventName: 'Komagu.Authorization.RoleDenied';
|
|
31
|
+
actorUserId?: string;
|
|
32
|
+
actorTenantId?: string;
|
|
33
|
+
requiredRoles: string[];
|
|
34
|
+
route?: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
export interface ISecurityAuditSink {
|
|
29
|
-
emit(event: SecurityAuditEvent): void | Promise<void>;
|
|
39
|
+
emit(event: SecurityAuditEvent | RoleDeniedAuditEvent): void | Promise<void>;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
export declare class HttpException extends Error {
|
|
@@ -45,14 +55,18 @@ export declare class UnauthorizedException extends HttpException {
|
|
|
45
55
|
constructor(message?: string);
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
export declare class ForbiddenException extends HttpException {
|
|
59
|
+
constructor(message?: string);
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
export declare class LoggingSecurityAuditSink implements ISecurityAuditSink {
|
|
49
63
|
constructor(logger?: any);
|
|
50
|
-
emit(event: SecurityAuditEvent): void;
|
|
64
|
+
emit(event: SecurityAuditEvent | RoleDeniedAuditEvent): void;
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
export declare class InMemorySecurityAuditSink implements ISecurityAuditSink {
|
|
54
|
-
readonly events: SecurityAuditEvent
|
|
55
|
-
emit(event: SecurityAuditEvent): void;
|
|
68
|
+
readonly events: Array<SecurityAuditEvent | RoleDeniedAuditEvent>;
|
|
69
|
+
emit(event: SecurityAuditEvent | RoleDeniedAuditEvent): void;
|
|
56
70
|
clear(): void;
|
|
57
71
|
}
|
|
58
72
|
|
|
@@ -61,5 +75,6 @@ export interface UserPrincipal {
|
|
|
61
75
|
tenantId: string;
|
|
62
76
|
claims?: Record<string, any>;
|
|
63
77
|
roles?: string[];
|
|
78
|
+
role?: string;
|
|
64
79
|
isAuthenticated?: boolean;
|
|
65
80
|
}
|
|
@@ -47,6 +47,15 @@ class UnauthorizedException extends HttpException {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Thrown on function-level (role) denial when the endpoint may be revealed.
|
|
52
|
+
*/
|
|
53
|
+
class ForbiddenException extends HttpException {
|
|
54
|
+
constructor(message = 'Forbidden') {
|
|
55
|
+
super(message, 403);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
class LoggingSecurityAuditSink {
|
|
51
60
|
constructor(logger = console) {
|
|
52
61
|
this.logger = logger;
|
|
@@ -76,6 +85,7 @@ module.exports = {
|
|
|
76
85
|
HttpException,
|
|
77
86
|
NotFoundException,
|
|
78
87
|
UnauthorizedException,
|
|
88
|
+
ForbiddenException,
|
|
79
89
|
LoggingSecurityAuditSink,
|
|
80
90
|
InMemorySecurityAuditSink,
|
|
81
91
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a new plain object holding only the allow-listed own enumerable keys of `source`.
|
|
3
|
+
* `__proto__`, `constructor` and `prototype` are always ignored; non-object source yields `{}`.
|
|
4
|
+
*/
|
|
5
|
+
export declare function allowFields<T extends object, K extends string>(
|
|
6
|
+
source: T | null | undefined,
|
|
7
|
+
allowed: readonly K[]
|
|
8
|
+
): Partial<Pick<T, Extract<K, keyof T>>>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BLOCKED_KEYS = Object.freeze(['__proto__', 'constructor', 'prototype']);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mass-assignment guard: copy only allow-listed own enumerable keys.
|
|
7
|
+
*
|
|
8
|
+
* - Returns a NEW plain object; `source` is never mutated.
|
|
9
|
+
* - Keys missing from `source` are skipped (not set to undefined).
|
|
10
|
+
* - `__proto__`, `constructor` and `prototype` are always ignored.
|
|
11
|
+
* - Non-object (or array) `source` yields `{}`.
|
|
12
|
+
* - Values are copied by reference (shallow copy).
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} source - untrusted input such as req.body
|
|
15
|
+
* @param {string[]} allowed - field names the caller may set
|
|
16
|
+
* @returns {Object}
|
|
17
|
+
*/
|
|
18
|
+
function allowFields(source, allowed) {
|
|
19
|
+
if (!Array.isArray(allowed)) {
|
|
20
|
+
throw new TypeError('allowFields: allowed must be an array of field names');
|
|
21
|
+
}
|
|
22
|
+
const result = {};
|
|
23
|
+
if (source === null || typeof source !== 'object' || Array.isArray(source)) {
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
for (const key of allowed) {
|
|
27
|
+
if (typeof key !== 'string' || BLOCKED_KEYS.includes(key)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (Object.prototype.hasOwnProperty.call(source, key) &&
|
|
31
|
+
Object.prototype.propertyIsEnumerable.call(source, key)) {
|
|
32
|
+
result[key] = source[key];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
allowFields,
|
|
40
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ResourceOperationType } from '../interfaces';
|
|
2
2
|
|
|
3
3
|
export declare const KOMAGU_REQUIRE_OWNERSHIP_KEY = 'komagu:require_ownership';
|
|
4
|
+
export declare const KOMAGU_REQUIRE_ROLE_KEY = 'komagu:require_role';
|
|
4
5
|
|
|
5
6
|
export interface RequireOwnershipOptions {
|
|
6
7
|
idParamName?: string;
|
|
@@ -13,4 +14,19 @@ export declare function RequireOwnership(
|
|
|
13
14
|
options?: RequireOwnershipOptions
|
|
14
15
|
): MethodDecorator & ClassDecorator;
|
|
15
16
|
|
|
17
|
+
export interface RequireRoleDecoratorOptions {
|
|
18
|
+
/** 403 Forbidden (default) or 404 Resource not found to hide the endpoint. */
|
|
19
|
+
denyStatus?: 403 | 404;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RequireRoleMetadata {
|
|
23
|
+
readonly roles: readonly string[];
|
|
24
|
+
readonly denyStatus: 403 | 404;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export declare function RequireRole(
|
|
28
|
+
roles: string | readonly string[],
|
|
29
|
+
options?: RequireRoleDecoratorOptions
|
|
30
|
+
): MethodDecorator & ClassDecorator;
|
|
31
|
+
|
|
16
32
|
export declare function CurrentUser(): ParameterDecorator;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { ResourceOperation } = require('../interfaces');
|
|
4
|
+
const { normalizeRequiredRoles, resolveDenyStatus } = require('../roles');
|
|
4
5
|
|
|
5
6
|
const KOMAGU_REQUIRE_OWNERSHIP_KEY = 'komagu:require_ownership';
|
|
7
|
+
const KOMAGU_REQUIRE_ROLE_KEY = 'komagu:require_role';
|
|
6
8
|
|
|
7
9
|
// WeakMap fallback when reflect-metadata is not loaded
|
|
8
10
|
const metadataStore = new WeakMap();
|
|
@@ -57,6 +59,29 @@ function RequireOwnership(operation = ResourceOperation.READ, options = {}) {
|
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Decorator requiring the caller to hold at least one of `roles` (function-level / BFLA check).
|
|
64
|
+
* Enforced by KomaguRoleGuard. Roles are validated here, so a bad argument fails at load time.
|
|
65
|
+
*
|
|
66
|
+
* @param {string|string[]} roles
|
|
67
|
+
* @param {Object} [options] - { denyStatus: 403 | 404 }
|
|
68
|
+
*/
|
|
69
|
+
function RequireRole(roles, options = {}) {
|
|
70
|
+
const meta = Object.freeze({
|
|
71
|
+
roles: normalizeRequiredRoles(roles),
|
|
72
|
+
denyStatus: resolveDenyStatus(options),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return function (target, propertyKey, descriptor) {
|
|
76
|
+
if (descriptor && descriptor.value) {
|
|
77
|
+
setMetadata(KOMAGU_REQUIRE_ROLE_KEY, meta, descriptor.value);
|
|
78
|
+
return descriptor;
|
|
79
|
+
}
|
|
80
|
+
setMetadata(KOMAGU_REQUIRE_ROLE_KEY, meta, target);
|
|
81
|
+
return target;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
/**
|
|
61
86
|
* Parameter decorator extracting the verified user principal from the request.
|
|
62
87
|
*/
|
|
@@ -70,7 +95,9 @@ function CurrentUser() {
|
|
|
70
95
|
|
|
71
96
|
module.exports = {
|
|
72
97
|
KOMAGU_REQUIRE_OWNERSHIP_KEY,
|
|
98
|
+
KOMAGU_REQUIRE_ROLE_KEY,
|
|
73
99
|
RequireOwnership,
|
|
100
|
+
RequireRole,
|
|
74
101
|
CurrentUser,
|
|
75
102
|
setMetadata,
|
|
76
103
|
getMetadata,
|
|
@@ -4,3 +4,8 @@ export declare class OwnershipGuard {
|
|
|
4
4
|
constructor(reflector: any, securityService: KomaguSecurityService);
|
|
5
5
|
canActivate(context: any): Promise<boolean>;
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
export declare class KomaguRoleGuard {
|
|
9
|
+
constructor(reflector: any, securityService?: KomaguSecurityService);
|
|
10
|
+
canActivate(context: any): Promise<boolean>;
|
|
11
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { NotFoundException, UnauthorizedException } = require('../interfaces');
|
|
4
|
-
const { KOMAGU_REQUIRE_OWNERSHIP_KEY, getMetadata } = require('./decorators');
|
|
3
|
+
const { NotFoundException, UnauthorizedException, ForbiddenException } = require('../interfaces');
|
|
4
|
+
const { KOMAGU_REQUIRE_OWNERSHIP_KEY, KOMAGU_REQUIRE_ROLE_KEY, getMetadata } = require('./decorators');
|
|
5
|
+
const { KomaguSecurityService } = require('../service');
|
|
6
|
+
const { hasAnyRole } = require('../roles');
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* NestJS Guard evaluating object-level authorization before route handler execution.
|
|
@@ -71,6 +73,71 @@ class OwnershipGuard {
|
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
function readMeta(reflector, key, handler, targetClass) {
|
|
77
|
+
let meta = null;
|
|
78
|
+
if (reflector && typeof reflector.get === 'function') {
|
|
79
|
+
meta = reflector.get(key, handler) || reflector.get(key, targetClass);
|
|
80
|
+
}
|
|
81
|
+
if (!meta) {
|
|
82
|
+
meta = (handler && getMetadata(key, handler)) ||
|
|
83
|
+
(targetClass && getMetadata(key, targetClass));
|
|
84
|
+
}
|
|
85
|
+
return meta || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function describeOperation(req, handler, targetClass) {
|
|
89
|
+
if (req && req.method && req.route && req.route.path) {
|
|
90
|
+
return `${req.method} ${req.baseUrl || ''}${req.route.path}`;
|
|
91
|
+
}
|
|
92
|
+
const className = targetClass && targetClass.name;
|
|
93
|
+
const handlerName = handler && handler.name;
|
|
94
|
+
if (className && handlerName) {
|
|
95
|
+
return `${className}.${handlerName}`;
|
|
96
|
+
}
|
|
97
|
+
return handlerName || className || undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* NestJS Guard enforcing @RequireRole (function-level / BFLA authorization).
|
|
102
|
+
* Roles are read only from the authenticated principal (req.user.roles / req.user.role).
|
|
103
|
+
* Method-level @RequireRole overrides class-level.
|
|
104
|
+
*/
|
|
105
|
+
class KomaguRoleGuard {
|
|
106
|
+
constructor(reflector, securityService) {
|
|
107
|
+
this.reflector = reflector;
|
|
108
|
+
this.securityService = securityService || new KomaguSecurityService();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async canActivate(context) {
|
|
112
|
+
const handler = context.getHandler ? context.getHandler() : null;
|
|
113
|
+
const targetClass = context.getClass ? context.getClass() : null;
|
|
114
|
+
|
|
115
|
+
const meta = readMeta(this.reflector, KOMAGU_REQUIRE_ROLE_KEY, handler, targetClass);
|
|
116
|
+
if (!meta) {
|
|
117
|
+
return true; // No role requirements on this route
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const httpContext = context.switchToHttp ? context.switchToHttp() : null;
|
|
121
|
+
const req = httpContext ? httpContext.getRequest() : context.request;
|
|
122
|
+
|
|
123
|
+
if (!req || !req.user) {
|
|
124
|
+
throw new UnauthorizedException('Unauthorized');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (hasAnyRole(req.user, meta.roles)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await this.securityService.emitRoleDenial(req.user, meta.roles, describeOperation(req, handler, targetClass));
|
|
132
|
+
|
|
133
|
+
if (meta.denyStatus === 404) {
|
|
134
|
+
throw new NotFoundException('Resource not found');
|
|
135
|
+
}
|
|
136
|
+
throw new ForbiddenException('Forbidden');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
74
140
|
module.exports = {
|
|
75
141
|
OwnershipGuard,
|
|
142
|
+
KomaguRoleGuard,
|
|
76
143
|
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared function-level authorization (BFLA) helpers used by the Express
|
|
5
|
+
* middleware and the NestJS guard.
|
|
6
|
+
*
|
|
7
|
+
* Security invariant: roles are read ONLY from the trusted authenticated
|
|
8
|
+
* principal (`req.user.roles` / `req.user.role`). Request body, query,
|
|
9
|
+
* params and headers are never consulted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ALLOWED_DENY_STATUSES = Object.freeze([403, 404]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate and normalize the required roles argument. Throws at construction time.
|
|
16
|
+
* @param {string|string[]} roles
|
|
17
|
+
* @returns {string[]} frozen copy of the required roles
|
|
18
|
+
*/
|
|
19
|
+
function normalizeRequiredRoles(roles) {
|
|
20
|
+
const list = typeof roles === 'string' ? [roles] : roles;
|
|
21
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
22
|
+
throw new TypeError('requireRole: roles must be a non-empty string or a non-empty array of strings');
|
|
23
|
+
}
|
|
24
|
+
for (const role of list) {
|
|
25
|
+
if (typeof role !== 'string' || role.trim() === '') {
|
|
26
|
+
throw new TypeError('requireRole: every role must be a non-empty string');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Object.freeze(list.slice());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate options.denyStatus (403 default, or 404 to hide the endpoint).
|
|
34
|
+
* @param {Object} [options]
|
|
35
|
+
* @returns {number}
|
|
36
|
+
*/
|
|
37
|
+
function resolveDenyStatus(options) {
|
|
38
|
+
const opts = options || {};
|
|
39
|
+
if (opts.denyStatus === undefined || opts.denyStatus === null) {
|
|
40
|
+
return 403;
|
|
41
|
+
}
|
|
42
|
+
if (!ALLOWED_DENY_STATUSES.includes(opts.denyStatus)) {
|
|
43
|
+
throw new TypeError(`requireRole: options.denyStatus must be 403 or 404 (got ${String(opts.denyStatus)})`);
|
|
44
|
+
}
|
|
45
|
+
return opts.denyStatus;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Collect the caller's roles from the trusted principal only.
|
|
50
|
+
* @param {Object} user - req.user
|
|
51
|
+
* @returns {string[]}
|
|
52
|
+
*/
|
|
53
|
+
function getPrincipalRoles(user) {
|
|
54
|
+
if (!user || typeof user !== 'object') {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const roles = [];
|
|
58
|
+
if (Array.isArray(user.roles)) {
|
|
59
|
+
for (const role of user.roles) {
|
|
60
|
+
if (typeof role === 'string') {
|
|
61
|
+
roles.push(role);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (typeof user.role === 'string') {
|
|
66
|
+
roles.push(user.role);
|
|
67
|
+
}
|
|
68
|
+
return roles;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True when the principal holds at least one required role (case-sensitive exact match).
|
|
73
|
+
*/
|
|
74
|
+
function hasAnyRole(user, requiredRoles) {
|
|
75
|
+
const held = getPrincipalRoles(user);
|
|
76
|
+
return requiredRoles.some((role) => held.includes(role));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function denyBody(status) {
|
|
80
|
+
return status === 404 ? { message: 'Resource not found' } : { message: 'Forbidden' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
ALLOWED_DENY_STATUSES,
|
|
85
|
+
normalizeRequiredRoles,
|
|
86
|
+
resolveDenyStatus,
|
|
87
|
+
getPrincipalRoles,
|
|
88
|
+
hasAnyRole,
|
|
89
|
+
denyBody,
|
|
90
|
+
};
|
|
@@ -32,4 +32,10 @@ export declare class KomaguSecurityService {
|
|
|
32
32
|
operation?: ResourceOperationType,
|
|
33
33
|
options?: AuthorizeOptions
|
|
34
34
|
): Promise<void>;
|
|
35
|
+
emitRoleDenial(
|
|
36
|
+
user: any,
|
|
37
|
+
requiredRoles: readonly string[],
|
|
38
|
+
route?: string,
|
|
39
|
+
reason?: string
|
|
40
|
+
): Promise<void>;
|
|
35
41
|
}
|