Skip to content

fix(subscriptions): restore exports with sharing disabled#60276

Merged
vdekrijger merged 1 commit into
PostHog:masterfrom
vdekrijger:posthog-code/fix-subscription-export-sharing-tokens
May 29, 2026
Merged

fix(subscriptions): restore exports with sharing disabled#60276
vdekrijger merged 1 commit into
PostHog:masterfrom
vdekrijger:posthog-code/fix-subscription-export-sharing-tokens

Conversation

@vdekrijger
Copy link
Copy Markdown
Contributor

@vdekrijger vdekrijger commented May 27, 2026

Problem

Dashboard and insight subscriptions rely on exported image assets for Slack and email delivery. When an organization disables public shared resources, subscription screenshot generation and delivery should continue to work for explicitly configured recipients without reopening the richer shared/exporter render surface to public asset tokens.

The previous exported asset sharing enforcement treated subscription delivery artifacts the same as general public shared resources, which caused subscription export rendering to fail. A safe fix also needs to avoid letting long-lived public content tokens render the full exporter page.

Changes

  • Add dedicated exported asset JWT audiences for render and subscription delivery content.
  • Gate worker usage of render-only tokens behind EXPORT_ASSET_RENDER_TOKEN_ENABLED for safe rollout.
  • Keep web backwards-compatible with legacy short-lived public render tokens during rollout.
  • Enforce exported asset token audiences by requested surface:
    • render route accepts render tokens, plus legacy short-lived public render tokens
    • normal content URLs accept public exported asset tokens only
    • subscription delivery content URLs accept subscription-delivery tokens
    • malformed exported asset paths are blocked
  • Keep is_system=True on subscription-generated assets for quota/system accounting only.
  • Update Slack and email subscription payloads to use subscription-delivery content URLs.
  • Add regression coverage for token audience/surface behavior and subscription asset classification.

How did you test this code?

Agent-authored. Automated tests run:

./bin/hogli test posthog/api/test/test_sharing.py::TestSharingConfigurationSerializerValidation posthog/tasks/exports/test/test_image_exporter.py ee/tasks/test/subscriptions/test_subscriptions_utils.py

Result: 36 passed.

Also ran:

git diff --check

Result: no whitespace errors.

Local cross-reference: PR branch vs master

Agent-driven manual verification on the demo dev stack (team 1, Hedgebox Inc.).
Repeatable on a fresh local stack — DB state was held constant across both branches,
only the view code differs.

Setup (paste into python manage.py shell):

Setup script
from datetime import timedelta
from posthog.constants import AvailableFeature
from posthog.models import Team
from posthog.models.exported_asset import (
    ExportedAsset,
    get_public_access_token,
    get_render_access_token,
    get_subscription_delivery_access_token,
)
from products.product_analytics.backend.models.insight import Insight

team = Team.objects.get(id=1)
org = team.organization
features = [f for f in (org.available_product_features or [])
            if f.get("key") != AvailableFeature.ORGANIZATION_SECURITY_SETTINGS]
features.append({"key": AvailableFeature.ORGANIZATION_SECURITY_SETTINGS,
                 "name": "organization_security_settings"})
org.available_product_features = features
org.allow_publicly_shared_resources = False
org.save()

insight = Insight.objects.filter(team=team, deleted=False).first()
asset = ExportedAsset.objects.create(team=team, insight=insight,
                                     export_format=ExportedAsset.ExportFormat.PNG)
asset.content = b"fake-png-bytes"
asset.save(update_fields=["content"])

for label, mk in [
    ("public",       get_public_access_token),
    ("render",       get_render_access_token),
    ("subscription", get_subscription_delivery_access_token),
]:
    t = mk(asset, timedelta(minutes=15))
    print(f"{label} /exporter:           http://localhost:8010/exporter?token={t}")
    print(f"{label} /exporter/<file>:    http://localhost:8010/exporter/{asset.filename}?token={t}")

Results — same DB state, same JWTs, only branch differs:

# Token Endpoint master PR
1 Public /exporter (page) 404 404
2 Render /exporter (page) 404 200
3 Public /exporter/<file> 404 404
4 Subscription /exporter/<file> 404 200
5 Subscription /exporter (page) 404 404
  • Checks 2 and 4 flip from 404 to 200 — the targeted regression is fixed.
  • Checks 1, 3, 5 stay 404 on both branches — public sharing remains blocked,
    and the subscription delivery token cannot be repurposed against the render page.

Publish to changelog?

no

Docs update

No docs update needed.

🤖 Agent context

PostHog Code investigated a regression (#59523) in subscription export rendering for organizations with public shared resources disabled. The first implementation allowed system exported assets to render, but review found that public content tokens could be reused against the richer exporter route and that delivered image URLs would remain blocked. The final design uses capability-specific token audiences for render, normal content, and subscription delivery content, restricts tokens to their intended URL surfaces, and adds a rollout flag so web compatibility can deploy before workers switch render token audiences.

Review-swarm findings were addressed before opening this PR. Remaining rollout guidance: deploy this code with EXPORT_ASSET_RENDER_TOKEN_ENABLED left at its default false, then enable the flag after web pods are rolled out.

Local cross-reference verification against master added by Claude Code in a follow-up session: minted public / render / subscription-delivery tokens against an asset on the demo team with allow_publicly_shared_resources=False, then re-ran the same five curls on both branches with identical DB state. Confirmed the two endpoints the PR is meant to restore (render page + subscription file delivery) flip from 404 to 200, and the three guarded endpoints stay 404 on both — i.e. the fix doesn't widen public sharing.


Created with PostHog Code

@assign-reviewers-posthog assign-reviewers-posthog Bot requested a review from a team May 27, 2026 15:47
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 27, 2026

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
posthog/api/sharing.py:795-797
The `request` parameter is accepted but never read inside the method, making it superfluous. The only relevant data is already on `resource`.

```suggestion
    def _is_blocked_by_public_sharing_setting(
        self, resource: SharingConfiguration | ExportedAsset
    ) -> bool:
```

### Issue 2 of 3
posthog/api/sharing.py:804-807
**`"subscription_delivery"` string duplicated across three files**

The key `"subscription_delivery"` is written as a bare string literal in `sharing.py`, `ee/tasks/subscriptions/subscription_utils.py`, and `posthog/temporal/subscriptions/activities.py`. A mismatch from a later rename would silently break the bypass allowance without a test failure on the mismatched writer. Defining it once as a constant (e.g., in `exported_asset.py`) and importing it would satisfy OnceAndOnlyOnce.

### Issue 3 of 3
posthog/models/exported_asset.py:235-242
**`has_short_lived_public_render_token` detects remaining lifetime, not original issue window**

The guard `expires_at <= now_ + timedelta(minutes=20)` is true any time the token has ≤ 20 minutes of validity left — including a long-lived content token (365-day default) that happens to be in its final 20 minutes of life. In that narrow window the token would pass the legacy-render-path check and be permitted on `/exporter`. The practical window is 20 minutes out of 525 600 (≈ 0.0038% of the token's lifespan), and `encode_jwt` does not embed `iat`, so there is no clean way to recover the original expiry without a schema change. Worth noting for when the legacy path is eventually retired and this heuristic can be removed.

Reviews (1): Last reviewed commit: "fix(subscriptions): restore exports with..." | Re-trigger Greptile

Comment thread posthog/api/sharing.py
Comment thread posthog/api/sharing.py Outdated
Comment thread posthog/models/exported_asset.py Outdated
Comment thread ee/tasks/subscriptions/subscription_utils.py Outdated
@veria-ai
Copy link
Copy Markdown

veria-ai Bot commented May 27, 2026

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch 2 times, most recently from f319e7e to 7a65b6b Compare May 28, 2026 07:08
@vdekrijger
Copy link
Copy Markdown
Contributor Author

Thanks — addressed in the latest push:

  • Removed access-control reliance on export_context.subscription_delivery; subscription delivery now has its own JWT audience (EXPORTED_ASSET_SUBSCRIPTION) and Slack/email use get_subscription_delivery_content_url().
  • is_system remains only for system/quota accounting on subscription-created assets.
  • Public/render/content surfaces are now enforced by token audience:
    • /exporter?token=... accepts render audience, plus a temporary legacy-render audience for rollout.
    • /exporter/<file>.png?token=... accepts normal content or subscription-delivery content audience.
    • malformed paths are blocked.
  • Replaced the ambiguous short-lived public-token fallback with a distinct temporary EXPORTED_ASSET_LEGACY_RENDER audience, so long-lived public content tokens can’t age into render access.
  • Kept EXPORT_ASSET_RENDER_TOKEN_ENABLED default-off so we can deploy web compatibility first, then flip workers to the new render audience.

Retested:

./bin/hogli test posthog/api/test/test_sharing.py::TestSharingConfigurationSerializerValidation posthog/tasks/exports/test/test_image_exporter.py ee/tasks/test/subscriptions/test_subscriptions_utils.py

Result: 36 passed.

@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch 2 times, most recently from e7478d4 to 7d874f9 Compare May 28, 2026 07:33
Comment thread ee/tasks/subscriptions/subscription_utils.py Outdated
Comment thread ee/tasks/test/subscriptions/test_subscriptions_utils.py Outdated
Comment thread posthog/api/test/test_sharing.py
Comment thread posthog/temporal/subscriptions/activities.py Outdated
Comment thread posthog/settings/base_variables.py Outdated
@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch from 7d874f9 to 3361925 Compare May 28, 2026 09:04
@vdekrijger
Copy link
Copy Markdown
Contributor Author

Addressed the latest review comments in the newest push:

  • Removed from subscription export asset creation, so it remains only for its original system/quota semantics.
  • Removed the test asserting subscription assets are .
  • Removed the rollout flag / legacy fallback path entirely.
  • Removed access-control use of .
  • Kept the clean capability-token model: render tokens for , normal content tokens for normal content URLs, and subscription-delivery tokens for Slack/email image URLs.
  • Fixed the Ruff/mypy issue by declaring transient decoded-token metadata on and using normal assignment.

Retested:

./bin/hogli test posthog/api/test/test_sharing.py::TestSharingConfigurationSerializerValidation posthog/tasks/exports/test/test_image_exporter.py ee/tasks/test/subscriptions/test_subscriptions_utils.py

Result: .

@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch from 3361925 to 00d595f Compare May 28, 2026 09:28
@vdekrijger
Copy link
Copy Markdown
Contributor Author

Updated again based on the discussion to simplify the model:

  • Removed EXPORTED_ASSET_RENDER and the render-token/flag/legacy fallback path entirely.
  • /exporter?token=... and /exporter/<file>.png?token=... now share the same gate for a given token audience.
  • Kept only two exported asset audiences:
    • EXPORTED_ASSET for normal exported assets, still blocked when org public sharing is disabled.
    • EXPORTED_ASSET_SUBSCRIPTION for subscription-delivery image URLs, allowed for the configured delivery use case.
  • Removed is_system=True from subscription asset creation and removed the corresponding test.
  • Kept Slack/email on get_subscription_delivery_content_url() so subscription delivery does not depend on public sharing being enabled.

Retested:

./bin/hogli test posthog/api/test/test_sharing.py::TestSharingConfigurationSerializerValidation posthog/tasks/exports/test/test_image_exporter.py ee/tasks/test/subscriptions/test_subscriptions_utils.py

Result: 34 passed.

Comment thread posthog/api/sharing.py Outdated
@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch from 00d595f to a623da2 Compare May 28, 2026 09:42
@vdekrijger
Copy link
Copy Markdown
Contributor Author

Updated again to use the standard JWT shape we discussed: a single EXPORTED_ASSET audience with a purpose claim.

  • Removed EXPORTED_ASSET_SUBSCRIPTION.
  • Subscription delivery URLs now use aud=EXPORTED_ASSET with purpose=subscription_delivery.
  • Normal exported asset tokens have no purpose claim and remain blocked when org public sharing is disabled.
  • Subscription delivery tokens are allowed by the org public-sharing gate because the purpose is carried in the token itself.
  • Render/content paths intentionally share the same exported-asset gate again, matching the simpler historical model.

Retested:

./bin/hogli test posthog/api/test/test_sharing.py::TestSharingConfigurationSerializerValidation posthog/tasks/exports/test/test_image_exporter.py ee/tasks/test/subscriptions/test_subscriptions_utils.py

Result: 34 passed.

@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch from a623da2 to b9fb1ac Compare May 28, 2026 09:46
@vdekrijger
Copy link
Copy Markdown
Contributor Author

Updated to the middle-ground token model:

  • Kept a single EXPORTED_ASSET audience.
  • Added purpose claims for the exceptional flows:
    • purpose=render for the exporter render URL.
    • purpose=subscription_delivery for subscription image delivery URLs.
  • Subscription delivery tokens are now content-surface only: /exporter/<file>.png?token=... works, but /exporter?token=... 404s.
  • Normal exported asset tokens still share the historical render/content gate, but remain blocked when org public sharing is disabled.
  • No is_system, export_context, extra audience, rollout flag, or legacy fallback is used for access control.

Retested:

./bin/hogli test posthog/api/test/test_sharing.py::TestSharingConfigurationSerializerValidation posthog/tasks/exports/test/test_image_exporter.py ee/tasks/test/subscriptions/test_subscriptions_utils.py

Result: 34 passed.

@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch from b9fb1ac to c8bf02a Compare May 28, 2026 13:09
Generated-By: PostHog Code
Task-Id: 09015dfc-1238-463a-a11c-e3944ff5f975
@vdekrijger vdekrijger force-pushed the posthog-code/fix-subscription-export-sharing-tokens branch from c8bf02a to 8576950 Compare May 28, 2026 13:22
@vdekrijger vdekrijger merged commit 771d0c9 into PostHog:master May 29, 2026
411 of 422 checks passed
@deployment-status-posthog
Copy link
Copy Markdown

deployment-status-posthog Bot commented May 29, 2026

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-05-29 06:49 UTC Run
prod-us ✅ Deployed 2026-05-29 07:02 UTC Run
prod-eu ✅ Deployed 2026-05-29 07:04 UTC Run

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.

3 participants