Define the tenancy, identity, and authorization model #11
Notifications
Due Date
No due date set.
Blocks
#14 Specify the two-party data release rules
christian/helmdocs-proposal-system
#12 Design the visibility and qualification model
christian/helmdocs-proposal-system
#13 Design the vendor profile and cross-retailer document vault
christian/helmdocs-proposal-system
#19 Draw the HelmDocs integration boundary
christian/helmdocs-proposal-system
#23 Design the app framework — retailer and vendor surfaces
christian/helmdocs-proposal-system
Reference: christian/helmdocs-proposal-system#11
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
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 orgissuing 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.
award) are the awkward case for RLS. The data-release ticket must now produce rules that are
expressible in policy SQL, not merely correct.
handlers can demonstrate.
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 throughcurrent_setting().Why
SET LOCAL. It is transaction-scoped, so it cannot leak across a pooled connection to thenext 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.
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.
other retailers. The whole point is that the requesting retailer cannot see them.
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.