AegisDB
中文GitHub
Security & compliance

Every mechanism, with its parameters and the shape of its failure

This page is written for the people who will check it line by line: algorithms, parameters and failure behaviour, including what each gate does not reach and what this system still owes.

Try it before reading on. Press Verify chain and the rows tick off one by one; press Simulate tampering row 3 and everything from row 3 down turns red. What turns red is not the row that was edited — it is every row after it. That is what the prev_hash column does.

Audit hash chain
  1. #12026-09-19 02:11:03PRODlinwei@vela.ioOKHash mismatch
    SELECT * FROM orders LIMIT 10prevhash60b8bc35
  2. #22026-09-19 02:14:20PRODlinwei@vela.ioOKHash mismatch
    UPDATE orders SET status = 1 WHERE id = 42prev60b8bc35hash049bfebb
  3. #32026-09-19 03:02:45PRODzhaoyun@vela.ioOKHash mismatch
    DELETE FROM orders WHERE id = 42prev049bfebbhash013e12a9
  4. #42026-09-19 03:20:11PRODzhaoyun@vela.ioOKHash mismatch
    ALTER TABLE orders ADD COLUMN note varchar(64)prev013e12a9hash64620042
  5. #52026-09-19 04:00:02PRODlinwei@vela.ioOKHash mismatch
    SELECT count(*) FROM ordersprev64620042hashe436ab86

Console UI replica · not a screenshot

The record

The audit hash chain

Every audit row carries the hash of the one before it: hash = SHA256(prev_hash followed by the payload). The payload is a JSON object built from that row, serialised with its keys in lexicographic order, and the timestamp is normalised to UTC. The genesis row has an empty prev_hash. The writer and the verifier share one payload builder — write that function twice and the two copies will eventually disagree, at which point the "this row was edited" the verifier reports is a lie.

That the chain cannot fork is enforced by the database, not by application discipline: prev_hash carries a single-column unique index. Each row also pins two snapshots, the environment and the control tier, and both go into the hash — a snapshot you can edit without breaking the chain proves nothing. Verification is a human-triggered action in the console: it recomputes from the genesis row all the way to the tail and reports the first row that does not line up. It blocks no writes and raises no alert; what it produces is a report with a conclusion.

Parameters you can check

Digest
SHA-256, 64 lowercase hex characters
Pre-image
A JSON object, keys in lexicographic order; 11 keys in the current version
Timestamp
RFC3339, normalised to UTC
Genesis row
prev_hash is the empty string
Fork protection
A single-column unique index on prev_hash — the database refuses the fork
Write conflict
A process-local mutex orders writers; across processes the row is re-hung on the tail, up to 5 times
Pre-image versions
5 of them since v1; verification tries them newest to oldest and relaxes none
Verification
GET /api/v1/audit/verify, triggered by hand; no background job in the gateway verifies the chain
Before results leave

Masking happens inside the gateway

Masking happens before the result leaves the gateway: in the JSON that reaches the browser, the national ID is already gone. Only 2 places in the whole gateway read result rows out of a user submitted statement: RealRun (the terminal and pipeline execution) and RealQueryEach (streaming export). The one bypass is called RealQueryEachRaw, and the conspicuous name is deliberate — a parameter defaulting to false would hide "was this masked or not" inside a boolean.

Matching runs in two passes. First against the returned column names, where SELECT * lands, and that is the safest shape. Then against the k-th expression in the select list, which catches AS aliases and function wrappers. Every UNION branch is inspected, because the result set takes its column names from the first branch only. The failure behaviour matters most here: when the database read fails the previous ruleset stays in force rather than returning an empty one, because empty means masking nothing, and a single database blip would ship sensitive values in the clear.

Parameters you can check

Paths that read rows
2 of them: RealRun (terminal and pipeline execution) and RealQueryEach (streaming export); the background execution path returns no result set
The one bypass
RealQueryEachRaw, with 1 caller: an approved export of sensitive fields
Rule granularity
Table name plus column name; an empty table name means every table
Masking modes
Keep head and tail (values longer than 8 keep the first 3 and the last 4; shorter ones are wiped) · wipe entirely (a fixed 6 asterisks) · hash consistently (the first 10 hex characters of SHA-256, behind a hash sign)
Rule cache
5 seconds; on a failed read the previous ruleset stays, never an empty one
Role exemptions
None, administrators included; disabling a rule writes an audit entry
Who is operating

Sessions and authentication

Every account carries a monotonically increasing session generation. It is written into the tv claim when a JWT is issued, and the middleware compares it against the stored value on every request — a mismatch is an immediate 401, "your session has expired". Logging out, an administrator resetting the password, and removing a user from a role each bump it, and every token issued before that moment dies with it. Editing a role, as opposed to removing someone from it, deliberately does not bump it: permissions are read from the database on every request, so a downgrade does not need anyone kicked out.

Login throttling counts by source IP, not by account. The comment calls that intentional: an attacker can only lock out the address they are attacking from, and can never lock a victim out of their own account. Only the attempt that crosses the threshold arms the lockout — later failures do not extend it indefinitely, or one attacker could permanently lock a shared NAT egress. Step-up verification is RFC 6238 and a consumed time step cannot be replayed. Whether a step-up is required is decided by the target tier flag, not by the word prod, and a tier that cannot be resolved is treated as demanding one.

Parameters you can check

Token
HS256 JWT; claims are only uid / name / tv plus iat / exp / iss, and deliberately not roles
Session lifetime
Only 4h / 8h / 24h are accepted; the default is 8h
Generation bumps
Logout · an admin password reset · removing a user from a role
Login throttling
Counted per source IP; the 5th failure in a 5 minute window locks for 5 minutes; success clears it; the attempt awaiting a one-time code is not a failure
MFA algorithm
RFC 6238: SHA-1, 6 digits, 30 seconds; a 160 bit random secret; a tolerance of 1 step either side; consumed steps cannot be replayed
Step-up grace
30 minutes by default, capped at 720, and 0 means verify on every command; keyed by user × session generation × instance; memory only
Mandatory MFA
The policy switch is on by default; "no TOTP enrolled means no execution" is off by default, and service accounts are always exempt
Where the keys live

Credentials and keys

Target database passwords are stored under AES-256-GCM. The key is derived from a passphrase with SHA-256 into 32 bytes; the nonce is 12 bytes drawn fresh on every encryption and stored in front of the ciphertext and its tag, the whole thing base64-encoded behind an enc:v1: prefix. A leaked database backup hands over no directly usable key.

Two keys, kept apart: VELA_JWT_SECRET signs tokens, VELA_SECRET_KEY encrypts data at rest. This is the sentence most often written backwards: once they are set apart, the one that can be rotated independently is the JWT secret, not the encryption key — VELA_SECRET_KEY cannot be changed once set, and changing it means stored passwords no longer decrypt. The price of rotating the JWT secret is that every live token fails to parse at that instant, which signs everyone out: the code has no key version and no dual-key decryption.

Parameters you can check

Encryption at rest
AES-256-GCM; key = SHA-256(passphrase) as 32 bytes; a fresh 12 byte random nonce per encryption, stored in front; no AAD; ciphertext prefix enc:v1:
What it covers
Instance passwords · export archive passwords · the external approval token and callback secret · webhook secrets
Two keys
VELA_JWT_SECRET signs, VELA_SECRET_KEY encrypts at rest; leaving the second unset falls back to the first and logs a WARN
What can rotate
VELA_SECRET_KEY cannot be changed once set; the JWT secret can rotate, at the price of every live token dying; no key version, no dual-key decryption
Production key strength
Not on the 10-entry weak-value blocklist, at least 32 characters, at least 8 distinct bytes; fail any and the service refuses to start
Open API credentials
Key is ak_ plus 16 hex characters, secret is 32 base64url characters; the database holds only the bcrypt hash of the secret, cost 10; no in-place edit
Export archives
WinZip AES-256 ZIP, not the ZipCrypto scheme broken by known-plaintext attack; a 20 character password with look-alike characters removed, generated by rejection sampling; the password itself is stored encrypted
Three doors

Three auth surfaces, none of which honours another

The gateway has three doors and each minds its own. The console covers everything under /api/v1 except the other two surfaces, authenticates the JWT in the Authorization header, and is called by signed-in people. The open API lives under /api/v1/open, authenticates a key, a dot and a secret, and is called by CI and DevOps platforms. The callback has a single path, authenticates a shared secret, and is called by the external approval service.

The open API is not a second pipeline, it is another door onto the same one: every write path under /api/v1/open ends in the same submission function the console uses, with the same flow template, rule library, approval chain, pre-execution re-judgement and audit chain. That is why the audit row carries two names: actor is the service account, operator reads API plus the credential name. (ADR 0006)

Parameters you can check

Console
/api/v1 (everything but the other two surfaces) · Authorization: Bearer JWT · chain: JWT verification → source-IP allowlist (off by default = pass-through) → menu guard → platform administrator on some routes
Open API
/api/v1/open · Authorization: Bearer key.secret, or X-Vela-Key + X-Vela-Secret · chain: credential verification → scope check; each credential carries its own source-IP allowlist (empty by default = any source)
Callback
/api/v1/approvals/lark/callback, a single path · shared secret (Authorization: Bearer, or ?secret= as a fallback) plus an optional source-IP allowlist · no middleware; authentication is inside the handler
No credential needed
GET /healthz · GET /openapi.yaml · GET /docs
Wrong door
40100 in every case; a missing scope is 40300; credentials may not bind a platform administrator, blocked at both creation and rebinding; a service account signing in to the console is refused outright
Global
No proxy is trusted by default, so X-Forwarded-For is ignored by default; a malformed trusted-proxy list refuses startup
What the gateway sends out

Outbound protection: signatures go out, a shared secret comes in

Every request the gateway initiates (event-hub webhooks, Lark bot cards, the external approval API) goes through one controlled HTTP client. The target address is checked twice — a DNS pre-flight when the configuration is saved, and then the address actually being dialled, which closes the window where a name re-resolves to an internal address between those two moments. A name that will not resolve is a refusal, not a pass.

Outbound requests are signed. The event-hub request carries an X-Vela-Signature header whose value is sha256= followed by the hex HMAC-SHA256 of the request body under the secret; the Lark card carries the vendor signature scheme. Both of these are signatures the gateway produces. One thing a receiver has to know: when the secret is empty the gateway does not sign at all, so the request carries no signature header — a receiver written on the assumption that a signature is always present will meet a request it cannot judge the moment the other side has no secret configured.

Inbound is not a signature. The approval callback has no signature verification and no timestamp replay protection: what it verifies is a shared secret, compared in constant time, plus an optional source-IP allowlist — and that allowlist defaults to an empty string, which means any source. The secret is the primary control; the allowlist is an optional second one. The feature switch being off refuses everything, and an unconfigured secret refuses everything.

Parameters you can check

Outbound client
http / https only; https enforced in production; redirects refused; 5 second timeout
Address checks
A DNS pre-flight on save, plus a check of the address actually dialled; a name that will not resolve is refused
Refused by default
Loopback · RFC1918 private ranges · IPv6 unique-local · link-local (including 169.254.169.254) · the unspecified address · 100.64.0.0/10 · NAT64 64:ff9b::/96 (the embedded IPv4 is unwrapped and judged again)
Outbound signature
Event hub: X-Vela-Signature: sha256= plus the hex HMAC-SHA256 of the body; Lark bot: base64(HMAC-SHA256(key = timestamp + newline + secret, message = empty)); neither signs at all when the secret is empty
Inbound callback
A shared secret compared in constant time, plus an optional source-IP allowlist; the allowlist defaults to an empty string, which permits any source; no signature, no timestamp replay protection
Callback payload
An external task id must exist and match; an approval must name its approver; the approver name is truncated to 128 bytes
Delivery retries
Backoff by powers of 2, capped at 30 seconds, up to 5 attempts by default, goroutine life capped at 2 minutes; every attempt recorded
One direction throughout

Failing closed, itemised

Every line below is a real branch in the code, not a statement of principle. The code even numbers the rule — ED3: a rule that cannot be read is not the same as a rule that is not there.

A judgement layer that cannot be read refuses

The capability matrix and the high-risk dictionary both live in the database, so one transient fault would disable them together: an unconfigured cell reads as allow and an empty dictionary reads as off, and DROP TABLE on production would sail straight through. So when either cannot be read, the verdict is deny at the highest risk, and the terminal prints "risk control is temporarily unavailable, treated as the strictest".

A tier that cannot be resolved stops the command

A tier has no sensible zero value: scan a script with an empty tier and the dictionary matches nothing, so every statement, DROP TABLE included, is reported as safe. The terminal, batches, approved execution and script scanning all treat it as a refusal, and so does MFA.

What cannot be recognised is read as the dangerous reading

An unrecognised SQL verb is filed under the write dimension. An unrecognised MongoDB method is gated as a write. When the engine cannot be told, both readings are tried and either one finding "no WHERE" makes it a no-WHERE verdict. When a masking column match is uncertain, one column too many is masked — one column too few is sensitive data sent in the clear.

When several verdicts coexist, the strictest wins

A statement that hits the dictionary more than once takes the strictest hit: ALTER TABLE t DROP PARTITION p is not graded medium just because ALTER comes first. A pasted batch is governed by its strictest statement. EXPLAIN is bound by the "Explain plan" and "Read data" dimensions at once and takes the stricter of them, because separating a permission must not hand out what the original one refused. When several roles are unioned, the unreadable level is deny, so it outranks nothing.

A permission check that cannot run refuses

A failed tag-authorization query never reports "unrestricted": an empty result means exactly "no restrictions", so swallowing the error would hand a restricted role the whole estate during one database blip. An unreadable menu table is a 403.

An external service that is unreachable or unclear is not believed

External approval refuses when the feature switch is off, when the callback secret is unset, when the secret does not match, and when the source IP is not on the allowlist. A callback with no vendor task id is not believed. An approval with no approver is refused. Open API credentials fail closed on every item in the same way.

The one relaxation mechanism relaxes nothing when it fails

Execution windows are the only thing in this system that moves toward permissive, and each of their own failure paths does not: an unclear database name allows nothing; an unreadable window table counts as no window; an unparsable timezone disables the window instead of falling back to the server timezone.

The same direction in data and process

Unreadable masking rules fall back to the previous set. An unreadable release capability matrix is an error on the spot. An empty approval chain fails loudly rather than creating a ticket nobody can ever approve. An operator-written regex that fails to compile reports itself as a finding instead of being skipped, because a rule that silently never fires is indistinguishable from one that always passes. And the replica lag ceiling is 30 seconds when unset rather than unlimited, with a negative number required to disable it, because 0 also means "this section does not exist at all".

Two places that deliberately do not fail closed

An inventory of nothing but virtues convinces nobody. These two are deliberately not strict, each for a reason, and each reason is written in a comment.

When the initiating account cannot be determined, it is not treated as self-approval

The self-approval net compares the approver against the initiator. When that comparison cannot be made the code lets it through, and the comment says not to over-block — an account name that fails to match should not keep a legitimate approval out. It pairs with the rule above that an approval naming no approver is refused: no approver at all is a refusal, an unrecognisable initiator is not.

When the role lookup fails, the existing set of role ids is kept

The comment is blunt: one database blip should not strip everyone of every role at once. The direction is still safe — what is kept is a set of ids that genuinely existed, not unknown values treated as permission. There is a relative of this rule elsewhere: a setting switch that cannot be understood keeps its default, and the security-relevant defaults are themselves chosen on the strict side.

A test comment puts the measure well: over-blocking in the judgement layer is safety margin, over-blocking in the report layer is a false alarm. Which is why everything above tightens the judgement layer, and the scan report side deliberately does not follow it.

On the books

Debts still outstanding

These are the things this system still owes. They are here not as a display of candour — they are here because a security team will ask about every one of them, and being asked is worse than having said it first.

  • There is no Content-Security-Policy

    The wording is direct: the backend sends no Content-Security-Policy at all, which means there is currently no defence in depth on the XSS layer. (ADR 0017)

  • The JWT lives in localStorage, not in an httpOnly cookie

    The key is aegis_token. ADR 0017 records it as a trade-off on the books and names three triggers for repaying it: the page starts loading any third-party script, any XSS report appears, or external users need access.

  • The white-box audit: four findings named unfixed, plus 18 low-severity ones untouched

    The table in ADR 0012 lists them: the backup and verify stages of the release pipeline do not go through the judgement engine; the MFA grace is keyed to the user generation rather than to an individual token; self-service MFA rebinding has no re-authentication and writes no audit entry; a subquery alias can evade masking; and 18 low-severity findings are untouched.

  • Chain verification is manual only, with no scheduled job

    None of the gateway background goroutines verifies the chain. So the shelf life of "the chain is intact" is however long ago somebody last pressed that button.

  • A broken chain only produces a report

    No alert, no blocking, no webhook event. The verification endpoint returns a report with a conclusion, and whether anyone reads it depends entirely on whether anyone looks.

  • There is no Prometheus endpoint

    The observability surface is an unauthenticated /healthz, one stats endpoint (p50 / p95 over the last 512 requests, plus a production-block counter), the gin access log and slog JSON on stdout.