Loading
feat(npm): remote-repository update with credential clearing (S15 Step 7, part 3/3)
📚 Stacked MRs
This change is split into 3 stacked MRs to keep each within the review size limit (≈800 reviewable LoC). Each part targets the previous one (part 1 targets main); review and merge proceed bottom-up.
📦 What this MR does
Part 3 of 3 of npm remote plan — Step 7: repo service layer — credentials + cache-validity:
Adds UpdateNpmRemoteRepository, the patch update beneath the management layer's future update endpoint:
- Credential clearing on URL change (S13 rule, in-store): when the supplied URL normalizes to a different value than the stored one and no new token accompanies it,
tmp_plaintext_auth_tokenis zeroed in the same UPDATE statement as the url write and the update reportsremote.CredentialClearin its result — an old bearer never survives to a re-pointed upstream, and a caller renderscredentials_clearedfrom that action. A new token always replaces; a respelled URL (host case, default port, trailing slash) is not a change and cannot clear credentials spuriously (URLs are stored normalized). - Transaction requirement: the method rejects any handle that is not a
*sql.Tx. Its FOR UPDATE read-compare-write is only serialized while the caller's transaction holds the lock; on a pool handle the lock dies with the SELECT's autocommit, leaving a race that could silently destroy a concurrently rotated credential. - Row gates: the lock's parent gate carries the read paths' full predicate set (npm format, remote kind, soft-delete, namespace isolation), so the update path can never mutate a binding the reads report as
ErrNotFound. - Result shape: the update returns
UpdateNpmRemoteRepositoryResult{Changed bool, CredentialAction remote.CredentialAction}rather than a bare cleared bool. A patch that resolved entirely to the stored state (a respelled url and nothing else) reportsChanged: falsewith a nil action — a success that writes nothing, no longer indistinguishable from a url change that cleared nothing — and a credential write reports the set/replace/clear action the audit payload already defines (remote.CredentialNonefor a write that touched no credential).Changedreports whether the UPDATE executed; onlyurlis compared against stored state, so a same-value token or cache-window write still reports true. - Credential-write audit events (AC 3 audit clause): every credential write the store performs emits one
remote.CredentialWriteEventthrough aremote.AuditSinkthe constructor now takes —setorreplacefor a supplied token (told apart by a credential-presence bit read under the sameFOR UPDATElock, so the distinction cannot race a concurrent token write),clearfor the url-change zeroing. The event carries the action and the namespace and parent-repository keys, never a credential value, and the token plaintext still never leaves the database on this path: the presence bit is computed in SQL. The create path emits asetwhen a token is supplied. - Create-only cache validity: updates write
cache_validity_hoursonly when an explicit value is supplied — the create-time resolution is never re-run, so a stored value survives a url re-point unless the operator overrides it. The plan's Step 7 wording is corrected to match (drift correction, ships in the implementing MR per the workflow's carve-out).
✅ Spec coverage
🎯 Acceptance criteria
| Criterion | Tests |
|---|---|
S15 Credentials and health AC 3 — url update zeroes the token in the same transaction unless a new token is supplied, emits an audit event, surfaces credentials_cleared |
TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository (integration: clear, replace, respelled no-op, rollback atomicity, FOR UPDATE serialization on both the url comparison and the credential-presence bit), TestNpmRemoteRepositoryStore_CredentialWriteAudit (integration: clear, clear on a token-less row, set, replace, empty stored token counts as absent, no event on a non-credential write, a respelled no-op, a refused write, create with and without a token), TestUpdateNpmRemoteRepositoryStmt (unit: the SET list and the reported action asserted together per patch shape) |
| Create-only cache validity — updates never re-run the create-time resolution | TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository (integration: cache validity survives a url change; an explicit value writes) |
⚠️ Error cases
| Case | Tests |
|---|---|
| Row and parent gates: missing binding, soft-deleted / maven / docker / oci / hosted / virtual parent, cross-namespace | TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository_RowGates (integration; each seeded case also asserts the stored token survived) |
| Argument guards: nil context, nil handle, zero keys, empty patch, empty and over-cap token, malformed url | TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository_ArgumentGuards (unit), TestNewNpmRemoteRepositoryStore_NilAuditSinkPanics (unit) |
Schema refusal classified to ErrNpmRemoteRepositoryCheckViolation, and a transient failure never misreported as ErrNotFound |
TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository_RowGates (integration) |
🔒 Security considerations
| Consideration | Tests |
|---|---|
| A refused write never leaks the failing row: the driver error is replaced, not wrapped, on classified and unclassified paths alike | TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository_RowGates (integration: requireNoPgErrorInChain on the CHECK refusal), TestNpmRemoteRepositoryWriteError (unit: an unclassified PgError carrying a token in Detail is dropped from the chain; a cancellation stays matchable) |
| Credentials scoped to the old host never reach a new one, and the clear cannot be split from the url write | TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository (integration: same-transaction clearing, rollback atomicity, lock serialization) |
| The stored plaintext never leaves the database on the update path — the locked read projects a presence bit, not the token | TestNpmRemoteRepositoryStore_UpdateNpmRemoteRepository (integration: set versus replace decided from the presence bit), TestNpmRemoteRepositoryStore_LockNpmRemoteRepository_PrunesToOnePartition (integration: the statement the store executes) |
🔍 Notes for reviewers
- AC 3 audit clause is satisfied here: an earlier revision deferred the audit event to the management layer on the premise that the store never sees the acting principal.
remote.CredentialWriteEventcarries no principal — S13 emits the payload, S17 adds the actor — so the store emits it. Converging this emission onto the tx-threadedremote.CredentialReposeam andremote.CredentialManager, sequenced with S17, is tracked in #487. The emission sits inside the caller's transaction, so a caller that rolls back afterwards has already recorded the event — the same propertycredentials_clearedhas, and part of what work item 487 converges. A url change on a token-less row still emitsclear: the event describes the write performed (the SET NULL is unconditional), matchingremote.CredentialManager.ClearCredentials. The plan's AC 1 (bearer-attach, Step 9) and AC 4 (probe, S13/S17) carve-outs are unaffected. - Commit sequence: the two commits that introduce the feature (4784e3f4 and ced25d90) each carry tests and implementation together, rather than the test-first pair the workflow asks for. The other commits on the branch are clean
test(/refactor(/docs(splits. Noted rather than repaired — fixing it means rewriting pushed history that review replies already cite by SHA. - No e2e scenario catalog update: datastore service layer beneath the not-yet-shipped management CRUD; no HTTP surface changes.
Related to #287
:bricks: Stacked MRs (review/merge bottom-up)
Edited by David Fernandez