943c1230 by Ean Schuessler

WIP switch to internal permissions

1 parent 48c958e9
...@@ -44,9 +44,10 @@ ...@@ -44,9 +44,10 @@
44 <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="McpServices.list#Products" artifactTypeEnumId="AT_SERVICE"/> 44 <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="McpServices.list#Products" artifactTypeEnumId="AT_SERVICE"/>
45 <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.ledger.LedgerServices.find#GlAccount" artifactTypeEnumId="AT_SERVICE"/> 45 <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.ledger.LedgerServices.find#GlAccount" artifactTypeEnumId="AT_SERVICE"/>
46 <!-- Entity Services --> 46 <!-- Entity Services -->
47 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.find#Map" artifactTypeEnumId="AT_SERVICE"/> 47 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.find#Entity" artifactTypeEnumId="AT_SERVICE"/>
48 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="create#moqui.server.Visit" artifactTypeEnumId="AT_SERVICE"/> 48 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.create#Entity" artifactTypeEnumId="AT_SERVICE"/>
49 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="update#moqui.server.Visit" artifactTypeEnumId="AT_SERVICE"/> 49 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.update#Entity" artifactTypeEnumId="AT_SERVICE"/>
50 <moqui.security.ArtifactGroupMember artifactGroupId="McpServices" artifactName="org.moqui.impl.EntityServices.delete#Entity" artifactTypeEnumId="AT_SERVICE"/>
50 51
51 <!-- Essential Business Entities --> 52 <!-- Essential Business Entities -->
52 <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.order.OrderHeader" artifactTypeEnumId="AT_ENTITY"/> 53 <moqui.security.ArtifactGroupMember artifactGroupId="McpBusinessServices" artifactName="mantle.order.OrderHeader" artifactTypeEnumId="AT_ENTITY"/>
...@@ -91,9 +92,4 @@ ...@@ -91,9 +92,4 @@
91 <!-- Add existing demo users to MCP business group for focused testing --> 92 <!-- Add existing demo users to MCP business group for focused testing -->
92 <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_JD" fromDate="2025-01-01 00:00:00.000"/> 93 <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_JD" fromDate="2025-01-01 00:00:00.000"/>
93 <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_BD" fromDate="2025-01-01 00:00:00.000"/> 94 <moqui.security.UserGroupMember userGroupId="MCP_BUSINESS" userId="ORG_ZIZI_BD" fromDate="2025-01-01 00:00:00.000"/>
94
95 <!-- Keep ADMIN access for system operations -->
96 <moqui.security.UserGroupMember userGroupId="ADMIN" userId="MCP_USER" fromDate="2025-01-01 00:00:00.000"/>
97 <moqui.security.UserGroupMember userGroupId="ADMIN" userId="MCP_BUSINESS" fromDate="2025-01-01 00:00:00.000"/>
98
99 </entity-facade-xml> 95 </entity-facade-xml>
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
16 <!-- MCP Services using Moqui's built-in JSON-RPC support --> 16 <!-- MCP Services using Moqui's built-in JSON-RPC support -->
17 17
18 18
19 <service verb="mcp" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30" authz-require="false"> 19 <service verb="mcp" noun="Initialize" authenticate="true" allow-remote="true" transaction-timeout="30">
20 <description>Handle MCP initialize request using Moqui authentication</description> 20 <description>Handle MCP initialize request using Moqui authentication</description>
21 <in-parameters> 21 <in-parameters>
22 <parameter name="sessionId" required="false"/> 22 <parameter name="sessionId" required="false"/>
...@@ -30,15 +30,17 @@ ...@@ -30,15 +30,17 @@
30 <actions> 30 <actions>
31 <script><![CDATA[ 31 <script><![CDATA[
32 import org.moqui.context.ExecutionContext 32 import org.moqui.context.ExecutionContext
33 import org.moqui.impl.context.UserFacadeImpl.UserInfo
33 34
34 ExecutionContext ec = context.ec 35 ExecutionContext ec = context.ec
35 36
37 // Permissions are handled by Moqui's artifact authorization system
38 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
39
36 // Get Visit (session) and validate access 40 // Get Visit (session) and validate access
37 def visit 41 def visit
38 if (sessionId) { 42 if (sessionId) {
39 // Existing session - run as ADMIN to access Visit entity 43 // Existing session - user can access their own visits
40 ec.artifactExecution.disableAuthz()
41 try {
42 visit = ec.entity.find("moqui.server.Visit") 44 visit = ec.entity.find("moqui.server.Visit")
43 .condition("visitId", sessionId) 45 .condition("visitId", sessionId)
44 .one() 46 .one()
...@@ -50,32 +52,26 @@ ...@@ -50,32 +52,26 @@
50 if (visit.userId != ec.user.userId) { 52 if (visit.userId != ec.user.userId) {
51 throw new Exception("Access denied for session: ${sessionId}") 53 throw new Exception("Access denied for session: ${sessionId}")
52 } 54 }
53 } finally {
54 ec.artifactExecution.enableAuthz()
55 }
56 } else { 55 } else {
57 // New session - create or get current Visit 56 // New session - create or get current Visit
58 if (ec.user.visitId) { 57 if (ec.user.visitId) {
59 ec.artifactExecution.disableAuthz()
60 try {
61 visit = ec.entity.find("moqui.server.Visit") 58 visit = ec.entity.find("moqui.server.Visit")
62 .condition("visitId", ec.user.visitId) 59 .condition("visitId", ec.user.visitId)
63 .one() 60 .one()
64 } finally {
65 ec.artifactExecution.enableAuthz()
66 }
67 } 61 }
68 62
69 if (!visit) { 63 if (!visit) {
70 // Create a new Visit for this MCP session - run as ADMIN 64 // Create a new Visit for this MCP session for the actual authenticated user
71 // but set userId to the actual authenticated user passed from servlet
72 String actualUserId = parameters.actualUserId ?: ec.user.userId 65 String actualUserId = parameters.actualUserId ?: ec.user.userId
73 logger.info("Creating Visit - actualUserId: ${actualUserId}") 66 logger.info("Creating Visit - actualUserId: ${actualUserId}")
74 ec.artifactExecution.disableAuthz() 67
68 // Use pushUser for admin-level Visit creation if needed
69 UserInfo adminUserInfo = null
75 try { 70 try {
71 adminUserInfo = ec.user.pushUser("ADMIN")
76 visit = ec.entity.makeValue("moqui.server.Visit") 72 visit = ec.entity.makeValue("moqui.server.Visit")
77 visit.visitId = ec.entity.sequencedIdPrimaryEd(ec.entity.getEntityDefinition("moqui.server.Visit")) 73 visit.visitId = ec.entity.sequencedIdPrimaryEd(ec.entity.getEntityDefinition("moqui.server.Visit"))
78 visit.userId = "ADMIN" // Use ADMIN for privileged MCP access pattern 74 visit.userId = actualUserId // Use actual user, not ADMIN
79 visit.visitorId = null 75 visit.visitorId = null
80 visit.webappName = "mcp" 76 visit.webappName = "mcp"
81 visit.initialRequest = groovy.json.JsonOutput.toJson([mcpCreated: true, createdFor: "mcp-session"]) 77 visit.initialRequest = groovy.json.JsonOutput.toJson([mcpCreated: true, createdFor: "mcp-session"])
...@@ -85,14 +81,17 @@ ...@@ -85,14 +81,17 @@
85 visit.sessionId = null // No HTTP session for direct API calls 81 visit.sessionId = null // No HTTP session for direct API calls
86 visit.create() 82 visit.create()
87 } finally { 83 } finally {
88 ec.artifactExecution.enableAuthz() 84 if (adminUserInfo != null) {
85 ec.user.popUser()
86 }
89 } 87 }
90 } 88 }
91 } 89 }
92 90
93 // Update Visit with MCP initialization data - run as ADMIN 91 // Update Visit with MCP initialization data
94 ec.artifactExecution.disableAuthz() 92 UserInfo adminUserInfo = null
95 try { 93 try {
94 adminUserInfo = ec.user.pushUser("ADMIN")
96 def metadata = [:] 95 def metadata = [:]
97 try { 96 try {
98 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map 97 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
...@@ -109,7 +108,9 @@ ...@@ -109,7 +108,9 @@
109 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) 108 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
110 visit.update() 109 visit.update()
111 } finally { 110 } finally {
112 ec.artifactExecution.enableAuthz() 111 if (adminUserInfo != null) {
112 ec.user.popUser()
113 }
113 } 114 }
114 115
115 // Validate protocol version - support common MCP versions 116 // Validate protocol version - support common MCP versions
...@@ -152,7 +153,7 @@ ...@@ -152,7 +153,7 @@
152 </actions> 153 </actions>
153 </service> 154 </service>
154 155
155 <service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60" authz-require="false"> 156 <service verb="mcp" noun="ToolsList" authenticate="true" allow-remote="true" transaction-timeout="60">
156 <description>Handle MCP tools/list request with admin discovery but user permission filtering</description> 157 <description>Handle MCP tools/list request with admin discovery but user permission filtering</description>
157 <in-parameters> 158 <in-parameters>
158 <parameter name="sessionId"/> 159 <parameter name="sessionId"/>
...@@ -168,40 +169,26 @@ ...@@ -168,40 +169,26 @@
168 169
169 ExecutionContext ec = context.ec 170 ExecutionContext ec = context.ec
170 171
171 // Store original user context before switching to admin for discovery 172 // Permissions are handled by Moqui's artifact authorization system
172 def originalUserId = ec.user.userId 173 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
173 def originalUsername = ec.user.username 174
175 // Permissions are handled by Moqui's artifact authorization system
176 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
174 177
175 // Validate session if provided (run as original user for security) 178 // Validate session if provided
176 if (sessionId) { 179 if (sessionId) {
177 def visit = null 180 def visit = ec.entity.find("moqui.server.Visit")
178 // Temporarily disable authz to access Visit entity for session validation
179 ec.artifactExecution.disableAuthz()
180 try {
181 visit = ec.entity.find("moqui.server.Visit")
182 .condition("visitId", sessionId) 181 .condition("visitId", sessionId)
183 .one() 182 .one()
184 } finally {
185 ec.artifactExecution.enableAuthz()
186 }
187 183
188 // Validate session - allow special MCP case where Visit was created with ADMIN but accessed by MCP_USER 184 if (!visit || visit.userId != ec.user.userId) {
189 boolean sessionValid = false
190 if (visit) {
191 if (visit.userId == originalUserId) {
192 sessionValid = true
193 } else if (visit.userId == "ADMIN" && (originalUserId == "MCP_USER" || originalUserId == "MCP_BUSINESS")) {
194 // Special case: MCP services run with ADMIN privileges but authenticate as MCP_USER or MCP_BUSINESS
195 sessionValid = true
196 ec.logger.info("Allowing MCP service access: Visit created with ADMIN, accessed by ${originalUserId}")
197 }
198 }
199
200 if (!sessionValid) {
201 throw new Exception("Invalid session: ${sessionId}") 185 throw new Exception("Invalid session: ${sessionId}")
202 } 186 }
187 }
203 188
189 /*
204 // Update session activity 190 // Update session activity
191 if (sessionId && visit) {
205 def metadata = [:] 192 def metadata = [:]
206 try { 193 try {
207 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map 194 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
...@@ -212,23 +199,75 @@ ...@@ -212,23 +199,75 @@
212 metadata.mcpLastActivity = System.currentTimeMillis() 199 metadata.mcpLastActivity = System.currentTimeMillis()
213 metadata.mcpLastOperation = "tools/list" 200 metadata.mcpLastOperation = "tools/list"
214 201
215 // Update Visit with authz disabled 202 // Update Visit - need admin context for Visit updates
216 ec.artifactExecution.disableAuthz() 203 adminUserInfo = null
217 try { 204 try {
205 adminUserInfo = ec.user.pushUser("ADMIN")
218 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) 206 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
219 visit.update() 207 visit.update()
220 } finally { 208 } finally {
221 ec.artifactExecution.enableAuthz() 209 if (adminUserInfo != null) {
210 ec.user.popUser()
211 }
222 } 212 }
223 } 213 }
214 */
215
216 // Store original user context before switching to ADMIN
217 def originalUsername = ec.user.username
218 def originalUserId = ec.user.userId
219 def userGroups = ec.user.getUserGroupIdSet().collect { it }
220
221 // Get user's accessible services in a single query for efficiency
222 def userAccessibleServices = null as Set<String>
223 adminUserInfo = null
224 try {
225 adminUserInfo = ec.user.pushUser("ADMIN")
226 def artifactGroupMembers = ec.entity.find("moqui.security.ArtifactGroupMember")
227 .condition("artifactTypeEnumId", "AT_SERVICE")
228 .condition("userGroupId", userGroups)
229 .selectField("artifactName")
230 .distinct(true)
231 .list()
232 userAccessibleServices = artifactGroupMembers.collect { it.artifactName } as Set<String>
233 } finally {
234 if (adminUserInfo != null) {
235 ec.user.popUser()
236 }
237 }
238
239 // Helper function to check if user has permission to a service
240 def userHasPermission = { serviceName ->
241 // Use pre-computed accessible services set for O(1) lookup
242 return userAccessibleServices != null && userAccessibleServices.contains(serviceName.toString())
243 }
224 244
225 // Switch to admin context for service discovery (to access all service definitions) 245 // Switch to admin context for service discovery (to access all service definitions)
226 ec.user.internalLoginUser("admin") 246 adminUserInfo = ec.user.pushUser("ADMIN")
227 247
228 try { 248 try {
229 def availableTools = [] 249 def availableTools = []
250
251 // Get only services user has access to via artifact groups
252 def accessibleServiceNames = []
253 for (serviceName in userAccessibleServices) {
254 // Handle wildcard patterns like "McpServices.*"
255 if (serviceName.contains("*")) {
256 def pattern = serviceName.replace("*", ".*")
230 def allServiceNames = ec.service.getKnownServiceNames() 257 def allServiceNames = ec.service.getKnownServiceNames()
231 ec.logger.info("MCP ToolsList: Admin discovered ${allServiceNames.size()} services, filtering for user ${originalUsername} (${originalUserId})${sessionId ? ' (session: ' + sessionId + ')' : ''}") 258 def matchingServices = allServiceNames.findAll { it.matches(pattern) }
259 // Only add services that actually exist
260 accessibleServiceNames.addAll(matchingServices.findAll { ec.service.isServiceDefined(it) })
261 } else {
262 // Only add if service actually exists
263 if (ec.service.isServiceDefined(serviceName)) {
264 accessibleServiceNames << serviceName
265 }
266 }
267 }
268 accessibleServiceNames = accessibleServiceNames.unique()
269
270 ec.logger.info("MCP ToolsList: Found ${accessibleServiceNames.size()} accessible services for user ${originalUsername} (${originalUserId})${sessionId ? ' (session: ' + sessionId + ')' : ''}")
232 271
233 // Helper function to convert service to MCP tool 272 // Helper function to convert service to MCP tool
234 def convertServiceToTool = { serviceName -> 273 def convertServiceToTool = { serviceName ->
...@@ -305,60 +344,8 @@ ...@@ -305,60 +344,8 @@
305 } 344 }
306 } 345 }
307 346
308 // Get user's accessible services in a single query for efficiency 347 // Add all accessible services as tools
309 def userAccessibleServices = null as Set<String> 348 for (serviceName in accessibleServiceNames) {
310 if (originalUsername != "mcp-user" && originalUsername != "mcp-business") {
311 // Query ArtifactGroupMembers directly to get all services user can access
312 ec.artifactExecution.disableAuthz()
313 try {
314 def artifactGroupMembers = ec.entity.find("moqui.security.ArtifactGroupMember")
315 .condition("artifactTypeEnumId", "AT_SERVICE")
316 .condition("userGroupId", ec.user.getUserGroups().collect { it.userGroupId })
317 .selectFields("artifactName")
318 .distinct()
319 .list()
320 userAccessibleServices = artifactGroupMembers.collect { it.artifactName } as Set<String>
321 } finally {
322 ec.artifactExecution.enableAuthz()
323 }
324 }
325
326 // Helper function to check if original user has permission to a service
327 def userHasPermission = { serviceName ->
328 // Grant all permissions to mcp-user and mcp-business for business toolkit
329 if (originalUsername == "mcp-user" || originalUsername == "mcp-business") {
330 return true
331 }
332
333 // Use pre-computed accessible services set for O(1) lookup
334 return userAccessibleServices != null && userAccessibleServices.contains(serviceName.toString())
335 }
336
337 // Add specific MCP services that should be exposed as tools
338 def mcpToolServices = ["McpServices.mcp#Ping"]
339 for (serviceName in mcpToolServices) {
340 boolean hasPermission = userHasPermission(serviceName)
341 ec.logger.info("MCP ToolsList: MCP service ${serviceName} userHasPermission=${hasPermission}")
342 if (!hasPermission) {
343 continue
344 }
345
346 def tool = convertServiceToTool(serviceName)
347 if (tool) {
348 availableTools << tool
349 }
350 }
351
352 // Now add all other services the user has permission to access
353 for (serviceName in allServiceNames) {
354 // Permissions system already controls access, no need for artificial exclusions
355
356 // Check permission using original user context
357 boolean hasPermission = userHasPermission(serviceName)
358 if (!hasPermission) {
359 continue
360 }
361
362 def tool = convertServiceToTool(serviceName) 349 def tool = convertServiceToTool(serviceName)
363 if (tool) { 350 if (tool) {
364 availableTools << tool 351 availableTools << tool
...@@ -394,13 +381,15 @@ ...@@ -394,13 +381,15 @@
394 381
395 } finally { 382 } finally {
396 // Always restore original user context 383 // Always restore original user context
397 ec.user.internalLoginUser(originalUsername) 384 if (adminUserInfo != null) {
385 ec.user.popUser()
386 }
398 } 387 }
399 ]]></script> 388 ]]></script>
400 </actions> 389 </actions>
401 </service> 390 </service>
402 391
403 <service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300" authz-require="false"> 392 <service verb="mcp" noun="ToolsCall" authenticate="true" allow-remote="true" transaction-timeout="300">
404 <description>Handle MCP tools/call request with direct Moqui service execution</description> 393 <description>Handle MCP tools/call request with direct Moqui service execution</description>
405 <in-parameters> 394 <in-parameters>
406 <parameter name="name" required="true"/> 395 <parameter name="name" required="true"/>
...@@ -427,13 +416,16 @@ ...@@ -427,13 +416,16 @@
427 // Validate session if provided 416 // Validate session if provided
428 if (sessionId) { 417 if (sessionId) {
429 def visit = null 418 def visit = null
430 ec.artifactExecution.disableAuthz() 419 UserInfo adminUserInfo = null
431 try { 420 try {
421 adminUserInfo = ec.user.pushUser("ADMIN")
432 visit = ec.entity.find("moqui.server.Visit") 422 visit = ec.entity.find("moqui.server.Visit")
433 .condition("visitId", sessionId) 423 .condition("visitId", sessionId)
434 .one() 424 .one()
435 } finally { 425 } finally {
436 ec.artifactExecution.enableAuthz() 426 if (adminUserInfo != null) {
427 ec.user.popUser()
428 }
437 } 429 }
438 430
439 // Validate session - allow special MCP case where Visit was created with ADMIN but accessed by MCP_USER or MCP_BUSINESS 431 // Validate session - allow special MCP case where Visit was created with ADMIN but accessed by MCP_USER or MCP_BUSINESS
...@@ -452,19 +444,24 @@ ...@@ -452,19 +444,24 @@
452 } 444 }
453 } 445 }
454 446
455 // Note: Permission checking handled by elevated execution pattern 447 // Check permission using current user context (not elevated)
456 // MCP services run with ADMIN privileges but audit as MCP_USER 448 if (!ec.user.hasPermission("service:${name}".toString())) {
449 throw new Exception("Permission denied for service: ${name}")
450 }
457 451
458 def startTime = System.currentTimeMillis() 452 def startTime = System.currentTimeMillis()
459 try { 453 try {
460 // Execute service with elevated privileges for system access 454 // Execute service with elevated privileges for system access
461 // but maintain audit context with actual user (MCP_USER) 455 // but maintain audit context with actual user
462 def serviceResult 456 def serviceResult
463 ec.artifactExecution.disableAuthz() 457 UserInfo adminUserInfo = null
464 try { 458 try {
459 adminUserInfo = ec.user.pushUser("ADMIN")
465 serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call() 460 serviceResult = ec.service.sync().name(name).parameters(arguments ?: [:]).call()
466 } finally { 461 } finally {
467 ec.artifactExecution.enableAuthz() 462 if (adminUserInfo != null) {
463 ec.user.popUser()
464 }
468 } 465 }
469 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 466 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
470 467
...@@ -497,13 +494,15 @@ ...@@ -497,13 +494,15 @@
497 ec.logger.error("MCP tool execution error", e) 494 ec.logger.error("MCP tool execution error", e)
498 } finally { 495 } finally {
499 // Always restore original user context 496 // Always restore original user context
500 ec.user.internalLoginUser(originalUsername) 497 if (adminUserInfo != null) {
498 ec.user.popUser()
499 }
501 } 500 }
502 ]]></script> 501 ]]></script>
503 </actions> 502 </actions>
504 </service> 503 </service>
505 504
506 <service verb="mcp" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60" authz-require="false"> 505 <service verb="mcp" noun="ResourcesList" authenticate="true" allow-remote="true" transaction-timeout="60">
507 <description>Handle MCP resources/list request with Moqui entity discovery</description> 506 <description>Handle MCP resources/list request with Moqui entity discovery</description>
508 <in-parameters> 507 <in-parameters>
509 <parameter name="sessionId"/> 508 <parameter name="sessionId"/>
...@@ -515,34 +514,29 @@ ...@@ -515,34 +514,29 @@
515 <actions> 514 <actions>
516 <script><![CDATA[ 515 <script><![CDATA[
517 import org.moqui.context.ExecutionContext 516 import org.moqui.context.ExecutionContext
517 import org.moqui.impl.context.UserFacadeImpl.UserInfo
518 518
519 ExecutionContext ec = context.ec 519 ExecutionContext ec = context.ec
520 520
521 // Permissions are handled by Moqui's artifact authorization system
522 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
523
521 // Validate session if provided 524 // Validate session if provided
522 if (sessionId) { 525 if (sessionId) {
523 def visit = null 526 def visit = ec.entity.find("moqui.server.Visit")
524 ec.artifactExecution.disableAuthz()
525 try {
526 visit = ec.entity.find("moqui.server.Visit")
527 .condition("visitId", sessionId) 527 .condition("visitId", sessionId)
528 .one() 528 .one()
529 } finally {
530 ec.artifactExecution.enableAuthz()
531 }
532 529
533 // Validate session - allow special MCP case where Visit was created with ADMIN but accessed by MCP_USER or MCP_BUSINESS 530 if (!visit || visit.userId != ec.user.userId) {
534 boolean sessionValid = false 531 throw new Exception("Invalid session: ${sessionId}")
535 if (visit) {
536 if (visit.userId == ec.user.userId) {
537 sessionValid = true
538 } else if (visit.userId == "ADMIN" && (ec.user.userId == "MCP_USER" || ec.user.userId == "MCP_BUSINESS")) {
539 // Special case: MCP services run with ADMIN privileges but authenticate as MCP_USER or MCP_BUSINESS
540 sessionValid = true
541 ec.logger.info("Allowing MCP service access: Visit created with ADMIN, accessed by ${ec.user.userId}")
542 } 532 }
543 } 533 }
544 534
545 if (!sessionValid) { 535 // Build list of available entities as resources
536 def resources = []
537
538 UserInfo adminUserInfo = null
539 try {
546 throw new Exception("Invalid session: ${sessionId}") 540 throw new Exception("Invalid session: ${sessionId}")
547 } 541 }
548 542
...@@ -557,17 +551,18 @@ ...@@ -557,17 +551,18 @@
557 metadata.mcpLastActivity = System.currentTimeMillis() 551 metadata.mcpLastActivity = System.currentTimeMillis()
558 metadata.mcpLastOperation = "resources/list" 552 metadata.mcpLastOperation = "resources/list"
559 553
560 ec.artifactExecution.disableAuthz() 554 // Update Visit - need admin context for Visit updates
555 UserInfo adminUserInfo = null
561 try { 556 try {
557 adminUserInfo = ec.user.pushUser("ADMIN")
562 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) 558 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
563 visit.update() 559 visit.update()
564 } finally { 560 } finally {
565 ec.artifactExecution.enableAuthz() 561 if (adminUserInfo != null) {
562 ec.user.popUser()
563 }
566 } 564 }
567 } 565 }
568
569 // Store original username for permission checks
570 def originalUsername = ec.user.username
571 566
572 // Use curated list of commonly used entities instead of discovering all entities 567 // Use curated list of commonly used entities instead of discovering all entities
573 def availableResources = [] 568 def availableResources = []
...@@ -577,32 +572,30 @@ ...@@ -577,32 +572,30 @@
577 // Get all entity names and filter by permissions (no hardcoded list) 572 // Get all entity names and filter by permissions (no hardcoded list)
578 def allEntityNames = ec.entity.getAllEntityNames() 573 def allEntityNames = ec.entity.getAllEntityNames()
579 574
575 // Store original username for permission checks
576 def originalUsername = ec.user.username
577
580 // Get user's accessible entities in a single query for efficiency 578 // Get user's accessible entities in a single query for efficiency
581 def userAccessibleEntities = null as Set<String> 579 def userAccessibleEntities = null as Set<String>
582 if (originalUsername != "mcp-user" && originalUsername != "mcp-business") {
583 // Query ArtifactGroupMembers directly to get all entities user can access 580 // Query ArtifactGroupMembers directly to get all entities user can access
584 ec.artifactExecution.disableAuthz() 581 UserInfo adminUserInfo = null
585 try { 582 try {
583 adminUserInfo = ec.user.pushUser("ADMIN")
586 def artifactGroupMembers = ec.entity.find("moqui.security.ArtifactGroupMember") 584 def artifactGroupMembers = ec.entity.find("moqui.security.ArtifactGroupMember")
587 .condition("artifactTypeEnumId", "AT_ENTITY") 585 .condition("artifactTypeEnumId", "AT_ENTITY")
588 .condition("userGroupId", ec.user.getUserGroups().collect { it.userGroupId }) 586 .condition("userGroupId", ec.user.getUserGroupsIdSet().collect { it.userGroupId })
589 .selectFields("artifactName") 587 .selectFields("artifactName")
590 .distinct() 588 .distinct(true)
591 .list() 589 .list()
592 userAccessibleEntities = artifactGroupMembers.collect { it.artifactName } as Set<String> 590 userAccessibleEntities = artifactGroupMembers.collect { it.artifactName } as Set<String>
593 } finally { 591 } finally {
594 ec.artifactExecution.enableAuthz() 592 if (adminUserInfo != null) {
593 ec.user.popUser()
595 } 594 }
596 } 595 }
597 596
598 // Helper function to check if original user has permission to an entity 597 // Helper function to check if user has permission to an entity
599 def userHasEntityPermission = { entityName -> 598 def userHasEntityPermission = { entityName ->
600 // For MCP users, trust Moqui's artifact security system
601 // The MCP_BUSINESS group has proper entity permissions through McpBusinessServices artifact group
602 if (originalUsername == "mcp-user" || originalUsername == "mcp-business") {
603 return true
604 }
605
606 // Use pre-computed accessible entities set for O(1) lookup 599 // Use pre-computed accessible entities set for O(1) lookup
607 return userAccessibleEntities != null && userAccessibleEntities.contains(entityName.toString()) 600 return userAccessibleEntities != null && userAccessibleEntities.contains(entityName.toString())
608 } 601 }
...@@ -625,7 +618,7 @@ ...@@ -625,7 +618,7 @@
625 </actions> 618 </actions>
626 </service> 619 </service>
627 620
628 <service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120" authz-require="false"> 621 <service verb="mcp" noun="ResourcesRead" authenticate="true" allow-remote="true" transaction-timeout="120">
629 <description>Handle MCP resources/read request with Moqui entity queries</description> 622 <description>Handle MCP resources/read request with Moqui entity queries</description>
630 <in-parameters> 623 <in-parameters>
631 <parameter name="sessionId"/> 624 <parameter name="sessionId"/>
...@@ -644,13 +637,16 @@ ...@@ -644,13 +637,16 @@
644 // Validate session if provided 637 // Validate session if provided
645 if (sessionId) { 638 if (sessionId) {
646 def visit = null 639 def visit = null
647 ec.artifactExecution.disableAuthz() 640 UserInfo adminUserInfo = null
648 try { 641 try {
642 adminUserInfo = ec.user.pushUser("ADMIN")
649 visit = ec.entity.find("moqui.server.Visit") 643 visit = ec.entity.find("moqui.server.Visit")
650 .condition("visitId", sessionId) 644 .condition("visitId", sessionId)
651 .one() 645 .one()
652 } finally { 646 } finally {
653 ec.artifactExecution.enableAuthz() 647 if (adminUserInfo != null) {
648 ec.user.popUser()
649 }
654 } 650 }
655 651
656 if (!visit || visit.userId != ec.user.userId) { 652 if (!visit || visit.userId != ec.user.userId) {
...@@ -669,12 +665,15 @@ ...@@ -669,12 +665,15 @@
669 metadata.mcpLastOperation = "resources/read" 665 metadata.mcpLastOperation = "resources/read"
670 metadata.mcpLastResource = uri 666 metadata.mcpLastResource = uri
671 667
672 ec.artifactExecution.disableAuthz() 668 UserInfo adminUserInfo = null
673 try { 669 try {
670 adminUserInfo = ec.user.pushUser("ADMIN")
674 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) 671 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
675 visit.update() 672 visit.update()
676 } finally { 673 } finally {
677 ec.artifactExecution.enableAuthz() 674 if (adminUserInfo != null) {
675 ec.user.popUser()
676 }
678 } 677 }
679 } 678 }
680 679
...@@ -690,10 +689,7 @@ ...@@ -690,10 +689,7 @@
690 throw new Exception("Entity not found: ${entityName}") 689 throw new Exception("Entity not found: ${entityName}")
691 } 690 }
692 691
693 // Check permission 692 // Permission checking is handled by Moqui's artifact authorization system through artifact groups
694 if (false && ec.user.username != "mcp-user" && !ec.user.hasPermission("entity:${entityName}".toString())) {
695 throw new Exception("Permission denied for entity: ${entityName}")
696 }
697 693
698 def startTime = System.currentTimeMillis() 694 def startTime = System.currentTimeMillis()
699 try { 695 try {
...@@ -749,7 +745,7 @@ ...@@ -749,7 +745,7 @@
749 </actions> 745 </actions>
750 </service> 746 </service>
751 747
752 <service verb="mcp" noun="Ping" authenticate="true" allow-remote="true" transaction-timeout="10" authz-require="false"> 748 <service verb="mcp" noun="Ping" authenticate="true" allow-remote="true" transaction-timeout="10">
753 <description>Handle MCP ping request for health check</description> 749 <description>Handle MCP ping request for health check</description>
754 <in-parameters> 750 <in-parameters>
755 <parameter name="sessionId"/> 751 <parameter name="sessionId"/>
...@@ -759,16 +755,41 @@ ...@@ -759,16 +755,41 @@
759 </out-parameters> 755 </out-parameters>
760 <actions> 756 <actions>
761 <script><![CDATA[ 757 <script><![CDATA[
758 // Permissions are handled by Moqui's artifact authorization system
759 // Users must be in appropriate groups (McpUser, MCP_BUSINESS) with access to McpServices artifact group
760
762 // Validate session if provided 761 // Validate session if provided
763 if (sessionId) { 762 if (sessionId) {
764 def visit = null 763 def visit = ec.entity.find("moqui.server.Visit")
765 ec.artifactExecution.disableAuthz()
766 try {
767 visit = ec.entity.find("moqui.server.Visit")
768 .condition("visitId", sessionId) 764 .condition("visitId", sessionId)
769 .one() 765 .one()
766
767 if (!visit || visit.userId != ec.user.userId) {
768 throw new Exception("Invalid session: ${sessionId}")
769 }
770
771 // Update session activity
772 def metadata = [:]
773 try {
774 metadata = groovy.json.JsonSlurper().parseText(visit.initialRequest ?: "{}") as Map
775 } catch (Exception e) {
776 ec.logger.debug("Failed to parse Visit metadata: ${e.message}")
777 }
778
779 metadata.mcpLastActivity = System.currentTimeMillis()
780 metadata.mcpLastOperation = "ping"
781
782 // Update Visit - need admin context for Visit updates
783 UserInfo adminUserInfo = null
784 try {
785 adminUserInfo = ec.user.pushUser("ADMIN")
786 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
787 visit.update()
770 } finally { 788 } finally {
771 ec.artifactExecution.enableAuthz() 789 if (adminUserInfo != null) {
790 ec.user.popUser()
791 }
792 }
772 } 793 }
773 794
774 if (!visit || visit.userId != ec.user.userId) { 795 if (!visit || visit.userId != ec.user.userId) {
...@@ -786,12 +807,15 @@ ...@@ -786,12 +807,15 @@
786 metadata.mcpLastActivity = System.currentTimeMillis() 807 metadata.mcpLastActivity = System.currentTimeMillis()
787 metadata.mcpLastOperation = "ping" 808 metadata.mcpLastOperation = "ping"
788 809
789 ec.artifactExecution.disableAuthz() 810 UserInfo adminUserInfo = null
790 try { 811 try {
812 adminUserInfo = ec.user.pushUser("ADMIN")
791 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata) 813 visit.initialRequest = groovy.json.JsonOutput.toJson(metadata)
792 visit.update() 814 visit.update()
793 } finally { 815 } finally {
794 ec.artifactExecution.enableAuthz() 816 if (adminUserInfo != null) {
817 ec.user.popUser()
818 }
795 } 819 }
796 } 820 }
797 821
...@@ -817,6 +841,7 @@ ...@@ -817,6 +841,7 @@
817 <actions> 841 <actions>
818 <script><![CDATA[ 842 <script><![CDATA[
819 import org.moqui.context.ExecutionContext 843 import org.moqui.context.ExecutionContext
844 import org.moqui.impl.context.UserFacadeImpl.UserInfo
820 845
821 ExecutionContext ec = context.ec 846 ExecutionContext ec = context.ec
822 847
...@@ -854,6 +879,7 @@ ...@@ -854,6 +879,7 @@
854 <actions> 879 <actions>
855 <script><![CDATA[ 880 <script><![CDATA[
856 import org.moqui.context.ExecutionContext 881 import org.moqui.context.ExecutionContext
882 import org.moqui.impl.context.UserFacadeImpl.UserInfo
857 883
858 ExecutionContext ec = context.ec 884 ExecutionContext ec = context.ec
859 885
...@@ -946,6 +972,7 @@ ...@@ -946,6 +972,7 @@
946 <actions> 972 <actions>
947 <script><![CDATA[ 973 <script><![CDATA[
948 import org.moqui.context.ExecutionContext 974 import org.moqui.context.ExecutionContext
975 import org.moqui.impl.context.UserFacadeImpl.UserInfo
949 976
950 ExecutionContext ec = context.ec 977 ExecutionContext ec = context.ec
951 978
......
...@@ -416,20 +416,10 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -416,20 +416,10 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
416 return 416 return
417 } 417 }
418 418
419 // Verify user has access to this Visit - more permissive for testing 419 // Verify user has access to this Visit - rely on Moqui security
420 logger.info("Session validation: visit.userId=${visit.userId}, ec.user.userId=${ec.user.userId}, ec.user.username=${ec.user.username}") 420 logger.info("Session validation: visit.userId=${visit.userId}, ec.user.userId=${ec.user.userId}, ec.user.username=${ec.user.username}")
421 logger.info("DEBUG2: visit.userId exists=${visit.userId != null}, ec.user.userId exists=${ec.user.userId != null}, notEqual=${visit.userId?.toString() != ec.user.userId?.toString()}")
422 if (visit.userId && ec.user.userId && visit.userId.toString() != ec.user.userId.toString()) { 421 if (visit.userId && ec.user.userId && visit.userId.toString() != ec.user.userId.toString()) {
423 logger.warn("Visit userId ${visit.userId} doesn't match current user userId ${ec.user.userId}") 422 logger.warn("Visit userId ${visit.userId} doesn't match current user userId ${ec.user.userId} - access denied")
424
425 // Special case: MCP services run with ADMIN privileges but authenticate as MCP_USER or MCP_BUSINESS
426 boolean specialMcpCase = visit.userId == "ADMIN" && (ec.user.userId == "MCP_USER" || ec.user.userId == "MCP_BUSINESS")
427 logger.info("DEBUG: visit.userId='${visit.userId}' (class: ${visit.userId?.class?.name}), ec.user.userId='${ec.user.userId}' (class: ${ec.user.userId?.class?.name}), specialMcpCase=${specialMcpCase}")
428 if (specialMcpCase) {
429 logger.info("Allowing MCP service access: Visit created with ADMIN, accessed by ${ec.user.userId}")
430 } else if (visit.userCreated == "Y" && ec.user.username) {
431 logger.info("Allowing access for user ${ec.user.username} to Visit ${sessionId}")
432 } else {
433 response.setContentType("application/json") 423 response.setContentType("application/json")
434 response.setCharacterEncoding("UTF-8") 424 response.setCharacterEncoding("UTF-8")
435 response.setStatus(HttpServletResponse.SC_FORBIDDEN) 425 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
...@@ -439,7 +429,6 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -439,7 +429,6 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
439 ])) 429 ]))
440 return 430 return
441 } 431 }
442 }
443 432
444 // Create session wrapper for this Visit 433 // Create session wrapper for this Visit
445 VisitBasedMcpSession session = new VisitBasedMcpSession(visit, response.writer, ec) 434 VisitBasedMcpSession session = new VisitBasedMcpSession(visit, response.writer, ec)
...@@ -689,18 +678,8 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}") ...@@ -689,18 +678,8 @@ logger.info("Handling Enhanced SSE connection from ${request.remoteAddr}")
689 // Allow access if: 678 // Allow access if:
690 // 1. Visit userId matches current user, OR 679 // 1. Visit userId matches current user, OR
691 // 2. Visit was created with ADMIN (for privileged access) but current user is MCP_USER (actual authenticated user) 680 // 2. Visit was created with ADMIN (for privileged access) but current user is MCP_USER (actual authenticated user)
692 boolean accessAllowed = false 681 // Rely on Moqui security - only allow access if visit and current user match
693 if (visit.userId && ec.user.userId) { 682 if (!visit.userId || !ec.user.userId || visit.userId.toString() != ec.user.userId.toString()) {
694 if (visit.userId.toString() == ec.user.userId.toString()) {
695 accessAllowed = true
696 } else if (visit.userId.toString() == "ADMIN" && (ec.user.userId.toString() == "MCP_USER" || ec.user.userId.toString() == "MCP_BUSINESS")) {
697 // Special case: MCP services run with ADMIN privileges but authenticate as MCP_USER or MCP_BUSINESS
698 accessAllowed = true
699 logger.info("Allowing MCP privileged access: Visit created with ADMIN, accessed by ${ec.user.userId}")
700 }
701 }
702
703 if (!accessAllowed) {
704 response.setStatus(HttpServletResponse.SC_FORBIDDEN) 683 response.setStatus(HttpServletResponse.SC_FORBIDDEN)
705 response.setContentType("application/json") 684 response.setContentType("application/json")
706 response.writer.write(groovy.json.JsonOutput.toJson([ 685 response.writer.write(groovy.json.JsonOutput.toJson([
......
1 <?xml version="1.0" encoding="UTF-8"?>
2 <!--
3 This software is in the public domain under CC0 1.0 Universal plus a
4 Grant of Patent License.
5
6 To the extent possible under law, author(s) have dedicated all
7 copyright and related and neighboring rights to this software to the
8 public domain worldwide. This software is distributed without any
9 warranty.
10
11 You should have received a copy of the CC0 Public Domain Dedication
12 along with this software (see the LICENSE.md file). If not, see
13 <http://creativecommons.org/publicdomain/zero/1.0/>.
14 -->
15
16 <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
17 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
18 xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
19 http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
20 version="4.0">
21
22 <!-- Service-Based MCP Servlet Configuration -->
23 <servlet>
24 <servlet-name>EnhancedMcpServlet</servlet-name>
25 <servlet-class>org.moqui.mcp.EnhancedMcpServlet</servlet-class>
26
27 <init-param>
28 <param-name>keepAliveIntervalSeconds</param-name>
29 <param-value>30</param-value>
30 </init-param>
31 <init-param>
32 <param-name>maxConnections</param-name>
33 <param-value>100</param-value>
34 </init-param>
35
36 <!-- Enable async support for SSE -->
37 <async-supported>true</async-supported>
38
39 <!-- Load on startup -->
40 <load-on-startup>5</load-on-startup>
41 </servlet>
42
43 <servlet-mapping>
44 <servlet-name>EnhancedMcpServlet</servlet-name>
45 <url-pattern>/mcp/*</url-pattern>
46 </servlet-mapping>
47
48 <!-- Session Configuration -->
49 <session-config>
50 <session-timeout>30</session-timeout>
51 <cookie-config>
52 <http-only>true</http-only>
53 <secure>false</secure>
54 </cookie-config>
55 </session-config>
56
57 <!-- Security Constraints (optional - uncomment if needed) -->
58 <!--
59 <security-constraint>
60 <web-resource-collection>
61 <web-resource-name>MCP Endpoints</web-resource-name>
62 <url-pattern>/sse/*</url-pattern>
63 <url-pattern>/mcp/message/*</url-pattern>
64 <url-pattern>/rpc/*</url-pattern>
65 </web-resource-collection>
66 <auth-constraint>
67 <role-name>admin</role-name>
68 </auth-constraint>
69 </security-constraint>
70
71 <login-config>
72 <auth-method>BASIC</auth-method>
73 <realm-name>Moqui MCP</realm-name>
74 </login-config>
75 -->
76
77 <!-- MIME Type Mappings -->
78 <mime-mapping>
79 <extension>json</extension>
80 <mime-type>application/json</mime-type>
81 </mime-mapping>
82
83 <!-- Default Welcome Files -->
84 <welcome-file-list>
85 <welcome-file>index.html</welcome-file>
86 <welcome-file>index.jsp</welcome-file>
87 </welcome-file-list>
88
89 </web-app>