Repository navigation
fix(storage): harden the KVS-backed identity and wallet stores - #2408
AkramBitar wants to merge 1 commit into
Conversation
57ee536 to
97f9bec
Compare
Effi-S
left a comment
There was a problem hiding this comment.
Some Findings:
1. Backward-incompatible Vault storage format — Medium
Location: token/services/storage/db/kvs/hashicorp/vault_kvs.go — NormalizeID
Keys with / or % (identity hashes routinely contain /) now map to different Vault
paths, so existing deployments stop finding previously written identity/wallet data. Called
out in a doc WARNING but there is no migration tooling — upgraders must re-register affected
identities manually. Confirm this is acceptable / has a migration story.
2. KVS IdentityExists cannot surface read failures — Medium
Location: token/services/storage/db/kvs/walletdb.go — IdentityExists
The new (bool, error) return only ever carries a key-construction error; kvs.Exists still
swallows Vault read errors (FSC GetExisting), so a transient failure reads as "not bound"
for the KVS backend. The hardening goal (fault ≠ non-membership) is only actually met by the
SQL backend. Documented, but the KVS half of the change is effectively inert.
3. Context threaded but not used for Vault I/O — Low
Location: token/services/storage/db/kvs/hashicorp/vault_kvs.go — read (ignores ctx),
Get, iterator
ctx is now plumbed through but read calls client.Logical().Read(...), not
ReadWithContext. Cancellation/deadlines are not honored. Cosmetic threading; switch to the
*WithContext Vault calls to make it real.
4. StoreIdentity intermediate inconsistency window — Low
Location: token/services/storage/db/kvs/walletdb.go — StoreIdentity
The GetWalletID key [tmsID, roleID, idHash] is written before the IdentityExists key.
A failure between them leaves GetWalletID returning a wallet while IdentityExists reports
false. Retries converge (writes are idempotent), so low impact, but the two reads disagree
in the window. Acceptable if IdentityExists is the sole commit marker.
97f9bec to
89f201d
Compare
89f201d to
5ff00ea
Compare
|
Thanks @Effi-S. All four addressed; squashed into 1. Vault format break — acknowledged, no migration tool. Intentional; doc note expanded. The old mapping escaped nothing, so it wasn't injective: 2. 3. Context unused — fixed. 4. Out of scope, found while fixing #2: |
5ff00ea to
91e317a
Compare
Addresses the review of #2408. GetByPartialCompositeID on the Vault backend listed one level. Vault's list is single-level and reports a path that has children as a directory marker holding no state of its own, so a caller whose entries sit deeper than one component scanned nothing: WalletStore.GetConfID listed walletDB/<tms>, got back the <role>/ markers, skipped every one of them as not found and reported each bound identity as unbound. Walk the subtree instead, iteratively so a deep tree cannot exhaust the stack, skipping a directory that disappears mid-walk and returning a leaf-and-directory path once. The result is sorted: a depth-first walk's order depends on the tree shape, and the SQL backends this store shares a test suite with scan in key order. Walking the subtree exposed a second defect, on every backend rather than just Vault. WalletStore.GetWalletIDs scans [tmsID, roleID] and did not filter what came back, so the per-wallet "configid" and "meta" entries below it were reported as wallet ids: GetWalletIDs -> ["alice_wallet" "CONF-ID-XYZ" "bWV0YQ=="] Filter by attribute count, the way GetConfID already filters for its own entry. Neither defect was caught because dbtest's wallet suite was wired to the SQL drivers only; export WalletCases and run it against both KVS backends. TWalletConfigurationLink is skipped there, as this store has no equivalent of the Wallets.conf_id foreign key it asserts. Also memoize vaultIterator's lookahead, so that calling HasNext more than once before Next neither repeats the Vault read nor drops the key it advanced to, and document the exported methods of the Vault KVS. Signed-off-by: AkramBitar <akram@il.ibm.com>
c7223d9 to
eb5cea4
Compare
Effi-S
left a comment
There was a problem hiding this comment.
@AkramBitar Looks good. Only minor findings
eb5cea4 to
60637af
Compare
Review round 4 on #2408. The Vault KVS' read() reported a missing secret as absent but answered a secret with no data, or a "data" field that was missing, empty or not a map, with an error. Exists already classified all three as absent, so the two disagreed on what exists within the same file, and the error aborted the whole prefix scan behind GetConfID and GetWalletIDs. KV v1 has one mount per writer-set, so a secret written under it by an operator or another application carries no "data" map of ours and is reachable by any scan over that mount: one foreign entry under the prefix hid every real entry below it. read() now mirrors Exists and reports all three shapes as absent, which the iterator already skips. A secret that does carry a "data" map is this KVS' own entry, so a "value" inside it that is missing, not a string or not decodable stays an error - that entry is corrupt, not absent. This removes the only place in the tree that produced "state of id [...] does not exist", the message WalletStore's notFoundHeads matches for this backend. The head stays listed because hashicorp is a module of its own, versioned independently of the root module, so a deployment can still pair this store with a release that returns it; walletdb.go now says that rather than claiming the in-tree backend emits it. Also from the same review, both documentation: - The leading space in notFoundSuffix is recorded as deliberate: it anchors the phrase on a word boundary, so an error whose final word merely ends in "does not exist" cannot match. - The anchored match's fragility is stated in the direction raised. It is one-sided on purpose: a reworded absence error propagates as a hard error, which makes the role Registry abort a wallet creation loudly and recoverably, where a widened match would answer ("", nil) for an unreadable store and create a duplicate wallet, which is #2063. The match is never to be loosened, only replaced by a sentinel. Tests: testForeignSecretIsAbsentNotAnError writes a secret straight through the Vault API plus one with an empty "data" and asserts Get misses, Exists is false and the scan still yields the real entry; it fails without the fix. TestIsNotFoundErrMatchesLiveBackend classifies the error the in-tree KVS really returns, so a rewording upstream fails in CI instead of turning every miss into an error in production. Signed-off-by: AkramBitar <akram@il.ibm.com>
The KVS-backed identity and wallet stores reported a storage fault as absence, so a transient backend failure was indistinguishable from "this identity is not registered" and could let a caller create a duplicate wallet. - WalletStore.IdentityExists returns (bool, error), and the role Registry and the mocks follow. An error means the lookup itself failed and the answer is unknown; it must not be read as "the binding does not exist". The entry is read with kvs.Get rather than probed with kvs.Exists, because FSC implements Exists over GetExisting, which drops the underlying store error: only a "not found" error is an authoritative miss, every other error reaches the caller. GetWalletID classifies absence the same way. The driver interface and the SQL backend carry this signature already, from #2409. FSC has no absence sentinel yet, so that classification has to match on the error message, and the match is anchored at both ends rather than searching for the "does not exist" substring. A store failure can carry that very phrase -- a missing or not-yet-migrated table makes Postgres answer `relation "kvs" does not exist` -- and reading it as a miss is the duplicate-wallet mode this change exists to close. A backend returns its absence error unwrapped, so a miss starts with the sentinel, while a failure always arrives wrapped in "failed retrieving state [...]" and can never match it, whatever its cause says. The anchoring ties the match to the exact wording of the heads it knows, which makes it fragile in one direction only, deliberately. A reworded absence error has its miss propagated as a hard error, so GetWalletID fails for an unbound identity and the Registry aborts the wallet creation: loud and recoverable. Widening the match buys that back at the price of the opposite failure, ("", nil) for an unreadable store, which is the duplicate wallet again and is neither. The match is therefore never to be loosened, only replaced once a typed sentinel lands. - StoreIdentity writes the entry IdentityExists reads last, and every write is idempotent, so a sequence that fails part-way leaves the identity reported as not bound and a retry converges on the complete binding. A partially applied sequence is never reported as a complete one. - The hashicorp vault KVS percent-escapes each composite-key component before it becomes a path component, which makes the key-to-path mapping injective: distinct keys no longer collide, a listed path decodes back to the composite key it was stored under, and a "." or ".." component cannot walk out of the configured mount point. This changes the stored path for components containing "/" or "%" -- see the warning in the docs. - Vault I/O goes through the client's *WithContext calls, so the context that is threaded through actually carries cancellation and deadlines. Exists has no way to report a read failure through its interface, so that failure is logged as an error rather than at debug level. - The iterator reads each key as it advances and skips a key deleted between the list and its read instead of yielding a zero-valued state; a prefix with nothing under it yields an empty iterator, never nil. Its lookahead is memoized, so calling HasNext more than once before Next neither repeats the Vault read nor drops the key it advanced to. - Every shape the Vault backend has for "nothing of ours is stored here" is one answer: no secret at the path, a secret carrying no data, and a secret whose "data" field is missing, empty or not a map. Only the first was reported as absent; the other two returned an error, which aborts the whole prefix scan behind GetConfID and GetWalletIDs. Exists already classified all three as absent, so it and the read path disagreed on what exists. KV v1 has one mount per writer-set, so an entry written under it by an operator or another application carries no "data" map of ours and is reachable by any scan over that mount -- one of those under the prefix hid every real entry below it. The read path now mirrors Exists and the iterator's skip handles the result. A secret that does carry a "data" map is this KVS' own entry, so a "value" inside it that is missing, not a string or not decodable stays an error: that entry is corrupt, not absent. This leaves the nil-error-with-untouched-state shape as the backend's only way of reporting a miss, which is why IdentityExists treats an empty wallet id as "not bound" too. GetByPartialCompositeID on the Vault backend also listed one level only. Vault's list is single-level and reports a path that has children as a directory marker holding no state of its own, so a caller whose entries sit deeper than one component scanned nothing: WalletStore.GetConfID listed walletDB/<tms>, got back the <role>/ markers, skipped every one of them as not found and reported each bound identity as unbound. It walks the subtree instead, iteratively so a deep tree cannot exhaust the stack, skipping a directory that disappears mid-walk and returning a leaf-and-directory path once. The scan is sorted, because a depth-first walk's order depends on the tree shape and the SQL backends this store shares a test suite with scan in key order. The sort is applied to the composite keys the listed paths decode to, not to the paths themselves: a path joins components with "/" where a composite key separates them with "\x00", and components that need it are percent-escaped, so the two forms do not order alike -- "\x00" and the "%" of an escape both sort below "-" while "/" sorts above it. Sorting the paths left a Vault scan ordered differently from the SQL backends, which is reachable with real keys, since identity hashes are base64 and routinely contain "/". Walking the subtree exposed a second defect, on every backend rather than just Vault. WalletStore.GetWalletIDs scans [tmsID, roleID] and did not filter what came back, so the per-wallet "configid" and "meta" entries below it were reported as wallet ids: GetWalletIDs -> ["alice_wallet" "CONF-ID-XYZ" "bWV0YQ=="] Filter by attribute count, the way GetConfID already filters for its own entry. Neither defect was caught because dbtest's wallet suite was wired to the SQL drivers only; export WalletCases and run it against both KVS backends. TWalletConfigurationLink is skipped there, as this store has no equivalent of the Wallets.conf_id foreign key it asserts. Adds unit tests for the path encoding, the not-found contracts, the fault-injection paths, the subtree walk, scan order, HasNext idempotency and a foreign secret under the mount, a FuzzNormalizeIDRoundTrip target wired into nightly-fuzz.yml, docs under docs/services/storage/kvs.md, and Godoc for the exported methods of the Vault KVS. The not-found classification is additionally pinned against the error the in-tree KVS really returns, so a rewording upstream fails in CI rather than turning every miss into an error in a deployment. Fixes #2041 Signed-off-by: AkramBitar <akram@il.ibm.com>
8b59488 to
569008f
Compare
Fixes #2041
Six fixes in
token/services/storage/db/kvs:/inside one (base64 identity hashes have them) or a./..can no longer make two different keys point at the same secret.StoreIdentity— the entryIdentityExistsreads is written last and every write is idempotent, so a partial failure reads as "not stored" and a retry converges.tracker.go— history entries are snapshots, not the caller's pointer.fscKVS.Close()— returns the store's close error instead of only logging it.Also fixed in the same code: a nil iterator for an empty list (panicked in
IdentityConfigurationsIterator), an uncheckedvalue.(string), and an unclosed iterator inGetWalletIDs.Warning
Storage-format change: Vault entries whose key components contain
/or%now live at a different path and are not found by the new code. Seedocs/services/storage/kvs.md.IdentityExistsreturns(bool, error)#2409 has landed, so the
WalletStoreService.IdentityExistssignature is already onmainand what remains here is only the KVS side of it: a failed lookup is no longer indistinguishable from "the binding does not exist". The interface,sql/common/wallet.go, the mock and the test callers all came from #2409 and are not in this diff.The entry is read with
kvs.Getrather than probed withkvs.Exists, the wayGetWalletIDalready does. FSC implementsExistsaslen(GetExisting(...)) > 0andGetExistingdrops the underlying store error, so a transient failure would masquerade as "not bound" — the confusion theerrorreturn exists to remove.Getis the only method on the KVS surface that propagates the store error, so absence is classified from the error instead: an authoritative "not found" is a miss, everything else reaches the caller.Registry.ContainsIdentitycannot grow an error return (it backsdriver.Wallet.Contains, a plainbool), so it logs the failure before reportingfalse.Second commit: every Vault absence shape is a miss
From review round 4.
read()reported a missing secret as absent, but answered a secret with no data — or adatafield that was missing, empty or not a map — with an error, which aborts the entire prefix scan behindGetConfIDandGetWalletIDs.Existsalready classified all three shapes as absent, so the two disagreed on what exists within the same file.It is reachable without a malformed secret: KV v1 has one mount per writer-set, so anything else writing under it (an operator running
vault kv put, another application sharing the mount) leaves an entry with nodatamap of ours that any scan over that mount reaches. One of those under the prefix hid every real entry below it.read()now mirrorsExistsand the iterator's existing skip path handles the result. A secret that does carry adatamap is this KVS' own entry, so avalueinside it that is missing, not a string or not decodable stays an error — that entry is corrupt, not absent.This removes the only place in the tree that produced
state of id [...] does not exist, the messageWalletStore's anchored not-found match looks for on this backend. The head stays listed becausehashicorpis a module of its own, versioned independently of the root module, so a deployment can still pair this store with a release that returns it.The issue's perf suggestion (hash-first keys) is left out — it needs a secondary index plus a scan fallback for existing data, so it belongs in its own change.
Tests: unit + fuzz for the key/path round trip (in the nightly fuzz matrix), a Vault-backed case for
/, empty,..and%components plus a key deleted after the list, a tracker regression test, anddbtest's wallet suite wired to the in-memory and Vault-backed KVS (it previously ran against the SQL drivers only, which is why theGetConfIDandGetWalletIDsdefects went unnoticed). From round 4:testForeignSecretIsAbsentNotAnError, andTestIsNotFoundErrMatchesLiveBackend, which classifies the error the in-tree KVS really returns so a rewording upstream fails in CI rather than in a deployment. New pagedocs/services/storage/kvs.md.