Authorization¶
Authorization is the second layer of IndentiaDB's three-layer security model. Once a caller is authenticated (identity established), authorization determines what operations that caller is allowed to perform.
Permission Hierarchy¶
IndentiaDB defines four permission levels in a strict hierarchy. Each level includes all permissions of every lower level:
| Level | Includes | Allowed Operations |
|---|---|---|
None |
— | No access. All requests rejected with 403. |
Read |
None | SPARQL SELECT, ASK, CONSTRUCT, DESCRIBE. SurrealQL SELECT. ES _search. WebSocket LIVE SELECT (read-only). |
Write |
Read | SPARQL INSERT DATA, DELETE DATA, DELETE/INSERT WHERE. SurrealQL CREATE, UPDATE, UPSERT, DELETE, RELATE. ES _doc PUT/POST/DELETE, _bulk. DEFINE EVENT. |
Admin |
Write | Schema changes (DEFINE TABLE, DEFINE INDEX, DEFINE FIELD). Inference re-materialization. Bitemporal purge. Cluster management commands. User and role management. |
Multiple Roles
A principal may have multiple roles. The effective permission level is the maximum across all assigned roles that have a mapping in [authorization.roles]. A user with both reader and writer roles has Write permission. Role names without a mapping are ignored.
RBAC (Role-Based Access Control)¶
Role Definition and Assignment¶
Roles are defined in the security configuration with a mapping from role name to permission level (none, read, write, or admin):
[authorization]
default_permission = "none" # fallback for authenticated principals without a mapped role
[authorization.roles]
admin = "admin"
writer = "write"
reader = "read"
data_analyst = "read"
etl_service = "write"
Without an [authorization.roles] table the built-in mapping is admin = "admin", writer = "write", reader = "read". A table you define replaces that mapping entirely, so keep admin = "admin" — the IG_PRODUCTION boot gate requires it.
Roles are assigned to principals via:
- LDAP: group_role_mapping in [authentication.ldap] — AD group → role
- OIDC: role_mapping in [authentication.oidc] — OIDC claim value → role. In production a roles_claim requires a non-empty role_mapping.
- JWT: the roles_claim in the token → role lookup in [authorization.roles]
- Service tokens: the roles list of the service token entry
- Basic Auth: roles array in the user definition
default_permission¶
[authorization].default_permission is a fallback, not a floor. It applies only to an authenticated principal for which none of its roles has a mapping in [authorization.roles]:
| Principal | Effective permission |
|---|---|
At least one role has a mapping (including an explicit "none") |
The highest mapped permission — default_permission is ignored |
| Authenticated, but no role has a mapping (unknown roles or no roles) | default_permission |
| Anonymous | anonymous_permission, which defaults to none; default_permission does not apply |
anonymous_permission supports a gradual development rollout when anonymous
access is enabled. admin is refused. Production and tenant-scope enforcement
require none. Authenticated callers still use their verified identity and
mapped roles; an anonymous fallback does not replace those identities.
The same effective permission is used by the per-endpoint permission guards, so a principal with an explicit reader role is refused Admin-only endpoints with 403 even when default_permission = "admin".
Breaking change: explicit roles take precedence over default_permission
Earlier releases granted the higher of the mapped role permission and default_permission. Now an explicit role mapping always wins. On a deployment whose default_permission is admin (or write), principals with a reader or writer role lose the elevated permission — including on Admin-guarded endpoints.
Before upgrading, find identities that depended on the fallback for elevated access and give them an explicit role with the permission they need. Do not raise a role's permission just to preserve an accidental dependency. Until every node of a cluster runs the new release, nodes can reach different decisions for the same principal.
Keep default_permission at none in Production
A non-none fallback grants that permission to every authenticated principal whose roles are not mapped. In enterprise environments with an OIDC provider that issues tokens to all employees, this exposes data to all staff. Configure explicit role mappings instead.
Graph visibility for principals without a matching ACL entry is a separate setting, [acl].default_access (deny_all, allow_all, or allow_graphs); production refuses allow_all.
[acl].service_token_default_access can separately deny unmatched machine
identities. Exclusive writer rules
can reserve graphs even against an otherwise authorized Admin.
Role Assignment via LDAP¶
[authentication.ldap.group_role_mapping]
"CN=IndentiaAdmins,OU=Groups,DC=corp,DC=example,DC=com" = "admin"
"CN=DataEngineers,OU=Groups,DC=corp,DC=example,DC=com" = "writer"
"CN=Analysts,OU=Groups,DC=corp,DC=example,DC=com" = "reader"
"CN=ETLServices,OU=Groups,DC=corp,DC=example,DC=com" = "etl_service"
Role Assignment via OIDC¶
[authentication.oidc.role_mapping]
"indentia-admin" = "admin"
"indentia-write" = "writer"
"indentia-read" = "reader"
"service-account" = "etl_service"
The OIDC claim path roles_claim = "realm_access.roles" (for Keycloak) is a dot-separated path into the JWT payload. For a flat roles array in the JWT, use roles_claim = "roles".
Tenant Scoping¶
With acl.require_tenant_scope = true (mandatory under IG_PRODUCTION), every authenticated principal is confined to its tenant:
- A principal with a valid tenant sees only graphs under
https://id.indentia.ai/sources/<tenant>/and its exact canonical RAG graphurn:indentia:rag:<tenant>, intersected with its normal graph-level ACL. The default graph is never visible. - A principal without a tenant gets no data access at all.
- An update that targets a graph outside the tenant, or the default graph, is rejected with
403 Forbidden.
The tenant comes from the verified credential: for OIDC the tenant_claim (default urn:indentia:tenant), for service tokens the configured tenant. With authentication enabled, no request header can change it.
Service Token Tenants and Principals¶
A service token's configured tenant is authoritative for authorization. It is the tenant used for tenant-scoped reads and writes and for the tenant checks on the cluster tenant and shard endpoints, exactly like a validated OIDC tenant claim.
By default a service token carries no SIDs, so it can only see statements without triple-level ACL annotations. To let a workload read ACL-protected data, declare its principals explicitly:
[[authentication.service_tokens]]
enabled = true
subject = "ingest-worker"
token_file = "/etc/indentiagraph/tokens/ingest-worker"
roles = ["writer"]
tenant = "platform-ingest"
principals = ["user:ingest-worker"]
- Each entry must be a canonical principal label:
user:…,user:id:…,group:entra:…,group:ad:…, orgroup:sp:…, with a non-empty value. Raw SDDL strings are not accepted here. - At most 256 entries, each at most 1024 bytes, with no whitespace or control characters. Invalid entries fail startup. Exact duplicates are collapsed; casing is preserved.
- Omitting
principals(orprincipals = []) keeps the token SID-less. Principals are never derived from roles, the subject, or delegation settings.
Principals are identities, not grants
Declaring a principal does not grant access to anything. A statement becomes visible to the token only when it carries a matching security:allowedSid annotation, and tenant scoping and graph-level ACL still apply.
Tenant Scoping on Distributed Shard Queries¶
In a sharded cluster, the /cluster/index/{index}/… query and count endpoints fan a request out to shards over the distributed query service. Before any fan-out, the coordinator resolves the principal's tenant against the replicated cluster state:
| Principal's tenant | Result |
|---|---|
| None (operator identity) | Full fan-out across the index |
| Mapped to this index | Fan-out restricted to the tenant's own shards; every sub-request is tagged with the tenant |
| Not a valid tenant slug, not mapped, or mapped to a different index | 403 Forbidden, before any shard is contacted |
Each worker re-verifies the tag against its own copy of the cluster state and refuses a request whose tenant is unmapped, mapped to another index, or does not own the requested shard. Untagged requests from older coordinators are refused when IG_DQS_REQUIRE_TENANT is on — the default under IG_PRODUCTION (see Cluster Posture). Denials are counted in ig_dqs_tenant_denied_total on /metrics.
Isolation on this path is per shard: tenants that share a shard are not separated from each other by this check. Give a tenant its own shard or index when it needs physical isolation.
ABAC (Attribute-Based Access Control)¶
ABAC policies are evaluated dynamically at query time and can make decisions based on arbitrary attributes of the request, the principal, and the data being accessed.
Defining ABAC Policies¶
ABAC policies are defined as SPARQL-based rules in config.toml or a referenced policy file:
Policy file (config/abac-policies.sparql):
# Policy: Users can only write to their own tenant's graph
PREFIX ex: <http://example.org/>
PREFIX acl: <http://indentiadb.io/acl#>
DEFINE POLICY write_own_tenant
FOR WRITE ON GRAPH ?graph
ALLOW WHEN {
# The principal's tenant claim must match the graph's tenant
BIND(acl:claim("tenant_id") AS ?userTenant)
?graph ex:belongsToTenant ?graphTenant .
FILTER(?userTenant = STR(?graphTenant))
}
# Policy: Data analysts can only query data older than 90 days
PREFIX ex: <http://example.org/>
PREFIX acl: <http://indentiadb.io/acl#>
DEFINE POLICY analyst_data_freshness
FOR READ ON GRAPH <http://example.org/live-data>
ALLOW WHEN {
BIND(acl:has_role("data_analyst") AS ?isAnalyst)
FILTER(!?isAnalyst) # Allow non-analysts unconditionally
# Analysts require data older than 90 days
BIND(acl:claim("created_at") AS ?ts)
FILTER(?ts < (NOW() - "P90D"^^xsd:duration))
}
ABAC policies are evaluated after RBAC. If RBAC grants sufficient permission, ABAC policies can further restrict access based on data attributes. ABAC cannot grant permissions beyond the RBAC level — it can only narrow them.
Write Access Control¶
SPARQL UPDATE Validation¶
Before executing a SPARQL UPDATE statement, IndentiaDB validates that the authenticated principal has Write permission on all target graphs:
# This UPDATE will be rejected if the principal does not have Write on
# <http://example.org/financial-data>
INSERT DATA {
GRAPH <http://example.org/financial-data> {
ex:q4report ex:amount 1500000 .
}
}
If the principal has Write on some target graphs but not others, the entire UPDATE is rejected atomically — partial writes do not occur.
Graph-Level Write Verification¶
Write permissions are checked at two levels:
- Role permission level — the principal must have
WriteorAdminfrom RBAC. - Graph-level ACL visibility — the target graph must be in the principal's visible graph set. A graph that is not visible for reading is also not writable.
# Example: writer role can see and write to specific graphs
[acl.contexts.writer_context]
visible_graphs = ["http://example.org/data", "http://example.org/staging"]
visible_default_graph = false
[acl.role_contexts]
"writer" = "writer_context"
A principal with the writer role can only INSERT or DELETE triples in http://example.org/data and http://example.org/staging. Attempts to write to any other graph receive 403 Forbidden.
Default Graph Access Control¶
The default graph (the unnamed graph in SPARQL queries without GRAPH clauses) requires explicit visible_default_graph = true in the ACL context. This prevents accidental writes to the default graph from applications that omit the named graph specification.
Atomic Update Rejection on Permission Failure¶
If a SPARQL UPDATE or SurrealQL transaction targets multiple graphs and the principal lacks permission on any one of them, the entire transaction is rejected before any write occurs. This ensures write atomicity and prevents partial-update inconsistencies.
# If the principal has Write on graph:A but not on graph:B,
# this entire transaction is rejected — graph:A is NOT modified
DELETE { GRAPH <http://example.org/A> { ex:alice ex:status "old" . } }
INSERT { GRAPH <http://example.org/A> { ex:alice ex:status "new" . } }
WHERE { }
;
INSERT DATA {
GRAPH <http://example.org/B> {
ex:log ex:entry "alice status changed" .
}
}
Result: 403 Forbidden with error message indicating which graph lacks permission. Neither graph is modified.
Permission Audit¶
All authorization decisions can be logged to the audit trail:
[audit]
enabled = true
log_auth = true # Log all 401/403 events
log_writes = true # Log all successful Write/Admin operations
log_reads = false # Log all successful Read operations (high volume)
Authorization failure log entry:
{
"event": "authorization_failure",
"timestamp": "2025-11-14T09:23:41.882Z",
"user": "alice@example.com",
"roles": ["reader"],
"operation": "SPARQL_UPDATE",
"target_graph": "http://example.org/financial-data",
"reason": "role 'reader' has permission 'Read'; required 'Write'",
"request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"client_ip": "10.0.0.55"
}