From 71ba55852245a4161bffceb23afc1183024caecf Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 4 Aug 2026 06:15:45 -0700 Subject: [PATCH] fix(secrets): store ops-created secrets encrypted via createSecret (security) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ops 'Add Secret' modal wrote the secret value straight to the DB from the browser in PLAINTEXT — despite the 'Stored encrypted at rest' label — because the AES key is server-only. Route it through the createSecret server action, which encrypts the value (AES-256-GCM), enforces the admin/board gate, validates input, and logs the activity. Existing plaintext rows (encryption_version 0) still reveal correctly; new secrets are encrypted when a key is configured. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(app)/ops/components/secret-modal.tsx | 27 +++++++++-------------- app/(app)/ops/ops-client.tsx | 1 - 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/app/(app)/ops/components/secret-modal.tsx b/app/(app)/ops/components/secret-modal.tsx index bba8c7d..e84a365 100644 --- a/app/(app)/ops/components/secret-modal.tsx +++ b/app/(app)/ops/components/secret-modal.tsx @@ -1,9 +1,8 @@ 'use client' import { useState } from 'react' -import { createClient } from '@/lib/supabase/client' +import { createSecret } from '@/lib/actions' import { toast } from 'sonner' -import type { TablesInsert } from '@/types/database' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import type { Secret } from '../types' @@ -11,11 +10,9 @@ import type { Secret } from '../types' export function AddSecretModal({ onClose, onSaved, - spaceId, }: { onClose: () => void onSaved: (s: Secret) => void - spaceId: string }) { const [title, setTitle] = useState('') const [area, setArea] = useState('') @@ -26,22 +23,20 @@ export function AddSecretModal({ e.preventDefault() if (!title.trim() || !value.trim()) return setSaving(true) - const supabase = createClient() - const { data, error } = await supabase.from('secrets').insert({ - space_id: spaceId, + // Use the createSecret server action, which AES-encrypts the value at rest + // (and applies the admin/board authz gate + Zod validation + activity log). + // The previous direct browser insert wrote the value in PLAINTEXT — despite + // the "Stored encrypted at rest" promise — since the encryption key is + // server-only and never reaches the browser. + const result = await createSecret({ title: title.trim(), - label: title.trim(), - area: area.trim() || null, value: value.trim(), - // `satisfies` keeps the payload type-checked; the trailing `as never` - // only bridges the typed browser client whose .insert() generic collapses - // to `never`. - // TODO(types): remove after regenerating types/database.ts (missing FK relationship metadata) - } satisfies TablesInsert<'secrets'> as never).select('id, title, area, created_at').single() + area: area.trim() || undefined, + }) setSaving(false) - if (error) { toast.error(error.message); return } + if (result.error) { toast.error(result.error); return } toast.success('Secret saved') - onSaved(data as Secret) + onSaved(result.data as Secret) onClose() } diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx index f3a48ca..68e65aa 100644 --- a/app/(app)/ops/ops-client.tsx +++ b/app/(app)/ops/ops-client.tsx @@ -208,7 +208,6 @@ export function OpsClient({ spaceId, kbEntries: initial, areaLeads: initialLeads setShowSecretModal(false)} onSaved={s => setSecrets(prev => [s, ...prev])} - spaceId={spaceId} /> )} {showLeadModal && (