diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
index f6b74fa033f..c757d89ff8f 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
@@ -88,17 +88,29 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
const {
data: apiKeysData,
isLoading: isLoadingKeys,
+ isError: isKeysError,
+ isPlaceholderData: isKeysPlaceholderData,
refetch: refetchApiKeys,
} = useApiKeys(workspaceId, scope)
- const { data: workspaceSettingsData, isLoading: isLoadingSettings } =
- useWorkspaceSettings(workspaceId)
+ const {
+ data: workspaceSettingsData,
+ isLoading: isLoadingSettings,
+ isError: isSettingsError,
+ isPlaceholderData: isSettingsPlaceholderData,
+ } = useWorkspaceSettings(workspaceId)
const deleteApiKeyMutation = useDeleteApiKey()
const updateSettingsMutation = useUpdateWorkspaceApiKeySettings()
const workspaceKeys = apiKeysData?.workspaceKeys ?? EMPTY_KEYS
const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS
const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES
- const isLoading = isLoadingKeys || (showsWorkspaceKeys && isLoadingSettings)
+ const hasLoadError = isKeysError || (showsWorkspaceKeys && isSettingsError)
+ const isLoading =
+ !hasLoadError &&
+ (isLoadingKeys ||
+ isKeysPlaceholderData ||
+ (showsWorkspaceKeys && (isLoadingSettings || isSettingsPlaceholderData)))
+ const dataState = hasLoadError ? 'error' : isLoading ? 'loading' : 'ready'
const allowPersonalApiKeys =
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
@@ -115,6 +127,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
: 'workspace'
const createButtonDisabled =
isLoading ||
+ hasLoadError ||
(isWorkspaceScope && !canManageWorkspaceKeys) ||
(isCombinedScope && !allowPersonalApiKeys && !canManageWorkspaceKeys)
@@ -190,18 +203,69 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
}}
actions={actions}
>
- {isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
- Click "Create API key" above to get started
- ) : (
-
- {showsWorkspaceKeys && !searchTerm.trim() ? (
-
- {workspaceKeys.length === 0 ? (
- No workspace API keys yet
- ) : (
+
+ {isLoading ? null : hasLoadError ? (
+ Unable to load API keys
+ ) : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
+ Click "Create API key" above to get started
+ ) : (
+
+ {showsWorkspaceKeys && !searchTerm.trim() ? (
+
+ {workspaceKeys.length === 0 ? (
+
+ No workspace API keys yet
+
+ ) : (
+
+ {workspaceKeys.map((key) => (
+
+
+
+
+ {key.name}
+
+
+ (last used: {formatLastUsed(key.lastUsed).toLowerCase()})
+
+
+
+ {key.displayKey}
+
+
+
{
+ setDeleteKey(key)
+ setShowDeleteDialog(true)
+ }}
+ canDelete={canManageWorkspaceKeys}
+ />
+
+ ))}
+
+ )}
+
+ ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? (
+
- {workspaceKeys.map((key) => (
-
+ {filteredWorkspaceKeys.map(({ key }) => (
+
@@ -226,141 +290,115 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
))}
- )}
-
- ) : showsWorkspaceKeys && filteredWorkspaceKeys.length > 0 ? (
-
-
- {filteredWorkspaceKeys.map(({ key }) => (
-
-
-
-
- {key.name}
-
-
- (last used: {formatLastUsed(key.lastUsed).toLowerCase()})
-
-
-
- {key.displayKey}
-
-
-
{
- setDeleteKey(key)
- setShowDeleteDialog(true)
- }}
- canDelete={canManageWorkspaceKeys}
- />
-
- ))}
-
-
- ) : null}
+
+ ) : null}
- {showsPersonalKeys && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && (
-
-
- {filteredPersonalKeys.map(({ key }) => {
- const isConflict = conflicts.includes(key.name)
- return (
-
-
-
-
-
- {key.name}
-
-
- (last used: {formatLastUsed(key.lastUsed).toLowerCase()})
-
+ {showsPersonalKeys && (!searchTerm.trim() || filteredPersonalKeys.length > 0) && (
+
+
+ {filteredPersonalKeys.map(({ key }) => {
+ const isConflict = conflicts.includes(key.name)
+ return (
+
+
+
+
+
+ {key.name}
+
+
+ (last used: {formatLastUsed(key.lastUsed).toLowerCase()})
+
+
+
+ {key.displayKey}
+
-
- {key.displayKey}
-
+
{
+ setDeleteKey(key)
+ setShowDeleteDialog(true)
+ }}
+ />
-
{
- setDeleteKey(key)
- setShowDeleteDialog(true)
- }}
- />
+ {isConflict && (
+
+ Workspace API key with the same name overrides this. Rename your
+ personal key to use it.
+
+ )}
- {isConflict && (
-
- Workspace API key with the same name overrides this. Rename your
- personal key to use it.
-
- )}
-
- )
- })}
-
-
- )}
-
- {searchTerm.trim() &&
- filteredPersonalKeys.length === 0 &&
- filteredWorkspaceKeys.length === 0 &&
- (personalKeys.length > 0 || workspaceKeys.length > 0) && (
-
- No API keys found matching "{searchTerm}"
-
+ )
+ })}
+
+
)}
-
- )}
- {showsWorkspaceKeys && !isLoading && canManageWorkspaceKeys && (
-
-
-
-
-
- Allow personal API keys
-
-
-
-
-
-
-
-
- Allow collaborators to authenticate with their own keys. Hosted usage is
- billed to this workspace, attributed to the key owner, and counted toward
- their member cap.
-
-
-
- {isLoadingSettings ? null : (
-
{
- try {
- await updateSettingsMutation.mutateAsync({
- workspaceId,
- allowPersonalApiKeys: checked,
- })
- } catch (error) {
- logger.error('Error updating workspace settings:', { error })
- }
- }}
- />
+ {searchTerm.trim() &&
+ filteredPersonalKeys.length === 0 &&
+ filteredWorkspaceKeys.length === 0 &&
+ (personalKeys.length > 0 || workspaceKeys.length > 0) && (
+
+ No API keys found matching "{searchTerm}"
+
)}
-
-
-
- )}
+
+ )}
+
+ {showsWorkspaceKeys && !isLoading && !hasLoadError && canManageWorkspaceKeys && (
+
+
+
+
+
+ Allow personal API keys
+
+
+
+
+
+
+
+
+ Allow collaborators to authenticate with their own keys. Hosted usage is
+ billed to this workspace, attributed to the key owner, and counted toward
+ their member cap.
+
+
+
+ {isLoadingSettings ? null : (
+
{
+ try {
+ await updateSettingsMutation.mutateAsync({
+ workspaceId,
+ allowPersonalApiKeys: checked,
+ })
+ } catch (error) {
+ logger.error('Error updating workspace settings:', { error })
+ }
+ }}
+ />
+ )}
+
+
+
+ )}
+
({
+ ChipInput: (props: ComponentProps<'input'>) => ,
+}))
+
+describe('SecretValueField', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('keeps editable plaintext out of the DOM until focus', () => {
+ act(() => root.render( undefined} />))
+ const input = container.querySelector('input')
+ expect(input).not.toBeNull()
+ expect(input?.type).toBe('text')
+ expect(input?.value).toBe('••••••••••')
+
+ act(() => input?.focus())
+ expect(input?.type).toBe('text')
+ expect(input?.value).toBe('private-value')
+
+ act(() => input?.blur())
+ expect(input?.type).toBe('text')
+ expect(input?.value).toBe('••••••••••')
+ })
+
+ it('uses a fixed mask for viewers regardless of secret length', () => {
+ act(() => root.render( ))
+ const input = container.querySelector('input')
+ expect(input?.value).toBe('••••••••••')
+
+ act(() => root.render( ))
+ expect(input?.value).toBe('••••••••••')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx
index 9c5e61c8df1..d32509f113a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field.tsx
@@ -57,7 +57,11 @@ export function SecretValueField({
const [focused, setFocused] = useState(false)
const editable = canEdit && !readOnly
const maskActive = canEdit && !unmasked && !focused
- const displayValue = canEdit ? value : BULLET.repeat(VIEWER_MASK_LENGTH)
+ const displayValue = canEdit
+ ? maskActive && value
+ ? BULLET.repeat(VIEWER_MASK_LENGTH)
+ : value
+ : BULLET.repeat(VIEWER_MASK_LENGTH)
const mergedStyle: CSSProperties | undefined = maskActive
? ({ ...style, WebkitTextSecurity: 'disc' } as CSSProperties)
@@ -69,13 +73,12 @@ export function SecretValueField({
className={className}
type='text'
value={displayValue}
- readOnly
+ readOnly={!editable || !focused}
style={mergedStyle}
onChange={(event) => {
if (editable) onChange?.(event.target.value)
}}
onFocus={(event) => {
- if (editable) event.currentTarget.removeAttribute('readOnly')
event.currentTarget.scrollLeft = 0
setFocused(true)
onFocus?.(event)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
index fd30b72c435..4abd8860b15 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx
@@ -46,6 +46,8 @@ function copyName(key: string) {
}
interface SecretRowMenuProps {
+ /** Accessible name that identifies the row owning this menu. */
+ label: string
/** Copies the secret's name. */
onCopyName: () => void
/** Opens credential details; omit when the row has no backing credential. */
@@ -58,10 +60,10 @@ interface SecretRowMenuProps {
* Trailing `...` actions menu for a secret row. Mirrors the Teammates /
* Organization member menu so the settings experience is consistent.
*/
-function SecretRowMenu({ onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) {
+function SecretRowMenu({ label, onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) {
return (
{
@@ -238,12 +241,14 @@ function WorkspaceVariableRow({
/>
onValueChange(envKey, next)}
canEdit={canEdit}
name={`workspace_env_value_${envKey}_${generateShortId()}`}
/>
copyName(envKey)}
onViewDetails={hasCredential && onViewDetails ? () => onViewDetails(envKey) : undefined}
onDelete={canEdit ? () => onDelete(envKey) : undefined}
@@ -298,6 +303,7 @@ function NewWorkspaceVariableRow({
/>
{hasContent ? (
copyName(envVar.key)}
onDelete={() => {
onUpdate(index, 'key', '')
@@ -861,6 +867,9 @@ export function SecretsManager() {
return (
updateEnvVar(originalIndex, 'value', next)}
@@ -888,6 +900,7 @@ export function SecretsManager() {
/>
{hasContent ? (
copyName(envVar.key)}
onDelete={() => removeEnvVar(originalIndex)}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx
index 153c32ac615..f71d1c94fe1 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx
@@ -1,5 +1,11 @@
import type { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { getCredentialActorContext } from '@/lib/credentials/access'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { SecretDetail } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail'
+import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check'
+import { canOpenSecretDetail } from './secret-detail-access'
export const metadata: Metadata = {
title: 'Secret',
@@ -10,6 +16,34 @@ export default async function SecretDetailPage({
}: {
params: Promise<{ workspaceId: string; credentialId: string }>
}) {
+ const session = await getSession()
+ if (!session?.user) redirect('/login')
+
const { workspaceId, credentialId } = await params
+ const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
+ if (!hostContext) notFound()
+
+ const [permissionGroup, access] = await Promise.all([
+ hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
+ ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
+ : null,
+ getCredentialActorContext(credentialId, session.user.id),
+ ])
+
+ if (
+ !canOpenSecretDetail({
+ workspaceId,
+ secretsHidden: permissionGroup?.config.hideSecretsTab === true,
+ access: {
+ credential: access.credential,
+ hasWorkspaceAccess: access.hasWorkspaceAccess,
+ hasActiveMembership: access.member?.status === 'active',
+ isAdmin: access.isAdmin,
+ },
+ })
+ ) {
+ notFound()
+ }
+
return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.test.ts
new file mode 100644
index 00000000000..45397b88534
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { canOpenSecretDetail } from './secret-detail-access'
+
+const allowedAccess = {
+ credential: { workspaceId: 'workspace-a', type: 'env_workspace' },
+ hasWorkspaceAccess: true,
+ hasActiveMembership: true,
+ isAdmin: false,
+}
+
+describe('canOpenSecretDetail', () => {
+ it('allows active members and derived admins for matching environment credentials', () => {
+ expect(
+ canOpenSecretDetail({
+ workspaceId: 'workspace-a',
+ secretsHidden: false,
+ access: allowedAccess,
+ })
+ ).toBe(true)
+ expect(
+ canOpenSecretDetail({
+ workspaceId: 'workspace-a',
+ secretsHidden: false,
+ access: {
+ ...allowedAccess,
+ credential: { workspaceId: 'workspace-a', type: 'env_personal' },
+ hasActiveMembership: false,
+ isAdmin: true,
+ },
+ })
+ ).toBe(true)
+ })
+
+ it.each([
+ ['missing credential', { ...allowedAccess, credential: null }, false],
+ [
+ 'cross-workspace credential',
+ {
+ ...allowedAccess,
+ credential: { workspaceId: 'workspace-b', type: 'env_workspace' },
+ },
+ false,
+ ],
+ [
+ 'wrong credential type',
+ { ...allowedAccess, credential: { workspaceId: 'workspace-a', type: 'oauth' } },
+ false,
+ ],
+ ['missing workspace access', { ...allowedAccess, hasWorkspaceAccess: false }, false],
+ [
+ 'missing credential membership',
+ { ...allowedAccess, hasActiveMembership: false, isAdmin: false },
+ false,
+ ],
+ ])('rejects %s', (_label, access, expected) => {
+ expect(
+ canOpenSecretDetail({
+ workspaceId: 'workspace-a',
+ secretsHidden: false,
+ access,
+ })
+ ).toBe(expected)
+ })
+
+ it('rejects permission-group hidden Secrets', () => {
+ expect(
+ canOpenSecretDetail({
+ workspaceId: 'workspace-a',
+ secretsHidden: true,
+ access: allowedAccess,
+ })
+ ).toBe(false)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.ts
new file mode 100644
index 00000000000..55110c1d29b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail-access.ts
@@ -0,0 +1,27 @@
+interface SecretDetailCredential {
+ workspaceId: string
+ type: string
+}
+
+interface SecretDetailAccess {
+ credential: SecretDetailCredential | null
+ hasWorkspaceAccess: boolean
+ hasActiveMembership: boolean
+ isAdmin: boolean
+}
+
+export function canOpenSecretDetail(options: {
+ workspaceId: string
+ secretsHidden: boolean
+ access: SecretDetailAccess
+}): boolean {
+ const { access } = options
+ return Boolean(
+ !options.secretsHidden &&
+ access.credential &&
+ access.credential.workspaceId === options.workspaceId &&
+ (access.credential.type === 'env_personal' || access.credential.type === 'env_workspace') &&
+ access.hasWorkspaceAccess &&
+ (access.hasActiveMembership || access.isAdmin)
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
index 7aca00a8c7f..55b7db36be7 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
@@ -95,6 +95,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
/`
+The credentials project keeps the HTML report, redacted automatic error
+contexts, and redacted service logs but does not produce browser traces,
+screenshots, videos, or test-authored attachments.
+
Open the report:
```bash
diff --git a/apps/sim/e2e/foundation/leak-canary.spec.ts b/apps/sim/e2e/foundation/leak-canary.spec.ts
index 0a1014437ac..5ed2db77fea 100644
--- a/apps/sim/e2e/foundation/leak-canary.spec.ts
+++ b/apps/sim/e2e/foundation/leak-canary.spec.ts
@@ -1,4 +1,4 @@
-import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { expect, test } from '@playwright/test'
@@ -68,6 +68,82 @@ test('credential leak canary scans artifacts but excludes its private source', a
}
})
+test('credential patterns are bounded and ZIP entries are scanned independently', async () => {
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'sim-e2e-credential-patterns-'))
+ const token = Array.from({ length: 32 }, (_, index) =>
+ String.fromCharCode('A'.charCodeAt(0) + (index % 26))
+ ).join('')
+ const encryptedKey = `${['sk', 'sim'].join('-')}-${token}`
+ const legacyKey = `${['sim', ''].join('_')}${token}`
+ const runtimeSecret = `${['E2E', 'RUNTIME', 'SECRET', 'V1'].join('_')}_${token}`
+ const scan = () =>
+ assertNoSyntheticSecretLeaks({
+ secrets: ['exact-canary-not-present'],
+ roots: [directory],
+ })
+
+ try {
+ const positivePath = path.join(directory, 'report.html')
+ writeFileSync(positivePath, `response: ${encryptedKey}`)
+ await expect(scan()).rejects.toThrow(/artifact-1-1/)
+ expect(await scan().catch((error: Error) => error.message)).not.toContain(positivePath)
+ unlinkSync(positivePath)
+
+ const positiveZip = new JSZip()
+ positiveZip.file('network.txt', `response: ${legacyKey}`)
+ positiveZip.file(`entry-${runtimeSecret}.txt`, 'entry names are scanned too')
+ const zipPath = path.join(directory, 'trace.zip')
+ writeFileSync(zipPath, await positiveZip.generateAsync({ type: 'nodebuffer' }))
+ await expect(scan()).rejects.toThrow(/secret leaked/)
+ unlinkSync(zipPath)
+
+ const directoryNameZip = new JSZip()
+ directoryNameZip.folder(runtimeSecret)
+ writeFileSync(zipPath, await directoryNameZip.generateAsync({ type: 'nodebuffer' }))
+ await expect(scan()).rejects.toThrow(/runtime-secret/)
+ unlinkSync(zipPath)
+
+ const directoryPath = path.join(directory, runtimeSecret)
+ mkdirSync(directoryPath)
+ await expect(scan()).rejects.toThrow(/runtime-secret/)
+ rmSync(directoryPath, { recursive: true })
+
+ const binaryPath = path.join(directory, `${encryptedKey}.png`)
+ writeFileSync(binaryPath, Buffer.alloc(0))
+ await expect(scan()).rejects.toThrow(/current-api-key/)
+ unlinkSync(binaryPath)
+
+ const outerZipPath = path.join(directory, `${legacyKey}.zip`)
+ const cleanZip = new JSZip()
+ cleanZip.file('clean.txt', 'clean')
+ writeFileSync(outerZipPath, await cleanZip.generateAsync({ type: 'nodebuffer' }))
+ await expect(scan()).rejects.toThrow(/legacy-api-key/)
+ unlinkSync(outerZipPath)
+
+ writeFileSync(
+ path.join(directory, 'clean.txt'),
+ [
+ 'sk-sim-••••••••',
+ 'sk-sim-prefix-only',
+ `sk-sim-${token.slice(0, 31)}`,
+ `sk-sim-${token}A`,
+ `sim_e2e_${token}`,
+ `E2E_RUNTIME_SECRET_V1_${token.slice(0, 31)}`,
+ ].join('\n')
+ )
+ const splitZip = new JSZip()
+ splitZip.file('first.txt', `sk-sim-${token.slice(0, 16)}`)
+ splitZip.file('second.txt', token.slice(16))
+ writeFileSync(
+ path.join(directory, 'split.zip'),
+ await splitZip.generateAsync({ type: 'nodebuffer' })
+ )
+ await expect(scan()).resolves.toBeUndefined()
+ } finally {
+ rmSync(directory, { recursive: true, force: true })
+ }
+})
+
test('malformed canaries fail closed by scrubbing unscannable diagnostics', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'sim-e2e-malformed-canary-'))
const secretsPath = path.join(directory, 'synthetic-secrets.json')
diff --git a/apps/sim/e2e/foundation/safety.spec.ts b/apps/sim/e2e/foundation/safety.spec.ts
index 5d9fbeb5bb5..678029dd04a 100644
--- a/apps/sim/e2e/foundation/safety.spec.ts
+++ b/apps/sim/e2e/foundation/safety.spec.ts
@@ -189,6 +189,14 @@ test.describe('foundation safety guards', () => {
expect(() =>
parseRunOptions(['--project=hosted-billing-chromium-authorization', '--shard=1/2'])
).toThrow(/coupled E2E projects must remain unsharded/)
+ expect(() =>
+ parseRunOptions(['--project=hosted-billing-chromium-credentials', '--no-deps'], {
+ ci: false,
+ })
+ ).not.toThrow()
+ expect(() =>
+ parseRunOptions(['--project=hosted-billing-chromium-credentials', '--shard=1/2'])
+ ).toThrow(/coupled E2E projects must remain unsharded/)
expect(() =>
parseRunOptions(['--project=hosted-billing-chromium-workflows', '--shard=1/2'])
).toThrow(/coupled E2E projects must remain unsharded/)
diff --git a/apps/sim/e2e/scripts/options.ts b/apps/sim/e2e/scripts/options.ts
index 5577a1aa97d..73d9e24ec9b 100644
--- a/apps/sim/e2e/scripts/options.ts
+++ b/apps/sim/e2e/scripts/options.ts
@@ -1,11 +1,13 @@
const NAVIGATION_PROJECT = 'hosted-billing-chromium-navigation'
const AUTHORIZATION_PROJECT = 'hosted-billing-chromium-authorization'
+const CREDENTIALS_PROJECT = 'hosted-billing-chromium-credentials'
const WORKFLOWS_PROJECT = 'hosted-billing-chromium-workflows'
const PERSONAS_PROJECT = 'hosted-billing-chromium-personas'
const PERSONA_ISOLATION_PROJECT = 'hosted-billing-chromium-persona-isolation'
const E2E_PROJECTS = new Set([
NAVIGATION_PROJECT,
AUTHORIZATION_PROJECT,
+ CREDENTIALS_PROJECT,
WORKFLOWS_PROJECT,
PERSONAS_PROJECT,
PERSONA_ISOLATION_PROJECT,
diff --git a/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts b/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts
index 7f20bc3b318..76534ee3500 100644
--- a/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts
+++ b/apps/sim/e2e/settings/authorization/mutation-controls.spec.ts
@@ -146,6 +146,11 @@ async function expectMutationPermissionsReady(page: Page, pathTemplate: string):
const navigation = sidebar.getByRole('navigation', { name: 'Workspace settings sections' })
await expect(navigation).toHaveAttribute('aria-busy', 'false')
await expect(navigation).toHaveAttribute('data-authorization-state', 'granted')
+ if (pathTemplate.endsWith('/settings/apikeys')) {
+ const apiKeys = page.getByRole('region', { name: 'API keys data', exact: true })
+ await expect(apiKeys).toHaveAttribute('aria-busy', 'false')
+ await expect(apiKeys).toHaveAttribute('data-api-keys-state', 'ready')
+ }
return
}
diff --git a/apps/sim/e2e/settings/credentials/api-keys.spec.ts b/apps/sim/e2e/settings/credentials/api-keys.spec.ts
new file mode 100644
index 00000000000..e75a0b92ebf
--- /dev/null
+++ b/apps/sim/e2e/settings/credentials/api-keys.spec.ts
@@ -0,0 +1,316 @@
+import type { Page } from '@playwright/test'
+import { absoluteE2eUrl } from '../navigation/contract-resolver'
+import { expect, test } from './credential-test'
+import {
+ attemptWorkspaceApiKeyCreate,
+ attemptWorkspaceApiKeyDelete,
+ deleteApiKeyByName,
+ expectWorkspaceSettingsReady,
+ listApiKeys,
+ newPersonaPage,
+ setWorkspacePersonalKeyPolicy,
+ uniqueResourceName,
+ waitForSameOriginResponse,
+ workspaceId,
+ workspacePersonalKeyPolicy,
+} from './helpers'
+
+test('creates and revokes a personal API key from account settings', async ({
+ contextForPersona,
+ credentialCleanup,
+}) => {
+ const page = await newPersonaPage(contextForPersona, 'personalPaidOwner', credentialCleanup)
+ const name = uniqueResourceName('account-personal-key')
+ credentialCleanup.register('account personal API key cleanup', async () => {
+ expect(await deleteApiKeyByName(page, 'personal', name)).toBe(200)
+ })
+
+ await page.goto(absoluteE2eUrl('/account/settings/api-keys'))
+ await expect(page.getByRole('textbox', { name: 'Search API keys...', exact: true })).toBeVisible()
+ await expectApiKeysReady(page)
+ await createApiKeyThroughUi(page, name, {
+ responsePath: '/api/users/me/api-keys',
+ })
+
+ const listed = await expectKeyByName(page, 'personal', name)
+ await deleteApiKeyThroughUi(page, name, `/api/users/me/api-keys/${listed.id}`)
+ await expectKeyAbsent(page, 'personal', name)
+})
+
+test('workspace admin creates and revokes a workspace API key', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const page = await newPersonaPage(contextForPersona, 'workspaceAdminMember', credentialCleanup)
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const name = uniqueResourceName('workspace-admin-key')
+ credentialCleanup.register('workspace admin API key cleanup', async () => {
+ expect(await deleteApiKeyByName(page, 'workspace', name, targetWorkspaceId)).toBe(200)
+ })
+
+ await page.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`))
+ await expectWorkspaceSettingsReady(page)
+ await expectApiKeysReady(page)
+ await createApiKeyThroughUi(page, name, {
+ keyType: 'Workspace',
+ responsePath: `/api/workspaces/${targetWorkspaceId}/api-keys`,
+ })
+
+ const listed = await expectKeyByName(page, 'workspace', name, targetWorkspaceId)
+ await deleteApiKeyThroughUi(
+ page,
+ name,
+ `/api/workspaces/${targetWorkspaceId}/api-keys/${listed.id}`
+ )
+ await expectKeyAbsent(page, 'workspace', name, targetWorkspaceId)
+})
+
+test('write member cannot create or revoke workspace API keys', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const adminPage = await newPersonaPage(
+ contextForPersona,
+ 'workspaceAdminMember',
+ credentialCleanup
+ )
+ const writePage = await newPersonaPage(
+ contextForPersona,
+ 'workspaceWriteMember',
+ credentialCleanup
+ )
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const name = uniqueResourceName('workspace-write-denial')
+ credentialCleanup.register('workspace write-denial API key cleanup', async () => {
+ expect(await deleteApiKeyByName(adminPage, 'workspace', name, targetWorkspaceId)).toBe(200)
+ })
+
+ await adminPage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`))
+ await expectWorkspaceSettingsReady(adminPage)
+ await expectApiKeysReady(adminPage)
+ await createApiKeyThroughUi(adminPage, name, {
+ keyType: 'Workspace',
+ responsePath: `/api/workspaces/${targetWorkspaceId}/api-keys`,
+ })
+ const listed = await expectKeyByName(adminPage, 'workspace', name, targetWorkspaceId)
+
+ await writePage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`))
+ await expectWorkspaceSettingsReady(writePage)
+ await expectApiKeysReady(writePage)
+ const row = writePage.getByRole('group', { name, exact: true })
+ await expect(row).toBeVisible()
+ await row.getByRole('button', { name: 'API key actions', exact: true }).click()
+ await expect(writePage.getByRole('menuitem', { name: 'Delete', exact: true })).toBeDisabled()
+ await writePage.keyboard.press('Escape')
+
+ const forbiddenName = uniqueResourceName('forbidden-workspace-key')
+ credentialCleanup.register('forbidden workspace API key cleanup', async () => {
+ expect(await deleteApiKeyByName(adminPage, 'workspace', forbiddenName, targetWorkspaceId)).toBe(
+ 200
+ )
+ })
+ expect(await attemptWorkspaceApiKeyCreate(writePage, targetWorkspaceId, forbiddenName)).toBe(403)
+ expect(await attemptWorkspaceApiKeyDelete(writePage, targetWorkspaceId, listed.id)).toBe(403)
+ expect(await expectKeyByName(adminPage, 'workspace', name, targetWorkspaceId)).toMatchObject({
+ id: listed.id,
+ })
+})
+
+test('workspace personal-key policy gates combined-page creation', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const adminPage = await newPersonaPage(
+ contextForPersona,
+ 'workspaceAdminMember',
+ credentialCleanup
+ )
+ const writePage = await newPersonaPage(
+ contextForPersona,
+ 'workspaceWriteMember',
+ credentialCleanup
+ )
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const name = uniqueResourceName('workspace-personal-policy')
+
+ expect(await setWorkspacePersonalKeyPolicy(adminPage, targetWorkspaceId, true)).toBe(200)
+ expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({
+ status: 200,
+ allowed: true,
+ })
+ credentialCleanup.register('workspace personal-key policy reset', async () => {
+ expect(await setWorkspacePersonalKeyPolicy(adminPage, targetWorkspaceId, true)).toBe(200)
+ expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({
+ status: 200,
+ allowed: true,
+ })
+ })
+ credentialCleanup.register('workspace personal API key cleanup', async () => {
+ expect(await deleteApiKeyByName(writePage, 'personal', name)).toBe(200)
+ })
+
+ await writePage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`))
+ await expectWorkspaceSettingsReady(writePage)
+ await expectApiKeysReady(writePage)
+ await createApiKeyThroughUi(writePage, name, {
+ responsePath: '/api/users/me/api-keys',
+ })
+ const personalKey = await expectKeyByName(writePage, 'personal', name)
+ await deleteApiKeyThroughUi(
+ writePage,
+ name,
+ `/api/users/me/api-keys/${encodeURIComponent(personalKey.id)}`
+ )
+ await expectKeyAbsent(writePage, 'personal', name)
+
+ await adminPage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`))
+ await expectWorkspaceSettingsReady(adminPage)
+ await expectApiKeysReady(adminPage)
+ const policySwitch = adminPage.getByRole('switch', {
+ name: 'Allow personal API keys',
+ exact: true,
+ })
+ await expect(policySwitch).toBeChecked()
+ const policyResponse = waitForSameOriginResponse(
+ adminPage,
+ 'PATCH',
+ `/api/workspaces/${targetWorkspaceId}`
+ )
+ await policySwitch.click()
+ expect((await policyResponse).status()).toBe(200)
+ await expect(policySwitch).not.toBeChecked()
+ expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({
+ status: 200,
+ allowed: false,
+ })
+
+ await writePage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/apikeys`))
+ await expectWorkspaceSettingsReady(writePage)
+ await expectApiKeysReady(writePage)
+ await expect(
+ writePage.getByRole('button', { name: 'Create API key', exact: true })
+ ).toBeDisabled()
+ expect(await setWorkspacePersonalKeyPolicy(writePage, targetWorkspaceId, true)).toBe(403)
+ expect(await workspacePersonalKeyPolicy(adminPage, targetWorkspaceId)).toEqual({
+ status: 200,
+ allowed: false,
+ })
+})
+
+interface CreateApiKeyOptions {
+ responsePath: string
+ keyType?: 'Personal' | 'Workspace'
+}
+
+async function expectApiKeysReady(page: Page): Promise {
+ const region = page.getByRole('region', { name: 'API keys data', exact: true })
+ await expect(region).toHaveAttribute('aria-busy', 'false')
+ await expect(region).toHaveAttribute('data-api-keys-state', 'ready')
+}
+
+async function createApiKeyThroughUi(
+ page: Page,
+ name: string,
+ options: CreateApiKeyOptions
+): Promise {
+ await page.getByRole('button', { name: 'Create API key', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: 'Create new API key', exact: true })
+ await expect(dialog).toBeVisible()
+ if (options.keyType) {
+ await dialog.getByRole('radio', { name: options.keyType, exact: true }).click()
+ }
+ await dialog.getByPlaceholder('e.g., Development, Production', { exact: true }).fill(name)
+ const response = waitForSameOriginResponse(page, 'POST', options.responsePath)
+ await dialog.getByRole('button', { name: 'Create', exact: true }).click()
+ expect((await response).status()).toBe(200)
+
+ await page.waitForFunction(() =>
+ [...document.querySelectorAll('[role="dialog"]')].some(
+ (candidate) =>
+ candidate.textContent?.includes('Your API key has been created') &&
+ [...candidate.querySelectorAll('button')].some(
+ (button) => button.textContent?.trim() === 'Done'
+ )
+ )
+ )
+ const revealClosed = await page.evaluate(() => {
+ const dialog = [...document.querySelectorAll('[role="dialog"]')].find(
+ (candidate) => candidate.textContent?.includes('Your API key has been created')
+ )
+ if (!dialog) return false
+ for (const code of dialog.querySelectorAll('code')) code.textContent = '[redacted]'
+ const done = [...dialog.querySelectorAll('button')].find(
+ (button) => button.textContent?.trim() === 'Done'
+ )
+ done?.click()
+ return Boolean(done)
+ })
+ expect(revealClosed).toBe(true)
+ await page.waitForFunction(
+ () =>
+ ![...document.querySelectorAll('[role="dialog"]')].some((candidate) =>
+ candidate.textContent?.includes('Your API key has been created')
+ )
+ )
+}
+
+async function expectKeyByName(
+ page: Page,
+ scope: 'personal' | 'workspace',
+ name: string,
+ targetWorkspaceId?: string
+): Promise<{ id: string; name: string; displayKey: string }> {
+ let match: { id: string; name: string; displayKey: string } | undefined
+ await expect
+ .poll(async () => {
+ const listed = await listApiKeys(page, scope, targetWorkspaceId)
+ expect(listed.status).toBe(200)
+ expect(listed.containsPlaintextField).toBe(false)
+ match = listed.keys.find((key) => key.name === name)
+ return match
+ })
+ .toEqual({
+ id: expect.any(String),
+ name,
+ displayKey: expect.stringMatching(/^(?:sk-sim-|sim_)\.\.\.[A-Za-z0-9_-]{4}$/),
+ })
+ if (!match) throw new Error('Created API key metadata was not found')
+ return match
+}
+
+async function expectKeyAbsent(
+ page: Page,
+ scope: 'personal' | 'workspace',
+ name: string,
+ targetWorkspaceId?: string
+): Promise {
+ await expect
+ .poll(async () => {
+ const listed = await listApiKeys(page, scope, targetWorkspaceId)
+ return {
+ status: listed.status,
+ containsPlaintextField: listed.containsPlaintextField,
+ present: listed.keys.some((key) => key.name === name),
+ }
+ })
+ .toEqual({ status: 200, containsPlaintextField: false, present: false })
+}
+
+async function deleteApiKeyThroughUi(
+ page: Page,
+ name: string,
+ responsePath: string
+): Promise {
+ const row = page.getByRole('group', { name, exact: true })
+ await row.getByRole('button', { name: 'API key actions', exact: true }).click()
+ await page.getByRole('menuitem', { name: 'Delete', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: 'Delete API key', exact: true })
+ await expect(dialog).toBeVisible()
+ const response = waitForSameOriginResponse(page, 'DELETE', responsePath)
+ await dialog.getByRole('button', { name: 'Delete', exact: true }).click()
+ expect((await response).status()).toBe(200)
+ await expect(dialog).toHaveCount(0)
+}
diff --git a/apps/sim/e2e/settings/credentials/contract-integrity.spec.ts b/apps/sim/e2e/settings/credentials/contract-integrity.spec.ts
new file mode 100644
index 00000000000..b8a8551542b
--- /dev/null
+++ b/apps/sim/e2e/settings/credentials/contract-integrity.spec.ts
@@ -0,0 +1,15 @@
+import { accessGateCases, mutationControlCases } from '../authorization/contracts'
+import { existingAuthorizationProofs } from './contracts'
+import { expect, test } from './credential-test'
+
+test('credential workflows reference stable Step 4 authorization proofs', () => {
+ const accessIds = new Set(accessGateCases.map(({ caseId }) => caseId))
+ const mutationIds = new Set(mutationControlCases.map(({ caseId }) => caseId))
+
+ expect(new Set(existingAuthorizationProofs.map(({ caseId }) => caseId)).size).toBe(
+ existingAuthorizationProofs.length
+ )
+ for (const proof of existingAuthorizationProofs) {
+ expect(proof.kind === 'access' ? accessIds : mutationIds).toContain(proof.caseId)
+ }
+})
diff --git a/apps/sim/e2e/settings/credentials/contracts.ts b/apps/sim/e2e/settings/credentials/contracts.ts
new file mode 100644
index 00000000000..5c902c70271
--- /dev/null
+++ b/apps/sim/e2e/settings/credentials/contracts.ts
@@ -0,0 +1,10 @@
+export const existingAuthorizationProofs = [
+ { kind: 'access', caseId: 'workspace-permission-group-secrets-denied' },
+ { kind: 'access', caseId: 'workspace-permission-group-apikeys-denied' },
+ { kind: 'mutation', caseId: 'workspace-secrets-workspaceReadMember' },
+ { kind: 'mutation', caseId: 'workspace-secrets-workspaceWriteMember' },
+ { kind: 'mutation', caseId: 'workspace-secrets-workspaceAdminMember' },
+ { kind: 'mutation', caseId: 'workspace-api-keys-workspaceReadMember' },
+ { kind: 'mutation', caseId: 'workspace-api-keys-workspaceWriteMember' },
+ { kind: 'mutation', caseId: 'workspace-api-keys-workspaceAdminMember' },
+] as const
diff --git a/apps/sim/e2e/settings/credentials/credential-test.ts b/apps/sim/e2e/settings/credentials/credential-test.ts
new file mode 100644
index 00000000000..7e3d4a94e50
--- /dev/null
+++ b/apps/sim/e2e/settings/credentials/credential-test.ts
@@ -0,0 +1,86 @@
+import type { Page, TestInfo } from '@playwright/test'
+import { redactCredentialDiagnostic } from '../../../lib/testing/credential-diagnostic-redaction'
+import { test as personaTest } from '../../fixtures/persona-test'
+
+export interface CredentialCleanupRegistry {
+ register(label: string, cleanup: () => Promise): void
+ protect(page: Page): void
+}
+
+interface CredentialFixtures {
+ credentialArtifactSafety: undefined
+ credentialCleanup: CredentialCleanupRegistry
+}
+
+export const test = personaTest.extend({
+ credentialArtifactSafety: [
+ async ({ browserName: _browserName }, use, testInfo) => {
+ assertCredentialArtifactPolicy(testInfo)
+ const attachmentCount = testInfo.attachments.length
+ await use(undefined)
+ if (testInfo.attachments.length !== attachmentCount) {
+ throw new Error('Credential E2E tests must not create report attachments')
+ }
+ },
+ { auto: true },
+ ],
+ credentialCleanup: async ({ contextForPersona: _contextForPersona }, use, testInfo) => {
+ const cleanups: Array<{ label: string; run: () => Promise }> = []
+ const protectedPages = new Set()
+ await use({
+ register(label, cleanup) {
+ cleanups.push({ label, run: cleanup })
+ },
+ protect(page) {
+ protectedPages.add(page)
+ },
+ })
+
+ const failures: unknown[] = []
+ for (const cleanup of cleanups.reverse()) {
+ try {
+ await cleanup.run()
+ } catch (error) {
+ failures.push(new Error(`Credential cleanup failed: ${cleanup.label}`, { cause: error }))
+ }
+ }
+ if (testInfo.status !== testInfo.expectedStatus || failures.length > 0) {
+ redactCredentialTestErrors(testInfo)
+ for (const page of protectedPages) {
+ if (page.isClosed()) continue
+ try {
+ await page.evaluate(() => {
+ document.documentElement.replaceChildren(document.createElement('head'))
+ })
+ } catch (error) {
+ failures.push(new Error('Unable to sanitize a failed credential page', { cause: error }))
+ }
+ }
+ }
+ if (failures.length > 0) {
+ throw new AggregateError(failures, 'Credential E2E cleanup failed')
+ }
+ },
+})
+
+export { expect } from '@playwright/test'
+
+function assertCredentialArtifactPolicy(testInfo: TestInfo): void {
+ const { trace, screenshot, video } = testInfo.project.use
+ if (trace !== 'off' || screenshot !== 'off' || video !== 'off') {
+ throw new Error('Credential E2E project must disable trace, screenshot, and video artifacts')
+ }
+}
+
+function redactCredentialTestErrors(testInfo: TestInfo): void {
+ for (const testError of testInfo.errors) {
+ const mutableError = testError as {
+ message?: string
+ stack?: string
+ errorContext?: string
+ }
+ mutableError.message = redactCredentialDiagnostic(mutableError.message)
+ mutableError.stack = redactCredentialDiagnostic(mutableError.stack)
+ mutableError.errorContext = redactCredentialDiagnostic(mutableError.errorContext)
+ }
+}
diff --git a/apps/sim/e2e/settings/credentials/helpers.ts b/apps/sim/e2e/settings/credentials/helpers.ts
new file mode 100644
index 00000000000..65400636af9
--- /dev/null
+++ b/apps/sim/e2e/settings/credentials/helpers.ts
@@ -0,0 +1,424 @@
+import { randomUUID } from 'node:crypto'
+import type { BrowserContext, Locator, Page, Response } from '@playwright/test'
+import type { ScenarioManifest } from '../../fixtures/e2e-world'
+import { absoluteE2eUrl } from '../navigation/contract-resolver'
+import type { CredentialCleanupRegistry } from './credential-test'
+import { expect } from './credential-test'
+
+const RUNTIME_SECRET_PREFIX = 'E2E_RUNTIME_SECRET_V1_'
+
+type SecretApiCommand =
+ | { kind: 'read-workspace'; workspaceId: string; key: string }
+ | { kind: 'upsert-workspace'; workspaceId: string; key: string }
+ | { kind: 'delete-workspace'; workspaceId: string; key: string }
+ | { kind: 'read-personal'; key: string }
+ | { kind: 'remove-personal'; key: string }
+
+export interface SecretApiResult {
+ status: number
+ readStatus?: number
+ present?: boolean
+ withheld?: boolean
+ fingerprint?: string
+}
+
+export interface EnvironmentCredentialMetadata {
+ status: number
+ id: string | null
+ role: string | null
+}
+
+export interface ApiKeyListResult {
+ status: number
+ containsPlaintextField: boolean
+ keys: Array<{ id: string; name: string; displayKey: string }>
+}
+
+export function uniqueEnvironmentKey(label: string): string {
+ return `E2E_${label.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_${randomUUID()
+ .replaceAll('-', '')
+ .slice(0, 12)
+ .toUpperCase()}`
+}
+
+export function uniqueResourceName(label: string): string {
+ return `e2e-${label}-${randomUUID()}`
+}
+
+export function workspaceId(manifest: ScenarioManifest, key = 'team-workspace'): string {
+ const id = manifest.worlds['settings-primary']?.workspaceIds[key]
+ if (!id) throw new Error(`Missing settings-primary workspace binding: ${key}`)
+ return id
+}
+
+export async function newPersonaPage(
+ contextForPersona: (personaKey: string) => Promise,
+ personaKey: string,
+ cleanup: CredentialCleanupRegistry
+): Promise {
+ const context = await contextForPersona(personaKey)
+ const page = await context.newPage()
+ cleanup.protect(page)
+ const response = await page.goto(absoluteE2eUrl('/account/settings/general'))
+ if (!response?.ok()) throw new Error('Unable to initialize credential test page origin')
+ return page
+}
+
+export async function expectWorkspaceSettingsReady(page: Page): Promise {
+ const sidebar = page.getByRole('complementary', { name: 'Workspace sidebar' })
+ const navigation = sidebar.getByRole('navigation', { name: 'Workspace settings sections' })
+ await expect(navigation).toHaveAttribute('aria-busy', 'false')
+ await expect(navigation).toHaveAttribute('data-authorization-state', 'granted')
+}
+
+export async function setSensitiveInput(locator: Locator): Promise {
+ await locator.focus()
+ await expect(locator).toHaveJSProperty('readOnly', false)
+ try {
+ const expectedFingerprint = await locator.evaluate((node, prefix) => {
+ if (!(node instanceof HTMLInputElement)) {
+ throw new Error('Sensitive credential control is not an input')
+ }
+ const bytes = new Uint8Array(24)
+ crypto.getRandomValues(bytes)
+ const token = btoa(String.fromCharCode(...bytes))
+ .replaceAll('+', '-')
+ .replaceAll('/', '_')
+ .replaceAll('=', '')
+ const value = `${prefix}${token}`
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ if (!setter) throw new Error('Native input value setter is unavailable')
+ setter.call(node, value)
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
+ return fingerprint(value)
+
+ function fingerprint(input: string): string {
+ const bytes = new TextEncoder().encode(input)
+ return [0xcbf29ce484222325n, 0x84222325cbf29cen]
+ .map((seed) => {
+ let hash = seed
+ for (const byte of bytes) {
+ hash ^= BigInt(byte)
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n)
+ }
+ return hash.toString(16).padStart(16, '0')
+ })
+ .join('')
+ }
+ }, RUNTIME_SECRET_PREFIX)
+
+ await locator.blur()
+ await expect(locator).toHaveJSProperty('readOnly', true)
+ await locator.focus()
+ const adoptedFingerprint = await sensitiveInputFingerprint(locator)
+ expect(adoptedFingerprint).toBe(expectedFingerprint)
+ return expectedFingerprint
+ } finally {
+ await locator.blur().catch(() => undefined)
+ }
+}
+
+export async function sensitiveInputFingerprint(locator: Locator): Promise {
+ await locator.focus()
+ await expect(locator).toHaveJSProperty('readOnly', false)
+ try {
+ return await locator.evaluate((node) => {
+ if (!(node instanceof HTMLInputElement)) {
+ throw new Error('Sensitive credential control is not an input')
+ }
+ const bytes = new TextEncoder().encode(node.value)
+ return [0xcbf29ce484222325n, 0x84222325cbf29cen]
+ .map((seed) => {
+ let hash = seed
+ for (const byte of bytes) {
+ hash ^= BigInt(byte)
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n)
+ }
+ return hash.toString(16).padStart(16, '0')
+ })
+ .join('')
+ })
+ } finally {
+ await locator.blur().catch(() => undefined)
+ }
+}
+
+export async function runSecretApi(
+ page: Page,
+ command: SecretApiCommand
+): Promise {
+ return page.evaluate(async (operation) => {
+ const headers = { 'content-type': 'application/json' }
+
+ if (operation.kind === 'read-workspace') {
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(
+ `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment`
+ )
+ if (response.status !== 200) return { status: response.status }
+ const payload = (await response.json()) as {
+ data?: { workspace?: Record }
+ }
+ const value = payload.data?.workspace?.[operation.key]
+ return {
+ status: response.status,
+ present: value !== undefined,
+ withheld: value === '',
+ ...(value ? { fingerprint: fingerprint(value) } : {}),
+ }
+ }
+
+ if (operation.kind === 'upsert-workspace') {
+ const value = generateSecret()
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(
+ `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment`,
+ {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify({ variables: { [operation.key]: value } }),
+ }
+ )
+ return { status: response.status, fingerprint: fingerprint(value) }
+ }
+
+ if (operation.kind === 'delete-workspace') {
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(
+ `/api/workspaces/${encodeURIComponent(operation.workspaceId)}/environment`,
+ {
+ method: 'DELETE',
+ headers,
+ body: JSON.stringify({ keys: [operation.key] }),
+ }
+ )
+ return { status: response.status }
+ }
+
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const readResponse = await fetch('/api/environment')
+ if (readResponse.status !== 200) return { status: readResponse.status }
+ const payload = (await readResponse.json()) as {
+ data?: Record
+ }
+ const variables = payload.data ?? {}
+
+ if (operation.kind === 'read-personal') {
+ const value = variables[operation.key]?.value
+ return {
+ status: readResponse.status,
+ present: value !== undefined,
+ ...(value ? { fingerprint: fingerprint(value) } : {}),
+ }
+ }
+
+ delete variables[operation.key]
+ const preservedVariables = Object.fromEntries(
+ Object.entries(variables).map(([key, variable]) => [key, variable.value])
+ )
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const writeResponse = await fetch('/api/environment', {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ variables: preservedVariables }),
+ })
+ return { status: writeResponse.status, readStatus: readResponse.status }
+
+ function generateSecret(): string {
+ const bytes = new Uint8Array(24)
+ crypto.getRandomValues(bytes)
+ const token = btoa(String.fromCharCode(...bytes))
+ .replaceAll('+', '-')
+ .replaceAll('/', '_')
+ .replaceAll('=', '')
+ return `E2E_RUNTIME_SECRET_V1_${token}`
+ }
+
+ function fingerprint(input: string): string {
+ const bytes = new TextEncoder().encode(input)
+ return [0xcbf29ce484222325n, 0x84222325cbf29cen]
+ .map((seed) => {
+ let hash = seed
+ for (const byte of bytes) {
+ hash ^= BigInt(byte)
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n)
+ }
+ return hash.toString(16).padStart(16, '0')
+ })
+ .join('')
+ }
+ }, command)
+}
+
+export async function findEnvironmentCredential(
+ page: Page,
+ workspaceId: string,
+ key: string,
+ type: 'env_personal' | 'env_workspace' = 'env_workspace'
+): Promise {
+ return page.evaluate(
+ async ({ workspaceId, key, type }) => {
+ const query = new URLSearchParams({ workspaceId, type })
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(`/api/credentials?${query}`)
+ if (response.status !== 200) {
+ return { status: response.status, id: null, role: null }
+ }
+ const payload = (await response.json()) as {
+ credentials?: Array<{ id: string; envKey?: string | null; role?: string | null }>
+ }
+ const match = payload.credentials?.find(({ envKey }) => envKey === key)
+ return {
+ status: response.status,
+ id: match?.id ?? null,
+ role: match?.role ?? null,
+ }
+ },
+ { workspaceId, key, type }
+ )
+}
+
+export async function listApiKeys(
+ page: Page,
+ scope: 'personal' | 'workspace',
+ targetWorkspaceId?: string
+): Promise {
+ return page.evaluate(
+ async ({ scope, workspaceId }) => {
+ const path =
+ scope === 'personal'
+ ? '/api/users/me/api-keys'
+ : `/api/workspaces/${encodeURIComponent(workspaceId ?? '')}/api-keys`
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(path)
+ if (response.status !== 200) {
+ return { status: response.status, containsPlaintextField: false, keys: [] }
+ }
+ const payload = (await response.json()) as {
+ keys?: Array>
+ }
+ const keys = payload.keys ?? []
+ return {
+ status: response.status,
+ containsPlaintextField: keys.some((key) => Object.hasOwn(key, 'key')),
+ keys: keys.map((key) => ({
+ id: String(key.id ?? ''),
+ name: String(key.name ?? ''),
+ displayKey: String(key.displayKey ?? ''),
+ })),
+ }
+ },
+ { scope, workspaceId: targetWorkspaceId }
+ )
+}
+
+export async function deleteApiKeyByName(
+ page: Page,
+ scope: 'personal' | 'workspace',
+ name: string,
+ targetWorkspaceId?: string
+): Promise {
+ const listed = await listApiKeys(page, scope, targetWorkspaceId)
+ if (listed.status !== 200) return listed.status
+ const match = listed.keys.find((key) => key.name === name)
+ if (!match) return 200
+ return page.evaluate(
+ async ({ scope, workspaceId, id }) => {
+ const path =
+ scope === 'personal'
+ ? `/api/users/me/api-keys/${encodeURIComponent(id)}`
+ : `/api/workspaces/${encodeURIComponent(workspaceId ?? '')}/api-keys/${encodeURIComponent(id)}`
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ return (await fetch(path, { method: 'DELETE' })).status
+ },
+ { scope, workspaceId: targetWorkspaceId, id: match.id }
+ )
+}
+
+export async function workspacePersonalKeyPolicy(
+ page: Page,
+ targetWorkspaceId: string
+): Promise<{ status: number; allowed?: boolean }> {
+ return page.evaluate(async (workspaceId) => {
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`)
+ if (response.status !== 200) return { status: response.status }
+ const payload = (await response.json()) as {
+ workspace?: { allowPersonalApiKeys?: boolean }
+ }
+ const allowed = payload.workspace?.allowPersonalApiKeys
+ return typeof allowed === 'boolean'
+ ? { status: response.status, allowed }
+ : { status: response.status }
+ }, targetWorkspaceId)
+}
+
+export async function setWorkspacePersonalKeyPolicy(
+ page: Page,
+ targetWorkspaceId: string,
+ allowed: boolean
+): Promise {
+ return page.evaluate(
+ async ({ workspaceId, allowed }) => {
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`, {
+ method: 'PATCH',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ allowPersonalApiKeys: allowed }),
+ })
+ return response.status
+ },
+ { workspaceId: targetWorkspaceId, allowed }
+ )
+}
+
+export async function attemptWorkspaceApiKeyCreate(
+ page: Page,
+ targetWorkspaceId: string,
+ name: string
+): Promise {
+ return page.evaluate(
+ async ({ workspaceId, name }) => {
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(`/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name, source: 'settings' }),
+ })
+ return response.status
+ },
+ { workspaceId: targetWorkspaceId, name }
+ )
+}
+
+export async function attemptWorkspaceApiKeyDelete(
+ page: Page,
+ targetWorkspaceId: string,
+ keyId: string
+): Promise {
+ return page.evaluate(
+ async ({ workspaceId, keyId }) => {
+ // boundary-raw-fetch: E2E browser-resident credential operation prevents plaintext crossing into Playwright
+ const response = await fetch(
+ `/api/workspaces/${encodeURIComponent(workspaceId)}/api-keys/${encodeURIComponent(keyId)}`,
+ { method: 'DELETE' }
+ )
+ return response.status
+ },
+ { workspaceId: targetWorkspaceId, keyId }
+ )
+}
+
+export function waitForSameOriginResponse(
+ page: Page,
+ method: string,
+ pathname: string
+): Promise {
+ const origin = new URL(absoluteE2eUrl('/')).origin
+ return page.waitForResponse((response) => {
+ const url = new URL(response.url())
+ return (
+ url.origin === origin && url.pathname === pathname && response.request().method() === method
+ )
+ })
+}
diff --git a/apps/sim/e2e/settings/credentials/secrets.spec.ts b/apps/sim/e2e/settings/credentials/secrets.spec.ts
new file mode 100644
index 00000000000..8e7f75c5293
--- /dev/null
+++ b/apps/sim/e2e/settings/credentials/secrets.spec.ts
@@ -0,0 +1,465 @@
+import { absoluteE2eUrl } from '../navigation/contract-resolver'
+import { expect, test } from './credential-test'
+import {
+ expectWorkspaceSettingsReady,
+ findEnvironmentCredential,
+ newPersonaPage,
+ runSecretApi,
+ sensitiveInputFingerprint,
+ setSensitiveInput,
+ uniqueEnvironmentKey,
+ waitForSameOriginResponse,
+ workspaceId,
+} from './helpers'
+
+test('read member manages a personal secret without workspace mutation rights', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const page = await newPersonaPage(contextForPersona, 'workspaceReadMember', credentialCleanup)
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const key = uniqueEnvironmentKey('PERSONAL_READ_MEMBER')
+ credentialCleanup.register('personal secret exact-key cleanup', async () => {
+ assertStatus(await runSecretApi(page, { kind: 'remove-personal', key }), 200)
+ })
+
+ await page.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/secrets`))
+ await expectWorkspaceSettingsReady(page)
+ await expect(page.getByRole('textbox', { name: 'Search secrets...', exact: true })).toBeVisible()
+
+ const name = page.getByRole('textbox', { name: 'New personal secret name', exact: true }).first()
+ await name.click()
+ await name.fill(key)
+ const fingerprint = await setSensitiveInput(
+ page.getByRole('textbox', { name: `Personal secret value ${key}`, exact: true })
+ )
+ const save = page.getByRole('button', { name: 'Save', exact: true })
+ await expect(save).toBeEnabled()
+ await save.click()
+ await expect
+ .poll(() => runSecretApi(page, { kind: 'read-personal', key }))
+ .toEqual({
+ status: 200,
+ present: true,
+ fingerprint,
+ })
+
+ await page.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click()
+ await page.getByRole('menuitem', { name: 'Delete', exact: true }).click()
+ await expect(save).toBeEnabled()
+ await save.click()
+ await expect
+ .poll(() => runSecretApi(page, { kind: 'read-personal', key }))
+ .toEqual({
+ status: 200,
+ present: false,
+ })
+})
+
+test('write member completes the workspace secret lifecycle', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const page = await newPersonaPage(contextForPersona, 'workspaceWriteMember', credentialCleanup)
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const key = uniqueEnvironmentKey('WORKSPACE_LIFECYCLE')
+ credentialCleanup.register('workspace secret exact-key cleanup', async () => {
+ assertStatus(
+ await runSecretApi(page, { kind: 'delete-workspace', workspaceId: targetWorkspaceId, key }),
+ 200
+ )
+ })
+
+ await page.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/secrets`))
+ await expectWorkspaceSettingsReady(page)
+
+ const newName = page
+ .getByRole('textbox', {
+ name: 'New workspace secret name',
+ exact: true,
+ })
+ .first()
+ await newName.click()
+ await newName.fill(key)
+ await setSensitiveInput(
+ page.getByRole('textbox', { name: 'New workspace secret value', exact: true }).first()
+ )
+ await page.getByRole('button', { name: 'Discard', exact: true }).click()
+ expect(
+ await runSecretApi(page, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toEqual({ status: 200, present: false, withheld: false })
+
+ await newName.click()
+ await newName.fill(key)
+ const originalFingerprint = await setSensitiveInput(
+ page.getByRole('textbox', { name: 'New workspace secret value', exact: true }).first()
+ )
+ const createResponse = waitForSameOriginResponse(
+ page,
+ 'PUT',
+ `/api/workspaces/${targetWorkspaceId}/environment`
+ )
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ expect((await createResponse).status()).toBe(200)
+ expect(
+ await runSecretApi(page, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toEqual({
+ status: 200,
+ present: true,
+ withheld: false,
+ fingerprint: originalFingerprint,
+ })
+ await expect
+ .poll(() => findEnvironmentCredential(page, targetWorkspaceId, key))
+ .toMatchObject({ status: 200, id: expect.any(String), role: 'admin' })
+
+ const existingValue = page.getByRole('textbox', {
+ name: `Workspace secret value ${key}`,
+ exact: true,
+ })
+ await setSensitiveInput(existingValue)
+ await page.getByRole('button', { name: 'Discard', exact: true }).click()
+ await page.reload()
+ await expectWorkspaceSettingsReady(page)
+ expect(
+ await runSecretApi(page, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toMatchObject({ status: 200, fingerprint: originalFingerprint })
+
+ const updatedFingerprint = await setSensitiveInput(
+ page.getByRole('textbox', { name: `Workspace secret value ${key}`, exact: true })
+ )
+ const updateResponse = waitForSameOriginResponse(
+ page,
+ 'PUT',
+ `/api/workspaces/${targetWorkspaceId}/environment`
+ )
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ expect((await updateResponse).status()).toBe(200)
+ expect(updatedFingerprint).not.toBe(originalFingerprint)
+ expect(
+ await runSecretApi(page, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toMatchObject({ status: 200, fingerprint: updatedFingerprint })
+
+ await page.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click()
+ await page.getByRole('menuitem', { name: 'Delete', exact: true }).click()
+ const deleteResponse = waitForSameOriginResponse(
+ page,
+ 'DELETE',
+ `/api/workspaces/${targetWorkspaceId}/environment`
+ )
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ expect((await deleteResponse).status()).toBe(200)
+ expect(
+ await runSecretApi(page, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toMatchObject({ status: 200, present: false })
+ await expect
+ .poll(() => findEnvironmentCredential(page, targetWorkspaceId, key))
+ .toEqual({
+ status: 200,
+ id: null,
+ role: null,
+ })
+})
+
+test('workspace admin edits a secret from its detail route', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const page = await newPersonaPage(contextForPersona, 'workspaceAdminMember', credentialCleanup)
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const key = uniqueEnvironmentKey('ADMIN_DETAIL_SAVE')
+ credentialCleanup.register('admin detail secret cleanup', async () => {
+ assertStatus(
+ await runSecretApi(page, { kind: 'delete-workspace', workspaceId: targetWorkspaceId, key }),
+ 200
+ )
+ })
+ const arranged = await runSecretApi(page, {
+ kind: 'upsert-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ assertStatus(arranged, 200)
+ const credential = await findEnvironmentCredential(page, targetWorkspaceId, key)
+ expect(credential).toMatchObject({ status: 200, id: expect.any(String), role: 'admin' })
+
+ await page.goto(
+ absoluteE2eUrl(
+ `/workspace/${targetWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}`
+ )
+ )
+ await expect(page.getByText('Workspace secret', { exact: true })).toBeVisible()
+ const updatedFingerprint = await setSensitiveInput(
+ page.getByRole('textbox', { name: 'Secret value', exact: true })
+ )
+ const saveResponse = waitForSameOriginResponse(
+ page,
+ 'PUT',
+ `/api/workspaces/${targetWorkspaceId}/environment`
+ )
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ expect((await saveResponse).status()).toBe(200)
+ expect(
+ await runSecretApi(page, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toMatchObject({ status: 200, fingerprint: updatedFingerprint })
+})
+
+for (const navigationKind of ['in-app Back', 'browser popstate'] as const) {
+ test(`secret detail protects dirty state for ${navigationKind}`, async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+ }) => {
+ const page = await newPersonaPage(contextForPersona, 'workspaceAdminMember', credentialCleanup)
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const key = uniqueEnvironmentKey(`DETAIL_GUARD_${navigationKind}`)
+ credentialCleanup.register('detail guard secret cleanup', async () => {
+ assertStatus(
+ await runSecretApi(page, { kind: 'delete-workspace', workspaceId: targetWorkspaceId, key }),
+ 200
+ )
+ })
+ const arranged = await runSecretApi(page, {
+ kind: 'upsert-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ assertStatus(arranged, 200)
+ const credential = await findEnvironmentCredential(page, targetWorkspaceId, key)
+ expect(credential.id).toEqual(expect.any(String))
+ const listPath = `/workspace/${targetWorkspaceId}/settings/secrets`
+ const detailPath = `${listPath}/${encodeURIComponent(credential.id ?? '')}`
+
+ await page.goto(absoluteE2eUrl(listPath))
+ await expectWorkspaceSettingsReady(page)
+ await page.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click()
+ await page.getByRole('menuitem', { name: 'View details', exact: true }).click()
+ await expect(page).toHaveURL(absoluteE2eUrl(detailPath))
+
+ const value = page.getByRole('textbox', { name: 'Secret value', exact: true })
+ const dirtyFingerprint = await setSensitiveInput(value)
+ const attemptNavigation = async () => {
+ if (navigationKind === 'in-app Back') {
+ await page.getByRole('link', { name: 'Secrets', exact: true }).click()
+ } else {
+ await page.evaluate(() => window.history.back())
+ }
+ }
+
+ await attemptNavigation()
+ const dialog = page.getByRole('dialog', { name: 'Unsaved Changes', exact: true })
+ await expect(dialog).toBeVisible()
+ await dialog.getByRole('button', { name: 'Keep editing', exact: true }).click()
+ await expect(page).toHaveURL(absoluteE2eUrl(detailPath))
+ expect(await sensitiveInputFingerprint(value)).toBe(dirtyFingerprint)
+
+ await attemptNavigation()
+ await expect(dialog).toBeVisible()
+ await dialog.getByRole('button', { name: 'Discard Changes', exact: true }).click()
+ await expect(page).toHaveURL(absoluteE2eUrl(listPath))
+ expect(
+ await runSecretApi(page, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toMatchObject({ status: 200, fingerprint: arranged.fingerprint })
+ })
+}
+
+test('read member sees masked workspace secret and cannot mutate it', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const adminPage = await newPersonaPage(
+ contextForPersona,
+ 'workspaceAdminMember',
+ credentialCleanup
+ )
+ const readPage = await newPersonaPage(contextForPersona, 'workspaceReadMember', credentialCleanup)
+ const targetWorkspaceId = workspaceId(personaManifest)
+ const key = uniqueEnvironmentKey('READ_DENIAL')
+ credentialCleanup.register('read denial secret cleanup', async () => {
+ assertStatus(
+ await runSecretApi(adminPage, {
+ kind: 'delete-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ }),
+ 200
+ )
+ })
+ assertStatus(
+ await runSecretApi(adminPage, {
+ kind: 'upsert-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ }),
+ 200
+ )
+ const credential = await findEnvironmentCredential(adminPage, targetWorkspaceId, key)
+ expect(credential.id).toEqual(expect.any(String))
+
+ await readPage.goto(absoluteE2eUrl(`/workspace/${targetWorkspaceId}/settings/secrets`))
+ await expectWorkspaceSettingsReady(readPage)
+ const value = readPage.getByRole('textbox', {
+ name: `Workspace secret value ${key}`,
+ exact: true,
+ })
+ await expect(value).toHaveValue('••••••••••')
+ await expect(value).toHaveJSProperty('readOnly', true)
+ await readPage.getByRole('button', { name: `Secret actions for ${key}`, exact: true }).click()
+ await expect(readPage.getByRole('menuitem', { name: 'Delete', exact: true })).toHaveCount(0)
+ await readPage.keyboard.press('Escape')
+ await expect(readPage.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0)
+
+ const detailResponse = await readPage.goto(
+ absoluteE2eUrl(
+ `/workspace/${targetWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}`
+ )
+ )
+ expect(detailResponse?.status()).toBe(200)
+ await expect(readPage.getByText('Workspace secret', { exact: true })).toBeVisible()
+ const detailValue = readPage.getByRole('textbox', { name: 'Secret value', exact: true })
+ await expect(detailValue).toHaveValue('••••••••••')
+ await expect(detailValue).toHaveJSProperty('readOnly', true)
+ await expect(readPage.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0)
+
+ expect(
+ await runSecretApi(readPage, {
+ kind: 'read-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toEqual({ status: 200, present: true, withheld: true })
+ expect(
+ await runSecretApi(readPage, {
+ kind: 'upsert-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toMatchObject({ status: 403 })
+ expect(
+ await runSecretApi(readPage, {
+ kind: 'delete-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ })
+ ).toEqual({ status: 403 })
+})
+
+test('nested secret route rejects cross-workspace credential binding', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const page = await newPersonaPage(contextForPersona, 'paidOrganizationOwner', credentialCleanup)
+ const urlWorkspaceId = workspaceId(personaManifest, 'team-workspace')
+ const credentialWorkspaceId = workspaceId(personaManifest, 'team-invitation-workspace')
+ const key = uniqueEnvironmentKey('CROSS_WORKSPACE')
+ credentialCleanup.register('cross-workspace secret cleanup', async () => {
+ assertStatus(
+ await runSecretApi(page, {
+ kind: 'delete-workspace',
+ workspaceId: credentialWorkspaceId,
+ key,
+ }),
+ 200
+ )
+ })
+ assertStatus(
+ await runSecretApi(page, {
+ kind: 'upsert-workspace',
+ workspaceId: credentialWorkspaceId,
+ key,
+ }),
+ 200
+ )
+ const credential = await findEnvironmentCredential(page, credentialWorkspaceId, key)
+ expect(credential.id).toEqual(expect.any(String))
+
+ const response = await page.goto(
+ absoluteE2eUrl(
+ `/workspace/${urlWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}`
+ )
+ )
+ expect(response?.status()).toBe(404)
+})
+
+test('permission-group restriction rejects nested secret detail', async ({
+ contextForPersona,
+ credentialCleanup,
+ personaManifest,
+}) => {
+ const adminPage = await newPersonaPage(
+ contextForPersona,
+ 'enterpriseOrganizationAdmin',
+ credentialCleanup
+ )
+ const restrictedPage = await newPersonaPage(
+ contextForPersona,
+ 'permissionGroupRestricted',
+ credentialCleanup
+ )
+ const targetWorkspaceId = workspaceId(personaManifest, 'enterprise-workspace')
+ const key = uniqueEnvironmentKey('PERMISSION_GROUP_DETAIL')
+ credentialCleanup.register('permission-group secret cleanup', async () => {
+ assertStatus(
+ await runSecretApi(adminPage, {
+ kind: 'delete-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ }),
+ 200
+ )
+ })
+ assertStatus(
+ await runSecretApi(adminPage, {
+ kind: 'upsert-workspace',
+ workspaceId: targetWorkspaceId,
+ key,
+ }),
+ 200
+ )
+ const credential = await findEnvironmentCredential(adminPage, targetWorkspaceId, key)
+ expect(credential.id).toEqual(expect.any(String))
+
+ const response = await restrictedPage.goto(
+ absoluteE2eUrl(
+ `/workspace/${targetWorkspaceId}/settings/secrets/${encodeURIComponent(credential.id ?? '')}`
+ )
+ )
+ expect(response?.status()).toBe(404)
+})
+
+function assertStatus(result: { status: number }, expected: number): void {
+ expect(result.status).toBe(expected)
+}
diff --git a/apps/sim/e2e/support/leak-canary.ts b/apps/sim/e2e/support/leak-canary.ts
index 4c4f48c1864..de1d06d99ed 100644
--- a/apps/sim/e2e/support/leak-canary.ts
+++ b/apps/sim/e2e/support/leak-canary.ts
@@ -4,6 +4,27 @@ import JSZip from 'jszip'
import { writeJsonAtomic } from '../fixtures/e2e-world'
const BINARY_EXTENSIONS = new Set(['.gif', '.jpeg', '.jpg', '.mp4', '.png', '.webp'])
+const BASE64URL_TOKEN = '[A-Za-z0-9_-]{32}'
+// Keep these patterns aligned with lib/testing/credential-diagnostic-redaction.ts.
+const FORBIDDEN_CREDENTIAL_PATTERNS = [
+ {
+ kind: 'current-api-key',
+ pattern: new RegExp(`sk-sim-${BASE64URL_TOKEN}(?![A-Za-z0-9_-])`),
+ },
+ {
+ kind: 'legacy-api-key',
+ pattern: new RegExp(`sim_(?!e2e_)${BASE64URL_TOKEN}(?![A-Za-z0-9_-])`),
+ },
+ {
+ kind: 'runtime-secret',
+ pattern: new RegExp(`E2E_RUNTIME_SECRET_V1_${BASE64URL_TOKEN}(?![A-Za-z0-9_-])`),
+ },
+] as const
+
+interface ArtifactEntry {
+ path: string
+ kind: 'directory' | 'file' | 'other'
+}
interface SyntheticSecretCanary {
schemaVersion: 1
@@ -64,16 +85,24 @@ export async function assertNoSyntheticSecretLeaks(options: {
const excluded = new Set((options.excludedPaths ?? []).map((value) => path.resolve(value)))
const violations: string[] = []
- for (const root of options.roots) {
+ for (const [rootIndex, root] of options.roots.entries()) {
if (!existsSync(root)) continue
- for (const filePath of listFiles(path.resolve(root), excluded)) {
- if (BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) continue
- const contents =
- path.extname(filePath).toLowerCase() === '.zip'
- ? await readZipContents(filePath)
- : readFileSync(filePath)
- if (secrets.some((secret) => contents.includes(Buffer.from(secret)))) {
- violations.push(filePath)
+ const resolvedRoot = path.resolve(root)
+ for (const [entryIndex, entry] of listArtifactEntries(resolvedRoot, excluded).entries()) {
+ const relativeName = path.relative(resolvedRoot, entry.path) || path.basename(entry.path)
+ const records: Buffer[] = [Buffer.from(relativeName)]
+ if (entry.kind === 'file' && !BINARY_EXTENSIONS.has(path.extname(entry.path).toLowerCase())) {
+ records.push(
+ ...(path.extname(entry.path).toLowerCase() === '.zip'
+ ? await readZipRecords(entry.path)
+ : [readFileSync(entry.path)])
+ )
+ }
+ const leakKind = records
+ .map((contents) => credentialLeakKind(contents, secrets))
+ .find((kind): kind is string => Boolean(kind))
+ if (leakKind) {
+ violations.push(`artifact-${rootIndex + 1}-${entryIndex + 1}:${leakKind}`)
}
}
}
@@ -81,7 +110,7 @@ export async function assertNoSyntheticSecretLeaks(options: {
if (violations.length > 0) {
throw new Error(
`Synthetic E2E secret leaked outside private artifacts:\n${violations
- .map((filePath) => `- ${filePath}`)
+ .map((identifier) => `- ${identifier}`)
.join('\n')}`
)
}
@@ -101,15 +130,24 @@ function readSyntheticSecretCanary(filePath: string): SyntheticSecretCanary {
return parsed as SyntheticSecretCanary
}
-function listFiles(currentPath: string, excluded: ReadonlySet = new Set()): string[] {
+function listArtifactEntries(
+ currentPath: string,
+ excluded: ReadonlySet = new Set(),
+ includeCurrent = false
+): ArtifactEntry[] {
if (excluded.has(currentPath)) return []
const stats = lstatSync(currentPath)
- if (stats.isSymbolicLink()) return []
- if (stats.isFile()) return [currentPath]
- if (!stats.isDirectory()) return []
- return readdirSync(currentPath).flatMap((name) =>
- listFiles(path.join(currentPath, name), excluded)
- )
+ const kind = stats.isFile() ? 'file' : stats.isDirectory() ? 'directory' : 'other'
+ const current = includeCurrent ? [{ path: currentPath, kind } satisfies ArtifactEntry] : []
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
+ return includeCurrent ? current : [{ path: currentPath, kind }]
+ }
+ return [
+ ...current,
+ ...readdirSync(currentPath).flatMap((name) =>
+ listArtifactEntries(path.join(currentPath, name), excluded, true)
+ ),
+ ]
}
function rmPath(filePath: string): void {
@@ -117,16 +155,23 @@ function rmPath(filePath: string): void {
if (existsSync(filePath)) throw new Error(`Unscannable E2E artifact still exists: ${filePath}`)
}
-async function readZipContents(filePath: string): Promise {
+function credentialLeakKind(contents: Buffer, secrets: string[]): string | null {
+ if (secrets.some((secret) => contents.includes(Buffer.from(secret)))) return 'exact-canary'
+ const text = contents.toString('utf8')
+ return FORBIDDEN_CREDENTIAL_PATTERNS.find(({ pattern }) => pattern.test(text))?.kind ?? null
+}
+
+async function readZipRecords(filePath: string): Promise {
try {
const archive = await JSZip.loadAsync(readFileSync(filePath))
+ const entries = Object.values(archive.files)
const contents = await Promise.all(
- Object.values(archive.files)
+ entries
.filter((entry) => !entry.dir)
.map(async (entry) => Buffer.from(await entry.async('uint8array')))
)
- return Buffer.concat(contents)
+ return [...entries.map((entry) => Buffer.from(entry.name)), ...contents]
} catch {
- throw new Error(`Unable to inspect E2E diagnostic archive: ${filePath}`)
+ throw new Error('Unable to inspect an E2E diagnostic archive')
}
}
diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts
index fcba37053f8..284bc8cf3f2 100644
--- a/apps/sim/lib/credentials/access.test.ts
+++ b/apps/sim/lib/credentials/access.test.ts
@@ -74,7 +74,17 @@ describe('getCredentialActorContext', () => {
})
it('does not derive credential admin on personal env credentials', async () => {
- dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'env_personal' }], []]
+ dbState.results = [
+ [
+ {
+ id: 'c1',
+ workspaceId: 'ws',
+ type: 'env_personal',
+ envOwnerUserId: 'owner-user',
+ },
+ ],
+ [],
+ ]
mockCheckWorkspaceAccess.mockResolvedValue(workspaceAdminAccess)
const ctx = await getCredentialActorContext('c1', 'admin-user')
@@ -82,6 +92,29 @@ describe('getCredentialActorContext', () => {
expect(ctx.isAdmin).toBe(false)
})
+ it('treats a personal env credential owner as admin without a membership row', async () => {
+ dbState.results = [
+ [
+ {
+ id: 'c1',
+ workspaceId: 'ws',
+ type: 'env_personal',
+ envOwnerUserId: 'owner-user',
+ },
+ ],
+ [],
+ ]
+ mockCheckWorkspaceAccess.mockResolvedValue({
+ hasAccess: true,
+ canWrite: false,
+ canAdmin: false,
+ })
+
+ const ctx = await getCredentialActorContext('c1', 'owner-user')
+
+ expect(ctx.isAdmin).toBe(true)
+ })
+
it('is not admin for a non-admin without membership', async () => {
dbState.results = [[{ id: 'c1', workspaceId: 'ws', type: 'oauth' }], []]
mockCheckWorkspaceAccess.mockResolvedValue({
diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts
index 1fd5728982a..ee184716d03 100644
--- a/apps/sim/lib/credentials/access.ts
+++ b/apps/sim/lib/credentials/access.ts
@@ -91,11 +91,16 @@ export async function getCredentialActorContext(
)
.limit(1)
- const isAdmin = deriveCredentialAdmin({
- credentialType: credentialRow.type,
- memberRole: memberRow?.role,
- workspaceCanAdmin: workspaceAccess.canAdmin,
- })
+ // Ownership remains authoritative if a personal credential's membership row is missing.
+ const isPersonalOwner =
+ credentialRow.type === 'env_personal' && credentialRow.envOwnerUserId === userId
+ const isAdmin =
+ isPersonalOwner ||
+ deriveCredentialAdmin({
+ credentialType: credentialRow.type,
+ memberRole: memberRow?.role,
+ workspaceCanAdmin: workspaceAccess.canAdmin,
+ })
return {
credential: credentialRow,
diff --git a/apps/sim/lib/testing/credential-diagnostic-redaction.test.ts b/apps/sim/lib/testing/credential-diagnostic-redaction.test.ts
new file mode 100644
index 00000000000..ccd1c68de45
--- /dev/null
+++ b/apps/sim/lib/testing/credential-diagnostic-redaction.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { redactCredentialDiagnostic } from './credential-diagnostic-redaction'
+
+describe('redactCredentialDiagnostic', () => {
+ it('redacts complete credential values without removing surrounding diagnostics', () => {
+ const token = Array.from({ length: 32 }, (_, index) =>
+ String.fromCharCode('A'.charCodeAt(0) + (index % 26))
+ ).join('')
+ const diagnostic = [
+ `input value: ${['sk', 'sim'].join('-')}-${token}`,
+ `legacy value: ${['sim', ''].join('_')}${token}`,
+ `runtime value: ${['E2E', 'RUNTIME', 'SECRET', 'V1'].join('_')}_${token}`,
+ ].join('\n')
+
+ expect(redactCredentialDiagnostic(diagnostic)).toBe(
+ [
+ 'input value: [credential-redacted]',
+ 'legacy value: [credential-redacted]',
+ 'runtime value: [credential-redacted]',
+ ].join('\n')
+ )
+ })
+
+ it('does not redact masked, partial, or bounded non-credentials', () => {
+ const token = 'A'.repeat(32)
+ const diagnostic = [
+ 'sk-sim-••••••••',
+ `sk-sim-${token.slice(0, 31)}`,
+ `sk-sim-${token}A`,
+ `sim_e2e_${token}`,
+ ].join('\n')
+ expect(redactCredentialDiagnostic(diagnostic)).toBe(diagnostic)
+ expect(redactCredentialDiagnostic(undefined)).toBeUndefined()
+ })
+})
diff --git a/apps/sim/lib/testing/credential-diagnostic-redaction.ts b/apps/sim/lib/testing/credential-diagnostic-redaction.ts
new file mode 100644
index 00000000000..908773b73ad
--- /dev/null
+++ b/apps/sim/lib/testing/credential-diagnostic-redaction.ts
@@ -0,0 +1,14 @@
+// Keep these patterns aligned with e2e/support/leak-canary.ts.
+const CREDENTIAL_PATTERNS = [
+ /sk-sim-[A-Za-z0-9_-]{32}(?![A-Za-z0-9_-])/g,
+ /sim_(?!e2e_)[A-Za-z0-9_-]{32}(?![A-Za-z0-9_-])/g,
+ /E2E_RUNTIME_SECRET_V1_[A-Za-z0-9_-]{32}(?![A-Za-z0-9_-])/g,
+] as const
+
+export function redactCredentialDiagnostic(value: string | undefined): string | undefined {
+ if (value === undefined) return undefined
+ return CREDENTIAL_PATTERNS.reduce(
+ (redacted, pattern) => redacted.replace(pattern, '[credential-redacted]'),
+ value
+ )
+}
diff --git a/apps/sim/playwright.config.ts b/apps/sim/playwright.config.ts
index 9432b4c9063..a47e69cd9bd 100644
--- a/apps/sim/playwright.config.ts
+++ b/apps/sim/playwright.config.ts
@@ -54,10 +54,22 @@ export default defineConfig({
dependencies: ['hosted-billing-chromium-navigation'],
workers: 2,
},
+ {
+ name: 'hosted-billing-chromium-credentials',
+ testMatch: '**/settings/credentials/**/*.spec.ts',
+ dependencies: ['hosted-billing-chromium-authorization'],
+ fullyParallel: false,
+ workers: 1,
+ use: {
+ trace: 'off',
+ screenshot: 'off',
+ video: 'off',
+ },
+ },
{
name: 'hosted-billing-chromium-workflows',
testMatch: ['**/settings/smoke/authenticated.spec.ts', '**/settings/workflows/**/*.spec.ts'],
- dependencies: ['hosted-billing-chromium-authorization'],
+ dependencies: ['hosted-billing-chromium-credentials'],
fullyParallel: false,
workers: 1,
},