Skip to content

Connector authority hardening: decouple artist association from act-as-artist authority (acute remediation) #1860

Description

@sweetmantech

Tracking issue for hardening how the platform grants authority to act on an artist's behalf — specifically, decoupling artist association (an artist showing up in an account's roster/catalog, for analytics) from act-as-artist authority (using that artist's authenticated OAuth connectors to publish or take private actions). Surfaced 2026-07-08 while reviewing the Catalog HQ funnel work (chat#1859): the funnel's claim-time canonical-artist attach (api#768, merged) writes the same account_artist_ids roster row that today doubles as full connector authority — so pointing funnel accounts at the connectable canonical widens a pre-existing privilege-escalation vector. (Business context, once: connectors are how customers publish to their real social accounts; a cross-account leak here is a direct customer-trust incident, so this gates how far we scale connector adoption.)

Status (2026-07-09): the acute objective is complete and verified in prod — this issue is CLOSED. The five shipped PRs below (P0b, P0a, deleteArtist guard, docs contract, join-row UNIQUE constraints) closed the live cross-account act-as-artist vector, the unauthenticated roster-write hole, and the shared-canonical data-loss path. The non-acute connection-keying redesign and the PATCH metadata gate were split to follow-up trackers — see the dated decision callout below. Follow-ups: chat#1865 (re-key + org-union + P2 audit/wipe) and chat#1866 (PATCH gate).

Decision (sweetman, 2026-07-08): connections will be scoped per (account, artist) and per (org, artist) by keying them on the existing join rows (account_artist_ids / artist_organization_ids) — not on the bare canonical artist account. Artists stay canonical/shared; associating a catalog stays free; using a connector is private to the account (or org) that established it. Migration approach: wipe all existing Composio connections and reconnect fresh in the new paradigm (believed near-zero live usage — audit first).

Decision (sweetman, 2026-07-08): PR matrix reordered by customer impact. P0b now leads — it severs the live cross-account act-as-artist vector (canonical roster links are already accumulating on prod via api#768, so this is active exposure, not theoretical). P0a ships second (the unauthenticated write is the door; P0b makes what's behind it non-authoritative). The deleteArtist guard is promoted above the P1 re-key chain: it is independent of the redesign and protects against irreversible data loss (a hard-deleted shared canonical cascades song_artists). P1 internal sequencing (docs → database → api) is unchanged.

Decision — scope narrowed + closed (sweetman, 2026-07-09): the acute objective of this issue is complete and verified in prod — P0b (api#772), P0a (api#771), and the deleteArtist guard (api#770) are all merged to main and live-verified, and the contract (docs#271) + join-row UNIQUE constraints (database#46, applied to prod) shipped. The remaining P1/P2 work is not acute (P0b already blocks cross-account connector use): it is a storage-keying redesign + a metadata-listing leak + an org-sharing feature + a destructive migration. Split to follow-up trackers so this issue can close: the connection-keying redesign (re-key + org-union + P2 audit/wipe) → chat#1865; the independent PATCH /api/artists metadata gate → chat#1866. Not abandoned — relocated.

Goal

Roster/catalog association grants read/analytics only. Authority to authorize, use, or disconnect an artist's OAuth connectors — and to take any act-as-artist action — is scoped to the account (or org) that owns the connection, and cannot be obtained merely by having the artist in a roster. Concretely, when done:

  1. No code path grants connector authorize/execute/disconnect off a flat account_artist_ids roster row.
  2. Composio connections are keyed on the join row (account_artist_ids / artist_organization_ids), so getConnectors/getComposioTools for artist A return only the caller's own (or their org's) connections for A — never another account's.
  3. POST /api/accounts/artists requires authentication (today it is fully open).
  4. deleteArtist can never hard-delete a shared canonical account that other accounts/song graphs depend on.
  5. A full matrix of existing Composio connections (account × artist × toolkit) exists, and the wipe-and-reconnect migration is executed against it.

Done at the macro level when: an account holding artist A in its roster but owning no connection for A gets zero connector tools for A (chat agent and POST /api/connectors/actions), verified live; and a second account in the same org, only if explicitly org-shared, can use A's org connection — nobody else can.

PRs (updated 2026-07-08 — reordered by customer impact, top = ship first; all to-open, none exist yet)

PR Item Base State
api#772 P0b: connector authorize/execute + getComposioTools artist branch stop granting off the flat account_artist_ids edge (interim: self / org co-membership only) main merged 2026-07-09 (fc1e0d7main) — new checkConnectorAuthority (fails closed) at all 4 call sites; checkAccountArtistAccess untouched (read/analytics keep roster access); TDD RED→GREEN, 272 tests pass. Preview-verified live both directions: non-staff roster-only account → 403 on authorize/execute/disconnect for the rostered artist (comment); staff/self grants + roster read paths intact (comment). Retroactively neutralizes every funnel roster link as connector authority
api#771 P0a: authenticate POST /api/accounts/artists (unauthenticated roster-write hole) main merged 2026-07-09 (69aa286main) — TDD RED→GREEN, 60 tests pass; email survives as a checkAccountAccess-gated override. Preview-verified live: no credential → 401 (was open); 400 bad body, 200 idempotent add (no row written), 404 unknown email, 403 foreign-account email override (verification). SRP-refactored to route→handler→validator→business per review + re-verified (refactor run). Still open: docs update (add-artist.mdx: auth required, 401/403/404); cubic P2 email-enumeration (404-vs-403) deferred for a decision
api#770 P1: deleteArtist must not hard-delete a shared canonical that has song_artists/catalog dependencies — only remove the caller's link main merged 2026-07-09 (6bdcff7main) — guard fails closed on query error; 172 tests pass. Review refactors folded in: bespoke existence helper → generic selectSongArtists({ songs?, artists? }) (fail-closed policy lives in deleteArtist). Preview-verified live both branches: sole-link + song deps → DELETE returns 200 and the canonical account + song_artists rows survive (guard run); sole-link + no deps → still hard-deletes (200 → 404); guardrails 401/400/404/403 (first run)
docs#271 P1: connector contract — connection ownership keyed per (account, artist) / (org, artist); authorize/execute/disconnect authority model main merged 2026-07-09 (668158dmain) — concept page connectors/ownership.mdx + Concepts nav group; flags (not rewrites) the shipped account-scoped OpenAPI descriptions; join-row-id-vs-pair deliberately undocumented. Verified on local mint dev: page renders, nav wired, callouts/tables/anchors/links resolve, contract reconciles with the live P0b 403 behavior. First of the re-key chain landed
database#46 P1: UNIQUE(account_id, artist_id) on account_artist_ids (+ UNIQUE(artist_id, organization_id) on artist_organization_ids) — prerequisite for join-row keying main merged + applied to prod 2026-07-09 — dedupe-first, idempotent (pg_constraint-guarded). Verified via Supabase MCP (before/after): both UNIQUE constraints now present with correct defs; recorded as 20260708200000 / join_row_unique_constraints (no version drift); 0 duplicate pairs pre/post so dedupe removed nothing; live duplicate insert now rejected with 23505 (verification). Unblocks the api re-key
chat#1865 P1/P2 (moved): re-key ownerId → join row · org-shared union · Composio audit · wipe-and-reconnect main ➡️ moved to chat#1865 — not acute; P0b already blocks cross-account use
chat#1866 P1 lower (moved): gate PATCH /api/artists metadata writes off the flat roster edge main ➡️ moved to chat#1866 — independent, lower severity

Merge sequencing (customer-impact order, 2026-07-08): P0b ships first — it severs roster→connector authority, which retroactively neutralizes every canonical roster link already accumulating on prod (the claim-time attach api#768 deployed 1b8012b, 2026-07-08 13:47 UTC; marketing#46 does not widen that exposure). P0a second, independent — it closes the unauthenticated roster-write door. The deleteArtist guard is independent of the redesign and ships early (data-loss protection). Then the P1 re-key chain in docs → database → api order (contract, then the UNIQUE constraint the keying depends on, then the re-key + org-union + metadata-gate). P2 ops last: run the audit, then the wipe-and-reconnect against the new keying. The funnel (chat#1859) can proceed regardless — P0b is remediation, not a gate on it.

P0 — acute (done, verified in prod)

  • P0b: connector authority is granted off the flat account_artist_ids roster edge. ✅ 2026-07-09 (→ api#772, merged fc1e0d7main) — Verified live: a non-staff account holding artist A only in its roster (no connection, no org tie) gets 403 from POST /api/connectors/actions, POST /api/connectors, and DELETE /api/connectors targeting A, while its own connectors and roster read/analytics stay functional; staff/self grants preserved (denial run · grant run). Chat-agent getComposioTools artist branch covered by the same checkConnectorAuthority cut (unit-tested).

    • Why: getComposioTools' artist branch (lib/composio/toolRouter/getComposioTools.ts:48-49) and the connector authorize/execute/disconnect validators (via checkAccountAccesscheckAccountArtistAccess, which returns true on a bare account_artist_ids row, lib/artists/checkAccountArtistAccess.ts:26-28) all treat roster membership as authority to use the artist's connectors. POST /api/connectors/actions additionally accepts an arbitrary account_id override gated by the same check (lib/composio/connectors/validateExecuteConnectorActionRequest.ts:49-58).
    • Fix (interim, pre-redesign): narrow the connector authorize/execute/disconnect and getComposioTools artist branch to require self or org co-membership — stop reading the account_artist_ids roster edge — until the full connection-keying lands. This makes any funnel/claim roster link non-authoritative by construction.
    • Done when: an account with artist A only in its roster (no connection, no org tie) gets zero connector tools for A in the chat agent and a 403 from POST /api/connectors/actions targeting A; verified live.
    • Review notes from api#772 (2026-07-08): (1) the new checkConnectorAuthority keeps the workspace-ownership and org-target grant paths from checkAccountAccess (they're membership edges, not the roster defect) — reviewer to confirm or tighten to strict self/org-co-member; (2) GET /api/connectors?account_id= still uses the flat check, so a roster-only account can still list (not use) another account's connections for a shared artist — decide whether that metadata leak joins this scope or the P1 re-key.
  • P0a: POST /api/accounts/artists is completely unauthenticated. ✅ 2026-07-09 (→ api#771, merged 69aa286main) — Verified live: the endpoint now 401s without a valid credential (previously fully open); an authenticated caller adds only to its own account (idempotent, no unintended write), an email override to a foreign account is 403 and to an unknown email 404 (verification). Follow-up remaining: docs update (add-artist.mdx) documenting auth + 401/403/404.

    • Why: the route (app/api/accounts/artists/route.ts) never calls validateAuthContext; the body is only {email, artistId} (lib/accounts/validateAddArtistBody.ts), and the handler resolves the account purely from the supplied email then writes insertAccountArtistId(accountId, artistId) (lib/accounts/addArtistToAccountHandler.ts). The only middleware is the x402 image-gen paywall. So anyone can link any account to any existing canonical artist — and under today's flat model, that roster row is act-as-artist authority. Exploitable today, no funnel needed.
    • Fix: require validateAuthContext; derive the account from the authenticated credential (or gate a body override via checkAccountAccess), never from an unauthenticated email.
    • Done when: the endpoint 401s without a valid credential; an authenticated caller can only add artists to an account it controls; verified live.

P1 (data-loss guard — done; redesign moved out)

  • P1 (promoted 2026-07-08): deleteArtist must not hard-delete a shared canonical. ✅ 2026-07-09 (→ api#770, merged 6bdcff7main) — Verified live: as the sole roster link of a real canonical with 2 song_artists rows, DELETE /api/artists/{id} returned 200 and the canonical account + both song_artists rows survived (pre-guard, that cascade would have destroyed them); the no-dependency case still hard-deletes (guard run). Guard fails closed on query error. Shipped with the generic selectSongArtists({ songs?, artists? }) lib per review.
    • Why: lib/artists/deleteArtist.ts removes only the caller's own link (deleteAccountArtistId(requester, artistId), scoped correctly), but then if zero roster links remain across all accounts it calls deleteAccountById(artistId) — hard-deleting the canonical account, which cascades song_artists (account_artist_ids.artist_id is ON DELETE CASCADE). A canonical with a catalog/song graph but a single roster link gets destroyed when that one account removes it. Independent of the re-key — can ship any time.
    • Fix: never hard-delete a canonical that has song_artists/catalog dependencies (arguably never hard-delete a shared canonical — only drop the caller's link).
    • Done when: removing the last roster link from a canonical that has song_artists leaves the canonical + its song graph intact; verified.

➡️ P1 connection-keying redesign + P2 ops — moved to chat#1865. The docs (docs#271) and database (database#46, applied to prod) prerequisites shipped as part of this issue. The remaining api re-key (ownerId → join row, with the join-row-id-vs-(account_id, artist_id)-pair design decision still open), the org-shared connection union, and the P2 Composio audit + wipe-and-reconnect migration are tracked there. Not acute — P0b already blocks cross-account connector use.

➡️ PATCH /api/artists shared-metadata gate — moved to chat#1866 (independent, lower severity; lib/artists/validateUpdateArtistRequest.ts:88checkAccountArtistAccess).

Architecture decisions

  • Association ≠ authority. account_artist_ids (and org/workspace membership) grants read/analytics only. Act-as-artist authority (connectors, publish, disconnect, destructive artist writes) is separate and connection-scoped. (sweetman, 2026-07-08)
  • Artists stay canonical/shared; connections are scoped, not artists. No per-signup artist duplication (that's the funnel P1 in chat#1859); isolation lives on the connection via the join-row ownerId, not by forking the artist.
  • Connection ownership is self-anchored, keyed on the join row. Private by default to (account, artist); explicitly shareable to (org, artist). Whoever establishes a connection owns it; roster membership never inherits it. (Supersedes the earlier "OAuth completion ⇒ artist manager" idea, which was wrong — an OAuth handshake proves control of the connected social account, not authority over the canonical artist.)
  • Admin override stays, admin-only. The account_id override on connector endpoints is retained for Recoup-staff support, gated on RECOUP_ORG admin — not on the flat roster edge.
  • Migration = wipe + reconnect, not attribute-and-remap (no establisher recorded in Composio; near-zero live usage). (sweetman, 2026-07-08)

Source references

  • Sibling / trigger: chat#1859 (Catalog HQ funnel) — the walkthrough + escalation write-up. api#768 (claim-time canonical attach) is the change that widened the vector.
  • Code anchors: api/lib/composio/toolRouter/getComposioTools.ts (artist branch + owner keying), api/lib/composio/connectors/{authorizeConnector,executeConnectorAction,validateExecuteConnectorActionRequest}.ts, api/lib/artists/checkAccountArtistAccess.ts + api/lib/auth/checkAccountAccess.ts (the flat gate), api/app/api/accounts/artists/route.ts + api/lib/accounts/addArtistToAccountHandler.ts (the unauthenticated write), api/lib/artists/deleteArtist.ts (cascade delete), database migration 20250129000001_account_artist_ids.sql (schema: bigint PK, no pair uniqueness).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions