Skip to content

bug(reporting): configuration generations are keyed by account only, but AdCP 3.2 scopes delivery_config_id to (caller, account) #1291

Closed

Description

@bokelley

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_id is "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:

  • ReportingConfigurationGenerationKey and ReportingConfiguration (adcp/reporting/ledger/models.py:329-380) have no caller field.
  • ReportingLedgerStore.list_configurations(*, account_id, ...) (adcp/reporting/ledger/store.py:324-326) and put_configuration (store.py:920-944) key on that triple. The PG table has PRIMARY 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 by caller.account_id only. consumer_id scopes consumer statuses, not configurations or obligations.
  • The managed production path has the same shape. ReportingDestinationBinding carries consumer_id, but admission still checks list_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:

  1. Add consumer_id to ReportingConfigurationGenerationKey and ReportingConfiguration. 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.
  2. Have list_configurations and the status snapshot take the ReportingStatusCaller / ReportingDeliveryPrincipal, not a bare account_id, so a caller can only read its own generations.

Activity

  1. added
    bugSomething isn't working
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  2. bokelley commented on Oct 2, 2026

    @bokelley
    ContributorAuthor

    Triage

    Classification: Bug
    Bucket(s): handlers (no matching repo label — gap noted)
    Status: ready-for-human

    What the experts said:

    • security-reviewer: two ship-stoppers — get_reporting_status leaks full ReportingConfiguration records (including media_buy_ids, feed_purpose, schedule) to any buyer on a shared account (status.py:132-138, status_snapshot.py:133,210 all drop consumer_id); write-side CONFIGURATION_GENERATION_IMMUTABLE DoS when two buyers choose the same caller-selected id; both are passively exploitable with normal buyer credentials, no ESA required; ESA synthetic-ledger_account_id workaround is independently broken at the production admission check (configuration.py:322 queries real account_id, not the synthetic key)
    • ad-tech-protocol-expert: confirmed spec-violation — AdCP 3.2 reporting-delivery-config.json:14 and get-reporting-status-request.json:6 mandate (caller, account) isolation and require that cross-caller identifiers be indistinguishable; adding consumer_id to the key is the protocol-correct fix; three caveats: consumer_id must come from transport auth only (never request payload), list_configurations signature must be updated, and the lease table PK (reporting_ledger.sql:92-98) needs the same column addition

    My 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:

    • Add consumer_id: str to ReportingConfigurationGenerationKey and ReportingConfiguration (models.py:329-380); populate exclusively from transport-authenticated identity, never from request fields
    • Thread consumer_id through list_configurations, put_configuration (store.py:324-944), ReportingStatusHandler._load (status.py:132), and both snapshot read paths (status_snapshot.py:133,210)
    • PG migration: extend PK from 3-tuple to (account_id, consumer_id, delivery_config_id, delivery_config_version) following advisory-lock pattern in reporting_ledger_account_generations.sql; also fix lease-table PK; pre-migration rows need NOT NULL sentinel that is provably unreachable by any real consumer_id
    • Fix production admission check (configuration.py:316-327); remove ESA synthetic-key workaround (it is broken today and becomes unnecessary once the core fix lands)

    Triaged by Claude Code. Session: https://claude.ai/code/session_01CY1ytGwwvBZZsr7thi2mDZ


    Generated by Claude Code

  3. added and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions