Skip to content

feat(messaging): let project secret keys manage messaging preferences - #109804

Open
Silthus wants to merge 12 commits into
PostHog:masterfrom
Silthus:feat/messaging-psak-preferences
Open

Silthus wants to merge 12 commits into
PostHog:masterfrom
Silthus:feat/messaging-psak-preferences

Conversation

@Silthus

@Silthus Silthus commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • A customer's server cannot set a recipient's email opt-in or opt-out without a person's API key.
  • The messaging preference endpoints accept only a personal API key with hog_flow:read or hog_flow:write.
  • That key dies when its owner leaves the project, and hog_flow:write also lets it edit workflows.
  • posthog-node is adding posthog.messaging.setPreferences() in PostHog/posthog-js#5173, which needs a project-scoped service credential.

Changes

  • A project secret API key (phs_) can now set recipients' messaging preferences.
  • The project secret key settings show a "Messaging preference" row with No access, Read, and Write, plus a "Messaging preference updates" preset.
  • A new messaging_preference scope covers the opt-out endpoints and nothing else in workflows.
  • Write includes read, the same as every other scope.
  • The SDK's exact request works: POST /api/projects/@current/messaging_preferences/{add_opt_out|remove_opt_out}/?token=phc_... with Authorization: Bearer phs_....
  • Personal API keys and MCP tools with hog_flow:* keep working unchanged.
  • Bulk opt-outs no longer return 500 when a concurrent request creates the same recipient first.
Decision Choice Reason
Scope New messaging_preference object Opt-outs are customer contact data, a different grant from editing workflows.
Write and read Write includes read A write that excluded read made the key picker inconsistent: every other row's Write covers Read. The key sits on the customer's server, so the clearer picker is worth letting a writer also list and export opt-outs.
Viewset scope_object Stays hog_flow Keeps access control rules and existing hog_flow keys working. The narrow scope is accepted per action.
Actions open to project secret keys add_opt_out, remove_opt_out, bulk_add_opt_outs (write); opt_outs, export_opt_outs_csv (read) The scope is the security boundary; every preference action sits inside it.
generate_link, webhook_url Denied Both stay session-only, as before.
OAuth messaging_preference is OAuth-hidden Built for project secret keys. OAuth clients and MCP keep hog_flow.
Personal key modal Not offered Personal keys keep hog_flow:*; the API still accepts the narrow scope.
Access control check Skipped for project secret keys The key has no user, and its scope is project-wide by design.
created_by None for project secret keys The synthetic user has no row to point at.
Throttles 480/minute and 4,800/hour per key, plus the same per project Same budget as a personal key; the project bucket stops extra keys from multiplying it.

Note

The secret key alone decides the project. The ?token= query parameter is not read on POST, so a mismatched public token cannot redirect a write.

The "after" screenshot replaces the earlier one, which showed a fourth "Read and write" option. The tooltip screenshot is gone, because the row no longer has a tooltip.

Before After
Create project secret API key, before Create project secret API key, after

Scope presets, after

How did you test this code?

  • test_message_preferences_project_secret_key.py sends the SDK's exact request with a project secret key. It failed with 401 before the change.
  • The same file covers:
    • A read or write key can list and export opt-outs, and a hog_flow:write key cannot.
    • A write returns the stored record.
    • A legacy hog_flow:write key, a read-only key, another project's key, a deleted key, a phc_ Bearer token, and no credentials cannot write.
    • generate_link and webhook_url refuse project secret keys.
    • Bulk opt-outs, a mismatched ?token=, and the shared per-project rate limit.
  • test_personal_api_key_access gains rows for personal keys with the narrow scope.
  • test_bulk_opt_out_concurrency.py holds another connection's uncommitted insert of the same recipient while a bulk opt-out runs. It returned 500 before the fix.
  • The screenshots come from a local, uncommitted Storybook story.
  • Not checked: a real posthog-node call against a running stack.

Test rationale: The existing personal key test covers hog_flow keys only. Project secret keys use a different authenticator, user type, scope and throttles, so their cases live in their own file. The main test file would otherwise pass 1,000 lines.

Release status

  • No feature flag controls this change
  • This change is behind a feature flag and is not available to users
  • This change makes a previously flagged feature available to everyone

Automatic notifications

  • Publish to changelog?

Docs update

  • No doc in this repository covers these endpoints.
  • Follow-up after merge: the posthog.com opt-out docs and the posthog-node JSDoc still say "personal API key". They should name a project secret key with messaging_preference:write.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5, 1M context)

  • Skills invoked: /adding-project-secret-api-key-auth, /adding-api-scopes, /writing-tests, /improving-drf-endpoints, /writing-user-facing-copy, /reviewing-with-coderabbit, /writing-pr-descriptions.
  • CodeRabbit CLI pass: skipped, because the CLI is signed out.
  • Design history:
    • The first version reused hog_flow:write for project secret keys.
    • Review feedback asked for a dedicated scope where write excludes read.
    • The screenshots of that version showed the cost: a Write that excludes Read reads wrong next to every other row. The scope now keeps standard semantics.
    • Removing the split also removed the write-only receipt, the "Read and write" picker option, and the MCP and OAuth special cases built for it.
  • Adversarial security reviews with Claude Fable 5.1 and GPT 6.1 Sol. Still-relevant fixes:
    • Two concurrent bulk opt-outs that created the same recipient raced on the unique key, and the loser returned 500. Bulk writes now insert with ON CONFLICT DO NOTHING, then lock and update the rows.
  • A fourth GPT 6.1 Sol adversarial review ran on the write-includes-read version and found no material issue.
  • Accepted, not changed:
    • Two parallel single writes for one recipient can lose an update. This predates the PR and also affects personal keys. Bulk writes no longer can.
    • A personal key with hog_flow:write cannot mint a project secret key with the narrow scope. This is by design.
  • hogli build:openapi and hogli build:projections regenerated the scope enum and the MCP OAuth hidden list.

🤖 Generated with Claude Code

Lets a customer's server opt recipients in to or out of messages with a
project secret API key instead of a user-owned personal API key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Oct 1, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds messaging preference read and write scopes for project secret keys across the scope catalogs and generated MCP API definitions. The messaging preference API accepts project secret keys for mapped actions, applies scope checks and throttles, and handles non-user credentials. Bulk opt-out saving now creates missing rows, locks the batch, and updates preferences. Tests cover scoped API access and concurrent recipient creation.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 8854b

Project secret keys can manage messaging preferences as intended; the scope checks match the platform's existing read/write rules. One small API documentation gap remains: the API schema lists only "201 Created" for opt-out updates, although updating an existing recipient returns "200 OK". Generated clients may therefore mis-type that response. This is a quick fix and does not block safe use of the feature.

Security Architecture Review

Security architecture risk: 🟠 High · up to 8854b

A credential intended only to update messaging preferences can also list and export recipient contact data. Individual updates additionally reveal stored preferences and whether the recipient already existed. Project isolation and rate limits contain the exposure, but the promised write-without-read boundary is not enforced.

Retained concerns

  • High · security · observed: The new messaging preference updates credential grants more authority than the intended write-only contract. Shared scope coverage expands messaging_preference:write into messaging_preference:read, allowing its holder to list and export project-wide opt-out contact data. The rule predates the PR, but accepting this new project-secret-key scope introduces the affected credential exposure.
  • High · security · observed: Both direct update endpoints expose the complete stored preference map, record UUID and creation-dependent 200/201 status to the newly admitted service credential, rather than the promised existence-independent receipt. These response behaviors predate the PR; their exposure to the new credential remains relevant and would survive fixing list/export scope coverage alone.
Security review details

Security Blast Radius

  • inferred — The demonstrated attack requires possession of a valid project secret key with messaging_preference:write. Its unintended read authority covers that project's opt-out lists and exports; direct writes reveal additional stored preferences for supplied identifiers. Team matching constrains the demonstrated maximum scope to the key's project, not every tenant or unrelated service.

Security Findings and Attack Paths

  • observed — The retained authorization finding is supported at head: a key issued for preference updates satisfies read requirements through shared scope expansion and can call list or CSV export. This is not caused by skipping scope enforcement altogether; it is a mismatch between the intended least-privilege contract and effective scope semantics.
  • observed — The retained disclosure finding is also supported: an authorized direct update returns unrelated stored preferences and a record UUID, while 200 versus 201 distinguishes an existing recipient from a newly created one. This overlaps the unintended read authority today, but the response path requires its own correction to establish write-only confidentiality.

Trust Boundaries and Controls

  • observed — Project-secret-key access is restricted to the five mapped preference actions. Team equality remains enforced, and tests assert rejection of session-only actions, unrelated write scopes, missing credentials, public-token credentials and revoked keys. Key management requires session or personal-key authentication with management permissions.
  • observed — The preference view adds per-key throttles and shared project-secret-key team throttles. Cache identities distinguish individual keys and the owning team; the targeted test asserts that a second key cannot evade the shared project limit. These controls limit request volume, not the data authority granted by the scope rule.

Resilience and Maintainability Implications

  • inferred — Bulk row locking does not protect against every writer: direct add/remove operations still save a JSON map read without a lock. Concurrent direct or mixed-path updates can overwrite an unrelated opt-out. The available base-to-head comparison shows these direct operations were unchanged, so this is a pre-existing consent-preservation risk rather than an established PR regression.
  • observed — Direct writes commit before synchronization dispatch and the task rereads current state. Those facts do not establish recovery after dispatch failure or ordering of concurrent external synchronization calls; no such guarantee is claimed by this assessment.

Hardening Proposals

  • proposed — Implement messaging preference read exclusion consistently across permission checks and scope issuance. For callers lacking effective read authority, return only an existence-independent receipt with a uniform success status. Preserve legacy hog_flow behavior explicitly and assert denied list/export access for the updates preset.
  • proposed — As separate hardening for the existing consent-preservation risk, make direct and bulk writers share a concurrency-safe update mechanism, and exercise mixed add/remove/bulk operations that must preserve unrelated opt-outs.
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the problem, user-visible changes, testing, test rationale, release status, documentation follow-up, and agent decisions. It is mostly complete, although some agent de…
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

Silthus and others added 3 commits October 1, 2026 10:39
…read

Project secret keys now get a dedicated messaging_preference scope instead
of hog_flow:write. Its write half does not cover read, so a key that sets
preferences cannot list or export every recipient's opt-outs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Write responses show a write-only caller only the preference it wrote,
  always with 200, so they no longer reveal stored preferences or whether
  the recipient existed.
- messaging_preference is OAuth-hidden: the consent screen assumes write
  covers read.
- Project secret key system-table access follows read coverage.
- The project secret key picker offers read and write together.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stored record id is a UUIDT that encodes creation time, so returning
it told a write-only caller whether the recipient existed. Write-only
callers now get only the identifier and the preference they set.

The project secret key picker only offers read and write together for
messaging preferences, so other scopes holding both halves still show
their last action.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Silthus Silthus changed the title feat(messaging): accept project secret keys for preference writes feat(messaging): let project secret keys manage messaging preferences Oct 1, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: efff078f-5393-45f6-9315-3d984c619ef8

📥 Commits

Reviewing files that changed from the base of the PR and between 6cb19fb and c60d7b7.

⛔ Files ignored due to path filters (5)
  • products/access_control/frontend/generated/api.schemas.ts is excluded by !**/generated/**
  • products/messaging/frontend/generated/api.schemas.ts is excluded by !**/generated/**
  • products/messaging/frontend/generated/api.ts is excluded by !**/generated/**
  • services/mcp/src/lib/oauth-scopes.generated.ts is excluded by !**/*.generated.*
  • services/mcp/src/tools/generated/messaging.ts is excluded by !**/generated/**
📒 Files selected for processing (14)
  • frontend/src/lib/components/ScopeAccessRow/ScopeAccessRow.tsx
  • frontend/src/lib/scopes.tsx
  • frontend/src/scenes/settings/project/ProjectSecretAPIKeys.tsx
  • frontend/src/scenes/settings/project/projectSecretAPIKeysLogic.test.ts
  • frontend/src/scenes/settings/project/projectSecretAPIKeysLogic.tsx
  • posthog/api/test/test_authentication.py
  • posthog/auth.py
  • posthog/scopes.py
  • posthog/test/test_scopes.py
  • products/messaging/backend/api/message_preferences.py
  • products/messaging/backend/api/test/test_message_preferences.py
  • products/messaging/backend/api/test/test_message_preferences_project_secret_key.py
  • products/workflows/frontend/OptOuts/optOutListLogic.ts
  • services/mcp/src/api/generated.ts

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.

Comment thread products/messaging/backend/api/message_preferences.py Outdated
Silthus and others added 6 commits October 1, 2026 11:32
A full preference response also matches the write-only shape, so oneOf
rejected valid responses. One write result serializer with optional id
and updated_at covers both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ipients

Two requests that both create the same recipient no longer race on the unique
constraint. The loser raised a 500, which also told a write-only key the
recipient had not existed before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rence write as read

The MCP scope gate now reads WRITE_EXCLUDES_READ_SCOPE_OBJECTS from the scopes
projection, and the 'Read and write' option is disabled when either half is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A scope whose write excluded read made the project secret key picker
confusing: every other row's Write covers Read. Since the key lives on a
customer's server, the clearer picker is worth the extra read access.
Write responses return the stored record again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟠 Major · Declare the HTTP 200 response for existing recipients. · message_preferences.py:321

products/messaging/backend/api/message_preferences.py:321
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Declare the HTTP 200 response for existing recipients.

When add_opt_out or remove_opt_out finds an existing record, it returns HTTP 200. Both extend_schema declarations currently document only HTTP 201. Add HTTP 200 with the MessagePreferencesSerializer response shape so generated clients can describe successful updates.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 7948ec4c-ead5-443a-b1e4-26de7c67f475

📥 Commits

Reviewing files that changed from the base of the PR and between 4bc694e and 8854b95.

⛔ Files ignored due to path filters (1)
  • services/mcp/src/lib/oauth-scopes.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (6)
  • frontend/src/lib/scopes.tsx
  • posthog/scopes.py
  • products/messaging/backend/api/message_preferences.py
  • products/messaging/backend/api/test/test_message_preferences.py
  • products/messaging/backend/api/test/test_message_preferences_project_secret_key.py
  • services/mcp/src/api/generated.ts
💤 Files with no reviewable changes (1)
  • services/mcp/src/api/generated.ts

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 7 remain after this review.

@Silthus
Silthus marked this pull request as ready for review October 2, 2026 06:23
@Silthus
Silthus requested a review from a team as a code owner October 2, 2026 06:23
@parameterai

parameterai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Risk: No findings

The change since the last review is confined to the project-secret-key test file: it pins the SDK's exact form-urlencoded request shape and changes the cross-team expectations to 403. I traced both rejection paths end-to-end (team resolution from the body/query token in posthog/api/routing.py:666-680 and the psak team equality checks in posthog/permissions.py:870-902 and 220-223) and confirmed production already enforces exactly what the new tests assert; no production code changed and no new risk is introduced.

Sentinel reviewed 8710856 · Review settings

Silthus and others added 2 commits October 2, 2026 07:57
add_opt_out and remove_opt_out return 200 when the recipient already
exists, which the schema did not declare.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant