feat: record statements to a hash-chained access ledger - #4
Open
mickamy wants to merge 14 commits into
Open
Conversation
…nd the prepared-statement maps
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the access ledger: every statement a session handles becomes a record of who ran it, when, what shape it had, what the proxy decided, and how many rows it returned. Records carry no SQL literals and no result values, and are hash-chained so tampering shows. This is the foundation the data-subject index (identifier HMACs) will build on.
What is in here
internal/sqlscan:Fingerprintreturns a statement with its literals removed (string, dollar-quoted, and numeric literals become?), so statements of the same shape share a fingerprint and no literal is stored.internal/ledger: theRecord, a JSON-linesSinkthat hash-chains each record to the previous, and aGuardthat wraps another guard to record each session's statements.internal/wire:Principal(agent, purpose, user, database, application),Decision, and theRecorder/Resultinterfaces.Enforcementnow carries the principal and an optional recorder;Session.Frontendtakes the recorder.internal/pg: the backend parsesRowDescription/DataRow/CommandCompleteto feed the result, and the frontend records each decision.Result.Columns/Roware wired so subject capture can be added without touching the protocol code.internal/policy: sets thePrincipalfrom the matched role.internal/cli: a-ledger PATHflag that appends records to a JSON-lines file.Recording model
Parse(row counts to follow).Testing
make testandmake lintpass.internal/sqlscan: fingerprints mask literals and drop comments while preserving identifiers.internal/ledger: records carry the principal, kind, fingerprint, decision, and row count; the fingerprint holds no literal; hashes chain.internal/pg: a recorder observes a simple query's row count and a denial.postgres:16: allowed and denied statements are recorded with agent, purpose, kind, masked fingerprint, row count, and a linked hash chain; the file contains no literal from the queries (verified by inspecting the output).Not in this PR
Identifier extraction from result columns into keyed HMACs (the data-subject index), extended-protocol row counts, at-least-once spooling, and the
limited/approveddecisions.Review round: accuracy, privacy, and the write path
SELECT 1; DELETEis not a plain SELECT).E'...'with backslash escapes, soE'O\'Brien'does not surface words as identifiers.ROLLCALL_LEDGER_KEYis set (plain SHA-256 otherwise).Verified against
postgres:16: simple and extended (\bind) queries record row counts, a mid-batch denial records only the denial, anE'...'literal does not leak, and the chain stays intact across a proxy restart.Review round: ledger size, key identity, and startup cost
key_id(a prefix of the key's hash, empty when unkeyed), so a verifier knows which key signed each record and can see where the key changed across restarts.readyForQuerywrites the client's ReadyForQuery and releases the lock before finalizing the record, so a full ledger queue never stalls the response path in the other direction. The Sink comment no longer overstates this — records are queued and never dropped, and Write applies backpressure when the queue fills.0x1F,1_000, and scientific notation no longer leave fragments as identifiers in the fingerprint.Covered by unit tests including a CLI-flow test that resumes the chain across a key change. (Docker was unavailable on this run for a full-binary end-to-end; the full pipeline was verified against
postgres:16in the previous rounds.)Review round: prepared statements and key hardening
Verified with unit tests reproducing the exact driver wire pattern (prepare then re-execute with no Parse → two records; prepare-only → none), plus a full-stack CLI test that drives the real proxy with
-ledgerand reads back two chained records. (Docker was unavailable on this run for a live driver end-to-end; the wire behavior is covered directly.)Review round: Execute attribution and map growth
Covered by unit tests: an unknown-portal Execute is not misattributed; an unrecorded Execute's result does not steal the next record's rows; a statement reused after Close is not recorded. Full suite passes under
-race -count=30on the prepared-statement paths.