From 1ae4d77e0460a494ceea94399403c42ab177054d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 09:42:32 -0800 Subject: [PATCH] chore(api-keys): remove the api-key-read-only flag now that read-only keys are live Every production pod runs v0.9.53, which enforces key access, and the flag is on in prod AppConfig. Read-only key creation is now always available: the create modal always offers Access (except where a key must be full access), and the create use case no longer consults the flag. --- .../docs/api-reference/authentication.mdx | 2 +- apps/sim/app/account/settings/layout.test.tsx | 47 ------------------- apps/sim/app/account/settings/layout.tsx | 7 +-- apps/sim/app/o/[organizationId]/layout.tsx | 4 -- .../app/workspace/[workspaceId]/layout.tsx | 4 -- .../providers/feature-flags-provider.tsx | 1 - .../create-api-key-modal.tsx | 4 +- .../read-only-access.integration.ts | 23 +-------- apps/sim/lib/api-key/feature-flag.ts | 11 ----- apps/sim/lib/api-key/orchestration/index.ts | 12 ++--- apps/sim/lib/core/config/env.ts | 1 - apps/sim/lib/core/config/feature-flags.ts | 5 -- 12 files changed, 8 insertions(+), 113 deletions(-) delete mode 100644 apps/sim/app/account/settings/layout.test.tsx delete mode 100644 apps/sim/lib/api-key/feature-flag.ts diff --git a/apps/docs/content/docs/api-reference/authentication.mdx b/apps/docs/content/docs/api-reference/authentication.mdx index 58b962765fe..adf02a95ad8 100644 --- a/apps/docs/content/docs/api-reference/authentication.mdx +++ b/apps/docs/content/docs/api-reference/authentication.mdx @@ -44,7 +44,7 @@ A read-only key cannot change workspace resources or run, resume, or cancel work A refused request fails with `403` and the message `This API key is read-only and cannot perform this operation`. Where the surface carries a code, it is `READ_ONLY_API_KEY`: in `error.details.code` on the v2 API and the Sim MCP server, in `details.code` on the v1 API and workflow execution, and in `error.data.code` on a workflow MCP server. -Access narrows a key; it never grants more than the key's identity already has. Access is fixed at creation, so create a new key to change it. Keys created before access levels existed have full access. Keys created from the deploy modal or for a workflow MCP server always have full access, because they exist to run workflows. If **Access** isn't shown when you create a key, read-only keys aren't available on your deployment yet. +Access narrows a key; it never grants more than the key's identity already has. Access is fixed at creation, so create a new key to change it. Keys created before access levels existed have full access. Keys created from the deploy modal or for a workflow MCP server always have full access, because they exist to run workflows. ### Expiration [#key-expiration] diff --git a/apps/sim/app/account/settings/layout.test.tsx b/apps/sim/app/account/settings/layout.test.tsx deleted file mode 100644 index a3214f53af2..00000000000 --- a/apps/sim/app/account/settings/layout.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { ReactNode } from 'react' -import { authMockFns } from '@sim/testing' -import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' -import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('next/navigation', () => nextNavigationMock) -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) -vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: vi.fn(async () => false) })) -vi.mock('@/components/settings/standalone-settings-shell', () => ({ - StandaloneSettingsShell: ({ children }: { children: ReactNode }) => children, -})) - -import AccountSettingsLayout from '@/app/account/settings/layout' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' - -/** - * Renders the account layout around a section that reads the read-only key flag the way the - * personal API keys page's create modal does on every render, and returns what it read. - */ -async function readFlagInAccountSettings(): Promise { - let observed: boolean | undefined - function ReadOnlyKeysSection() { - observed = useFeatureFlag('api-key-read-only') - return null - } - renderToStaticMarkup(await AccountSettingsLayout({ children: })) - if (observed === undefined) throw new Error('The account section never rendered') - return observed -} - -describe('AccountSettingsLayout', () => { - beforeEach(() => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'viewer-1' }, - session: { id: 'session-1' }, - }) - }) - - it.each([true, false])('gives account sections the read-only key flag (%s)', async (enabled) => { - featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( - async (flag) => flag === 'api-key-read-only' && enabled - ) - expect(await readFlagInAccountSettings()).toBe(enabled) - }) -}) diff --git a/apps/sim/app/account/settings/layout.tsx b/apps/sim/app/account/settings/layout.tsx index 2cc46a3d1f8..43427e014f6 100644 --- a/apps/sim/app/account/settings/layout.tsx +++ b/apps/sim/app/account/settings/layout.tsx @@ -1,6 +1,5 @@ import { redirect } from 'next/navigation' import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' -import { isReadOnlyApiKeyCreationEnabled } from '@/lib/api-key/feature-flag' import { getSession } from '@/lib/auth' import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { isPlatformAdmin } from '@/lib/permissions/super-user' @@ -9,10 +8,7 @@ import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/fe export default async function AccountSettingsLayout({ children }: { children: React.ReactNode }) { const session = await getSession() if (!session?.user) redirect('/login') - const [isSuperUser, readOnlyApiKeysEnabled] = await Promise.all([ - isPlatformAdmin(session.user.id), - isReadOnlyApiKeyCreationEnabled(), - ]) + const isSuperUser = await isPlatformAdmin(session.user.id) /** Account settings host no workspace surface, so only the flags its sections read are on. */ return ( @@ -23,7 +19,6 @@ export default async function AccountSettingsLayout({ children }: { children: Re changelog: false, 'mothership-model-selector': false, 'mothership-plan-mode': false, - 'api-key-read-only': readOnlyApiKeysEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index bac856a0651..fb9ac90703f 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -4,7 +4,6 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' -import { isReadOnlyApiKeyCreationEnabled } from '@/lib/api-key/feature-flag' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isChangelogEnabled } from '@/lib/changelog/feature-flag' @@ -138,7 +137,6 @@ async function WorkspaceContent({ computerUseEnabled, projectsEnabled, simLinkOrganizationId, - readOnlyApiKeysEnabled, ] = await Promise.all([ cookies(), orgSettings, @@ -159,7 +157,6 @@ async function WorkspaceContent({ isComputerUseAvailable(hostContext.hostOrganizationId), isProjectsEnabled(), resolveSimLinkOrganization(principal), - isReadOnlyApiKeyCreationEnabled(), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -176,7 +173,6 @@ async function WorkspaceContent({ 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': planModeEnabled, projects: projectsEnabled, - 'api-key-read-only': readOnlyApiKeysEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index 083d412a7e4..f33fd8f230f 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -11,7 +11,6 @@ export interface WorkspaceFeatureFlags { 'mothership-computer-use'?: boolean 'mothership-memory-spaces'?: boolean projects?: boolean - 'api-key-read-only'?: boolean } const FeatureFlagsContext = createContext(null) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx index e96fe9dbe76..a68d70b90e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx @@ -15,7 +15,6 @@ import { import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type ApiKeyAccessLevel, DAY_MS } from '@/lib/api/contracts/api-keys' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { type CreatedApiKey, useCreateApiKey } from '@/hooks/queries/api-keys' const logger = createLogger('CreateApiKeyModal') @@ -75,8 +74,7 @@ export function CreateApiKeyModal({ const [newKey, setNewKey] = useState(null) const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) const createApiKeyMutation = useCreateApiKey() - const readOnlyKeysEnabled = useFeatureFlag('api-key-read-only') - const canChooseAccess = !fullAccessOnly && readOnlyKeysEnabled + const canChooseAccess = !fullAccessOnly /** * The form is seeded when the modal OPENS, not when this component mounts. diff --git a/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts b/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts index 7bbc0ad1c34..a811b0a4134 100644 --- a/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts +++ b/apps/sim/lib/api-key/__integration__/read-only-access.integration.ts @@ -22,7 +22,7 @@ import { } from '@sim/db/schema' import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { authMock, authMockFns } from '@sim/testing' -import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { featureFlagsMock } from '@sim/testing/mocks/feature-flags.mock' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { NextRequest } from 'next/server' @@ -60,7 +60,6 @@ const expiredKey = `sk-sim-fixture-${generateId()}` let readPersonalKey = '' let readWorkspaceKey = '' -let readOnlyKeysEnabled = false type RouteHandler = (request: NextRequest, context: { params: Promise }) => Promise @@ -176,9 +175,6 @@ describe('read-only and expiring API keys in PostgreSQL', () => { displayName: 'Topic', fieldType: 'text', }) - featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( - async (flag) => flag === 'api-key-read-only' && readOnlyKeysEnabled - ) await db.insert(apiKey).values([ { id: generateId(), @@ -231,22 +227,6 @@ describe('read-only and expiring API keys in PostgreSQL', () => { expect(after).toHaveLength(before.length) }) - it('refuses to create a read-only key while the api-key-read-only flag is off', async () => { - const before = await db.select({ id: apiKey.id }).from(apiKey).where(eq(apiKey.userId, userId)) - const personal = await call(createPersonalKey, '/api/users/me/api-keys', { - body: { name: `Flag off personal ${generateId()}`, access: 'read' }, - }) - expect(personal.status).toBe(400) - expect(personal.body).toEqual({ error: 'Read-only API keys are not available yet' }) - const scoped = await call(createWorkspaceKey, `/api/workspaces/${workspaceId}/api-keys`, { - body: { name: `Flag off workspace ${generateId()}`, access: 'read' }, - params: { id: workspaceId }, - }) - expect(scoped.status).toBe(400) - const after = await db.select({ id: apiKey.id }).from(apiKey).where(eq(apiKey.userId, userId)) - expect(after).toHaveLength(before.length) - }) - it('reserves the delegation key-name prefix', async () => { const created = await call(createPersonalKey, '/api/users/me/api-keys', { body: { name: `mothership-delegation:${userId}`, access: 'write' }, @@ -255,7 +235,6 @@ describe('read-only and expiring API keys in PostgreSQL', () => { }) it('mints read-only personal and workspace keys that report their access and expiry', async () => { - readOnlyKeysEnabled = true const expiresAt = new Date(Date.now() + 30 * DAY_MS).toISOString() const personal = await call(createPersonalKey, '/api/users/me/api-keys', { body: { name: `Read personal ${generateId()}`, access: 'read', expiresAt }, diff --git a/apps/sim/lib/api-key/feature-flag.ts b/apps/sim/lib/api-key/feature-flag.ts deleted file mode 100644 index 43e9b529a36..00000000000 --- a/apps/sim/lib/api-key/feature-flag.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' - -/** - * Whether new read-only API keys may be created. A pod older than the release - * that enforces key access serves a read-only key as full access, so this turns - * on only after every pod enforces it. Enforcement of existing read-only keys - * never depends on it. - */ -export function isReadOnlyApiKeyCreationEnabled(): Promise { - return isFeatureEnabled('api-key-read-only') -} diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index ac2b794fc07..263f6a1332e 100644 --- a/apps/sim/lib/api-key/orchestration/index.ts +++ b/apps/sim/lib/api-key/orchestration/index.ts @@ -9,7 +9,6 @@ import { and, eq } from 'drizzle-orm' import { DAY_MS } from '@/lib/api/contracts/api-keys' import { createApiKey, createWorkspaceApiKey } from '@/lib/api-key/auth' import { hashApiKey } from '@/lib/api-key/crypto' -import { isReadOnlyApiKeyCreationEnabled } from '@/lib/api-key/feature-flag' import { DELEGATION_KEY_NAME_PREFIX } from '@/lib/api-key/reserved-names' import { PlatformEvents } from '@/lib/core/telemetry' @@ -70,16 +69,13 @@ type ResolvedIssueSettings = | { ok: false; error: string } /** Validates what a caller asked a new key to be, before anything is written. */ -async function resolveIssueSettings( +function resolveIssueSettings( params: ApiKeyIssueSettings & { name: string } -): Promise { +): ResolvedIssueSettings { if (params.name.startsWith(DELEGATION_KEY_NAME_PREFIX)) { return { ok: false, error: `API key names may not start with "${DELEGATION_KEY_NAME_PREFIX}"` } } const access = params.access ?? 'write' - if (access === 'read' && !(await isReadOnlyApiKeyCreationEnabled())) { - return { ok: false, error: 'Read-only API keys are not available yet' } - } const expiry = resolveApiKeyExpiry(params.expiresAt) if (!expiry.ok) return expiry return { ok: true, access, expiresAt: expiry.expiresAt } @@ -131,7 +127,7 @@ export interface PerformCreatePersonalApiKeyResult { export async function performCreatePersonalApiKey( params: PerformCreatePersonalApiKeyParams ): Promise { - const settings = await resolveIssueSettings(params) + const settings = resolveIssueSettings(params) if (!settings.ok) return { success: false, errorCode: 'validation', error: settings.error } const { access, expiresAt } = settings try { @@ -216,7 +212,7 @@ export async function performCreatePersonalApiKey( export async function performCreateWorkspaceApiKey( params: PerformCreateWorkspaceApiKeyParams ): Promise { - const settings = await resolveIssueSettings(params) + const settings = resolveIssueSettings(params) if (!settings.ok) return { success: false, errorCode: 'validation', error: settings.error } const { access, expiresAt } = settings try { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index e3e15b869c6..99ce75e3603 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -621,7 +621,6 @@ export const env = createEnv({ CHANGELOG: z.boolean().optional(), // Fallback for the `changelog` feature flag off AppConfig SIM_LINKS: z.boolean().optional(), // Fallback for the `sim-links` feature flag off AppConfig PEOPLE: z.boolean().optional(), // Fallback for the `people` feature flag off AppConfig - API_KEY_READ_ONLY: z.boolean().optional(), // Fallback for the `api-key-read-only` feature flag off AppConfig MSHIP_MODEL_SELECTOR: z.boolean().optional(), INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements) SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted (bypasses hosted requirements) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index cc58baf4ac1..5394541ae6e 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -77,11 +77,6 @@ const FEATURE_FLAGS = { 'Enable the organization People directory, org chart, profile imports, and directory sync. Supports organization rollout; disabled by default.', fallback: 'PEOPLE', }, - 'api-key-read-only': { - description: - 'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Before rolling back below the release that added read-only keys, turn this off and revoke or replace every existing read-only key: turning the flag off only stops new ones, and that older code serves existing read-only keys as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.', - fallback: 'API_KEY_READ_ONLY', - }, 'mothership-model-selector': { description: 'Show the Mothership model selector, model-specific effort levels, and Fast for supported ' +