Access control
Moʻolelo's authorization layer is designed around a single invariant: the platform never grants access the source system would not grant. When a user queries Moʻolelo, the answer they get back is exactly the subset of relevant records that they are allowed to read in the upstream sources — no more, no less.
This page explains how the platform makes that mechanically true.
The big picture
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Content source │ │ Identity source │ │ Permission grant │
│ (Confluence, │ │ (Entra ID, │ │ (per-page │
│ SharePoint, │ │ Atlassian, │ │ restriction, │
│ GitHub) │ │ GitHub orgs) │ │ site role, │
│ │ │ │ │ collaborator) │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ acl-projector │
│ Translates source-system ACLs + identity memberships into │
│ OpenFGA tuples in the tenant's authorization store. │
└─────────────────────────────┬───────────────────────────────┘
▼
┌────────────────────────┐
│ OpenFGA tenant store │
│ ───────────────────── │
│ user:alice@… │
│ ↳ viewer ↳ entity:… │
│ ↳ member ↳ group:… │
└─────────────┬──────────┘
▼
┌────────────────────────┐
│ query-coordinator │
│ Filters retrieval │
│ candidates against │
│ the user's identity. │
└────────────────────────┘Three streams converge in the projector:
- Content-side ACLs from each connector. When the connector indexes a Confluence page, it also fetches that page's read restrictions; when it indexes a GitHub repo, it fetches the collaborators and team grants. Each grant becomes one ACL change event.
- Identity catalog updates from the identity-collector. User upserts populate the alias store so non-email principals (a GitHub login like
alice) resolve to the canonical email (alice@example.com). Group membership updates become OpenFGAgroup#membertuples. - Source authority chain kept on every ACL grant. When the projector writes a tuple it records why — which source policy justified the grant — so the audit warehouse can reproduce the reasoning later.
What gets written to OpenFGA
The authorization model has four types:
| Type | What it represents |
|---|---|
user | A canonical-person identity (user:alice@example.com). |
group | A source-system group (group:engineers, group:acme/backend). |
entity | A record-level ACL anchor (entity:confluence:page:123). |
chunk | The searchable unit; inherits from parent_entity. |
Direct grants land on entity:; the chunks the search index returns inherit access from their parent entity. This keeps the OpenFGA tuple count proportional to the number of records, not the number of chunks (which is typically 5–50× larger).
For deployments that need finer-grained authorization (clearance levels, classification labels), the model includes a conditional cleared_at_or_above grant on the chunk type:
type chunk
relations
define parent_entity: [entity]
define viewer: [user, group#member] or
viewer from parent_entity or
user with cleared_at_or_aboveThe cleared_at_or_above condition compares the user's clearance level (passed in the request context at query time) against the document's classification level (stored on the tuple). The two fields are operator-configured at deployment time.
Per-source ACL mapping
| Source | What's harvested | OpenFGA tuple shape |
|---|---|---|
| GitHub | Repo collaborators + team permissions | user:<email> ↔ viewer ↔ entity:github:repo:<owner>/<name> |
| Confluence | Page-level read restrictions (when explicit) | user:<email> ↔ viewer ↔ entity:confluence:page:<id> |
| SharePoint | Drive-item permissions + sensitivity labels | user:<email> ↔ viewer ↔ entity:sharepoint:driveitem:<id> (+ classification condition) |
For Confluence, pages with no explicit restriction inherit from their space — the projector emits no event in that case, and the inheritance shows up via the parent_entity relation written by the chunk pipeline. Same shape for SharePoint items in folders without explicit grants.
Reconciliation
OpenFGA tuple writes are idempotent on (user, relation, object), so a Pulsar redelivery after a sink restart cannot create a duplicate tuple. The reconciliation strategy is:
- Continuous — every ACL change event the connector emits and every membership change the identity-collector emits flows through within seconds.
- On-restart replay — the projector resumes from the persisted Pulsar offset; idempotency on tuple keys guarantees no double-grant.
- Periodic full re-snapshot — on the roadmap is a scheduled (default weekly) re-snapshot pass that compares projector-side state against OpenFGA's view and fixes drift from any missed deliveries.
Unknown principals
Some source-system ACLs reference principals the identity catalog doesn't know about — typically external collaborators or a GitHub user whose email isn't published. When that happens, the projector:
- Emits an
acl.unknown_principalaudit event with the source reference, the resource, and the ACL grant the projector couldn't resolve. - Skips the tuple — the principal doesn't get access through Moʻolelo, even if they have it in the source.
Operators inspect these events in the audit warehouse and resolve them by either:
- Adding an explicit
(provider, provider_id) → canonical_emailalias via the admin API. - Re-running the identity-collector for the relevant source.
- Accepting the principal as out-of-scope — typical for external collaborators on a GitHub repo.
The "fail closed" default is deliberate: granting access to a principal whose identity Moʻolelo can't verify would violate the "never more than the source system grants" invariant.
Per-tenant isolation
Each tenant has its own OpenFGA store (store_id = tenant_<id>). The provisioner creates the store at tenant-create time, ships the authorization model, and persists (store_id, model_id, endpoint) on tenant_acl_config. The acl-projector and the query-coordinator both read this row at boot to know which store to write to / check against.
There is no cross-tenant tuple traffic. A bug in one tenant's projector cannot leak into another tenant's authorization store.
Where ACLs get enforced
Moʻolelo enforces access in three complementary places:
- At projection — the acl-projector writes OpenFGA tuples that reflect each source's permissions, and emits an
acl.unknown_principalevent whenever it can't resolve someone. - At the query coordinator — the coordinator runs the OpenFGA evaluator over candidate results, applies per-source policy hooks for permissions OpenFGA can't model directly, and emits an audit row per served result with the authority chain.
- Inside Vespa — an Apache 2.0 Searcher plugin filters retrieval candidates inside Vespa via OpenFGA
BatchCheck,ListObjects, or static-attribute pre-filter. Per-query latency stays bounded regardless of corpus size, and ReBAC-shaped permissions (group-of-group, transitive sharing, parent-folder-implies-child-access) are honored at the retrieval layer with no recall loss. The plugin lives in a clearly isolated subdirectory so it can be extracted into its own project later. See Vespa OpenFGA plugin for the operator-facing setup.
On the roadmap:
- Periodic full re-snapshot reconciliation (in addition to the continuous projection that ships today).
- ML-driven identity resolution for users whose emails aren't exposed by their source systems.
- ReBAC-graph-aware ACL projection for tenants with deep permission inheritance trees beyond what
parent_entitycovers.

