Wine2You — Identity, Authorization & API-Key Architecture (deployed)
Wine2You — Identity, Authorization & API-Key Architecture
Status: ACCEPTED & DEPLOYED (Kris, 2026-10-06). Scope: how machines/services/users communicate under Wine2You's own infrastructure. Databunker is out of scope here (separate age-verification module).
1. Domain scope (what the platform must express)
| Concept | Definition |
|---|---|
| User | One identity (human). |
| User type(s) | A user may hold multiple types simultaneously: buyer, supplier, integrator (+ later: curator, staff). Types are not exclusive. |
| Entity | A trading unit — an Estate (supplier) or a Shop (a curated selection, optionally with storefront). |
| Membership | Multiple users join one entity. A user may belong to several entities. |
| Integrator | A user type that creates/manages API credentials for the entities they are associated with — enabling feeds of inventory, prices, shipping, etc. |
| API key | A machine credential bound to exactly one entity — keys do NOT span entities (Kris, 2026-10-06). |
2. Architecture — deployed shape (3 containers + Unkey Cloud)
┌──────────────────────────────────────────────────────────────────────────────┐
│ Wine2You services (Docker on contabo1, tailnet 100.64.0.8) │
│ │
│ ┌────────────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ (a) user-mgmt module │──▶│ (b) ZITADEL │ │ (c) Unkey │ │
│ │ OUR CODE (Next.js+TS) │ │ identity/OIDC │ │ CLOUD │ │
│ │ - owns domain model │ │ :18080 │ │ api.unkey. │ │
│ │ - integrator UI │ │ auth.tail. │ │ dev │ │
│ │ - key-mgmt UI │ │ digid.co │ │ (free tier) │ │
│ │ - orchestrates b/c/d │ └──────────────────┘ └──────────────┘ │
│ └───────────┬────────────┘ ▲ │
│ │ │ mint checks │
│ ▼ │ │
│ ┌────────────────────────┐ :18081/18082 │ │
│ │ (d) OpenFGA │◀────────────────────────────────┘ │
│ │ ReBAC relations │ "may integrator X mint key for entity E?" │
│ │ user↔type, user↔ │ │
│ │ entity, integrator↔ │ openfga.tail.digid.co │
│ │ entity, key↔entity │ │
│ └────────────────────────┘ │
│ │
│ Postgres 16 (:15432, internal) — ZITADEL + OpenFGA schemas │
└──────────────────────────────────────────────────────────────────────────────┘
Tailnet-only (WireGuard). Public path: Cloudflare Tower/edge → api.wine2you.shop
(Access service tokens for M2M; OIDC for users). No inbound port on contabo1.
Core app (Medusa v2 + Next.js + Meilisearch) lives on the Oracle free box
(wine2you-dev-zrh), separate host — see blast-radius rule (§5).
Deployment facts (live 2026-10-06)
| Component | Where | Address | Status |
|---|---|---|---|
| user-mgmt (a) | not yet deployed (task 3) | — | coded, tests pass |
| ZITADEL (b) | contabo1 /opt/compose/w2y-identity |
100.64.0.8:18080 / auth.tail.digid.co:18080 |
UP |
| OpenFGA (d) | contabo1 /opt/compose/w2y-identity |
100.64.0.8:18081 (http) :18082 (grpc) / openfga.tail.digid.co |
UP |
| Postgres | contabo1 /opt/compose/w2y-identity |
100.64.0.8:15432 (internal) |
UP |
| Unkey (c) | Unkey Cloud | https://api.unkey.com (key in 1P Digid/Unkey) |
live (SaaS) |
| Core app | Oracle wine2you-dev-zrh |
dev.wine2you.shop | reserved |
(a) user-mgmt module — ours, the source of truth
Owns the domain model and the Wine2You UI for integrators:
- Users, multi-type assignment, entities, memberships, integrator↔entity associations.
- Key-management UI: integrator mints/revokes entity-scoped keys.
- Orchestrates the other three: registers users/orgs in ZITADEL, writes relationship tuples to OpenFGA, calls Unkey to mint keys only after OpenFGA authorizes the integrator→entity pair.
- Built:
apps/user-mgmt/(Next.js + TypeScript + Prisma);tscclean, 9/9 tests pass.
(b) ZITADEL — authentication (who you are)
- Human login (OIDC/OAuth2), organizations, roles (user types), service accounts.
- ZITADEL is the authenticator; the module remains the authorizer/registrar.
- Runs on contabo1, tailnet-only,
auth.tail.digid.co:18080.
(c) Unkey — API-key lifecycle (what a machine presents) — CLOUD
- Issues, hashes (SHA-256, shown once), scopes, per-key rate limits/quotas, analytics, revocation.
- Entity-scoped: one key ↔ one entity. Called by the module after an OpenFGA check passes.
- Hosted on Unkey Cloud free tier (self-host min was 8 cores/16 GB/K8s — does not fit any box;
see §6). Root key in 1Password
Digidvault itemUnkey. Adapter:apps/user-mgmt/src/lib/unkey.ts.
(d) OpenFGA — fine-grained authorization (what a user may do to what)
- Zanzibar-style ReBAC; multi-tenant is a first-class production shape.
- Relationships:
user:<id> #type supplier,user:<id> #member entity:<estate1>,integrator:<id> #manages entity:<shop1>,key:<kid> #owner entity:<estate1>. - The module asks OpenFGA
Check(integrator:i, can_mint_key, entity:E)→ allow/deny. - Why not ZITADEL roles alone: roles express what a user is; the integrator→entity delegation is a relationship graph — OpenFGA's exact job.
Division of responsibility (one line each)
| Layer | Owns | Answers |
|---|---|---|
| user-mgmt module | domain model, UI, orchestration | "what IS the state?" |
| ZITADEL | credentials, login, service accounts | "who are you?" |
| OpenFGA | relationship tuples | "may you do X to Y?" |
| Unkey | key material + lifecycle | "is this key valid/scoped?" |
3. Core flows
A. User onboarding — user signs up → module registers in ZITADEL (org) → module writes
user#type + user#member entity tuples to OpenFGA → token carries type claims.
B. Integrator mints a key for an entity —
- Integrator opens the key UI (module).
- Module → OpenFGA
Check(integrator:i, can_mint_key, entity:E). - Allowed → module → Unkey
createKey(scopes, entity:E). - Module stores
key#owner entity:Ein OpenFGA + shows the plaintext key once. - Any service verifying the key → Unkey → entity scope resolved.
C. Machine feed (inventory/prices/shipping) — integrator's system calls
api.wine2you.shop/… with its entity-scoped key → Cloudflare Access + Unkey verify → module/API
acts within that entity only.
D. Entity membership — user invites users to an Estate/Shop → module writes membership tuples → OpenFGA governs what members can read/write.
4. Exposure & security
- Transport (public): Cloudflare Tunnel/edge →
api.wine2you.shop(no inbound port on the host). - Transport (internal): tailnet-only. Services bound to
100.64.0.8, addressed by tailnet DNS names (auth.tail.digid.co,openfga.tail.digid.co). Nothing publicly exposed. - M2M auth: Cloudflare Access service tokens + Unkey keys for the product API layer.
- User auth: OIDC via ZITADEL; the API verifies the session/JWT.
- One key = one entity — enforced at mint (OpenFGA + module), not left to convention.
- License note: ZITADEL (AGPL-3.0) self-hosted on our infra — fine. Unkey Cloud = SaaS.
5. Hosting split (approved 2026-10-06) — blast-radius rule
| Host | Runs | Why |
|---|---|---|
Oracle free box (wine2you-dev-zrh, 2 OCPU/12 GB) |
Wine2You core app (Medusa v2, Next.js, Meilisearch) | Project app owns its own host |
| contabo1 (8 vCPU/23 GB) | ZITADEL + OpenFGA + user-mgmt (identity = shared service) | Shared service, blast-radius isolation |
| Unkey | Cloud (free tier) | Self-host min 8 cores/16 GB/K8s — doesn't fit |
Blast-radius principle: identity/auth is expensive to rebuild and shared across projects → runs on the shared-services host (contabo1). Project apps own their own hosts.
6. Why Unkey is Cloud (not self-hosted)
Unkey's self-hosted deployment requires 8 cores / 16 GB RAM / 100 GB disk + Kubernetes 1.28+.
Neither the Oracle free box (2/12) nor hetzner1 (7.6 GB) fits; contabo1 could but at a large share
of its capacity for a component that Unkey runs well as SaaS. The Cloud free tier covers the
pre-launch need; the module talks to it over HTTPS with UNKEY_ROOT_KEY from 1Password.
7. Build order (status)
CF Tunnel + tailnet-only admin + Docker host— done.Postgres container— done (contabo1,:15432).ZITADEL container— done (contabo1:18080, live, migrations applied).OpenFGA container + model— done (contabo1:18081/:18082, live; model = task 3).Unkey— done (Cloud free tier, key in 1P).user-mgmtmodule — code done (apps/user-mgmt/, 9/9 tests); wiring to live stack = task 3.- Medusa/Next.js storefront + Meilisearch — on Oracle box (pending).
8. Open questions / carry-forward
- Task 3: wire
user-mgmtto the live ZITADEL/OpenFGA/Unkey (real E2E). - OpenFGA authorization model + ZITADEL org/role seeding (module responsibility).
- ZITADEL org mapping: one org per entity-owner vs one org with entity-projects (decide at impl).
- Module packaging: one container (API+UI) vs split — start monolithic.
Related
reference-identity-stack-deployment.md— the deployed stack (contabo1, ports, aliases, runbook)reference-oci-dev-host.md— Oracle host + billing (2 OCPU/12 GB, permanently free)project-overview.md— business model (Estate/Shop/curator concepts)