diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 5fe0f4add09..ec78330932a 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -18,6 +18,7 @@ import { } from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { accessError, checkAccess, @@ -154,8 +155,20 @@ export const PATCH = withRouteHandler( const flag = TABLE_LOCK_FLAGS[kind] return validated.locks?.[flag] === true && !table.locks[flag] }) - if (enablesALock && !(await isFeatureEnabled('table-locks'))) { - return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 }) + if (enablesALock) { + // Resolve with the same context the page uses to decide whether to + // show the panel — keyed on the workspace's host organization, not + // the viewer's active one. Without it an org- or user-targeted + // rollout would open the panel and then 403 on save. Looked up only + // on the enabling path, so an unlock never pays for it. + const workspace = await getWorkspaceWithOwner(table.workspaceId) + const enabled = await isFeatureEnabled('table-locks', { + userId: authResult.userId, + orgId: workspace?.organizationId ?? undefined, + }) + if (!enabled) { + return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 }) + } } const adminResult = await checkAccess(tableId, authResult.userId, 'admin') if (!adminResult.ok) { @@ -164,7 +177,7 @@ export const PATCH = withRouteHandler( { status: 403 } ) } - await updateTableLocks(tableId, validated.locks, authResult.userId, requestId) + await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request) } if (validated.name !== undefined) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts index 359bcd25178..f87a5bdbcd1 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -60,10 +60,16 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string if (locked.length === LOCK_FIELDS.length) { return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' } } - // Only the exact three-lock shape is Append-only — with the schema locked too - // the chip would claim columns are mutable when they aren't. - if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked && !locks.schemaLocked) { - return { name: 'Append-only', detail: 'rows can be added, but not edited or deleted.' } + // Append-only describes the row semantics — adding is the only thing left. + // A schema lock on top doesn't change that, so it keeps the name and is + // called out in the detail rather than demoted to the generic case. + if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) { + return { + name: 'Append-only', + detail: locks.schemaLocked + ? 'rows can be added, but not edited or deleted, and columns are locked.' + : 'rows can be added, but not edited or deleted.', + } } return { name: 'Locked', detail: `${locked.join(', ')} locked.` } } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 9f3c382c3ff..c35d081c954 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,5 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading' import { Table } from './table' @@ -7,15 +10,35 @@ export const metadata: Metadata = { title: 'Table', } +interface TablePageProps { + params: Promise<{ workspaceId: string }> +} + /** * Table-detail page entry. `Table` reads URL query params via nuqs (which uses * `useSearchParams` internally), so it must sit under a Suspense boundary. The * fallback renders the real chrome so a suspend never shows a blank frame. + * + * The lock flag is resolved here rather than mirrored into a `NEXT_PUBLIC_` var: + * gating lives in AppConfig, which has no client counterpart, so resolving it + * server-side is the only way its org/user clauses reach the UI. The org is the + * workspace's host organization, not the viewer's active one — the same key the + * PATCH gate uses, so the panel can't open onto a Save that 403s. Both + * `getSession` and the host context are request-memoized, so this reuses the + * layout's reads. */ -export default function TablePage() { +export default async function TablePage({ params }: TablePageProps) { + const [{ workspaceId }, session] = await Promise.all([params, getSession()]) + const userId = session?.user?.id + const host = userId ? await getWorkspaceHostContextForViewer(workspaceId, userId) : null + const tableLocksEnabled = await isFeatureEnabled('table-locks', { + userId, + orgId: host?.hostOrganizationId ?? undefined, + }) + return ( }> - +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 006c74c8307..1c0daa5fed7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -8,7 +8,6 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode } from '@/lib/api/contracts/tables' -import { getEnv, isTruthy } from '@/lib/core/config/env' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, @@ -78,14 +77,6 @@ import { generateColumnName } from './utils' const logger = createLogger('Table') -/** - * Client mirror of the server's `table-locks` flag. Hides the settings entry - * point when locks can't be set, so the panel never opens onto a Save that - * 403s. An already-locked table still shows its state regardless, so the - * gating stays legible if the flag is turned off after locks were applied. - */ -const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS')) - /** Blocked-action toasts carry a button, so they linger past the 5s default. */ const BLOCKED_TOAST_MS = 8000 @@ -96,6 +87,13 @@ interface TableProps { /** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */ workspaceId?: string tableId?: string + /** + * Whether an admin may CHANGE locks, resolved server-side by the page (the + * flag's gating lives in AppConfig and has no client counterpart). Defaults + * to false so embedded renders, which have no server resolution, fail closed + * — enforcement of stored locks is unaffected either way. + */ + tableLocksEnabled?: boolean } /** @@ -152,6 +150,7 @@ export function Table({ embedded, workspaceId: propWorkspaceId, tableId: propTableId, + tableLocksEnabled = false, }: TableProps = {}) { const params = useParams() const router = useRouter() @@ -644,16 +643,15 @@ export function Table({ const headerActions = useMemo(() => { if (!tableData) return undefined + // Header space is for state, not for settings: the chip appears only once + // something is actually locked, and names the mode so it reads at a glance. + // Reaching the panel on an unlocked table is the dropdown's job. const anyLocked = lockedNouns(tableData.locks).length > 0 - // Name the mode when locked so the state is legible on open; admins get the - // entry point on an unlocked table only where the feature is enabled — - // otherwise the panel opens and Save 403s against the server-side gate. - const lockLabel = anyLocked ? describeLocks(tableData.locks).name : 'Lock settings' return [ - ...(anyLocked || (userPermissions.canAdmin && tableLocksEnabled) + ...(anyLocked ? [ { - label: lockLabel, + label: describeLocks(tableData.locks).name, icon: Lock, onClick: () => userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'), diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5c135e7d12e..76b87384171 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -572,7 +572,6 @@ export const env = createEnv({ NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted - NEXT_PUBLIC_TABLE_LOCKS: z.boolean().optional(), // Surface the per-table mutation-lock settings UI (client mirror of the server-side TABLE_LOCKS gate) NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements) @@ -614,7 +613,6 @@ export const env = createEnv({ NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED, NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED, NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED, - NEXT_PUBLIC_TABLE_LOCKS: process.env.NEXT_PUBLIC_TABLE_LOCKS, NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED, NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED, NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 3a76842f2ed..ef55b56ca55 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -27,6 +27,8 @@ import type { DbTransaction } from '@/lib/table/planner' import { setTableTxTimeouts } from '@/lib/table/tx' import { type CreateTableData, + TABLE_LOCK_FLAGS, + TABLE_LOCK_KINDS, type TableDefinition, type TableLocks, type TableMetadata, @@ -627,9 +629,13 @@ export async function updateTableLocks( tableId: string, partial: Partial, actingUserId: string, - requestId: string + requestId: string, + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: { headers: { get(name: string): string | null } } ): Promise { + let previousLocks: TableLocks = UNLOCKED_TABLE_LOCKS const updated = await withLockedTable(tableId, async (table, trx) => { + previousLocks = table.locks const nextLocks: TableLocks = { ...table.locks, ...partial } const now = new Date() await trx @@ -639,6 +645,18 @@ export async function updateTableLocks( return { ...table, locks: nextLocks, updatedAt: now } }) + // Name the transitions in the description so the audit list is readable + // without expanding metadata — "who locked my production table" is the + // question this feature exists to answer. + const flipped = TABLE_LOCK_KINDS.filter( + (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== updated.locks[TABLE_LOCK_FLAGS[kind]] + ) + const description = flipped.length + ? `Table locks changed: ${flipped + .map((kind) => `${kind} ${updated.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`) + .join(', ')}` + : 'Updated table locks (no change)' + recordAudit({ workspaceId: updated.workspaceId, actorId: actingUserId, @@ -646,8 +664,9 @@ export async function updateTableLocks( resourceType: AuditResourceType.TABLE, resourceId: tableId, resourceName: updated.name, - description: 'Updated table locks', - metadata: { op: 'update_locks', locks: updated.locks }, + description, + metadata: { op: 'update_locks', before: previousLocks, after: updated.locks }, + ...(request ? { request } : {}), }) await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => {