openportfolio

Multi-tenancy

One deployment holds many books. The invariant is that a caller never says which tenant it is.

Where the tenant comes from

tenantId is derived from the caller's membership rows, or from the service key's own row. There is no argument a client can set to reach another book. The public API accepts tenantSlug, and only as a disambiguator for a caller who belongs to several tenants; membership is still what decides.

Missing, not forbidden

A document id belonging to another tenant reads as not found. Answering "forbidden" would confirm the row exists, which is itself the cross-tenant read you were trying to prevent.

Indexes lead with the tenant

Every index leads with tenantId, so a query that forgets the scope cannot use an index at all and fails loudly in review rather than quietly returning somebody else's rows.

One exception is deliberate and marked: the resolver cron sweeps every book's due forecasts through a tenant-less index. It is an internalMutation for exactly that reason and is unreachable from any client. A test asserts it scores each book against its own prices.

Service keys

Workers and the MCP server have no browser session, so they present a key. The operator generates it locally and registers only its hash, which means the deployment never returns a secret and a leaked database does not leak a credential.

KEY="$(openssl rand -hex 32)"
npx convex run tenants:issueServiceKey "{\"key\":\"$KEY\",\"label\":\"sync-worker\",\"role\":\"viewer\"}"

One key maps to exactly one tenant and carries its own role. Rotate by issuing a new label and revoking the old one.