Security

The tenant boundary is a database policy, not an if statement.

Every question about who may read what is answered by Postgres, inside the query, as the role that asked it. There is no privileged read path for your own pages: a production screen runs the same SQL for a caterer as it does for the unit production manager, and the policies decide what comes back.

01

Row-level security, on everything

All ninety tables in the application schema have row-level security enabled, and forced.

Forced is the word that matters. Without it the table owner is exempt from its own policies, and any function running as the owner would quietly see every tenant’s rows while appearing to be filtered. With it, even the owner is subject to the policy set — which is why the few functions that must run with elevated rights each need an explicit policy admitting them, rather than silently inheriting the run of the database.

The web pool is not privilegedcrewfuel_web
The credential the request pool connects with is not a superuser and does not hold BYPASSRLS. Exactly one role in the database holds BYPASSRLS — crewfuel_migrator, the migration credential — and the application never connects as it.
Entitlement is resolved in one placeauth.granted_productions()
The function that answers “which productions may this actor reach” takes no argument. It reads the actor from the session itself, so there is no shape of the call that enumerates somebody else’s productions. That no-argument design is the entire security property of the function, and every policy in the system funnels through it rather than reading the grant tables directly.
A test fails the build if this slipspolicy-invariants
A migration runs as the one role that bypasses row-level security, so a function created without an explicit owner inherits that and skips every policy it was written to respect. An invariant test in the suite fails if any such function is left owned by the migration role.

02

Roles are separate database identities

A request opens as the pool credential and then becomes the identity the actor has actually earned, for that transaction only.

There are eight application identities and none of them can log in. The transaction issues SET LOCAL ROLE to one of them before it reads anything, and every privilege check from that point is made against that identity — a grant to the pool role is never consulted. The separation is not advisory: an action a caterer is not entitled to fails because Postgres refuses the statement, not because a component chose not to render a button.

  • app_customer
  • app_customer_ro
  • app_vendor
  • app_vendor_ro
  • app_member
  • app_kiosk
  • app_platform
  • app_worker
Privileges go down to the column
A caterer reads the production record through eight column grants and the shooting-day record through fourteen, with no table-level grant at all. They see the dates and call times they are cooking to and not the fields beside them.
Read-only twins for people who should only read_ro identities
An auditor and a read-only vendor account resolve to identities that hold SELECT and nothing else, so “read-only” is a property of the database session rather than of the screens somebody remembered to disable.
Writes are refused, not hidden
The console probes what a role may do and says plainly where a capability belongs to someone else — budget entry is the production accountant’s, what is cooked is the caterer’s — rather than offering a form that fails on submit.

03

Badge UIDs never leave the database

A badge is stored as an HMAC of its UID, keyed by a thirty-two byte pepper generated per production — and no application identity can read that pepper.

Turning a tap into a person requires the pepper, so the hash is computed where the pepper lives. A kiosk sends a UID and receives a member; it never holds the material to forge one. Reading the pepper into the application would put it in process memory, and in every heap dump and log line that follows, the one secret that lets somebody write a blank card which opens the catering line under another person’s name.

  • The table holding peppers grants no privilege to the production office identity, the vendor identity or the kiosk identity. The hash is produced inside the database and the UID travels one way: in, to be hashed, never out.
  • The pepper is per production on purpose. The same physical card carried between two shows hashes to two different values, so one production’s badge table can never be replayed against another’s.
  • The same reasoning covers kiosk tablets. What is stored is a hash of the device token; the plaintext exists in exactly one place for exactly one moment — the response to the request that minted it. It is never written to the database, never logged, never put in a URL and never recoverable. Losing a token means minting a new one.

04

A vendor is scoped to their engagement

Not to being a vendor. The difference is the most valuable thing on this page, and we know that because we got it wrong first.

An early version of the catering policy group asked “is this actor a caterer?” and never “which caterer?”. Two caterers on one production is not an edge case — it is what a splinter unit looks like, and what any mid-shoot caterer change looks like. Both sat in the same identity with catering in their service list, so every one of those policies evaluated true for both, across all rows. What the second caterer could read was the first one’s negotiated per-head price, overage price and guaranteed minimum. A rival’s price is the single thing a caterer would pay to see, and it was one SELECT away.

It was found by adversarial review of that policy group and fixed by scoping every policy to the actor’s own engagement rather than to their service type. The cross-tenant path a supplier legitimately needs still resolves to a concrete, active engagement row; there is no “or is a vendor” escape hatch anywhere in the policy set. A later migration went further and contained a function that had punched through the policy layer and named a caterer’s rivals to them.

A scoped figure says it is scoped
A caterer opening the rates screen sees their own card and no other, which is correct and is also a trap: a page rendering one card without comment reads as “this show has one supplier”. The screen says whose figures it is showing, because a scoped zero otherwise reads as a fact about the whole production.
Unreachable and non-existent look identical
A production you hold no grant on returns “nothing here”, not “forbidden”. The distinction matters: “this exists but is not yours” confirms an unreleased show to anyone who can guess an id, and an id becomes an oracle.
An unreadable panel never renders a zero
Where a single panel is beyond a role rather than the whole page, the screen says it is not visible to that role. A caterer shown a craft cost of 0.00 reads it as a fact about the show.

05

The record is append-only where the record is the point

A change of mind is a new row. Several tables grant UPDATE to nobody at all.

Tap resolutionsapp.tap_resolution
Deciding who an unplaced card belonged to — or that it was not a person at all — writes a row. No application identity holds UPDATE or DELETE on that table, and the queue reads the most recent decision rather than editing the previous one.
Ratesorg.rate_card_item
Insert only. No UPDATE, no DELETE, and the insert policy refuses a row dated at or before the newest row for that key — so a rate cannot be back-dated over a period a different rate already governed. Last month’s invoice recomputes to last month’s number forever.
Anything history points at
Nothing deletes a dish, a craft product or a device. A product that stops being stocked has months of transaction rows pointing at it and appears on invoices already reconciled; a retired tablet has a trail of what it recorded. They are switched off, never removed.
Totals are never taken from the browser
Quantities and prices on a billing screen are recomputed server-side from the commitment and the tap log on every write. A stock count is the same: the counted figure comes from the tablet, the expected figure is recomputed in the same transaction. A client-supplied expectation would let a stale tablet post a variance of zero against numbers that moved an hour ago — which is exactly what somebody covering a shortfall would want it to say.

06

Sessions, and people with access to yours

A session answers only whether somebody is who they say they are. It carries no authorization at all.

What a person may reach is resolved from live grants on every request. A session carrying roles would go stale the moment somebody was removed from a production, and on a shoot people are added and removed daily — so revoking access takes effect immediately rather than whenever a token happens to expire.

Support access is not standingplatform.support_session
Platform staff hold no ambient access to a production. The grant resolver admits them through exactly one path: a support session on a named production, carrying a reason and a ticket reference, still open, not revoked, not expired, and approved — unless the session was created with approval not required, which is itself a recorded flag on the session row rather than a quiet exception.
Background jobs are bounded to their own credential
A job has no person to resolve grants against, so it is trusted for the production it claims. That trust is pinned to the credential which opened the connection — the worker’s own login role, which the web pool does not hold. Without that check, one request-path bug setting the wrong actor kind would become unrestricted cross-tenant access.
Sessions are long on purpose
A shoot day runs twelve to sixteen hours. A session that expires mid-shift means somebody re-authenticating on a phone in a field, so the session is long and the authorization behind it is re-resolved on every single request instead.

07

One thing the database does not enforce

A caterer needs to know a nut allergy is on the line. They do not need to know whose.

A dietary requirement can be marked not to disclose the person’s name to the supplier, and that flag defaults to withholding. The withholding itself is done in application code, in one place — because there is no policy underneath it to catch a mistake. The vendor identity does hold a column grant on the member’s display name, and a special meal request is readable to them in full.

Everything else on this page is enforced by Postgres and will refuse the statement. This one is enforced by us, and it is listed separately because the difference is the whole point of the rest of the page.

08

What we do not claim

Everything above is a mechanism you can ask us to demonstrate. None of it is a certification, and we are not going to imply otherwise.

Not true of CrewFuel today

No SOC 2, no ISO 27001, no third-party penetration test. There is no report to send you, and no badge belongs in this footer.

No published uptime history and no service-level agreement. The product has not run long enough for a number here to mean anything, and a figure without history behind it would be decoration.

No independent audit of the policy set. The adversarial review that found the vendor-scoping defect described above was our own. It is the reason we describe the defect rather than the reason we would claim to have none left.

CrewFuel is early software. Parts of the console are not built, and the product says which rather than linking to them. The home page lists what is missing.

If one of those is a condition of putting a show on this, say so. It is a better conversation to have now than a badge on a marketing page that nobody checks.

Sign in as a caterer and try to reach something that is not yours. It returns nothing here, and it does so from the database.