fix(secrets): preserve binding egress posture across delete+reinsert syncs - #337
Open
claudegoogl-sudo wants to merge 2 commits into
Open
fix(secrets): preserve binding egress posture across delete+reinsert syncs#337claudegoogl-sudo wants to merge 2 commits into
claudegoogl-sudo wants to merge 2 commits into
Conversation
…syncs replaceSecretRefsForInstanceTarget deleted every non-env binding for an instance-scoped target and re-inserted the rows bare, so every instance-target config save silently reset operator egress posture: allowedEgress back to [] (deny-all while enforcing) and egressAllowlistEnforced back to true (re-arming a log-only posture). The sibling syncs preserved posture with hand-rolled copies; the drift between the copies is what let this one wipe. Unify the preserve semantics into one shared helper, preservedEgressPosture (+ egressPostureCarryIndex / egressPostureCarryKey) in services/egress-posture.ts, and route all four posture-carrying write paths through it: - syncSecretRefsForTarget and syncEnvBindingsForTarget keep their existing preserve behaviour, now via the shared helper - replaceSecretRefsForInstanceTarget captures posture before the delete and carries it onto the re-inserted rows (the fix) - syncPluginSecretBindings captures plugin-binding posture before its revoke deletes, so a repoint (delete+reinsert of the same path) no longer resets posture; the upsert's conflict branch still leaves live rows untouched New bindings at a config path with no prior row are born enforcing with an empty allowlist (the column defaults); no path regresses to born-false. Document the invariants in doc/SECURITY-POSTURE-COLUMN-SWEEP.md. Co-Authored-By: Paperclip <noreply@paperclip.ing>
The CI policy gate rejects internal/instance-local references in the diff; two comment mentions survived the first scrub pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
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.
Follows
CONTRIBUTING.mdPR template — sections present: Thinking Path, Linked Issues or Issue Description, What Changed, Verification, Risks, Model Used, Checklist.Thinking Path
Linked Issues or Issue Description
No public GitHub issue exists for this change. Problem description, bug-report shape:
Description
A binding's egress posture is operator state keyed by the binding identity.
replaceSecretRefsForInstanceTargetdeleted every non-env.%binding for an instance-scoped target and re-inserted the new rows withoutallowedEgressoregressAllowlistEnforced, so each instance-target config save resetallowedEgressto[]andegressAllowlistEnforcedtotrue. The three sibling syncs (syncSecretRefsForTarget,syncEnvBindingsForTarget,syncPluginSecretBindings) preserved posture with hand-rolledexisting?.x ?? defaultcopies; the drift between the copies is the defect.Steps to reproduce
(company, environment, env-1, apiKey)withallowedEgress = ['https://example.com'].replaceSecretRefsForInstanceTarget({ targetType: 'environment', targetId: 'env-1' }, [{ secretId, configPath: 'apiKey' }]).allowedEgress = []. After, it is unchanged.Expected behavior
A config re-save preserves operator-set egress posture in both directions. A new binding at a path with no prior row is born enforcing with an empty allowlist (the column defaults).
Related merged work: #176 (security-posture column registry sweep) and #205 (operator-reachable egress posture panel) — this change is the write-path preservation rule those surfaces report on.
What Changed
server/src/services/egress-posture.ts:preservedEgressPosture(carry the prior row's posture; fall back to the born-enforcing column defaults when there is no prior row),egressPostureCarryKey, andegressPostureCarryIndex.syncSecretRefsForTargetandsyncEnvBindingsForTargetnow build their carry index and resolve re-insert posture through the shared helper. Behavior is unchanged; the hand-rolled copies are gone.replaceSecretRefsForInstanceTarget.writeBindingsnow captures the posture of the rows it is about to delete (inside the same executor/transaction) and carries it onto the re-inserted rows. This is the fix: instance-target config saves no longer wipe operator allowlists or flip enforcement.syncPluginSecretBindingscaptures plugin-binding posture before its revoke deletes, so a repoint (delete+reinsert of the same config path) carries the old posture. The upsert's conflict branch still only updatessecretId, so live rows stay untouched.doc/SECURITY-POSTURE-COLUMN-SWEEP.md: posture is preserved across delete+reinsert; new bindings are born enforcing; a legacy log-only row stays log-only until an operator flips it.server/src/__tests__/egress-posture-carry.test.ts(helper unit tests) andserver/src/__tests__/secret-binding-egress-posture-preserve.test.ts(embedded-postgres tests, one per observable behavior).Verification
Red-then-green, measured against base
14a4d4b59(currentmaster) and this branch:cd server && pnpm exec vitest run src/__tests__/secret-binding-egress-posture-preserve.test.ts14a4d4b59:Tests 4 failed | 3 passed (7)— the failures are exactly the posture wipe:AssertionError: expected [] to deeply equal [ 'https://example.com' ](replace path drops the operator allowlist)AssertionError: expected true to be false(replace path re-arms a log-only posture)expected [] to deeply equal [ 'https://a.example' ](per-company posture on a shared instance target)expected [] to deeply equal [ 'https://example.com' ](plugin repoint reset)Test Files 1 passed (1),Tests 7 passed (7).cd server && pnpm exec vitest run src/__tests__/egress-posture-carry.test.ts—Tests 3 passed (3)(helper unit tests).pnpm exec vitest run src/__tests__/secret-binding-resilience.test.ts src/__tests__/plugin-secret-bindings-sync.test.ts src/__tests__/egress-posture-deny-all-sweep.test.ts—Tests 30 passed (30).pnpm exec tsc --noEmitinserver/— no errors in the touched files. The pre-existing module-resolution errors from unbuilt workspace deps are identical to base.(companyId, configPath); the per-company test covers that case directly.Risks
replaceSecretRefsForInstanceTarget: instance-target config saves now preserve operator-set posture instead of resetting it. That is the fix. Any caller that relied on re-save resetting posture to deny-all must use the operator binding API, which is the documented surface.Model Used
Claude Opus 4.6 (
claude-opus-4-7) via Claude Code (Paperclip agent runtime), extended thinking + tool use: local file edits, bash execution, embedded-postgres test runs, and the red-then-green A/B against the base commit.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template#NNN/github.com/paperclipai/paperclipURLs)docs/...,fix/...) and contains no internal Paperclip ticket id or instance-derived details