← Poka-YokeCONTENT HISTORY

Update to Poka-Yoke

Snapshot Sep 30, 2026 · 23:14 UTC · version 0.2.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Multi-tenant isolation, IDOR and row-level security. Use to find every path where one tenant could read or write another tenant data: \"we forgot to filter by org_id\", \"can users see each other data\", \"audit these endpoints for cross-tenant leaks\", \"make an unscoped query impossible\". Covers scoped repositories, RLS, default-deny routing and the two-tenant test. For what the UI shows use ux.",
  "included_files": [],
  "name": "authz",
  "skill_md_contents": "---\nname: authz\ndescription: >-\n  Multi-tenant isolation, IDOR and row-level security. Use to find every path where one tenant could read or write another tenant data: \"we forgot to filter by org_id\", \"can users see each other data\", \"audit these endpoints for cross-tenant leaks\", \"make an unscoped query impossible\". Covers scoped repositories, RLS, default-deny routing and the two-tenant test. For what the UI shows use ux.\n---\n\n# Poka-Yoke for Authorization\n\nCross-tenant data leaks are almost never caused by a wrong access-control decision. They are\ncaused by *no decision at all*: a query that is correct except it lacks `WHERE tenant_id = ?`,\nan endpoint that loads by ID without checking who is asking. The developer did not choose\nwrongly; they forgot, in one of the two hundred places the check was required.\n\nThat is the signature of a poka-yoke problem: a step that must be performed every single time,\nby a human, with nothing enforcing it. The fix is never \"be more careful in code review,\" and\nit is never a checklist. **The fix is to make the unscoped query unwritable.**\n\n## Building, not reviewing\n\nMost of the time this mode is reached *while someone is building the thing*, not afterwards.\nThat changes the deliverable. They asked for the scoping, so produce the scoping, working, complete,\nin their stack. Do not hand back a severity table when the person is mid-feature; a list of\nfindings about code they have not written yet is not useful to them.\n\nThen add a short closing note, three or four lines, covering:\n\n- which misuses the shape you chose makes impossible, and at which rung,\n- what you left possible on purpose, and why that tradeoff is the right one here.\n\nThat closing note is what stops the device being undone in six months by someone who cannot\nsee why it is there. It is also the difference between mistake-proofing and a code generator:\nthe reasoning travels with the code.\n\nWhen the code already exists and they are asking what is wrong with it, switch to the audit\nvoice, ranked findings with the mistake, the consequence, and the device. Match the mode to\nwhere they are in the work, not to this file's default.\n\n## The one principle: unsafe should be hard to say\n\nRight now, in most codebases, the unsafe form is the *short* form:\n\n```python\nuser = db.query(User).filter(User.id == user_id).first()          # unscoped: 1 line\nuser = db.query(User).filter(User.id == user_id,\n                             User.tenant_id == current_tenant).first()   # safe: longer\n```\n\nEvery incentive points at the first line, and it works perfectly in every test, because tests\nusually have one tenant. Invert it so the safe form is the default and the unsafe form\nrequires deliberate, visible effort:\n\n```python\nuser = tenant_db.users.get(user_id)     # tenant scope baked in; cannot be omitted\nuser = db.unscoped().users.get(user_id) # possible, greppable, reviewable, rare\n```\n\nEverything below is a variation on that inversion. When you audit, the question is not \"is\nthis query scoped?\" but \"**could an unscoped query even be written here?**\"\n\n## Devices, strongest first\n\n### 1. Database row-level security (Control, and the one with the widest reach)\n\nRLS enforces the predicate in the database, so it applies to every query from every service,\nevery migration, every script, and every engineer with a psql shell. It is the only device\nthat protects you from code paths you did not write. Its reach stops only at roles that are\nexempt from policies: superusers, roles with `BYPASSRLS`, and the table owner unless you force\nthe policy on.\n\n```sql\nALTER TABLE documents ENABLE ROW LEVEL SECURITY;\nALTER TABLE documents FORCE ROW LEVEL SECURITY;   -- applies to the table owner too\n\nCREATE POLICY tenant_isolation ON documents\n  USING (tenant_id = current_setting('app.tenant_id')::uuid);\n```\n\nThe catch that turns this into a false sense of security: the connection must set\n`app.tenant_id` reliably, and a pooled connection that carries a previous request's setting is\na cross-tenant leak with extra steps. Set it per-transaction, and make the middleware that sets\nit the only path to a connection. `FORCE ROW LEVEL SECURITY` matters too, without it the\ntable owner bypasses the policy, and your application user is often the owner.\n\n### 2. Scoped repositories (Control at the type level)\n\nMake the tenant a required constructor argument, so no repository exists without one:\n\n```ts\nclass DocumentRepo {\n  // No default. There is no way to construct this without a tenant.\n  constructor(private readonly db: Db, private readonly tenant: TenantId) {}\n\n  async byId(id: DocumentId): Promise<Document | null> {\n    return this.db.documents.findFirst({ where: { id, tenantId: this.tenant } });\n  }\n}\n```\n\nThe raw client is then confined to infrastructure code and lint-banned from handlers. The\ndevice is not the `where` clause. It is that the handler has no way to reach a client that\nlacks one.\n\n### 3. Authorization in the type (Control)\n\nRather than loading an object and then checking it, make the check the only way to obtain it:\n\n```ts\n// Handlers accept Owned<Document>. There is no path to one that skips the check.\nasync function authorizeDocument(user: User, id: DocumentId): Promise<Owned<Document>>\n```\n\nA handler that takes `Owned<Document>` cannot receive an unauthorized document, so the check\ncannot be forgotten: the compiler asks for it. This is the same move as parse-don't-validate,\napplied to permission instead of shape.\n\n### 4. Default-deny at the router (Control, cheap)\n\nRequire every route to declare its authorization explicitly, and refuse to start if any route\nhas not:\n\n- A middleware that denies unless a route declares a policy, with a startup check that\n  enumerates routes and fails the boot on any undeclared one. A new endpoint is then secure\n  before anyone writes a line of it: the failure mode of forgetting becomes \"the service\n  won't start\" rather than \"the data is public.\"\n- Public routes are explicitly marked. Making public the opt-in and private the default means\n  forgetting fails closed.\n\n### 5. Unguessable identifiers (defense in depth, not a device)\n\nUUIDs and ULIDs instead of sequential integers raise the cost of enumeration, and they are\nworth using. But an ID is not a permission, anyone who has ever seen the resource still has\nthe ID forever. Never treat unguessability as the control; it is a mitigation layered behind\none.\n\n## Auditing for missing authorization\n\nThe high-yield sequence, in order:\n\n1. **Find every path that loads by ID.** For each: where does the tenant or ownership\n   constraint come from? If it comes from the request rather than from the session, that is a\n   finding on its own, `tenant_id` in a request body is client-controlled.\n2. **Grep for raw client use in handlers.** Anywhere the unscoped query builder is reachable\n   from request-handling code is a place the mistake is available.\n3. **Check the update and delete paths specifically.** Reads get the attention; writes get\n   missed, and an unscoped `UPDATE ... WHERE id = ?` lets one tenant modify another's data.\n4. **Check every non-primary path**: bulk endpoints, exports, search, webhooks, background\n   jobs, admin tools, GraphQL resolvers on nested fields, and anything reached via an\n   association (`document.comments` where the comment scope is assumed rather than enforced).\n   Nested resolvers are a common blind spot because the parent was checked and the child\n   inherits nothing.\n5. **Check that admin is scoped too.** \"Admin\" usually means admin *of a tenant*; a global\n   admin query in a tenant-facing endpoint is a leak.\n6. **Ask what happens on a missing session**: does the query run with `tenant_id = None`, and\n   what does that match? In SQL, `tenant_id = NULL` matches nothing; the dangerous failure is\n   a query builder that drops a missing predicate and issues the query unscoped.\n\n## The test that proves it\n\nOne test pattern is worth more than any number of unit tests here: **create two tenants, then\nattempt every operation from tenant A against tenant B's resources, and assert 404 for all of\nthem.** Table-drive it over your route list so a new endpoint without a case is visible.\n\nTwo details matter. Assert **404, not 403**: a 403 confirms the resource exists, which leaks\nmembership. And make the test enumerate routes automatically where you can, so adding an\nendpoint without isolation coverage fails rather than passes silently.\n\nThis is a Detection-rung device, and it is the one that tells you whether your Control-rung\ndevices actually work. Write it even when RLS is in place, especially then, since RLS failures\nare silent and total.\n\n## Reporting\n\nUse the finding structure from `audit`. Blast radius for this class is\nnear-maximum, cross-tenant exposure is a breach, with disclosure obligations, so findings\nhere outrank almost everything else in an audit. Propose before changing anything, and be\nprecise about which device reaches Control: adding a `where` clause to one query fixes one\nsite, and the whole point is that there are two hundred.\n"
}

SHA-256 of public snapshot: 428b144794273bae9e39f62703fc9a45e16b19dceb84105a07d5c9776811735c