feat(server)!: require an explicit refAccess on createTenantStore - #2433
Merged
Conversation
BREAKING CHANGE: `createTenantStore`'s `refAccess` is now required. It defaulted to 'ref-routed', under which `accounts.resolve` returns whatever tenant the buyer's ref names without consulting the authenticated principal. That is correct for an agency hub whose single credential legitimately spans tenants, and a cross-tenant spend hole for a hub whose tenants are unrelated clients — `resolve` is the account path for create_media_buy and update_media_buy, not just reads. Nothing in the code can distinguish those two deployments, so any default silently picks one of them. Requiring the field converts that into a tsc error the adopter resolves once, at construction. Neither behavior changed; only the choice is now explicit. The type alone would only bind TypeScript callers, so the helper also throws at construction on a missing or unrecognized value — a JS adopter or an `as any` cast would otherwise fall through to the permissive branch silently, which is the exact failure the field exists to prevent. Docs were the larger half of the problem. skills/build-holdco-agent's front-matter promises "per-tenant data isolation" and its account- resolution section presented createTenantStore as the answer while never mentioning refAccess — and its "What the helper guarantees" list covers only upsertRow / syncGovernanceRow / non-writability. An adopter who followed the skill and never opened the .d.ts shipped cross-tenant resolve believing they had isolation. That skill now documents the choice with a decision table, gains a "What the helper does NOT guarantee" section, and cross-cutting.md no longer calls the helper an unqualified isolation gate. The multi-tenant example takes 'auth-scoped' — each buyer agent_url maps to exactly one home tenant there, so a ref naming another must fail closed. Its storyboard tests pass unchanged, confirming the example never relied on cross-tenant resolution. Addresses the third item in #2428. The other two items in that issue are ordinary fixes and are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — security-hardening PR making createTenantStore's refAccess required with no default, backed by a fail-closed construction guard.
Verified clean:
- Guard logic, resolve path, and new throw paths (with test coverage)
- Major changeset correctly reflects the required-param flip / breaking change
- Call-site sweep and example/doc updates are consistent
- No witness-not-translator or transport-client concerns
No blocking or Medium findings reported by the reviewer.
Gate checks: gated_paths is false, so row 2 does not apply despite review_decision: REVIEW_REQUIRED. No no-auto-approve team match, high_risk false, no prior decision. No findings at any severity → falls through to row 9.
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.
Addresses the third item in #2428 — the one that needed a product decision rather than a patch.
Why
refAccessdefaulted to'ref-routed', under whichaccounts.resolvereturns whatever tenant the buyer's ref names without consulting the authenticated principal (tenant-store.ts:217-235).That default is correct for an agency hub whose single credential legitimately spans tenants — the model the helper was designed for, documented in its own header. It is a cross-tenant spend hole for a hub whose tenants are unrelated clients, because
resolveis the account path forcreate_media_buyandupdate_media_buy, not just reads.Nothing in the code can tell those two deployments apart. So any default silently picks one, and the wrong pick is invisible until someone notices another tenant's budget moving.
To be clear about what this is not: the previous behavior was not a bug. It was documented in three places, has a fail-closed alternative shipped, and has a characterization test named
"default ('ref-routed') resolves a cross-tenant ref without checking the caller". Acodex-securityscan flagged it as a vulnerability; on the code, that was refuted. The problem is the default, and the docs.What changed
refAccessis required. Neither behavior changed; only the choice is now explicit.resolvewith a ref naming another tenant'auth-scoped'null→ACCOUNT_NOT_FOUND'ref-routed'AccountMigration is one line per call site. Considered and rejected: flipping the default to
'auth-scoped', which would break agency-hub adopters quietly — their calls start returningnulland they debug it as a resolution bug. A required field breaks them attsc, before they ship.The type alone isn't enough, so the helper also throws at construction on a missing or unrecognized value. A JS adopter or an
as anycast would otherwise fall through to the permissive branch silently — the exact failure the field exists to prevent.The docs were the larger half
skills/build-holdco-agent/SKILL.mdis the file an adopter actually follows. Its front-matter promises "per-tenant data isolation", its § "Account resolution + tenant-isolation gate" presentscreateTenantStoreas the answer — and it mentionedrefAccesszero times. Its "What the helper guarantees" list coversupsertRow/syncGovernanceRow/ non-writability and omitsresolveentirely.So an adopter could follow the skill end to end, verify every guarantee it lists, and ship cross-tenant
resolvebelieving they had isolation. Worse, the guarantee they did verify — the per-entry gate — coverssync_accounts/sync_governanceonly, which is a genuinely separate decision fromrefAccess.That skill now documents the choice with a decision table, states the consequence of getting it wrong, and gains a "What the helper does NOT guarantee" section.
skills/cross-cutting.mdno longer describes the helper as an unqualified "built-in isolation gate", anddecisioning/account.ts's header no longer says resolve "is NOT an isolation gate by default" (there is no default now).Verification
'ref-routed'explicitly; the four'auth-scoped'tests are unchanged.examples/hello_seller_adapter_multi_tenant.tstakes'auth-scoped'— each buyeragent_urlmaps to exactly one home tenant there, so a ref naming another must fail closed. Its storyboard tests pass unchanged, which confirms the example never relied on cross-tenant resolution.Changeset is
major. Timed for the13.0.0-rcline, andcreateTenantStoreisPreview / 6.xsurface, so the bar for a required-field change is lower than it would be for stable API — but it's worth checking how many adopters are on this helper before merging.Not in scope
The other two items in #2428 are ordinary fixes, unaffected by this and still open: the test-controller bridge admitting on a buyer-supplied
sandboxmarker when onlyresolveAccountis wired, and the comply controller whoseADCP_SANDBOX/ADCP_COMPLY_CONTROLLER_UNGATEDenv flags suppress the warning rather than gating anything.🤖 Generated with Claude Code