feat(oauth): "@default" union scope ceilings (unprivileged default + explicit extras)
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_SCOPESdefault, 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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