How a request is protected¶
Three parts, with all security enforced in the middle one: a PostgreSQL database reachable only by the application container, the FastAPI backend where every rule lives, and one HTML page that holds no security logic on purpose, because a browser page is fully under its user's control and anything enforced there is decoration.
Every request to the backend passes the same gates in order, and each gate exists because of a specific failure:
- Session check. Who is calling. The session token is an opaque random value, and the database stores only its SHA-256 digest, a one-way fingerprint: someone who reads the table holds nothing they can replay. Sessions are individually revocable and expire absolutely, because expiry without revocation means one stolen token can only be ended by logging everyone out, which in practice means nobody does it.
- Two different refusals. A missing or dead session gets 401, a valid session without the needed role gets 403 naming the roles that would be admitted. An authenticated caller deserves an answer they can act on, and separating no identity from insufficient authority costs an attacker nothing they could not learn anyway.
- The role matrix. May this role call this route. One data structure in manifest_identity/core/roles.py is the single answer: the route dependencies read it to enforce and the tests read it to verify, so the enforced matrix and the tested matrix cannot drift apart. A route missing from the matrix fails the build, and a typo in a matrix key crashes the process at startup rather than leaving a route unguarded.
- The scope check. May this caller act here. The matrix answers which roles a route admits; a write whose target belongs to a place in the estate asks a second question, answered by bindings at that node, at any node above it, or at the global node (D-072). An operator for one account holding the role the matrix admits is still refused on another account's identity, and the refusal says it is the scope, not the role. Authority is answered in one function so the tests can walk it and the mutation check can remove it and watch them fail.
- Typed validation. Is the request sane. Every body passes a typed model with bounds, and a rejected value is never echoed back, because an error message that repeats attacker input is a reflection surface.
- The action, through the ORM. The object-relational mapper, the library that turns Python objects into parameterized database queries, is the only path to the database, which removes SQL injection, attacker text becoming database commands, as a class rather than defending it query by query.
- The audit row, in the same transaction. Any action that changes governance state commits together with its audit record, so neither can exist without the other. A best-effort trail was rejected because the gap between action and record is exactly where an investigation dies.
- The response, through a declared model. What a client may see is defined by schema, not by what the row happens to contain, so an internal field added next year does not leak by default.
In classic terms the gates implement authentication, access control, and accounting; the design principle is that each is a mechanism that runs, not a rule that hopes. This product reserves the word authorization for the record of what an identity is allowed to hold (D-073), and calls its own gates the role matrix and the scope check.
Sign-in itself gets four defenses of its own. Passwords hash with bcrypt, which salts automatically and is deliberately slow by an adjustable work factor, turning a bulk password-cracking run from hours into years. An unknown username pays the same bcrypt cost and receives the identical response body as a wrong password, so neither timing nor wording reveals which accounts exist. Failed attempts are rate limited per username and per address, counting failures only, with no account lockout, because a lockout hands any attacker who can spell a username a denial of service against its owner. And the attempted password never reaches a log; the log line records that a failure happened, not what was typed.
Trust boundaries¶
Three boundaries, in order of hostility:
- The imported file. The only input the application accepts from outside, treated as hostile in every particular even though it nominally comes from a cloud provider's own reporting: bounded, parsed in memory, verified against its own claims, never echoed.
- The browser session. Authenticated on every request; nothing about a session is trusted from one request to the next. Identity names, tags, and paths inside imported data are attacker-influenceable and are rendered as text, never markup, because the person most exposed to this data is the operator reading it.
- The exports. Everything leaving the system passes an allowlist: the response models for the API, formula escaping for the spreadsheet forms, and deliberate field selection for the report, because the inventory is a map of the account's weakest identities and an export is that map on the move.
Version one has no outbound connection to any provider. The cloud credential and its boundary arrive with the cloud phases and get their own threat model revision first.