Document Permissions Query
Using cts:document-permission-query()
Security questions are often operationally urgent and technically awkward. "Which documents can contractors read?" "Which sensitive records are missing the curator role?" "Which content under this directory grants update access too broadly?" Those questions matter, but pre-MarkLogic-11 they frequently pushed teams into expensive workarounds: impersonating users, marshalling enormous URI lists, or inspecting permissions document by document in procedural loops.
cts:document-permission-query() changes that story. It lets you stay in CTS query space and ask the database to resolve documents by permission through its security indexes. The function is conceptually simple, but it unlocks a class of auditing patterns that would otherwise be painful to implement.
The examples in this article assume the llamaverse (v2.0+) is deployed. The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.
Why This Function Exists
Document permissions are part of document metadata, not an afterthought layered on top. MarkLogic already had to understand permissions for access control decisions. What developers lacked was a direct way to query for documents that grant a given capability to a given role without scanning the entire database procedurally.
The function makes permission state queryable and composable. You can combine it with collection queries, directory queries, and cts:not-query() exactly as you would any other constraint. That composability is what makes it genuinely useful rather than merely convenient.
How Permission-Aware Querying Works
cts:document-permission-query() lets MarkLogic resolve permission-based constraints directly in query planning instead of forcing procedural, document-by-document inspection logic in application code.
| Aspect | Detail |
|---|---|
| Scope | Document permissions only — not execute privileges or role grants generally |
| Best use cases | Security audits, permission gap detection, compliance checks |
| Important caveat | Results reflect indexed permissions currently attached to documents |
Syntax
The function takes two arguments: the role name as a string and the capability as a string. It does not require a numeric role ID. The XQuery form is cts:document-permission-query() and the JavaScript equivalent is cts.documentPermissionQuery().
xquery version "1.0-ml";
cts:document-permission-query("cleverllamas-llamaverse-reader", "read")
The capability values are read, update, insert, execute, and node-update. Each maps to a specific question:
| Capability | Question it answers |
|---|---|
read | Which roles can read this content? |
update | Which roles can replace or delete this document? |
insert | Which roles are used in controlled insertion flows? |
execute | Which roles can execute modules stored in the database? |
node-update | Which roles can perform fine-grained node updates? |
Role Helper Functions
Although the query constructor takes role names directly, role helper functions are still useful in auditing workflows. xdmp:role() converts a role name to its internal ID; xdmp:role-name() converts back. Use them when you need to inspect raw permission entries or verify that a role name still resolves.
xquery version "1.0-ml";
(: Convert between role names and internal role IDs. :)
(: xdmp:role() is useful in auditing code alongside the query constructor. :)
let $id := xdmp:role("cleverllamas-llamaverse-reader")
return (
$id,
xdmp:role-name($id)
)
13627388108119887129
cleverllamas-llamaverse-reader
Basic Permission Query
The most direct form queries across the entire database for all documents readable by a given role:
xquery version "1.0-ml";
(
concat(
"estimated-uris:",
cts:estimate(cts:document-permission-query("cleverllamas-llamaverse-reader", "read"))
),
cts:uris(
(),
("document", "truncate=5"),
cts:document-permission-query("cleverllamas-llamaverse-reader", "read")
)
)
estimated-uris:6028
/cleverllamas/llamaverse/content/pets/fa934c54-aa80-4ade-a8b0-5b79d15b00ce.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/67384a21-62fe-4f80-a964-72783cfb076f.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/c45a4f65-d14a-4206-b921-ffc9470e14bb.json
/cleverllamas/llamaverse/raw/wild-llamas/movement/2022_W12_batch6.json
/cleverllamas/llamaverse/raw/wild-llamas/movement/2023_W41_batch1.json
Scoped Permission Queries
In practice, querying across the entire database is rarely what you want. Real audits are almost always scoped: documents in a specific collection, under a particular directory, or of a certain content type. Combining cts:document-permission-query() with a collection or directory constraint reduces the search space early and produces more actionable results.
xquery version "1.0-ml";
(: Scope the permission query to a specific collection. :)
(: This is more efficient than querying across the entire database. :)
cts:uris(
(),
("document", "item-order"),
cts:and-query((
cts:collection-query("wild-llamas"),
cts:document-permission-query("cleverllamas-llamaverse-reader", "read")
))
)[1 to 5]
/cleverllamas/llamaverse/raw/wild-llamas/llamas/00048384-cb13-4557-805e-a6b4e57f7eab.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/0005e261-4302-4ae8-9574-732a54040423.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/000eca10-d166-469f-b17a-3c3b35ee0883.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/002c7133-e985-4bd6-9ea8-f057fa0c6ee7.json
/cleverllamas/llamaverse/raw/wild-llamas/llamas/0043e95e-3209-4b97-8871-f9b7073377f2.json
Negative Permission Queries
Some of the most valuable audits are negative. You are not looking for documents that have a permission — you are looking for documents that do not, and therefore violate a policy. Wrapping cts:document-permission-query() in cts:not-query() gives you this directly.
xquery version "1.0-ml";
(: Negative permission query: find documents that are missing an expected :)
(: permission. This is the core pattern for policy compliance checks. :)
(: A clean database returns an empty sequence here — which is the goal. :)
cts:uris(
(),
("document", "item-order"),
cts:and-query((
cts:collection-query("wild-llamas"),
cts:not-query(cts:document-permission-query("cleverllamas-llamaverse-reader", "read"))
))
)[1 to 5]
()
An empty result is the correct outcome here: it confirms that every document in the wild-llamas collection carries the expected read permission. If your database had a gap — a batch import that assigned the wrong permissions, for example — those URIs would appear in this list.
Inspecting Permissions on a Single Document
For a specific URI, xdmp:document-get-permissions() returns the concrete permission entries rather than a query abstraction. Use this to confirm what a document actually carries after a permission query has flagged it.
xquery version "1.0-ml";
declare namespace sec = "http://marklogic.com/xdmp/security";
(: Inspect the concrete permission entries on a single document. :)
(: Use this after a permission query to understand what a document carries. :)
xdmp:document-get-permissions(
"/cleverllamas/llamaverse/raw/wild-llamas/llamas/0c8bdb0d-ac62-49b7-ac74-94dbba46efa5.json"
)
<sec:permission xmlns:sec="http://marklogic.com/xdmp/security">
<sec:capability>read</sec:capability>
<sec:role-id>13627388108119887129</sec:role-id>
</sec:permission>
<sec:permission xmlns:sec="http://marklogic.com/xdmp/security">
<sec:capability>insert</sec:capability>
<sec:role-id>3611960165157250657</sec:role-id>
</sec:permission>
<sec:permission xmlns:sec="http://marklogic.com/xdmp/security">
<sec:capability>node-update</sec:capability>
<sec:role-id>3611960165157250657</sec:role-id>
</sec:permission>
<sec:permission xmlns:sec="http://marklogic.com/xdmp/security">
<sec:capability>update</sec:capability>
<sec:role-id>3611960165157250657</sec:role-id>
</sec:permission>
The sec:role-id fields contain the internal numeric IDs. Use xdmp:role-name() to convert them to human-readable names if you are inspecting unfamiliar content.
Permission Cleanup Workflows
Finding permission gaps is only half the job. Once you have the non-compliant URIs, you can repair them in the same query — the negative permission query feeds directly into the update loop:
xquery version "1.0-ml";
declare option xdmp:update "true";
(: Permission repair workflow: find documents missing an expected permission :)
(: and add it to each. This modifies documents — run in a controlled context. :)
for $uri in cts:uris(
(),
("document", "item-order"),
cts:and-query((
cts:collection-query("wild-llamas"),
cts:not-query(cts:document-permission-query("cleverllamas-llamaverse-reader", "read"))
))
)
let $current := xdmp:document-get-permissions($uri)
let $patched := ($current, xdmp:permission("cleverllamas-llamaverse-reader", "read"))
return xdmp:document-set-permissions($uri, $patched)
Run this in a context where the executing user has permission to update document metadata. Test against a small scope first before applying to large collections.
Caveats and Boundaries
cts:document-permission-query() operates on document permissions specifically. It does not query execute privileges, role inheritance, or system-level security objects. Permission and privilege are different security layers in MarkLogic, and it is worth keeping that distinction clear in your thinking.
| Boundary | Implication |
|---|---|
| Document permissions only | Does not query privileges, amps, or role inheritance |
| Role names are resolved at query time | An invalid role name raises SEC-ROLEDNE (role does not exist) |
Performance Notes
Permission queries are generally efficient because they are evaluated by the query engine against permission metadata rather than by manual per-document inspection loops. They are still more specialised than a plain collection or directory query, so the recommended pattern is always to scope them. If you can constrain the collection or directory first, do so — this reduces the search space and makes results easier to act on.
| Pattern | Advisable? | Why |
|---|---|---|
| Unscoped permission query across the entire database | Only for targeted global audits | Can be broad; fine for occasional compliance sweeps |
| Permission query scoped to a collection | Yes | Reduces search space early |
| Permission query scoped to a directory | Yes | Excellent for URI-partitioned content policies |
| Manual full-database permission inspection loops | No | Much more expensive and harder to reason about |
Quick Reference
| Need | Expression |
|---|---|
| Documents readable by a role | cts:document-permission-query("role-name", "read") |
| Documents updatable by a role | cts:document-permission-query("role-name", "update") |
| Scoped to a collection | cts:and-query((cts:collection-query("name"), ...)) |
| Documents missing a permission | cts:not-query(cts:document-permission-query(...)) |
| URI-only result set | cts:uris((), (), $query) |
| Inspect one document directly | xdmp:document-get-permissions($uri) |
Common Mistakes
| Mistake | Why it matters | Fix |
|---|---|---|
| Assuming this function also checks privileges and role inheritance | It only evaluates document permissions | Use privilege/role APIs separately when you need those layers |
| Using a non-existent role name | Raises SEC-ROLEDNE, so the query fails | Verify role names with xdmp:role() before relying on them |
| Querying without scoping | Results can be much broader than the audit target | Add a collection or directory constraint |
| Confusing document permissions with execute privileges | They are distinct security layers | Use the appropriate API for each layer |
| Inspecting every document manually | Wastes effort and is harder to reason about | Use cts:document-permission-query() for bulk detection, then drill in with xdmp:document-get-permissions() for specific documents |
Need Some Help?
Looking for more information on this subject or any other topic related to MarkLogic? Contact Us (info@cleverllamas.com) to find out how we can assist you with consulting or training!
- Why This Function Exists
- Syntax
- Role Helper Functions
- Caveats and Boundaries
- Performance Notes
- Quick Reference
- Common Mistakes