PostHog / PostHog/posthog

feat(oauth): "@default" union scope ceilings (unprivileged default + explicit extras)

Open
#64,086 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
39.9k
Forks
3.4k
Avg merge
6h 51m
Merged PRs (30d)
232

Description

Problem

OAuthApplication.scopes (the scope "ceiling") is all-or-nothing:

  • empty → resolves to the broad UNPRIVILEGED_SCOPES default, and auto-tracks new scopes as products add them.
  • non-empty → an exhaustive allow-list; the app gets exactly those scopes and nothing else.

That breaks down for an app that needs almost the whole unprivileged surface plus a privileged scope — e.g. a first-party agent that needs the broad API surface and llm_gateway:read. It can't use the empty default (that excludes the privileged scope), so it has to seed a ~180-element explicit ceiling. That list then has to be hand-maintained forever: every time a product adds a scope the ceiling drifts, and the agent silently can't reach the new product until someone updates it.

Proposal

Support a @default sentinel element in OAuthApplication.scopes meaning "the UNPRIVILEGED_SCOPES default plus the other listed scopes."

An app's ceiling can then be ["@default", "llm_gateway:read"] — it auto-tracks every unprivileged scope added later while still explicitly granting the one privileged extra. Same shape as Microsoft Graph's .default scope: the grantable set lives server-side and the app references it generically, so adding scopes needs no client-side list update.

Design

All ceiling math in posthog/scopes.py routes through one new resolver:

DEFAULT_CEILING_SENTINEL = "@default"

def resolve_ceiling(app_scopes) -> frozenset[str] | None:
    app = set(app_scopes or [])
    if not app:
        return None  # empty => UNPRIVILEGED default, unchanged
    if DEFAULT_CEILING_SENTINEL in app:
        return frozenset(UNPRIVILEGED_SCOPES | (app - {DEFAULT_CEILING_SENTINEL}))
    return frozenset(app)  # explicit exhaustive list, unchanged

effective_ceiling, scopes_within_ceiling, scopes_outside_ceiling, and narrow_scopes_to_ceiling all resolve through it. Backward-compatible by construction — empty ceilings and existing explicit-list ceilings are unchanged (the sentinel only does anything when literally present). No migration. The sentinel starts with @ so it can't collide with a real obj:action scope, it's stripped from the resolved ceiling (not grantable itself), and filter_to_unprivileged_scopes drops it so a self-registering app can't inject it to widen its own ceiling.

Why

Unblocks moving the PostHog Code CLI off the * wildcard (https://github.com/PostHog/posthog/issues/60342) without seeding and maintaining a ~180-element static ceiling — the app's ceiling becomes ["@default", "llm_gateway:read"].

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in posthog/scopes.py with the ceiling helpers: effective_ceiling, scopes_within_ceiling, scopes_outside_ceiling, narrow_scopes_to_ceiling, and filter_to_unprivileged_scopes. Trace how each currently handles empty and explicit ceilings, then verify that @default expands to UNPRIVILEGED_SCOPES plus explicit extras, is not grantable, and leaves existing behavior unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
authorization, backend-api-design, security
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.