Define the tenancy, identity, and authorization model #11

Closed
opened 2026-08-02 03:05:53 +00:00 by christian · 2 comments
Owner

Question

Server-authoritative, two party classes, and one hard invariant: a vendor's bid must never be reachable by another vendor.

Resolve: what an organization is, how a single company can be both a retailer and a vendor, how users belong to organizations, how a retailer-scoped vendor roster relates to a global vendor identity, and where authorization is enforced. Model this so the data-release rules can be expressed as policy over it rather than scattered through handlers.


Parent: #1

## Question Server-authoritative, two party classes, and one hard invariant: a vendor's bid must never be reachable by another vendor. Resolve: what an organization is, how a single company can be both a retailer and a vendor, how users belong to organizations, how a retailer-scoped vendor roster relates to a global vendor identity, and where authorization is enforced. Model this so the data-release rules can be expressed as policy over it rather than scattered through handlers. --- Parent: #1
christian added the
wayfinder:grilling
wayfinder:ticket
labels 2026-08-02 03:05:53 +00:00
christian added a new dependency 2026-08-02 03:06:25 +00:00
christian added a new dependency 2026-08-02 03:06:26 +00:00
christian added a new dependency 2026-08-02 03:06:26 +00:00
christian added a new dependency 2026-08-02 03:06:26 +00:00
christian added a new dependency 2026-08-02 03:11:54 +00:00
christian self-assigned this 2026-08-02 03:11:54 +00:00
Author
Owner

Resolution

Four decisions. Together they fix the shape of the data layer for the whole system.

1. Two entity types: roster entry and claimed vendor org

A retailer's import creates roster entries — retailer-scoped rows carrying a name, contact,
possibly a DUNS. They are owned by the retailer, have no account and no login. When a vendor
accepts an invitation and signs up, they claim their entry, which links it to a global
vendor org.

Why lazily rather than eagerly. You cannot mint global identities for thousands of companies
that have not signed up, and most roster entries never will. This reaches the one-profile-many-
retailers outcome exactly when a vendor has a reason to care, and avoids creating account-shaped
records for companies that have never heard of us.

What it costs. Two entity types to reason about in every query, and claim/merge is real work —
"Acme Foods Inc" on one roster and "Acme Foods" on another must resolve to one org. See the
graduated claim-flow ticket.

2. One org, two capabilities

An organization holds the issuer capability, the vendor capability, or both. Permissions
never resolve against an org alone — always against (org, capability, resource). The same org
issuing an RFP and bidding on a different one are two authorization contexts that cannot see each
other.

Why. Forcing separate orgs breaks the one-profile promise the moment a company does both: two
vaults, two member lists, two billing relationships. The mid-market case is real — a grocery chain
issues RFPs to its suppliers and bids on private-label work for a larger retailer.

What it costs. Every release rule must be capability-scoped, and getting that wrong leaks
across the exact boundary the product sells.

3. Enforcement is Postgres row-level security

The database refuses to return rows the current actor may not see. Application bugs cannot leak a
competitor's bid — isolation is structural, not policy-enforced.

This was chosen over the recommended central policy module, deliberately: the invariant that a
vendor's bid is never reachable by another vendor is the one failure that would destroy the
product's credibility, and it is worth enforcing at the lowest layer.

Consequences the map must absorb.

  • Lifecycle-dependent release rules (what a retailer sees before bid / at bid / at shortlist / at
    award) are the awkward case for RLS. The data-release ticket must now produce rules that are
    expressible in policy SQL, not merely correct.
  • Testing shifts toward database-level tests. Policy coverage is not something unit tests of
    handlers can demonstrate.
  • Empty results become the failure mode for authorization bugs, which are harder to diagnose than
    explicit denials. Observability around policy denial is not optional.

4. Actor context via transaction-scoped session GUCs

Request middleware opens a transaction and issues SET LOCAL app.actor_id / app.org_id / app.capability; policies read them through current_setting().

Why SET LOCAL. It is transaction-scoped, so it cannot leak across a pooled connection to the
next request — the specific failure that would be catastrophic here. It also keeps PgBouncer in
transaction pooling mode viable.

What it costs, and what the layout must do about it. Every data path must run inside a
transaction, and any code touching the pool outside that discipline silently runs with no actor
set. The code layout needs a single database entry point that makes bypassing it awkward — this
is a structural requirement on the layout, not a convention to remember.

Still open, graduated to its own ticket

Who may claim a roster entry, and how that claim is verified.

## Resolution Four decisions. Together they fix the shape of the data layer for the whole system. ### 1. Two entity types: roster entry and claimed vendor org A retailer's import creates **roster entries** — retailer-scoped rows carrying a name, contact, possibly a DUNS. They are owned by the retailer, have no account and no login. When a vendor accepts an invitation and signs up, they **claim** their entry, which links it to a global **vendor org**. **Why lazily rather than eagerly.** You cannot mint global identities for thousands of companies that have not signed up, and most roster entries never will. This reaches the one-profile-many- retailers outcome exactly when a vendor has a reason to care, and avoids creating account-shaped records for companies that have never heard of us. **What it costs.** Two entity types to reason about in every query, and claim/merge is real work — "Acme Foods Inc" on one roster and "Acme Foods" on another must resolve to one org. See the graduated claim-flow ticket. ### 2. One org, two capabilities An organization holds the **issuer** capability, the **vendor** capability, or both. Permissions never resolve against an org alone — always against `(org, capability, resource)`. The same org issuing an RFP and bidding on a different one are two authorization contexts that cannot see each other. **Why.** Forcing separate orgs breaks the one-profile promise the moment a company does both: two vaults, two member lists, two billing relationships. The mid-market case is real — a grocery chain issues RFPs to its suppliers and bids on private-label work for a larger retailer. **What it costs.** Every release rule must be capability-scoped, and getting that wrong leaks across the exact boundary the product sells. ### 3. Enforcement is Postgres row-level security The database refuses to return rows the current actor may not see. Application bugs cannot leak a competitor's bid — isolation is structural, not policy-enforced. **This was chosen over the recommended central policy module**, deliberately: the invariant that a vendor's bid is never reachable by another vendor is the one failure that would destroy the product's credibility, and it is worth enforcing at the lowest layer. **Consequences the map must absorb.** - Lifecycle-dependent release rules (what a retailer sees before bid / at bid / at shortlist / at award) are the awkward case for RLS. The data-release ticket must now produce rules that are *expressible in policy SQL*, not merely correct. - Testing shifts toward database-level tests. Policy coverage is not something unit tests of handlers can demonstrate. - Empty results become the failure mode for authorization bugs, which are harder to diagnose than explicit denials. Observability around policy denial is not optional. ### 4. Actor context via transaction-scoped session GUCs Request middleware opens a transaction and issues `SET LOCAL app.actor_id / app.org_id / app.capability`; policies read them through `current_setting()`. **Why `SET LOCAL`.** It is transaction-scoped, so it cannot leak across a pooled connection to the next request — the specific failure that would be catastrophic here. It also keeps PgBouncer in transaction pooling mode viable. **What it costs, and what the layout must do about it.** Every data path must run inside a transaction, and any code touching the pool outside that discipline silently runs with no actor set. **The code layout needs a single database entry point that makes bypassing it awkward** — this is a structural requirement on the layout, not a convention to remember. ### Still open, graduated to its own ticket Who may claim a roster entry, and how that claim is verified.
Author
Owner

Note from a skeptical review pass — RLS needs sanctioned bypasses, enumerated

Decision 3 sells isolation as structural: the database refuses rows the current actor may not see.
Two decisions taken since require reading rows no actor context can legally see.

  • The anonymised acceptance count (the vault, decision 2) reads acceptance rows owned by
    other retailers. The whole point is that the requesting retailer cannot see them.
  • The expiry scheduler (the vault, decision 4; reaffirmed by entitlement) runs
    across all vendors on a clock with no request and no actor at all.

Both require running around RLS. That does not invalidate decision 3, but it changes what the layout
owes: not merely "a single database entry point that makes bypassing it awkward", but a short,
enumerated, reviewable list of sanctioned privileged paths
, each with a stated reason. The
k-anonymity floor lives inside one of them, which makes it privileged code rather than application
code.

Carried as a constraint on the code layout.

## Note from a skeptical review pass — RLS needs sanctioned bypasses, enumerated Decision 3 sells isolation as structural: the database refuses rows the current actor may not see. Two decisions taken since require reading rows **no actor context can legally see**. - **The anonymised acceptance count** ([the vault](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/13), decision 2) reads acceptance rows owned by *other retailers*. The whole point is that the requesting retailer cannot see them. - **The expiry scheduler** ([the vault](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/13), decision 4; reaffirmed by [entitlement](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/20)) runs across all vendors on a clock with **no request and no actor at all**. Both require running around RLS. That does not invalidate decision 3, but it changes what the layout owes: not merely "a single database entry point that makes bypassing it awkward", but a **short, enumerated, reviewable list of sanctioned privileged paths**, each with a stated reason. The k-anonymity floor lives inside one of them, which makes it privileged code rather than application code. Carried as a constraint on the code layout.
christian referenced this issue from a commit 2026-08-03 21:21:06 +00:00
Sign in to join this conversation.
No description provided.