ACL Filtering¶
ACL (Access Control List) filtering is the third layer of IndentiaDB's security model and its most distinctive feature. While graph-level ACL is standard practice in triple stores, triple-level ACL — per-triple access control embedded directly in RDF-star annotations — is unique to IndentiaDB.
ACL filtering is transparent: users never add ACL conditions to their queries, and users cannot enumerate what data they are excluded from. Filtering happens at the storage layer before query execution delivers results.
Graph-Level ACL¶
Graph-level ACL controls which named graphs are visible to each principal. Invisible graphs are excluded from query planning entirely — they do not appear in FROM NAMED enumeration, and triple patterns that would match triples in invisible graphs simply produce no bindings.
Configuration¶
# Define named ACL contexts
[acl.contexts.public_reader]
visible_graphs = ["http://example.org/public"]
visible_default_graph = false
[acl.contexts.data_scientist]
visible_graphs = ["*"] # all named graphs (not the default graph)
visible_default_graph = false
[acl.contexts.analyst]
visible_graphs = [
"http://example.org/reports",
"http://example.org/aggregates"
]
visible_default_graph = false
[acl.contexts.admin]
visible_graphs = ["**"] # all graphs including the default graph
visible_default_graph = true
# Assign contexts to specific actors (by username/subject claim)
[acl.actor_contexts]
"guest" = "public_reader"
"alice" = "data_scientist"
"bob" = "analyst"
# Assign contexts to roles (applies to all principals with that role)
[acl.role_contexts]
"admin" = "admin"
"writer" = "data_scientist"
"reader" = "public_reader"
"data_analyst" = "analyst"
Visibility Pattern Reference¶
| Pattern | Meaning |
|---|---|
"**" |
All graphs including the default (unnamed) graph |
"*" |
All named graphs; the default graph is excluded |
["<uri1>", "<uri2>"] |
Specific named graphs only; no others are visible |
[] |
No graphs visible — effectively no data access |
"https://id.indentia.ai/sources/acme/*" |
A glob; * matches any sequence, including / and : |
"!https://id.indentia.ai/sources/acme/audit" |
Exclude this graph from an explicit graph list |
An exclusion overrides every positive match in the same list. A list containing
only exclusions grants nothing. With exclusions present, * and ** are globs
over named graphs and do not grant the default graph. Use anchored tenant paths
for grants because a wildcard also matches separators.
Machine identities and exclusive writers¶
[acl].service_token_default_access = "deny_all" denies a service token without
an actor or role ACL entry, independently of the default for human identities.
An explicit actor entry takes precedence over a role entry, then the applicable
default applies.
Reserve a graph for configured service-token subjects with:
[[acl.exclusive_writers]]
graphs = ["https://id.indentia.ai/sources/acme/audit"]
subjects = ["audit-writer"]
Every matching exclusive-writer rule must allow the actor. The reservation also restricts Admin callers. An OIDC token with the same subject does not qualify as that service token. Reservations control writes; normal ACLs still control reads.
Actor vs. Role Contexts¶
actor_contexts— binds a specific username (thesubclaim from JWT, or LDAP username) to a context. Takes precedence over role-based assignment.role_contexts— binds a role name to a context. Applies to all principals whose role list includes the named role.
If a principal matches both an actor context and a role context, the actor context takes precedence.
FROM / FROM NAMED Rewriting¶
When a SPARQL query includes explicit FROM or FROM NAMED clauses, IndentiaDB intersects the requested graphs with the principal's visible graphs. Requested graphs that are not in the visible set are silently removed. If the intersection is empty, the query runs against an empty dataset and returns no results — not an error.
# Principal has visible_graphs = ["http://example.org/public"]
# This query requests both public and classified graphs
SELECT ?s ?p ?o
FROM NAMED <http://example.org/public>
FROM NAMED <http://example.org/classified>
WHERE { GRAPH ?g { ?s ?p ?o } }
After rewriting: the query runs as if FROM NAMED <http://example.org/classified> was never present. The principal sees only results from the public graph.
Triple-Level ACL (The Unique Feature)¶
Triple-level ACL allows individual triples within a named graph to carry access-control annotations stored as RDF-star quoted triple metadata. A triple annotated with security:allowedSid is only visible to principals whose SID set intersects the allowed SID set. Unannotated triples are visible to all principals with graph access.
Use the exact predicate IRIs
Annotations are recognised only by their full predicate IRIs: http://indentiagraph.io/security#allowedSid and http://indentiagraph.io/security#allowedRid. The prefix label is free (some examples use acl:), but an annotation with any other namespace is ordinary data and protects nothing. Write the annotation in the same named graph as the statement it protects.
This creates a two-dimensional access control matrix: graph-level controls which graphs are accessible, and triple-level controls which rows within a visible graph are accessible.
ACL Annotation Format¶
PREFIX security: <http://indentiagraph.io/security#>
PREFIX ex: <http://example.org/>
# This triple is restricted to the Finance group (SID S-1-5-21-domain-2001)
<< ex:confidential_report ex:contains ex:financial_data >>
security:allowedSid "S-1-5-21-domain-2001" .
# This triple is restricted to the HR group AND the Finance group
<< ex:employee_record ex:hasSalary "75000"^^xsd:decimal >>
security:allowedSid "S-1-5-21-domain-2001" ;
security:allowedSid "S-1-5-21-domain-2002" .
# Multiple allowed SIDs = access if ANY SID matches (OR logic)
# This triple has no ACL annotation — visible to all principals with graph access
ex:public_report ex:title "Q4 Highlights" .
Setting ACL Annotations via SPARQL INSERT¶
PREFIX security: <http://indentiagraph.io/security#>
PREFIX ex: <http://example.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
INSERT DATA {
GRAPH <http://example.org/hr-data> {
# Salary data: Finance group and HR group only
<< ex:employee_alice ex:hasSalary "82000"^^xsd:decimal >>
security:allowedSid "S-1-5-21-domain-2001" ;
security:allowedSid "S-1-5-21-domain-2002" .
# Performance review: HR group only
<< ex:employee_alice ex:performanceScore "4.2"^^xsd:decimal >>
security:allowedSid "S-1-5-21-domain-2002" .
# Bonus amount: Finance group and Executive group only
<< ex:employee_alice ex:bonusAmount "10000"^^xsd:decimal >>
security:allowedSid "S-1-5-21-domain-2001" ;
security:allowedSid "S-1-5-21-domain-2004" .
# Public: manager name, department — no ACL restriction
ex:employee_alice ex:manager "Bob Smith" ;
ex:department "Engineering" .
}
}
Setting ACL Annotations via RID (Numeric Role ID)¶
For simpler scenarios, use security:allowedRid with a numeric role identifier:
PREFIX security: <http://indentiagraph.io/security#>
PREFIX ex: <http://example.org/>
INSERT DATA {
GRAPH <http://example.org/financial> {
<< ex:budget_report ex:totalBudget "5000000"^^xsd:decimal >>
security:allowedRid "2001" ;
security:allowedRid "2004" .
}
}
RIDs are the RID component of a Windows SID (S-1-5-21-<domain>-<RID>). This is a shorthand for cases where you only need to match against a specific sub-authority within the domain: the RID is expanded with [acl] default_domain (default S-1-5-21-0-0-0). An allowedRid value must be an integer.
Transparent Filtering: AclDatasetWrapper¶
The AclDatasetWrapper is a Rust struct that wraps the underlying triple store and intercepts every triple retrieval operation. It:
- Reads the principal's SID set from the request context (populated by the authentication layer).
- For each triple retrieved from storage, checks if the triple has a
security:allowedSidannotation. - If annotated: returns the triple only if
principal.sids ∩ triple.allowedSids ≠ ∅(any SID match grants access). - If unannotated: returns the triple unconditionally (visible to all principals with graph access).
- Strips the ACL annotations from the triple before delivering it to the query engine — ACL metadata is never visible in query results.
This filtering is applied at the storage layer, before SPARQL evaluation. The SPARQL query engine sees a clean, already-filtered view of the data and never needs to know about ACL annotations.
Users cannot distinguish whether a triple does not exist or is ACL-filtered. Both cases produce identical query results (no binding for the filtered triple).
SID Format (Windows-Compatible)¶
IndentiaDB uses Windows Security Identifier format for all ACL annotations:
Example:
S-1-5-21-1234567890-2345678901-3456789012-1001 (user SID)
S-1-5-21-1234567890-2345678901-3456789012-1025 (group SID)
Principal Labels¶
Besides SDDL SIDs, an allowedSid value may be a principal label such as user:jan@example.com, user:id:<issuer>|<subject>, group:entra:…, group:ad:…, or group:sp:…. Any non-empty value that is not a valid SDDL SID is treated as an opaque label and matches only a principal that carries exactly the same label (casing preserved). An empty value is malformed.
PrincipalSidSet Composition¶
A principal's effective SID set is the union of:
- Their own user SID: the objectSid LDAP attribute, or for OIDC the user_sid_claim value or the derived label user:id:<iss>|<sub> (see User SIDs)
- All group SIDs from their group memberships (via group_sid_mapping in LDAP config; via sids_claim and group_sid_mapping for OIDC)
- For service tokens, the explicitly configured principals — a service token without principals has no SIDs and sees only unannotated triples
SID Intersection Logic¶
Access is granted if any SID in the principal's SID set matches any security:allowedSid annotation on the triple. This is OR logic:
If a triple has two security:allowedSid values — one for Finance and one for HR — then both Finance members AND HR members can see it. There is no AND logic at the SID level; combine graph-level ACL with triple-level ACL to achieve AND-style restrictions.
Practical Example: Multi-Level Data Sensitivity¶
The following example demonstrates layered ACL in a healthcare scenario:
PREFIX security: <http://indentiagraph.io/security#>
PREFIX fhir: <http://hl7.org/fhir/>
PREFIX ex: <http://example.org/hospital/>
# SID assignments (configured via LDAP group_sid_mapping):
# S-1-5-21-hosp-1001 = Clinicians
# S-1-5-21-hosp-1002 = Billing Department
# S-1-5-21-hosp-1003 = Researchers (anonymized access)
# S-1-5-21-hosp-1004 = Hospital Administrators
# Patient name: visible to clinicians and administrators
<< ex:patient-7842 fhir:name "Jane Doe" >>
security:allowedSid "S-1-5-21-hosp-1001" ;
security:allowedSid "S-1-5-21-hosp-1004" .
# Diagnosis: visible to clinicians only
<< ex:patient-7842 fhir:condition ex:diabetes-type2 >>
security:allowedSid "S-1-5-21-hosp-1001" .
# Billing code: visible to billing and administrators
<< ex:patient-7842 fhir:claim ex:claim-456 >>
security:allowedSid "S-1-5-21-hosp-1002" ;
security:allowedSid "S-1-5-21-hosp-1004" .
# Anonymized demographic (no ACL): visible to all including researchers
ex:patient-7842 fhir:ageGroup "40-50" ;
fhir:gender "female" .
A researcher running SELECT * WHERE { ex:patient-7842 ?p ?o } sees:
- fhir:ageGroup "40-50"
- fhir:gender "female"
A clinician sees all four properties including name and diagnosis. The billing department sees age, gender, and the claim — but not the diagnosis.
Operating the ACL Index¶
Triple-level ACL decisions are made against an in-memory security annotation index. The index is a projection of the RDF graph: it is built from the security:allowedSid and security:allowedRid annotations and is only served while it is certified as an exact copy of the graph. When it cannot be certified, access fails closed.
Grants Are RDF¶
A grant is ordinary RDF, written with SPARQL UPDATE like any other data (see the examples above). RDF 1.2 stores << s p o >> security:allowedSid "S-…" as a reifier — _:r rdf:reifies <<( s p o )>> plus _:r security:allowedSid "S-…" — and both that form and the older quoted-triple-subject form are recognised, as long as the rdf:reifies statement and the annotation are in the same graph.
The same live index is used by /sparql and, when IG_ES_ACL_ENFORCE=true, by the :9200 search path, so one RDF grant is enforced on both. With IG_ES_RDF_SOT=true the :9200 documents are themselves derived from the graph; a lagging document projection can only hide results, never reveal them, and a revocation is enforced on search immediately. See Production Hardening.
How Grant Changes Are Applied¶
- In a cluster, a commit that only adds annotations updates the index incrementally.
- Grant removals also update the index incrementally when the affected targets can be resolved. Multiple reifiers and replacement grants are evaluated together so a protected triple never becomes public between operations. Rebuilds remain necessary for changes without a known incremental difference.
- While the index is fenced, queries and updates that need the ACL view return a retryable
503 Service Unavailable. UnderIG_PRODUCTION,/readyalso reports the node as not ready. - A rebuild whose read overlaps a concurrent write — for example one that sees a grant before its
rdf:reifiesstatement — is retried automatically instead of being treated as a malformed graph.
Reads retain their starting ACL generation¶
A SPARQL read uses the certified ACL generation at admission. The index retains earlier ACL values while an active read needs them. A revocation committed during that read applies to the next query. A triple without an ACL entry in the starting generation is denied if a later ACL change makes its historical visibility uncertain; RDF data itself is not snapshot-isolated by this feature.
An unknown change, such as a rebuild or snapshot installation, invalidates older
reads. Retention limits also cause those reads to abort with
SecurityProjectionChanged. IG_ACL_MVCC_MAX_ENTRIES defaults to 2,000,000 and
IG_ACL_MVCC_MAX_RECORDS to 4,096. IG_ACL_MVCC=off disables generation retention.
Writes, including update-WHERE and GraphQL mutations, retain strict generation
checks.
While rebuilding, affected responses carry Indentia-Acl-State: rebuilding.
Treat a retryable 503 as unavailable authorization state, not an empty result.
Triple Terms Carry Their ACL¶
A statement that appears inside a triple term — the object of rdf:reifies, or any metadata statement that quotes it — is returned only if the quoted statement is itself visible to the caller in that graph. Nested triple terms are checked at every level, so quoting a protected statement in another triple cannot expose its contents.
Malformed Annotations¶
A malformed grant must never turn a protected statement into public data.
When the server can identify every affected triple, it denies those triples to
all callers and certifies the remaining ACL projection. Multiple distinct
reifier targets or invalid SID/RID values use this local denial. The
ig_acl_faulted_triples gauge reports the affected triple count.
When a malformed annotation has an unknown target, the projection remains
globally fail-closed. Examples include dangling grants, literal or unknown
targets, and reifier cycles. Warnings on log target acl identify the graph,
reifier, and known targets. Repair the authoritative RDF annotation; do not
remove an ACL solely to make a query succeed.
acl log message |
Cause |
|---|---|
rdf:reifies targets a literal, not a quoted triple |
The reifier's triple term was stored as a literal; the log line names the reifier, graph, and literal |
rdf:reifies object id is unknown |
The reifier's target term cannot be resolved; names the reifier and graph |
malformed security reifier |
A reifier that does not resolve to a quoted triple; names the reifier, graph, and error |
Malformed security annotation |
The annotation itself is invalid — for example no rdf:reifies target in the same graph, a value that is not a literal, an empty allowedSid, or a non-integer allowedRid |
To repair:
- Collect the complete list of reported quads from the
aclwarnings of a rebuild that ended with thatgraph is invalidwarning — all faults are listed together, so there is no need to discover them one at a time. Warnings from a scan that overlapped a concurrent write are followed by an automatic retry and are not necessarily real faults. - Remove or correct every reported quad in one commit. Where a grant was damaged, write the intended grant again in the same commit: deleting a grant without replacing it makes the statement visible to everyone with access to its graph. If the protected statement can no longer be identified, remove the statement or restrict its graph instead.
- The commit starts a fresh rebuild. The index is serving again when the log reports
Security annotation index certified after canonical-state rebuild(standalone:Standalone security annotation index certified after exact rebuild), and — underIG_PRODUCTION— when/readyreturns200.
To avoid the most common cause, use RDF 1.2-aware formats when exporting and re-importing data so triple terms survive the round trip.
Audit Logging¶
All ACL-filtered accesses are optionally logged:
[audit]
enabled = true
log_acl_filter = false # true = log every filtered triple (very high volume)
log_writes = true
log_auth = true
output = "sparql_graph"
When log_acl_filter = true, every triple that was filtered out for a principal is logged with the principal's identity, the filtered triple's subject/predicate, and the timestamp. This is useful for compliance auditing but generates high log volume in systems with many ACL-annotated triples.
Audit log entries are stored in the named graph <http://indentiadb.io/audit> when output = "sparql_graph", queryable via SPARQL: