Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Permission-aware multimodal search: Mixpeek + OpenFGA

A runnable, end-to-end example of authorized retrieval: two users run the same image search and get different, correctly-scoped results — enforced server-side, fail-closed, with zero client-side filtering.

Mixpeek does the multimodal retrieval (image/text/video search over embeddings). OpenFGA (CNCF, a Zanzibar-style ReBAC engine) is your source of truth for who-can-see-what. Mixpeek acts as a relying party: at query time it asks your OpenFGA what the acting user can see and drops everything else before the response leaves the box.

The point: add fine-grained authorization to multimodal search without duplicating your permission model in the search layer.

What you'll build

  • A local OpenFGA (Docker + Postgres) with a user / group / document model
  • A Mixpeek namespace that indexes six sample images
  • viewer tuples mapping each Mixpeek document_id to a user, a group, or public
  • Two user-scoped Mixpeek keys (alice, bob)
  • A demo that proves alice and bob see different result sets for one query
alice sees: her 2 private docs + 1 group-shared + 1 public  = 4
bob   sees: his 2 private docs + 1 group-shared + 1 public  = 4
neither can ever see the other's private docs

Why localhost alone won't work (read this)

Mixpeek's API is hosted at api.mixpeek.com. It physically cannot reach http://localhost:8080. If you point Mixpeek's authorization config at a localhost OpenFGA, every query silently returns the fail-closed (empty) set because Mixpeek can't call your store.

The fix is a 2-minute tunnel that gives OpenFGA a public https:// URL:

cloudflared tunnel --url http://localhost:8080
# prints e.g. https://random-words.trycloudflare.com  -> put that in OPENFGA_API_URL

OpenFGA runs with a preshared key so the tunnel isn't an open door, and that same key is stored as a Mixpeek org secret (never in your namespace config). (ngrok http 8080 works too. The deprecated api_token + http://localhost path only works if you also run Mixpeek locally — out of scope here.)

Prerequisites

  • Docker + Docker Compose
  • Python 3.9+
  • A free Mixpeek API key (org-scoped, mxp_sk_...) from https://mixpeek.com
  • cloudflared (install) or ngrok

Run it

git clone https://github.com/mixpeek/mixpeek-openfga-example
cd mixpeek-openfga-example
pip install -r requirements.txt
cp .env.example .env

1. Set a preshared key in .env:

# OPENFGA_PRESHARED_KEY=<paste output of: openssl rand -hex 32>
# MIXPEEK_API_KEY=mxp_sk_...
# MIXPEEK_USER_EMAIL=you@example.com   (an email on your Mixpeek org)

2. Start OpenFGA and bootstrap the store/model/tuples:

docker compose up -d
./scripts/01_bootstrap_openfga.sh      # creates store + model, loads group tuples

3. Expose OpenFGA and point Mixpeek at it. In a second terminal:

cloudflared tunnel --url http://localhost:8080

Copy the printed https://...trycloudflare.com URL into OPENFGA_API_URL in .env.

4. Set up Mixpeek and wire authorization:

python scripts/02_setup_mixpeek.py

This creates the namespace, ingests the images, writes per-document viewer tuples to OpenFGA (keyed on the real document_ids), mints alice/bob keys, stores your OpenFGA token as an org secret, and opts the namespace into authorization. IDs and keys are saved back to .env.

5. See it work:

python scripts/03_demo_authorized_search.py
Query: "a photo"  (same for both users, no client-side filtering)

  alice  sees 4 docs: [...]
  bob    sees 4 docs: [...]

  shared/public (both see): 2 docs
  alice-only (private):     2 docs
  bob-only (private):       2 docs

PASS — server-side, fail-closed authorization is working.

How the integration works

  1. The acting subject is the user-scoped key's principal_id, mapped to an OpenFGA subject — user:<principal_id> by default (aliceuser:alice).
  2. Each Mixpeek document is an OpenFGA object whose id equals the Mixpeek document_id (object type document, relation viewer by default).
  3. At retrieval time Mixpeek queries OpenFGA (ListObjects / BatchCheck) and filters results to what the subject can viewer. Group, role, and folder inheritance all resolve inside OpenFGA — Mixpeek only relays the decision.

The namespace opt-in (set by 02_setup_mixpeek.py):

{
  "infrastructure": {
    "authorization": {
      "enabled": true,
      "provider": "openfga",
      "api_url": "https://<your-tunnel>.trycloudflare.com",
      "store_id": "01J0X...",
      "relation": "viewer",
      "object_type": "document",
      "mode": "pull_list_objects",
      "api_token_secret_ref": "openfga_token"
    }
  }
}

Enforcement modes

mode Strategy Use when
auto (default) ListObjects pre-filter when the accessible set is small (≤ list_objects_max), else BatchCheck post-filter Sensible at any size
pull_list_objects Always ListObjects → pre-filter Small accessible set per user (≤ ~1000) — used in this demo
pull_batch_check Search, then BatchCheck candidates, drop unauthorized Users can access many docs
push Sync grants into an indexed field, filter in-index Lowest latency; eventual consistency ok

For BatchCheck post-filtering, Mixpeek over-fetches by over_fetch_factor (default 2×) so a page stays full after unauthorized docs are dropped.

Layout

docker-compose.yml          OpenFGA + Postgres (+ commented cloudflared)
fga/model.fga               authorization model (human-readable DSL)
fga/model.json              same model as OpenFGA API JSON (used by bootstrap)
fga/tuples.json             doc-independent tuples (group memberships)
sample-data/images.json     six images + their grants
scripts/01_bootstrap_openfga.sh   create store, write model, load tuples
scripts/02_setup_mixpeek.py       namespace -> ingest -> tuples -> keys -> opt-in
scripts/03_demo_authorized_search.py   alice vs bob, asserted
scripts/common.py           tiny Mixpeek + OpenFGA clients

Notes

  • Doc IDs come from ingestion. A document's OpenFGA object id must equal its Mixpeek document_id, which only exists after ingestion. So the per-document viewer tuples are written by 02_setup_mixpeek.py after the batch completes — not in fga/tuples.json (which only holds group memberships). In production you'd write/revoke these tuples as documents are created and shared.
  • Fail-closed. If OpenFGA is unreachable, queries return the safe subset (fewer results), never unauthorized documents.
  • Enforced at the API layer. Always route end-user traffic through the Mixpeek API; direct vector-store access bypasses these filters.

Troubleshooting

  • Search returns 0 results right after ingestion. The vectors are written but the searchable index can lag on fresh batch writes (you'll see a mvs_searchable_index_gap warning in the retriever's stage statistics). Verify with the org key first ({"inputs": {"query": "a photo"}}) to separate a search problem from an authorization problem, then retry after the index hydrates.
  • URL validation failed ... File is empty (0 bytes). Mixpeek validates image URLs and doesn't follow redirects. Use direct image URLs (the sample data uses images.unsplash.com, not redirecting shorteners like picsum.photos).
  • Image ingestion is slow the first time. A cold image model can take 10+ minutes to load before the batch processes. The setup script waits up to 30.
  • qdrant_collection Field required when enabling authorization. The namespace PATCH validates the whole infrastructure object; the script sends qdrant_collection (the namespace id) alongside the authorization block.

Links

MIT licensed.

About

Authorized multimodal retrieval: Mixpeek + OpenFGA (ReBAC permission-aware image search), runnable end-to-end

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages