Wiki source code of Access Doors
Last modified by gabrielc on 2026/10/21 11:18
Show last authors
| author | version | line-number | content |
|---|---|---|---|
| 1 | The access doors centralize the MCP server's authorization and scoping decisions so that no tool re-implements them. | ||
| 2 | |||
| 3 | Three are public component roles of the ##application-ai-llm-mcp-api## module (package ##org.xwiki.contrib.llm.mcp##, each annotated ##@Role## and ##@Unstable##), so a tool contributed by any module can inject them: ##MCPDocumentAccess##, ##MCPWikiReach## and ##MCPDocumentSearch##. Two are internal to the server module (package ##org.xwiki.contrib.llm.mcp.internal.access##): ##MCPSpaceFilter##, which the public doors apply on the caller's behalf, and ##MCPRowQuery##. | ||
| 4 | |||
| 5 | A tool must never resolve a ##DocumentReference## itself: doing so escapes the space policy and can reach another wiki unchecked. | ||
| 6 | |||
| 7 | == MCPDocumentAccess == | ||
| 8 | |||
| 9 | Single sanctioned way to resolve a reference and enforce authorization. | ||
| 10 | |||
| 11 | {{code language="java"}} | ||
| 12 | @Role | ||
| 13 | public interface MCPDocumentAccess { | ||
| 14 | DocumentReference resolveAndAuthorize(String reference, Right right) | ||
| 15 | throws MCPAccessDeniedException; | ||
| 16 | |||
| 17 | DocumentReference resolveAndAuthorize(String reference, Right right, WikiReference wikiContext) | ||
| 18 | throws MCPAccessDeniedException; | ||
| 19 | } | ||
| 20 | {{/code}} | ||
| 21 | |||
| 22 | Applies in order: reach gate, then ##ContextualAuthorizationManager.hasAccess##, then space filter. Throws ##MCPAccessDeniedException## with an agent-facing refusal message. The three-argument overload resolves an unqualified reference into the given wiki; a reference carrying an explicit wiki prefix that differs from ##wikiContext## is refused, so a tool's ##wiki## parameter and a prefixed reference cannot contradict each other. | ||
| 23 | |||
| 24 | {{plantuml}} | ||
| 25 | @startuml | ||
| 26 | !theme bluegray | ||
| 27 | start | ||
| 28 | :a tool calls resolveAndAuthorize\nwith the reference it was given; | ||
| 29 | :resolve it against the current context; | ||
| 30 | if (the target is in another wiki?) then (yes) | ||
| 31 | if (this endpoint may reach that wiki?) then (no) | ||
| 32 | :refused: cross-wiki reach\nis not enabled for this endpoint; | ||
| 33 | stop | ||
| 34 | else (yes) | ||
| 35 | endif | ||
| 36 | else (no) | ||
| 37 | endif | ||
| 38 | if (the user has the required right\non the document?) then (no) | ||
| 39 | :refused: you do not have\npermission to view or edit it; | ||
| 40 | stop | ||
| 41 | else (yes) | ||
| 42 | endif | ||
| 43 | if (the space filter allows it?) then (no) | ||
| 44 | :refused: outside the content\nthis endpoint exposes; | ||
| 45 | stop | ||
| 46 | else (yes) | ||
| 47 | endif | ||
| 48 | :the tool receives the DocumentReference; | ||
| 49 | stop | ||
| 50 | @enduml | ||
| 51 | {{/plantuml}} | ||
| 52 | |||
| 53 | == MCPSpaceFilter == | ||
| 54 | |||
| 55 | Applies the MCP space whitelist or blacklist. Internal to the server module: the public doors above already apply it, so a tool in another module does not call it directly. | ||
| 56 | |||
| 57 | {{code language="java"}} | ||
| 58 | @Role | ||
| 59 | public interface MCPSpaceFilter { | ||
| 60 | boolean isAllowed(DocumentReference reference); | ||
| 61 | List<String> filterQueries(); | ||
| 62 | void invalidate(String wikiId); | ||
| 63 | void invalidateAll(); | ||
| 64 | } | ||
| 65 | {{/code}} | ||
| 66 | |||
| 67 | |=Method |=Returns |=What it does | ||
| 68 | |##isAllowed(reference)## |##boolean## |Whether that one document passes the configured filter mode and entries | ||
| 69 | |##filterQueries()## |##List<String>## |The Solr filter query clauses that scope search results to the same set | ||
| 70 | |##invalidate(wikiId)## |##void## |Drops the parsed filter cached for one source wiki | ||
| 71 | |##invalidateAll()## |##void## |Drops every wiki's parsed filter | ||
| 72 | |||
| 73 | A space entry covers the space and its whole subtree; a document entry matches exactly one document. An entry is resolved with the current resolver: ##Sandbox## lands in the source wiki, while a wiki-qualified ##second:Sandbox## lands in the wiki it names and therefore matches nothing in the source wiki. The parsed filter is cached per source wiki, and the configuration listener drops that cache when the wiki's configuration page is saved or deleted. Both checks fail //closed// on read errors: ##isAllowed## denies, and ##filterQueries## returns a match-nothing ##-*:*## clause. | ||
| 74 | |||
| 75 | == MCPWikiReach == | ||
| 76 | |||
| 77 | Decides how far an endpoint reaches beyond its own wiki. | ||
| 78 | |||
| 79 | {{code language="java"}} | ||
| 80 | @Role | ||
| 81 | public interface MCPWikiReach { | ||
| 82 | boolean isReachEnabled(); | ||
| 83 | boolean canReachWiki(String wikiId); | ||
| 84 | List<String> resolveSearchWikis(String wikiParam) throws MCPAccessDeniedException; | ||
| 85 | String resolveSingleWiki(String wikiParam) throws MCPAccessDeniedException; | ||
| 86 | } | ||
| 87 | {{/code}} | ||
| 88 | |||
| 89 | |=Method |=Returns |=What it does | ||
| 90 | |##isReachEnabled()## |##boolean## |Whether this endpoint may cross wikis at all | ||
| 91 | |##canReachWiki(wikiId)## |##boolean## |Whether that one target wiki is reachable: always true for the current wiki, otherwise whether cross-wiki reach is enabled | ||
| 92 | |##resolveSearchWikis(wikiParam)## |##List<String>## |The wikis a search covers: blank or the current wiki gives the current wiki, ##"all"## gives ##null## meaning the whole farm, an id gives that single wiki | ||
| 93 | |##resolveSingleWiki(wikiParam)## |##String## |The one wiki a single-wiki call operates on; there is no ##"all"## value here | ||
| 94 | |||
| 95 | Backed by the main-wiki-only ##reachEnabledWikis## list. A reach-enabled endpoint reaches every wiki in the farm regardless of the target's own enable gate, tool toggles, or space filter. | ||
| 96 | |||
| 97 | === Default posture === | ||
| 98 | |||
| 99 | Until the main wiki's reach dashboard is saved for the first time, only the main wiki has reach and every other wiki fails closed. That first save materializes the list, after which it is authoritative and may exclude the main wiki too. If the main wiki's configuration page is untrusted, reach is off for every wiki, the main wiki included. | ||
| 100 | |||
| 101 | == MCPDocumentSearch == | ||
| 102 | |||
| 103 | Builds a Solr search query scoped to wikis, space filter, and rights. | ||
| 104 | |||
| 105 | {{code language="java"}} | ||
| 106 | @Role | ||
| 107 | public interface MCPDocumentSearch { | ||
| 108 | Query createQuery(String statement, List<String> additionalFilterQueries, | ||
| 109 | List<String> targetWikiIds) throws QueryException; | ||
| 110 | } | ||
| 111 | {{/code}} | ||
| 112 | |||
| 113 | Uses ##SecureQuery## with ##checkCurrentUser(true)##, wiki-scope clauses, and the space filter. It is the single query path for ##query_documents##. A ##null## wiki list means the whole farm; an empty list is a defensive no-match. | ||
| 114 | |||
| 115 | == MCPAccessDeniedException == | ||
| 116 | |||
| 117 | Checked exception (##extends Exception##) whose message is the agent-facing refusal. Thrown by ##MCPDocumentAccess.resolveAndAuthorize()## and caught by the tool to produce an ##isError## result. | ||
| 118 | |||
| 119 | == MCPRowQuery == | ||
| 120 | |||
| 121 | Internal door to the authorized HQL row pipeline of the navigation tools: bounded row fetching against one target wiki, plus per-row resolution and authorization of the fetched rows. | ||
| 122 | |||
| 123 | {{code language="java"}} | ||
| 124 | @Role | ||
| 125 | public interface MCPRowQuery { | ||
| 126 | int MAX_FETCH_PER_QUERY = 2000; | ||
| 127 | |||
| 128 | List<Object[]> rows(String statement, String wiki, String bindName, Object bindValue, int limit) | ||
| 129 | throws QueryException; | ||
| 130 | |||
| 131 | List<Object[]> rows(String statement, String wiki, Map<String, Object> bindValues, int limit) | ||
| 132 | throws QueryException; | ||
| 133 | |||
| 134 | List<Object[]> hierarchyRows(String baseStatement, boolean showHidden, boolean hasSpaceAlias, | ||
| 135 | String wiki, String bindName, Object bindValue, int limit) throws QueryException; | ||
| 136 | |||
| 137 | DocumentReference resolveInto(String fullName, WikiReference wiki); | ||
| 138 | boolean isAuthorized(DocumentReference reference); | ||
| 139 | DocumentReference authorizedDocument(String fullName, WikiReference wiki); | ||
| 140 | |||
| 141 | record Contains(String text) { } | ||
| 142 | } | ||
| 143 | {{/code}} | ||
| 144 | |||
| 145 | |=Member |=Returns |=What it does | ||
| 146 | |##rows(statement, wiki, bindName, bindValue, limit)## |##List<Object[]>## |Runs one HQL statement against one wiki with an optional named bind | ||
| 147 | |##rows(statement, wiki, bindValues, limit)## |##List<Object[]>## |The same with any number of named binds; a ##Contains## value is escaped by the platform | ||
| 148 | |##hierarchyRows(base, showHidden, hasSpaceAlias, wiki, bindName, bindValue, limit)## |##List<Object[]>## |Composes the hidden-page clauses and a deterministic ##order by doc.fullName## | ||
| 149 | |##resolveInto(fullName, wiki)## |##DocumentReference## |Resolves a row explicitly into the wiki it was fetched from | ||
| 150 | |##isAuthorized(reference)## |##boolean## |Applies the space filter, then the view right, in that order | ||
| 151 | |##authorizedDocument(fullName, wiki)## |##DocumentReference## |The resolved reference when authorized, otherwise ##null## | ||
| 152 | |##Contains(text)## |bind value |Marks a raw substring bind, escaped at execution time | ||
| 153 | |||
| 154 | Every fetch limit is clamped into ##1..MAX_FETCH_PER_QUERY##, so a broad statement cannot materialize an unbounded row set. Hidden-page exclusion is explicit HQL rather than a query filter, so it holds regardless of the calling user's own profile preference. A single-column statement yields the scalar itself in each row element at runtime, despite the declared ##List<Object[]>## type. |