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.
- A local OpenFGA (Docker + Postgres) with a
user/group/documentmodel - A Mixpeek namespace that indexes six sample images
viewertuples mapping each Mixpeekdocument_idto 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
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_URLOpenFGA 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.)
- Docker + Docker Compose
- Python 3.9+
- A free Mixpeek API key (org-scoped,
mxp_sk_...) from https://mixpeek.com cloudflared(install) orngrok
git clone https://github.com/mixpeek/mixpeek-openfga-example
cd mixpeek-openfga-example
pip install -r requirements.txt
cp .env.example .env1. 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 tuples3. Expose OpenFGA and point Mixpeek at it. In a second terminal:
cloudflared tunnel --url http://localhost:8080Copy the printed https://...trycloudflare.com URL into OPENFGA_API_URL in .env.
4. Set up Mixpeek and wire authorization:
python scripts/02_setup_mixpeek.pyThis 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.pyQuery: "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.
- The acting subject is the user-scoped key's
principal_id, mapped to an OpenFGA subject —user:<principal_id>by default (alice→user:alice). - Each Mixpeek document is an OpenFGA object whose id equals the Mixpeek
document_id(object typedocument, relationviewerby default). - At retrieval time Mixpeek queries OpenFGA (
ListObjects/BatchCheck) and filters results to what the subject canviewer. 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"
}
}
}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.
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
- 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-documentviewertuples are written by02_setup_mixpeek.pyafter the batch completes — not infga/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.
- 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_gapwarning 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 usesimages.unsplash.com, not redirecting shorteners likepicsum.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_collectionField required when enabling authorization. The namespace PATCH validates the wholeinfrastructureobject; the script sendsqdrant_collection(the namespace id) alongside theauthorizationblock.
- Mixpeek permissions / OpenFGA: https://docs.mixpeek.com/platform/permissions
- Mixpeek quickstart: https://docs.mixpeek.com/overview/quickstart
- OpenFGA: https://openfga.dev
MIT licensed.