Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
91e137b
fix(scopes): generate the OAuth-hidden scope objects for the frontend
a-lider Oct 1, 2026
cdbfc17
chore(scopes): add the generated frontend OAuth scope lists
a-lider Oct 1, 2026
3e739fd
Merge remote-tracking branch 'origin/master' into alex/oauth-hidden-s…
a-lider Oct 2, 2026
04d01ff
fix(scopes): align the consent fallback test with the generated hidde…
a-lider Oct 2, 2026
a31ddd6
fix(scopes): give the frontend the generated OAuth scope lists
a-lider Oct 2, 2026
f81f7c0
chore(scopes): point the omission comment at the generated hidden list
a-lider Oct 2, 2026
1f1851a
chore(skills): simplify the hidden-object guidance in adding-api-scopes
a-lider Oct 2, 2026
e01beb5
feat(settings): group personal API key scopes by product area
a-lider Oct 2, 2026
8150e6f
chore(settings): address review on the grouped key scope picker
a-lider Oct 2, 2026
8e571e9
chore(visual): update storybook baselines
posthog[bot] Oct 2, 2026
6880f56
fix(settings): keep key scope presets within the levels the picker al…
a-lider Oct 2, 2026
e9d4324
fix(settings): type the scope row map by string key
a-lider Oct 2, 2026
c3c1106
chore(visual): update storybook baselines
posthog[bot] Oct 2, 2026
e301b99
Merge remote-tracking branch 'origin/master' into alex/oauth-hidden-s…
a-lider Oct 2, 2026
71c9c89
Merge branch 'alex/oauth-hidden-scopes-generated' into alex/api-key-s…
a-lider Oct 2, 2026
9c3e9b9
chore(scopes): drop the history comment from the preset test
a-lider Oct 2, 2026
bc56746
chore(scopes): name both consumers in the generated OAuth scope header
a-lider Oct 5, 2026
77b721b
Merge branch 'alex/oauth-hidden-scopes-generated' into alex/api-key-s…
a-lider Oct 5, 2026
c45f573
chore(scopes): drop the Agent CLI preset test that re-implements the …
a-lider Oct 5, 2026
4467452
chore(scopes): add a data-attr to each group access option
a-lider Oct 5, 2026
63419c6
Merge remote-tracking branch 'origin/master' into alex/api-key-scope-…
a-lider Oct 5, 2026
708432d
chore(scopes): drop the copied level order map from the key logic
a-lider Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13146,6 +13146,10 @@ snapshots:
hash: v1.k794b7964.a464549645ee031d1943952d051b8a9b3299c53e8ae5e37e06c29f47c22a9801.rh3jn-mkm-MBeAIvordg5eBjfB4XkBnNw1HK7WA3QBM
scenes-app-settings-user--settings-user-api-keys--light:
hash: v1.k794b7964.c56cd95732f9e4355cc2b62b37ae9c36394637d232ea0d75cfaa63ede57205f5.ZgEv-Uxm9273XkhrJDzf9Cj_DESxhuf6OO_1o_fTd4k
scenes-app-settings-user--settings-user-api-keys-create-modal--dark:
hash: v1.k794b7964.0c60f46bd47ba425439c87fcb40dc1b7e215a81a128d11649402e515d2675432.Z1DsSFhodE7mOsQ1EnLFhrdbCKWRYMP2lB6clnbyMC8
scenes-app-settings-user--settings-user-api-keys-create-modal--light:
hash: v1.k794b7964.d3f6e5103966ece2e3212959e66e7ae3e35ee71cb6d2c579b0871cc01601bda8.7jToX6wggX9huxSMWqwHFRxrXQL9d8EjJLrsruKVyAs
scenes-app-settings-user--settings-user-backup-codes-modal--dark:
hash: v1.k794b7964.fee174652504bedf45b5050dae77b7f38ffaac07d04c14c8c77b010d565cba97.n_drnTbGpOMa04Hy05-KZlJhx74bmdscz3V2bjDSQiw
scenes-app-settings-user--settings-user-backup-codes-modal--light:
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/lib/components/ScopeAccessRow/ScopeAccessGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import clsx from 'clsx'
import { type ReactNode, useId, useState } from 'react'

import { IconChevronRight } from '@posthog/icons'
import { LemonSegmentedButton, LemonTag } from '@posthog/lemon-ui'

import type { ScopeAccessLevel } from 'lib/scopes'

interface ScopeAccessGroupProps {
/** Group heading, such as a product area. */
label: string
/** How many rows in the group sit at each level, for the tags in the header. */
counts: Record<ScopeAccessLevel, number>
/** The level the group control shows as selected. Undefined when the rows disagree. */
value: ScopeAccessLevel | undefined
/** Called with the level the user picks for the whole group. */
onChange: (level: ScopeAccessLevel) => void
/** Reason the No access option should be disabled. Set to a non-empty string to disable. */
noneDisabledReason?: string
/** Reason the Read option should be disabled. Set to a non-empty string to disable. */
readDisabledReason?: string
/** Reason the Write option should be disabled. Set to a non-empty string to disable. */
writeDisabledReason?: string
/** Tooltip on the selected level, for when some rows sit at another level. */
valueTooltip?: string
/** Whether the rows show on first render. */
defaultOpen?: boolean
/** Prefix of the `data-attr`s: `<prefix>-toggle-<slug>` on the toggle, `<prefix>-<slug>-<level>` on an option. */
dataAttrPrefix: string
/** The rows of the group, rendered when the group is open. */
children: ReactNode
}

export function ScopeAccessGroup({
label,
counts,
value,
onChange,
noneDisabledReason,
readDisabledReason,
writeDisabledReason,
valueTooltip,
defaultOpen = false,
dataAttrPrefix,
children,
}: ScopeAccessGroupProps): JSX.Element {
const [open, setOpen] = useState(defaultOpen)
const panelId = useId()
const total = counts.none + counts.read + counts.write
const groupSlug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-')

return (
<div className="border-t border-border first:border-t-0 @container/scope-group">
{/* The header stays on one line. In a narrow container, such as an OAuth popup window,
the label truncates and the counts hide, so the control keeps its place. */}
<div className="flex items-center gap-2 py-2 min-h-10">
<button
type="button"
className="flex items-center gap-2 flex-1 min-w-0 text-left font-semibold cursor-pointer bg-transparent border-0 p-0 text-inherit"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={panelId}
data-attr={`${dataAttrPrefix}-toggle-${groupSlug}`}
>
<IconChevronRight
className={clsx(
'shrink-0 text-muted transition-transform motion-reduce:transition-none',
open && 'rotate-90'
)}
/>
<span className="truncate">{label}</span>
</button>
<div className="flex items-center gap-2 ml-auto shrink-0">
<span className="hidden @min-[36rem]/scope-group:flex items-center gap-2">
<span className="text-xs text-muted whitespace-nowrap">
<span translate="no">{total}</span> {total === 1 ? 'permission' : 'permissions'}
</span>
<span className="flex items-center gap-1">
{counts.none > 0 && (
<LemonTag size="small" type="muted">
<span translate="no">{counts.none}</span> none
</LemonTag>
)}
{counts.read > 0 && (
<LemonTag size="small" type="success">
<span translate="no">{counts.read}</span> read
</LemonTag>
)}
{counts.write > 0 && (
<LemonTag size="small" type="warning">
<span translate="no">{counts.write}</span> write
</LemonTag>
)}
</span>
</span>
<div role="group" aria-label={`${label} access`}>
<LemonSegmentedButton
Comment thread
a-lider marked this conversation as resolved.
Resolved
size="xsmall"
value={value}
onChange={(level) => onChange(level as ScopeAccessLevel)}
options={[
{ label: 'No access', value: 'none', disabledReason: noneDisabledReason },
{ label: 'Read', value: 'read', disabledReason: readDisabledReason },
{ label: 'Write', value: 'write', disabledReason: writeDisabledReason },
].map((option) => ({
...option,
// Tells a group change apart from a row change in autocapture.
'data-attr': `${dataAttrPrefix}-${groupSlug}-${option.value}`,
tooltip: option.value === value ? valueTooltip : undefined,
}))}
/>
</div>
</div>
</div>
{open && (
<div id={panelId} className="flex flex-col pb-2 pl-7">
{children}
</div>
)}
</div>
)
}
24 changes: 13 additions & 11 deletions frontend/src/lib/scopes.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AGENT_USE_CASE_SCOPES } from 'lib/agentScopes.generated'
import { OAUTH_SCOPES_HIDDEN } from 'lib/oauthScopes.generated'
import {
AGENT_CLI_API_KEY_SCOPES,
Expand Down Expand Up @@ -140,9 +139,12 @@ describe('API_KEY_SCOPE_PRESETS', () => {
expect(preset.label).toBe('Read-only access')
})

it('contains :read for every entry in API_SCOPES except unprivileged-excluded scopes', () => {
it('contains :read for every readable entry in API_SCOPES except unprivileged-excluded scopes', () => {
const preset = findPreset('read_only_access')
const expected = API_SCOPES.filter(({ unprivilegedExcluded }) => !unprivilegedExcluded)
const expected = API_SCOPES.filter(
({ unprivilegedExcluded, disabledActions }) =>
!unprivilegedExcluded && !disabledActions?.includes('read')
)
.map(({ key }) => `${key}:read`)
.sort()
expect([...preset.scopes].sort()).toEqual(expected)
Expand Down Expand Up @@ -176,14 +178,14 @@ describe('API_KEY_SCOPE_PRESETS', () => {
expect(preset.scopes).not.toContain('integration:write')
expect(preset.scopes).not.toContain('user:write')
})
})

it('only includes scopes the key creation UI can render', () => {
it.each(API_KEY_SCOPE_PRESETS.filter(({ value }) => value !== 'all_access').map(({ value }) => value))(
'preset %s only sets levels the key creation UI can render',
(value) => {
const renderableScopes = getRenderableKeyCreationScopes()

expect(AGENT_CLI_API_KEY_SCOPES).toEqual(
(AGENT_USE_CASE_SCOPES as readonly string[]).filter((scope) => renderableScopes.has(scope))
)
expect(AGENT_CLI_API_KEY_SCOPES.every((scope) => renderableScopes.has(scope))).toBe(true)
})
})
const unrenderable = findPreset(value).scopes.filter((scope) => !renderableScopes.has(scope))
expect(unrenderable).toEqual([])
Comment on lines +183 to +188

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.

P2 Agent CLI completeness goes untested

The replacement test only checks that preset scopes are selectable. It would still pass if the clamp dropped a generated Agent CLI scope instead of falling back from write to read. Keep an assertion that every generated scope is retained at its nearest selectable level; otherwise, a missing permission could go unnoticed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/lib/scopes.test.ts
Line: 183-188

Comment:
**Agent CLI completeness goes untested**

The replacement test only checks that preset scopes are selectable. It would still pass if the clamp dropped a generated Agent CLI scope instead of falling back from write to read. Keep an assertion that every generated scope is retained at its nearest selectable level; otherwise, a missing permission could go unnoticed.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
)
})
42 changes: 35 additions & 7 deletions frontend/src/lib/scopes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import type { APIScopeAction, APIScopeObject } from '~/types'

export const MAX_API_KEYS_PER_USER = 10 // Same as in posthog/api/personal_api_key.py

export type ScopeAccessLevel = 'none' | 'read' | 'write'

export const countScopeRowsByLevel = (rows: { value: ScopeAccessLevel }[]): Record<ScopeAccessLevel, number> => {
const counts: Record<ScopeAccessLevel, number> = { none: 0, read: 0, write: 0 }
for (const row of rows) {
counts[row.value] += 1
}
return counts
}

export type APIScope = {
key: APIScopeObject
objectName: string
Expand Down Expand Up @@ -315,9 +325,23 @@ export const API_KEY_CREATION_DISABLED_SCOPES = new Set(
API_SCOPES.flatMap(({ key, disabledActions }) => (disabledActions ?? []).map((action) => `${key}:${action}`))
)

export const AGENT_CLI_API_KEY_SCOPES = AGENT_USE_CASE_SCOPES.filter((scope) =>
API_KEY_CREATION_RENDERABLE_SCOPES.has(scope)
)
// A preset must not set a level the key picker cannot show, or the picker shows the level on a
// disabled segment. A write on a write-disabled object falls back to read, which write implies on the
// server anyway. A scope with no allowed level is dropped.
export const clampToKeyCreationScopes = (scopes: readonly string[]): string[] => [
...new Set(
scopes.flatMap((scope) => {
if (API_KEY_CREATION_RENDERABLE_SCOPES.has(scope)) {
return [scope]
}
const [object, action] = scope.split(':')
const read = `${object}:read`
return action === 'write' && API_KEY_CREATION_RENDERABLE_SCOPES.has(read) ? [read] : []
})
),
]

export const AGENT_CLI_API_KEY_SCOPES = clampToKeyCreationScopes(AGENT_USE_CASE_SCOPES)

export const API_KEY_SCOPE_PRESETS: {
value: string
Expand Down Expand Up @@ -353,9 +377,11 @@ export const API_KEY_SCOPE_PRESETS: {
value: 'mcp_server',
label: 'MCP Server',
// file_system is excluded because the MCP server doesn't request it, not because it's privileged.
scopes: API_SCOPES.filter(
({ key, unprivilegedExcluded }) => !unprivilegedExcluded && key !== 'file_system'
).map(({ key }) => `${key}:write`),
scopes: clampToKeyCreationScopes(
Comment thread
a-lider marked this conversation as resolved.
Resolved
API_SCOPES.filter(({ key, unprivilegedExcluded }) => !unprivilegedExcluded && key !== 'file_system').map(
({ key }) => `${key}:write`
)
),
Comment on lines +380 to +384

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.

P1 MCP preset loses write access

When someone creates a personal API key with the MCP Server preset, this clamp changes user:write to user:read. The MCP catalog requires user:write for user-settings-update, so the tool disappears for that key. Preserve the permission the preset needs, or change the preset’s stated capabilities.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/lib/scopes.tsx
Line: 380-384

Comment:
**MCP preset loses write access**

When someone creates a personal API key with the MCP Server preset, this clamp changes `user:write` to `user:read`. The MCP catalog requires `user:write` for `user-settings-update`, so the tool disappears for that key. Preserve the permission the preset needs, or change the preset’s stated capabilities.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intended. A key made from the MCP Server preset now ends up with the same scopes as one where you pick every scope by hand: the picker doesn't offer user:write or integration:write either.

Why write is disabled on some scopes: disabledActions: ['write'] in scopes.tsx means the key picker only lets you choose read for that object. A few objects have it:

The MCP preset had user:write and integration:write only because it set every object to :write. It skipped the picker's limits rather than choosing these on purpose. The Agent CLI preset already drops them, and this change brings MCP in line. Write still implies read on the server, so clamping these to read keeps every read tool working. A key from this preset can't use the reminder, user settings or integration write tools, same as a key built by hand.

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.

That makes sense — thanks for clarifying the picker’s intentional limits and the MCP preset’s previous mismatch. Since user:write and integration:write are not selectable for personal API keys, clamping them to read produces the same effective scopes as manually selecting every available scope, while preserving the read-only tools. I’m withdrawing this finding; the change is correct.

access_type: 'all',
},
{
Expand All @@ -367,7 +393,9 @@ export const API_KEY_SCOPE_PRESETS: {
{
value: 'read_only_access',
label: 'Read-only access',
scopes: API_SCOPES.filter(({ unprivilegedExcluded }) => !unprivilegedExcluded).map(({ key }) => `${key}:read`),
scopes: clampToKeyCreationScopes(
API_SCOPES.filter(({ unprivilegedExcluded }) => !unprivilegedExcluded).map(({ key }) => `${key}:read`)
),
},
{ value: 'all_access', label: 'All access', scopes: ['*'] },
]
Expand Down
117 changes: 17 additions & 100 deletions frontend/src/scenes/oauth/OAuthScopeGroupControl.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import clsx from 'clsx'
import { useId, useState } from 'react'
import { ScopeAccessGroup } from 'lib/components/ScopeAccessRow/ScopeAccessGroup'
import { countScopeRowsByLevel, type ScopeAccessLevel } from 'lib/scopes'

import { IconChevronRight } from '@posthog/icons'
import { LemonSegmentedButton, LemonTag } from '@posthog/lemon-ui'

import {
type OAuthScopeRow,
type ScopeAccessLevel,
scopeGroupAccessLevel,
scopeGroupLevelTooltip,
} from './oauthAuthorizeLogic'
import { type OAuthScopeRow, scopeGroupAccessLevel, scopeGroupLevelTooltip } from './oauthAuthorizeLogic'
import { OAuthScopeRowControl } from './OAuthScopeRowControl'

interface OAuthScopeGroupControlProps {
Expand All @@ -20,107 +12,32 @@ interface OAuthScopeGroupControlProps {
onChangeGroup: (scopeObjects: string[], level: ScopeAccessLevel) => void
}

const countByLevel = (rows: OAuthScopeRow[]): Record<ScopeAccessLevel, number> => {
const counts: Record<ScopeAccessLevel, number> = { none: 0, read: 0, write: 0 }
for (const row of rows) {
counts[row.value] += 1
}
return counts
}

export function OAuthScopeGroupControl({
label,
rows,
appName,
onChangeRow,
onChangeGroup,
}: OAuthScopeGroupControlProps): JSX.Element {
const [open, setOpen] = useState(false)
const panelId = useId()
const counts = countByLevel(rows)
const groupLevel = scopeGroupAccessLevel(rows)
const groupLevelTooltip = scopeGroupLevelTooltip(rows, groupLevel, appName)
const keys = rows.map((row) => row.key)
const anyWritable = rows.some((row) => row.maxLevel === 'write')
const allRequired = rows.every((row) => row.minLevel !== 'none')
const groupSlug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-')

return (
<div className="border-t border-border first:border-t-0 @container/scope-group">
{/* The header stays on one line. In a narrow container, such as an OAuth popup window,
the label truncates and the counts hide, so the control keeps its place. */}
<div className="flex items-center gap-2 py-2 min-h-10">
<button
type="button"
className="flex items-center gap-2 flex-1 min-w-0 text-left font-semibold cursor-pointer bg-transparent border-0 p-0 text-inherit"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={panelId}
data-attr={`oauth-scope-group-toggle-${groupSlug}`}
>
<IconChevronRight
className={clsx(
'shrink-0 text-muted transition-transform motion-reduce:transition-none',
open && 'rotate-90'
)}
/>
<span className="truncate">{label}</span>
</button>
<div className="flex items-center gap-2 ml-auto shrink-0">
<span className="hidden @min-[36rem]/scope-group:flex items-center gap-2">
<span className="text-xs text-muted whitespace-nowrap">
<span translate="no">{rows.length}</span> {rows.length === 1 ? 'permission' : 'permissions'}
</span>
<span className="flex items-center gap-1">
{/* Same order as the segmented control: no access, read, write. */}
{counts.none > 0 && (
<LemonTag size="small" type="muted">
<span translate="no">{counts.none}</span> none
</LemonTag>
)}
{counts.read > 0 && (
<LemonTag size="small" type="success">
<span translate="no">{counts.read}</span> read
</LemonTag>
)}
{counts.write > 0 && (
<LemonTag size="small" type="warning">
<span translate="no">{counts.write}</span> write
</LemonTag>
)}
</span>
</span>
<div role="group" aria-label={`${label} access`}>
<LemonSegmentedButton
size="xsmall"
value={groupLevel}
onChange={(level) => onChangeGroup(keys, level as ScopeAccessLevel)}
options={[
{
label: 'No access',
value: 'none',
disabledReason: allRequired ? `${appName} requires these permissions` : undefined,
},
{ label: 'Read', value: 'read' },
{
label: 'Write',
value: 'write',
disabledReason: anyWritable ? undefined : `Not requested by ${appName}`,
},
].map((option) =>
option.value === groupLevel ? { ...option, tooltip: groupLevelTooltip } : option
)}
/>
</div>
</div>
</div>
{open && (
<div id={panelId} className="flex flex-col pb-2 pl-7">
{rows.map((row) => (
<OAuthScopeRowControl key={row.key} row={row} appName={appName} onChange={onChangeRow} />
))}
</div>
)}
</div>
<ScopeAccessGroup
label={label}
counts={countScopeRowsByLevel(rows)}
value={groupLevel}
valueTooltip={scopeGroupLevelTooltip(rows, groupLevel, appName)}
onChange={(level) => onChangeGroup(keys, level)}
noneDisabledReason={allRequired ? `${appName} requires these permissions` : undefined}
writeDisabledReason={anyWritable ? undefined : `Not requested by ${appName}`}
dataAttrPrefix="oauth-scope-group"
>
{rows.map((row) => (
<OAuthScopeRowControl key={row.key} row={row} appName={appName} onChange={onChangeRow} />
))}
</ScopeAccessGroup>
)
}
3 changes: 2 additions & 1 deletion frontend/src/scenes/oauth/OAuthScopeRowControl.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ScopeAccessRow } from 'lib/components/ScopeAccessRow/ScopeAccessRow'
import type { ScopeAccessLevel } from 'lib/scopes'

import type { OAuthScopeRow, ScopeAccessLevel } from './oauthAuthorizeLogic'
import type { OAuthScopeRow } from './oauthAuthorizeLogic'

interface OAuthScopeRowControlProps {
row: OAuthScopeRow
Expand Down
Loading