Document Permissions Query

Using cts:document-permission-query()

personClever Llamas
CleverLlamasMinimum Llamaverse Version: 2
databaseMinimum MarkLogic Version: 11

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.

AspectDetail
ScopeDocument permissions only — not execute privileges or role grants generally
Best use casesSecurity audits, permission gap detection, compliance checks
Important caveatResults 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:

CapabilityQuestion it answers
readWhich roles can read this content?
updateWhich roles can replace or delete this document?
insertWhich roles are used in controlled insertion flows?
executeWhich roles can execute modules stored in the database?
node-updateWhich 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.

BoundaryImplication
Document permissions onlyDoes not query privileges, amps, or role inheritance
Role names are resolved at query timeAn 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.

PatternAdvisable?Why
Unscoped permission query across the entire databaseOnly for targeted global auditsCan be broad; fine for occasional compliance sweeps
Permission query scoped to a collectionYesReduces search space early
Permission query scoped to a directoryYesExcellent for URI-partitioned content policies
Manual full-database permission inspection loopsNoMuch more expensive and harder to reason about

Quick Reference

NeedExpression
Documents readable by a rolects:document-permission-query("role-name", "read")
Documents updatable by a rolects:document-permission-query("role-name", "update")
Scoped to a collectioncts:and-query((cts:collection-query("name"), ...))
Documents missing a permissioncts:not-query(cts:document-permission-query(...))
URI-only result setcts:uris((), (), $query)
Inspect one document directlyxdmp:document-get-permissions($uri)

Common Mistakes

MistakeWhy it mattersFix
Assuming this function also checks privileges and role inheritanceIt only evaluates document permissionsUse privilege/role APIs separately when you need those layers
Using a non-existent role nameRaises SEC-ROLEDNE, so the query failsVerify role names with xdmp:role() before relying on them
Querying without scopingResults can be much broader than the audit targetAdd a collection or directory constraint
Confusing document permissions with execute privilegesThey are distinct security layersUse the appropriate API for each layer
Inspecting every document manuallyWastes effort and is harder to reason aboutUse 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!