@hoanghainh/komagu 2.6.0 → 2.7.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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -0
  3. package/assess/lsa/knowledge/path-ssrf-patterns.md +19 -0
  4. package/assess/lsa/knowledge/severity-and-cwe.md +3 -1
  5. package/assess/lsa/scripts/find_path_ssrf.py +176 -0
  6. package/assess/lsa/scripts/findings_to_sarif.py +29 -0
  7. package/assess/lsa/scripts/merge_findings.py +1 -1
  8. package/assess/lsa/scripts/run_prescan.py +4 -0
  9. package/assess/lsa/tests/run_tests.py +4 -0
  10. package/assess/lsa/tests/test_path_ssrf.py +94 -0
  11. package/contracts/schemas/finding.schema.json +4 -2
  12. package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
  13. package/examples/member-portal/backend-dotnet/Program.cs +1 -72
  14. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
  15. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
  16. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
  17. package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
  18. package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
  19. package/operate/README.md +6 -1
  20. package/operate/ai/client.py +5 -1
  21. package/operate/cli/__init__.py +1 -1
  22. package/operate/cli/commands/fix.py +47 -0
  23. package/operate/cli/commands/mcp.py +54 -0
  24. package/operate/cli/main.py +42 -4
  25. package/operate/cross_user_tester/runner.py +4 -1
  26. package/operate/devsecops/sbom.py +12 -4
  27. package/operate/mcp/__init__.py +11 -0
  28. package/operate/mcp/server.py +206 -0
  29. package/operate/mcp/tools.py +464 -0
  30. package/operate/remediator/ai_fixer.py +285 -0
  31. package/operate/remediator/runner.py +11 -0
  32. package/operate/remediator/strategies.py +64 -0
  33. package/operate/simulate/scenarios.py +11 -2
  34. package/operate/tests/test_ai_remediation.py +288 -0
  35. package/operate/tests/test_audit_minimum_versions.py +89 -0
  36. package/operate/tests/test_cli.py +1 -1
  37. package/operate/tests/test_mcp_server.py +380 -0
  38. package/operate/tests/test_scheme_and_xml_guards.py +67 -0
  39. package/package.json +1 -1
  40. package/remediate/authz/bfla_patcher.py +319 -0
  41. package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
  42. package/remediate/authz/golang/authz_test.go +5 -2
  43. package/remediate/authz/mass_assignment_patcher.py +188 -0
  44. package/remediate/authz/patcher.py +8 -0
  45. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
  46. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
  47. package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
  48. package/remediate/deps/patcher.py +2 -1
  49. package/remediate/edge/mock-backend/app.py +2 -1
  50. package/remediate/edge/tests/test_edge_security.py +5 -5
  51. package/remediate/ekyc/requirements.txt +1 -1
  52. package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
  53. package/remediate/identity/python/requirements.txt +2 -2
  54. package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
  55. package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
  56. package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
  57. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
  58. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/StringBuiltSqlQueryRule.kt +19 -24
@@ -39,93 +39,85 @@ class DefaultKomaguSecurityManager(
39
39
  tenantIdExtractor: ((T) -> String)?
40
40
  ): AuthorizationDecision {
41
41
  val authentication = SecurityContextHolder.getContext().authentication
42
-
43
- if (authentication == null || !authentication.isAuthenticated || authentication is AnonymousAuthenticationToken) {
44
- val reason = "Caller is unauthenticated"
45
- emitDenialAudit(authentication, resource, operation, reason)
46
- return AuthorizationDecision.denied(reason)
47
- }
48
-
49
- val currentUserId = try {
50
- getCurrentUserId(authentication)
51
- } catch (ex: Exception) {
52
- emitDenialAudit(authentication, resource, operation, ex.message ?: "Invalid user identity")
53
- return AuthorizationDecision.denied(ex.message ?: "Invalid user identity")
54
- }
55
-
56
- val currentTenantId = try {
57
- getCurrentTenantId(authentication)
58
- } catch (ex: Exception) {
59
- emitDenialAudit(authentication, resource, operation, ex.message ?: "Invalid tenant identity")
60
- return AuthorizationDecision.denied(ex.message ?: "Invalid tenant identity")
42
+ val denial = findDenial(authentication, resource, ownerUserIdExtractor, tenantIdExtractor)
43
+ ?: return AuthorizationDecision.granted()
44
+ if (denial.audited) {
45
+ emitDenialAudit(authentication, resource, operation, denial.reason, denial.resourceId)
61
46
  }
47
+ return AuthorizationDecision.denied(denial.reason)
48
+ }
62
49
 
63
- if (resource == null) {
64
- return AuthorizationDecision.denied("Resource is null")
50
+ /** Why the caller may not act on [resource], or null when it may. Checks run in order; the first failure wins. */
51
+ private fun <T> findDenial(
52
+ authentication: Authentication?,
53
+ resource: T?,
54
+ ownerUserIdExtractor: ((T) -> String)?,
55
+ tenantIdExtractor: ((T) -> String)?
56
+ ): Denial? {
57
+ val caller = authentication?.takeIf { it.isAuthenticated && it !is AnonymousAuthenticationToken }
58
+ val userId = caller?.let { runCatching { getCurrentUserId(it) } }
59
+ val tenantId = caller?.let { runCatching { getCurrentTenantId(it) } }
60
+ return when {
61
+ userId == null || tenantId == null -> Denial("Caller is unauthenticated")
62
+ userId.isFailure -> Denial(userId.exceptionOrNull()?.message ?: "Invalid user identity")
63
+ tenantId.isFailure -> Denial(tenantId.exceptionOrNull()?.message ?: "Invalid tenant identity")
64
+ resource == null -> Denial("Resource is null", audited = false)
65
+ else -> ownershipDenial(
66
+ resourceIdentity(resource, ownerUserIdExtractor, tenantIdExtractor),
67
+ userId.getOrThrow(),
68
+ tenantId.getOrThrow()
69
+ )
65
70
  }
71
+ }
66
72
 
67
- // Extract ownerUserId and tenantId from resource
68
- var resourceOwnerUserId: String? = null
69
- var resourceTenantId: String? = null
70
- var resourceId: String? = null
71
-
72
- if (resource is OwnedResource<*>) {
73
- resourceOwnerUserId = resource.ownerUserId
74
- resourceTenantId = resource.tenantId
75
- resourceId = resource.id?.toString()
76
- }
73
+ private fun ownershipDenial(identity: ResourceIdentity, currentUserId: String, currentTenantId: String): Denial? {
74
+ val owner = identity.ownerUserId?.trim()
75
+ val tenant = identity.tenantId?.trim()
76
+ val reason = when {
77
+ owner.isNullOrEmpty() || tenant.isNullOrEmpty() ->
78
+ "Target resource does not expose ownerUserId and tenantId"
79
+ // 1. Tenant boundary check
80
+ tenant != currentTenantId ->
81
+ "Tenant boundary violation: current tenant '$currentTenantId' != resource tenant '$tenant'"
82
+ // 2. User ownership check
83
+ owner != currentUserId ->
84
+ "User ownership violation: current user '$currentUserId' != resource owner '$owner'"
85
+ else -> null
86
+ }
87
+ return reason?.let { Denial(it, resourceId = identity.id) }
88
+ }
77
89
 
78
- if (ownerUserIdExtractor != null) {
79
- resourceOwnerUserId = ownerUserIdExtractor(resource)
80
- }
81
- if (tenantIdExtractor != null) {
82
- resourceTenantId = tenantIdExtractor(resource)
90
+ /** Owner, tenant and id from [OwnedResource], the extractors, then getters as a fallback. */
91
+ private fun <T> resourceIdentity(
92
+ resource: T & Any,
93
+ ownerUserIdExtractor: ((T) -> String)?,
94
+ tenantIdExtractor: ((T) -> String)?
95
+ ): ResourceIdentity {
96
+ val owned = resource as? OwnedResource<*>
97
+ val identity = ResourceIdentity(
98
+ ownerUserId = ownerUserIdExtractor?.invoke(resource) ?: owned?.ownerUserId,
99
+ tenantId = tenantIdExtractor?.invoke(resource) ?: owned?.tenantId,
100
+ id = owned?.id?.toString()
101
+ )
102
+ if (identity.ownerUserId != null && identity.tenantId != null) {
103
+ return identity
83
104
  }
84
-
85
105
  // Reflection fallback if not implementing OwnedResource directly
86
- if (resourceOwnerUserId == null || resourceTenantId == null) {
87
- try {
88
- val clazz = resource::class.java
89
- val ownerField = clazz.methods.firstOrNull { it.name == "getOwnerUserId" || it.name == "ownerUserId" }
90
- val tenantField = clazz.methods.firstOrNull { it.name == "getTenantId" || it.name == "tenantId" }
91
- val idField = clazz.methods.firstOrNull { it.name == "getId" || it.name == "id" }
92
-
93
- resourceOwnerUserId = resourceOwnerUserId ?: ownerField?.invoke(resource)?.toString()
94
- resourceTenantId = resourceTenantId ?: tenantField?.invoke(resource)?.toString()
95
- resourceId = resourceId ?: idField?.invoke(resource)?.toString()
96
- } catch (_: Exception) {
97
- }
98
- }
99
-
100
- val trimmedOwner = resourceOwnerUserId?.trim()
101
- val trimmedTenant = resourceTenantId?.trim()
102
-
103
- if (trimmedOwner.isNullOrEmpty() || trimmedTenant.isNullOrEmpty()) {
104
- val reason = "Target resource does not expose ownerUserId and tenantId"
105
- emitDenialAudit(authentication, resource, operation, reason, resourceId)
106
- return AuthorizationDecision.denied(reason)
107
- }
108
-
109
- // 1. Tenant boundary check
110
- if (trimmedTenant != currentTenantId) {
111
- val reason = "Tenant boundary violation: current tenant '$currentTenantId' != resource tenant '$trimmedTenant'"
112
- emitDenialAudit(authentication, resource, operation, reason, resourceId)
113
- return AuthorizationDecision.denied(reason)
114
- }
115
-
116
- // 2. User ownership check
117
- if (trimmedOwner != currentUserId) {
118
- val reason = "User ownership violation: current user '$currentUserId' != resource owner '$trimmedOwner'"
119
- emitDenialAudit(authentication, resource, operation, reason, resourceId)
120
- return AuthorizationDecision.denied(reason)
106
+ fun read(vararg names: String): String? = try {
107
+ resource::class.java.methods.firstOrNull { it.name in names }?.invoke(resource)?.toString()
108
+ } catch (_: Exception) {
109
+ null
121
110
  }
122
-
123
- return AuthorizationDecision.granted()
111
+ return ResourceIdentity(
112
+ ownerUserId = identity.ownerUserId ?: read("getOwnerUserId", "ownerUserId"),
113
+ tenantId = identity.tenantId ?: read("getTenantId", "tenantId"),
114
+ id = identity.id ?: read("getId", "id")
115
+ )
124
116
  }
125
117
 
126
118
  override fun getCurrentUserId(authentication: Authentication?): String {
127
119
  val auth = authentication ?: SecurityContextHolder.getContext().authentication
128
- ?: throw IllegalStateException("No authentication present in security context")
120
+ ?: error("No authentication present in security context")
129
121
 
130
122
  when (val principal = auth.principal) {
131
123
  is Jwt -> {
@@ -141,12 +133,12 @@ class DefaultKomaguSecurityManager(
141
133
  return name
142
134
  }
143
135
 
144
- throw IllegalStateException("Verified user identity claim missing in token context")
136
+ error("Verified user identity claim missing in token context")
145
137
  }
146
138
 
147
139
  override fun getCurrentTenantId(authentication: Authentication?): String {
148
140
  val auth = authentication ?: SecurityContextHolder.getContext().authentication
149
- ?: throw IllegalStateException("No authentication present in security context")
141
+ ?: error("No authentication present in security context")
150
142
 
151
143
  when (val principal = auth.principal) {
152
144
  is Jwt -> {
@@ -166,7 +158,7 @@ class DefaultKomaguSecurityManager(
166
158
  }
167
159
  }
168
160
 
169
- throw IllegalStateException("Verified tenant identity claim missing in token context")
161
+ error("Verified tenant identity claim missing in token context")
170
162
  }
171
163
 
172
164
  private fun <T> emitDenialAudit(
@@ -195,4 +187,8 @@ class DefaultKomaguSecurityManager(
195
187
  // Auditing failures must never alter the denial outcome
196
188
  }
197
189
  }
190
+
191
+ private class Denial(val reason: String, val resourceId: String? = null, val audited: Boolean = true)
192
+
193
+ private class ResourceIdentity(val ownerUserId: String?, val tenantId: String?, val id: String?)
198
194
  }
@@ -49,6 +49,7 @@ class OwnershipAspect(
49
49
  joinPoint: ProceedingJoinPoint,
50
50
  requireOwnership: RequireOwnership
51
51
  ): Any? {
52
+ val operation = requireOwnership.operation
52
53
  val targetClass = if (requireOwnership.entityClass != Any::class) {
53
54
  requireOwnership.entityClass.java
54
55
  } else null
@@ -58,83 +59,21 @@ class OwnershipAspect(
58
59
  } catch (_: Exception) {
59
60
  null
60
61
  }
61
- val method = signature?.method
62
- val paramNames = signature?.parameterNames ?: emptyArray()
63
- val args = joinPoint.args ?: emptyArray()
64
-
65
- var preInvocationChecked = false
66
- val isMutation = requireOwnership.operation == ResourceOperation.UPDATE ||
67
- requireOwnership.operation == ResourceOperation.DELETE
62
+ val args: Array<Any?> = joinPoint.args ?: emptyArray()
68
63
 
69
64
  // 1. Determine if an ID parameter is present for ID-based resolution
70
- var idArg: Any? = null
71
- val idParamIndex = paramNames.indexOf(requireOwnership.idParam)
72
- if (idParamIndex != -1 && idParamIndex < args.size) {
73
- idArg = args[idParamIndex]
74
- } else if (paramNames.isEmpty()) {
75
- // Fallback ONLY when parameter names are not preserved in bytecode (no -parameters flag):
76
- if (args.size == 1 && (args[0] is Number || args[0] is String || args[0] is java.util.UUID)) {
77
- idArg = args[0]
78
- } else if (targetClass != null) {
79
- idArg = args.firstOrNull { it is Number || it is String || it is java.util.UUID }
80
- }
81
- }
82
-
83
- if (idArg != null) {
84
- // ID-addressed operation: The resource MUST be resolved server-side from the ID.
85
- // Client-supplied body objects MUST NEVER be used as the authorization subject.
86
- if (targetClass != null) {
87
- val resolver = resolvers.find { it.supports(targetClass) }
88
- ?: throw ResourceNotFoundException(
89
- "No ResourceOwnershipResolver found for entity ${targetClass.simpleName} to resolve id '$idArg'"
90
- )
91
- val resolved = resolver.resolve(idArg)
92
- ?: throw ResourceNotFoundException("Resource of type ${targetClass.simpleName} with id '$idArg' not found")
93
-
94
- securityManager.requireOwnership(resolved, requireOwnership.operation)
95
-
96
- // A bound object of the entity type must describe the same resource: same id when it
97
- // carries one, and no change of owner or tenant (else the method could act on body.id).
98
- for (arg in args) {
99
- if (arg == null || arg === idArg) continue
100
- if ((arg is OwnedResource<*> || targetClass.isInstance(arg)) &&
101
- !describesSameResource(arg, resolved, idArg)
102
- ) {
103
- throw ResourceNotFoundException(
104
- "Argument of type ${arg.javaClass.simpleName} does not match resolved ${targetClass.simpleName} '$idArg'"
105
- )
106
- }
107
- }
108
-
109
- preInvocationChecked = true
110
- } else {
111
- // targetClass not specified but id is present: fail-closed
112
- throw ResourceNotFoundException(
113
- "Cannot resolve resource by ID '$idArg' because entityClass is not specified on @RequireOwnership"
114
- )
115
- }
65
+ val idArg = findIdArg(signature?.parameterNames ?: emptyArray(), args, requireOwnership.idParam, targetClass)
66
+ val preInvocationChecked = if (idArg != null) {
67
+ verifyById(idArg, args, targetClass, operation)
68
+ true
116
69
  } else {
117
- // No id under the configured parameter name. Mutations are id-addressed by definition, so a
118
- // missing id means a misconfigured idParam: never authorize a client-supplied object instead.
119
- if (isMutation) {
120
- throw ResourceNotFoundException(
121
- "Access denied: '${requireOwnership.operation}' requires the '${requireOwnership.idParam}' parameter to resolve the resource server-side"
122
- )
123
- }
124
- // Non-ID operation (e.g. creation or batch operations): inspect input parameters for OwnedResource / targetClass instances
125
- for (i in args.indices) {
126
- val arg = args[i] ?: continue
127
- if (arg is OwnedResource<*> || (targetClass != null && targetClass.isInstance(arg))) {
128
- securityManager.requireOwnership(arg, requireOwnership.operation)
129
- preInvocationChecked = true
130
- }
131
- }
70
+ verifyInputs(args, targetClass, requireOwnership)
132
71
  }
133
72
 
134
73
  // 2. Pre-mutation gate: for UPDATE / DELETE, fail closed before proceed() if no resource was verified
135
- if (isMutation && !preInvocationChecked) {
136
- throw ResourceNotFoundException(
137
- "Access denied: mutating operation '${requireOwnership.operation}' requires pre-invocation ownership verification, but no valid resource could be resolved"
74
+ if (isMutation(operation) && !preInvocationChecked) {
75
+ deny(
76
+ "Access denied: mutating operation '$operation' requires pre-invocation ownership verification, but no valid resource could be resolved"
138
77
  )
139
78
  }
140
79
 
@@ -142,108 +81,140 @@ class OwnershipAspect(
142
81
  val result = joinPoint.proceed()
143
82
 
144
83
  // 4. Inspect return value for READ operations
145
- var postInvocationChecked = false
146
- val extractedResources = unwrapValue(result)
147
- for (res in extractedResources) {
148
- if (res is OwnedResource<*> || (targetClass != null && targetClass.isInstance(res))) {
149
- securityManager.requireOwnership(res, requireOwnership.operation)
150
- postInvocationChecked = true
151
- }
152
- }
84
+ val postInvocationChecked = verifyOwned(unwrapValue(result), targetClass, operation)
153
85
 
154
86
  // 5. Fail-closed: if neither input nor output could be verified, deny access
155
87
  if (!preInvocationChecked && !postInvocationChecked) {
156
- throw ResourceNotFoundException(
157
- "Access denied: @RequireOwnership enforced on '${method?.name ?: "method"}' but no resource was verified"
88
+ deny(
89
+ "Access denied: @RequireOwnership enforced on '${signature?.method?.name ?: "method"}' but no resource was verified"
158
90
  )
159
91
  }
160
92
 
161
93
  return result
162
94
  }
163
95
 
164
- private fun describesSameResource(candidate: Any, resolved: Any, idArg: Any): Boolean {
165
- val candidateId = readProperty(candidate, "id")
166
- if (candidateId != null && candidateId.toString().isNotEmpty() && candidateId.toString() != idArg.toString()) {
167
- return false
96
+ /**
97
+ * ID-addressed operation: the resource MUST be resolved server-side from the ID.
98
+ * Client-supplied body objects MUST NEVER be used as the authorization subject.
99
+ */
100
+ private fun verifyById(idArg: Any, args: Array<Any?>, targetClass: Class<*>?, operation: ResourceOperation) {
101
+ // targetClass not specified but id is present: fail-closed
102
+ val type = targetClass ?: deny(
103
+ "Cannot resolve resource by ID '$idArg' because entityClass is not specified on @RequireOwnership"
104
+ )
105
+ val resolver = resolvers.find { it.supports(type) } ?: deny(
106
+ "No ResourceOwnershipResolver found for entity ${type.simpleName} to resolve id '$idArg'"
107
+ )
108
+ val resolved = resolver.resolve(idArg)
109
+ ?: deny("Resource of type ${type.simpleName} with id '$idArg' not found")
110
+
111
+ securityManager.requireOwnership(resolved, operation)
112
+
113
+ // A bound object of the entity type must describe the same resource: same id when it
114
+ // carries one, and no change of owner or tenant (else the method could act on body.id).
115
+ val mismatch = args.firstOrNull { arg ->
116
+ arg != null && arg !== idArg &&
117
+ (arg is OwnedResource<*> || type.isInstance(arg)) &&
118
+ !describesSameResource(arg, resolved, idArg)
119
+ }
120
+ if (mismatch != null) {
121
+ deny("Argument of type ${mismatch.javaClass.simpleName} does not match resolved ${type.simpleName} '$idArg'")
168
122
  }
169
- for (field in listOf("ownerUserId", "tenantId")) {
170
- val claimed = readProperty(candidate, field)?.toString()
171
- if (!claimed.isNullOrEmpty() && claimed != readProperty(resolved, field)?.toString()) {
172
- return false
173
- }
174
- }
175
- return true
176
123
  }
177
124
 
178
- private fun readProperty(target: Any, name: String): Any? {
179
- if (target is OwnedResource<*>) {
180
- return when (name) {
181
- "id" -> target.id
182
- "ownerUserId" -> target.ownerUserId
183
- "tenantId" -> target.tenantId
184
- else -> null
185
- }
186
- }
187
- val getter = "get" + name.replaceFirstChar { it.uppercase() }
188
- return try {
189
- target.javaClass.getMethod(getter).invoke(target)
190
- } catch (_: Exception) {
191
- null
125
+ /** No id under the configured parameter name: check owned objects passed in. Returns true if one was verified. */
126
+ private fun verifyInputs(args: Array<Any?>, targetClass: Class<*>?, requireOwnership: RequireOwnership): Boolean {
127
+ // Mutations are id-addressed by definition, so a missing id means a misconfigured idParam:
128
+ // never authorize a client-supplied object instead.
129
+ if (isMutation(requireOwnership.operation)) {
130
+ deny(
131
+ "Access denied: '${requireOwnership.operation}' requires the '${requireOwnership.idParam}' parameter to resolve the resource server-side"
132
+ )
192
133
  }
134
+ // Non-ID operation (e.g. creation or batch operations): inspect input parameters for OwnedResource / targetClass instances
135
+ return verifyOwned(args.filterNotNull(), targetClass, requireOwnership.operation)
193
136
  }
194
137
 
195
- private fun unwrapValue(value: Any?): List<Any> {
196
- if (value == null) return emptyList()
197
- // Optional
198
- if (value is java.util.Optional<*>) {
199
- return if (value.isPresent) unwrapValue(value.get()) else emptyList()
200
- }
201
- // Iterable (e.g., List, Set)
202
- if (value is Iterable<*>) {
203
- return value.filterNotNull().flatMap { unwrapValue(it) }
204
- }
205
- // Array
206
- if (value is Array<*>) {
207
- return value.filterNotNull().flatMap { unwrapValue(it) }
208
- }
209
- // Map values
210
- if (value is Map<*, *>) {
211
- return value.values.filterNotNull().flatMap { unwrapValue(it) }
212
- }
213
- // Java Stream
214
- if (value is java.util.stream.Stream<*>) {
215
- // Convert stream to a list first, then recursively unwrap each element.
216
- return value.filter { it != null }.toList().flatMap { unwrapValue(it) }
217
- }
218
- // Reactor Mono (optional – requires Reactor on classpath)
219
- // if (value is reactor.core.publisher.Mono<*>) {
220
- // val resolved = value.block()
221
- // return if (resolved != null) unwrapValue(resolved) else emptyList()
222
- // }
223
-
224
- // CompletableFuture
225
- if (value is java.util.concurrent.CompletableFuture<*>) {
226
- val resolved = value.get()
227
- return if (resolved != null) unwrapValue(resolved) else emptyList()
228
- }
229
- // ResponseEntity
230
- val className = value.javaClass.name
231
- if (className == "org.springframework.http.ResponseEntity") {
232
- try {
233
- val bodyMethod = value.javaClass.getMethod("getBody")
234
- val body = bodyMethod.invoke(value)
235
- return unwrapValue(body)
236
- } catch (_: Exception) {}
237
- }
238
- // Spring Data Page / Slice
239
- if (className.endsWith("Page") || className.endsWith("Slice")) {
240
- try {
241
- val contentMethod = value.javaClass.getMethod("getContent")
242
- val content = contentMethod.invoke(value)
243
- return unwrapValue(content)
244
- } catch (_: Exception) {}
138
+ /** Requires ownership of every owned value; returns true if at least one was checked. */
139
+ private fun verifyOwned(values: List<Any>, targetClass: Class<*>?, operation: ResourceOperation): Boolean {
140
+ val owned = values.filter { it is OwnedResource<*> || (targetClass != null && targetClass.isInstance(it)) }
141
+ owned.forEach { securityManager.requireOwnership(it, operation) }
142
+ return owned.isNotEmpty()
143
+ }
144
+ }
145
+
146
+ private fun isMutation(operation: ResourceOperation): Boolean =
147
+ operation == ResourceOperation.UPDATE || operation == ResourceOperation.DELETE
148
+
149
+ private fun isIdValue(value: Any?): Boolean = value is Number || value is String || value is java.util.UUID
150
+
151
+ private fun findIdArg(paramNames: Array<String>, args: Array<Any?>, idParam: String, targetClass: Class<*>?): Any? {
152
+ val idParamIndex = paramNames.indexOf(idParam)
153
+ return when {
154
+ idParamIndex != -1 && idParamIndex < args.size -> args[idParamIndex]
155
+ paramNames.isNotEmpty() -> null
156
+ // Fallback ONLY when parameter names are not preserved in bytecode (no -parameters flag):
157
+ args.size == 1 && isIdValue(args[0]) -> args[0]
158
+ targetClass != null -> args.firstOrNull { isIdValue(it) }
159
+ else -> null
160
+ }
161
+ }
162
+
163
+ /** Deny by default: every refusal is a 404, so callers cannot probe which resources exist. */
164
+ private fun deny(reason: String): Nothing = throw ResourceNotFoundException(reason)
165
+
166
+ private fun describesSameResource(candidate: Any, resolved: Any, idArg: Any): Boolean {
167
+ val candidateId = readProperty(candidate, "id")?.toString()
168
+ val sameId = candidateId.isNullOrEmpty() || candidateId == idArg.toString()
169
+ return sameId && listOf("ownerUserId", "tenantId").all { field ->
170
+ val claimed = readProperty(candidate, field)?.toString()
171
+ claimed.isNullOrEmpty() || claimed == readProperty(resolved, field)?.toString()
172
+ }
173
+ }
174
+
175
+ private fun readProperty(target: Any, name: String): Any? {
176
+ if (target is OwnedResource<*>) {
177
+ return when (name) {
178
+ "id" -> target.id
179
+ "ownerUserId" -> target.ownerUserId
180
+ "tenantId" -> target.tenantId
181
+ else -> null
245
182
  }
246
- // Default single value
247
- return listOf(value)
248
183
  }
184
+ val getter = "get" + name.replaceFirstChar { it.uppercase() }
185
+ return try {
186
+ target.javaClass.getMethod(getter).invoke(target)
187
+ } catch (_: Exception) {
188
+ null
189
+ }
190
+ }
191
+
192
+ private fun unwrapValue(value: Any?): List<Any> = when (value) {
193
+ null -> emptyList()
194
+ is java.util.Optional<*> -> if (value.isPresent) unwrapValue(value.get()) else emptyList()
195
+ // Iterable (e.g., List, Set)
196
+ is Iterable<*> -> value.filterNotNull().flatMap { unwrapValue(it) }
197
+ is Array<*> -> value.filterNotNull().flatMap { unwrapValue(it) }
198
+ // Map values
199
+ is Map<*, *> -> value.values.filterNotNull().flatMap { unwrapValue(it) }
200
+ // Java Stream: convert to a list first, then recursively unwrap each element.
201
+ is java.util.stream.Stream<*> -> value.filter { it != null }.toList().flatMap { unwrapValue(it) }
202
+ // Reactor Mono would need Reactor on the classpath; it is not unwrapped.
203
+ is java.util.concurrent.CompletableFuture<*> -> unwrapValue(value.get())
204
+ else -> unwrapContainer(value)
205
+ }
206
+
207
+ /** ResponseEntity body and Spring Data Page / Slice content, read by reflection; anything else is a single value. */
208
+ private fun unwrapContainer(value: Any): List<Any> {
209
+ val className = value.javaClass.name
210
+ val accessor = when {
211
+ className == "org.springframework.http.ResponseEntity" -> "getBody"
212
+ className.endsWith("Page") || className.endsWith("Slice") -> "getContent"
213
+ else -> null
214
+ }
215
+ // Any failure, including one while unwrapping the inner value, keeps the container as a single value.
216
+ val inner = accessor?.let { name ->
217
+ runCatching { unwrapValue(value.javaClass.getMethod(name).invoke(value)) }.getOrNull()
218
+ }
219
+ return inner ?: listOf(value)
249
220
  }