Repository navigation
bug(reporting): configuration generations are keyed by account only, but AdCP 3.2 scopes delivery_config_id to (caller, account) #1291
Description
Summary
On 8.0.0, a reporting configuration generation is identified by (account_id, delivery_config_id, delivery_config_version). AdCP 3.2 makes delivery_config_id caller-selected and unique within the authenticated caller and account. Two buyers that share an account and both pick daily@1 therefore collide. The second buyer gets CONFIGURATION_GENERATION_IMMUTABLE, and either buyer's get_reporting_status lists the other buyer's generations.
#1169 fixed the cross-account version of this bug. The caller dimension is still missing.
Details
Spec (bundled 3.2 schemas):
core/reporting-delivery-config.json:5,14: "Entries are owned by (authenticated caller, account)";delivery_config_idis "unique within the authenticated caller and account."account/sync-accounts-request.json:91: "another caller's entries MUST NOT be read, replaced, or deleted."media-buy/get-reporting-status-request.json:6: "Authoritative caller/account-isolated reporting reliability read."
SDK:
ReportingConfigurationGenerationKeyandReportingConfiguration(adcp/reporting/ledger/models.py:329-380) have no caller field.ReportingLedgerStore.list_configurations(*, account_id, ...)(adcp/reporting/ledger/store.py:324-326) andput_configuration(store.py:920-944) key on that triple. The PG table hasPRIMARY KEY (account_id, delivery_config_id, delivery_config_version)(adcp/reporting/ledger/reporting_ledger.sql:25-51).ReportingStatusHandler._load(adcp/reporting/ledger/status.py:132-138) reads configurations and the snapshot bycaller.account_idonly.consumer_idscopes consumer statuses, not configurations or obligations.- The managed production path has the same shape.
ReportingDestinationBindingcarriesconsumer_id, but admission still checkslist_configurations(account_id=...)(adcp/reporting/production/configuration.py:316-327).
Reproduction (adcp 8.0.0)
import asyncio
from dataclasses import replace
from datetime import datetime, timezone
from adcp.reporting.ledger import (
InMemoryReportingLedgerStore,
LedgerConflictError,
ReportingConfiguration,
ReportingScheduleSpec,
)
from adcp.reporting.ledger.status import ReportingStatusCaller, ReportingStatusHandler
# Buyer A configures daily@1 on a shared account. The generation has no caller field.
buyer_a = ReportingConfiguration(
delivery_config_id="daily",
delivery_config_version=1,
account_id="shared-acct",
report_definition_id="delivery",
reporting_profile="adcp.delivery.v1",
feed_purpose="analytics",
schedule=ReportingScheduleSpec(period_duration="P1D", delivery_sla="PT1H"),
required_finality="snapshot",
activated_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
media_buy_ids=("mb_a",),
)
# Buyer B independently picks the same caller-selected id for its own feed.
buyer_b = replace(buyer_a, feed_purpose="pacing", media_buy_ids=("mb_b",))
async def main():
store = InMemoryReportingLedgerStore()
await store.put_configuration(buyer_a)
try:
await store.put_configuration(buyer_b)
except LedgerConflictError as exc:
print("buyer B put:", exc.code)
view = await ReportingStatusHandler(store).handle(
{"view": "summary"},
caller=ReportingStatusCaller(account_id="shared-acct", consumer_id="buyer-b"),
)
print("buyer B summary scope:", view["scope"]["delivery_config_generations"])
asyncio.run(main())Output:
buyer B put: CONFIGURATION_GENERATION_IMMUTABLE
buyer B summary scope: [{'delivery_config_id': 'daily', 'delivery_config_version': 1, 'feed_purpose': 'analytics'}]
Why an adopter hits this
In Embedded Sales Agent, several buyer principals can hold grants on one advertiser account. To keep their configurations apart, ESA folds the principal into the ledger key: ledger_account_id() builds tenant:account:principal, and split_ledger_account_id() parses it back (core/reporting.py). The SDK then echoes that synthetic id in every response, so _caller_scope, _present_account and _serve rewrite it back to the account the buyer addressed. The account-context resolver and the sync_accounts projection also have to un-fold it. That is about 90 LOC of identity plumbing. Any status reader or account_id field that misses the rewrite leaks an internal key to the buyer.
Ask
Make the caller part of configuration identity everywhere the account already is:
- Add
consumer_idtoReportingConfigurationGenerationKeyandReportingConfiguration. Key the in-memory store, the PG primary key, leases, obligations and status scopes on(account_id, consumer_id, delivery_config_id, delivery_config_version). Include a migration, as bug(reporting): scope configuration generations and leases by account #1169 did. - Have
list_configurationsand the status snapshot take theReportingStatusCaller/ReportingDeliveryPrincipal, not a bareaccount_id, so a caller can only read its own generations.
Activity
bokelley commentedon Oct 2, 2026
Triage
Classification: Bug
Bucket(s): handlers (no matching repo label — gap noted)
Status: ready-for-human
What the experts said:
get_reporting_statusleaks fullReportingConfigurationrecords (includingmedia_buy_ids,feed_purpose,schedule) to any buyer on a shared account (status.py:132-138, status_snapshot.py:133,210 all dropconsumer_id); write-sideCONFIGURATION_GENERATION_IMMUTABLEDoS when two buyers choose the same caller-selected id; both are passively exploitable with normal buyer credentials, no ESA required; ESA synthetic-ledger_account_idworkaround is independently broken at the production admission check (configuration.py:322 queries realaccount_id, not the synthetic key)reporting-delivery-config.json:14andget-reporting-status-request.json:6mandate(caller, account)isolation and require that cross-caller identifiers be indistinguishable; addingconsumer_idto the key is the protocol-correct fix; three caveats:consumer_idmust come from transport auth only (never request payload),list_configurationssignature must be updated, and the lease table PK (reporting_ledger.sql:92-98) needs the same column additionMy take: Both experts converge: this is a confirmed, passively exploitable multi-tenant isolation failure and a direct AdCP 3.2 spec violation. The fix is breaking (PK migration, model field additions, function-signature changes) and security-sensitive — auto-PR is blocked; needs a human-reviewed security PR tagged for
@bokelley.When authorized, implementation scope:
consumer_id: strtoReportingConfigurationGenerationKeyandReportingConfiguration(models.py:329-380); populate exclusively from transport-authenticated identity, never from request fieldsconsumer_idthroughlist_configurations,put_configuration(store.py:324-944),ReportingStatusHandler._load(status.py:132), and both snapshot read paths (status_snapshot.py:133,210)(account_id, consumer_id, delivery_config_id, delivery_config_version)following advisory-lock pattern inreporting_ledger_account_generations.sql; also fix lease-table PK; pre-migration rows need NOT NULL sentinel that is provably unreachable by any realconsumer_idTriaged by Claude Code. Session: https://claude.ai/code/session_01CY1ytGwwvBZZsr7thi2mDZ
Generated by Claude Code
fix(reporting): repair owned replay receipts and conformance fixtures
fix(reporting)!: isolate generations by authenticated caller (#1342)