Skip to content

Vespa OpenFGA Searcher plugin ​

The Vespa OpenFGA Searcher plugin moves ACL filtering from the query-coordinator into Vespa itself. With the plugin enabled, the coordinator stops emitting its own where clause for authorization and instead passes the caller's identity to Vespa as query properties; the plugin then runs the configured strategy against the tenant's OpenFGA store.

This page covers what the plugin is, when to enable it, and how operators wire it up.

Why the plugin exists ​

The coordinator-side strategies covered in ACL evaluator cutover work for the common case. They start to bite when:

  • The corpus has deep ReBAC graphs — group-of-group nesting, transitive sharing, parent-folder-implies-child-access. The list-then-filter strategy is safe but bounded by OpenFGA's ListObjects cap (default 1 000 entries per user); past that cap, search-then-check is the correct fallback because OpenFGA is the only authoritative ACL source — flattening transitive grants onto Vespa documents would drift the moment a relation changes.
  • The query rate is high enough that one extra round-trip per query matters. Coordinator-side strategies always mean an extra hop between Vespa and OpenFGA; the plugin co-locates that hop inside the Vespa container.
  • Conditional access (clearance levels, sensitivity labels) needs to be evaluated at retrieval rather than post-filter. The plugin passes the user's clearance level as the OpenFGA request context on every check, so cleared_at_or_above decisions land inside the retrieval layer.

If none of those apply, you can stay on the coordinator-side strategy. The plugin is opt-in, not the default.

What you get ​

  • Two strategies, same names as the coordinator-side ones: search-then-check (default) and list-then-filter. Picked at plugin-config time per Vespa cluster. OpenFGA is the only authoritative ACL source — there is no flatten-onto-document strategy.
  • TTL-based caching of ListObjects results (default 5 s). No external invalidation; the staleness budget is the TTL.
  • HTTP/2 keep-alive to OpenFGA — connections pool across queries.
  • Defense in depth — the coordinator's post-retrieval evaluator still runs, so a misconfigured plugin can over-filter but cannot over-grant.
  • Apache 2.0 license isolated from the rest of the BSL-licensed repository, so operators can extract the plugin into their own project if they want to.

License ​

Apache 2.0. The plugin lives in deploy/vespa/components/openfga-searcher/ and every source file carries // SPDX-License-Identifier: Apache-2.0. The repo's CI gate enforces this.

The repo-wide BSL v1.1 license does not apply to the plugin directory. Operators that fork the rest of Moʻolelo are bound by BSL terms; the plugin is independently re-distributable under Apache 2.0.

Building the plugin ​

The plugin is a Vespa "container plugin" Maven artifact:

sh
cd deploy/vespa/components/openfga-searcher
mvn -B clean package

Artifact lands at target/openfga-searcher-<version>-deploy.jar. Drop it into your Vespa application bundle's components/ directory.

Wiring into your Vespa application ​

Reference the searcher in services.xml:

xml
<container id="default" version="1.0">
    <search>
        <chain id="default" inherits="vespa">
            <searcher id="ai.moolelo.vespa.openfga.OpenFgaAclSearcher"
                      bundle="openfga-searcher"/>
        </chain>
    </search>

    <nodes>
        <node hostalias="vespa-1">
            <jvm options="-DMOOLELO_OPENFGA_URL=http://moolelo-openfga.moolelo-system:8080
                          -DMOOLELO_OPENFGA_STORE_ID=tenant_acme
                          -DMOOLELO_ACL_STRATEGY=search-then-check
                          -DMOOLELO_ACL_CACHE_TTL_SECONDS=5"/>
        </node>
    </nodes>
</container>

The store id comes from tenant_acl_config.openfga_store_id, populated by the webserver tenant provisioner at tenant-create time.

Wiring the coordinator ​

Set QUERY_COORDINATOR_VESPA_PLUGIN_ENABLED=true on the query-coordinator Deployment. From that point:

  • The coordinator stops emitting the YQL ACL clause it would have emitted for the configured strategy.
  • It adds auth.subject, auth.tenant, auth.groups, and any auth.context.* query properties to every Vespa request.
  • The post-retrieval ACLEvaluator still runs (defense in depth).

If you also want clearance-level checks, set up the coordinator's UserContextFn (in code today; helm-config support is on the roadmap) to return the user's clearance level. The plugin passes it through as auth.context.user_clearance_level, which the OpenFGA model's cleared_at_or_above condition reads.

Failure mode ​

If the plugin can't reach OpenFGA, it tags the Vespa result with an error message. The coordinator sees the error, logs an acl.plugin_unavailable audit row, and falls back to the coordinator-side static-attribute filter for that query — slower and possibly less precise, but never serves results that violate upstream permissions.

The plugin never falls open. An OpenFGA outage stops serving content, never starts serving more.

Multi-tenant deployments ​

One Vespa cluster per tenant group that shares an OpenFGA store. Tenants that need their own store get their own Vespa cluster; tenants that share a store can share a cluster, with the plugin's auth.tenant query property partitioning per-tenant cache state.

For most operators, the simplest topology is:

  • One Vespa cluster per tenant.
  • One OpenFGA store per tenant.
  • Plugin's MOOLELO_OPENFGA_STORE_ID pinned to that tenant's store at boot.

This keeps the blast radius of any plugin/cluster issue scoped to one tenant.

Strategy selection ​

StrategyWhat you give upWhat you gain
search-then-check (default)One OpenFGA round-trip per query (cached connection, ~1–5 ms).Correct for any ReBAC graph; no allow-list cap.
list-then-filterBounded by OpenFGA ListObjects cap (default 1 000); 5 s allow-list staleness window.Vespa retrieves only authorized entities — minimum recall pressure.

Operators that don't have a reason to pick should leave it on search-then-check.

Adaptive refill (search-then-check) ​

When a tenant's deny rate is high enough that the post-retrieval BatchCheck drops too many hits, the plugin re-issues retrieval at a bumped Vespa offset, BatchChecks the new page, and appends survivors. The loop terminates as soon as survivors reach the caller's hits target, or one of these bounds trips:

KnobDefaultMeaning
MOOLELO_ACL_OVERFETCH_FACTOR2Multiplier on remaining hits when sizing a refill page.
MOOLELO_ACL_MAX_REFILL_ITERATIONS4Hard cap on refill rounds.
MOOLELO_ACL_MAX_CANDIDATES1000Hard cap on cumulative candidates BatchChecked (initial pass + refills).

Raise these on tenants where users see persistent under-recall; lower them on latency-sensitive tenants. Hitting the MOOLELO_ACL_MAX_CANDIDATES cap is a strong signal the tenant should move to list-then-filter.

Observability ​

The plugin emits Vespa query traces with the prefix openfga-acl:. They show up in Vespa's standard tracing output:

text
openfga-acl: search-then-check kept 18/50 candidates
openfga-acl: refill 1 kept 3/20 (survivors=21, evaluated=70)
openfga-acl: list-then-filter applied (124 entities)
openfga-acl: no identity on query, skipping plugin

Use these to verify the plugin is engaging the way you expect during the rollout window.

Future spin-out ​

The plugin's directory layout is preserved verbatim for a future extraction into its own Apache 2.0 project. The only repository- local couplings are the CI gate that enforces SPDX headers and the Apache 2.0 LICENSE check on the plugin directory. Both rules are documented in the parent repository's CI config so an operator forking the plugin gets a clean, self-contained tree.

Moʻolelo — self-hosted federated enterprise knowledge platform.