fix(secrets): store ops-created secrets encrypted via createSecret (security)

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) <[email protected]>
This commit is contained in:
Moheeb Zara
2026-08-04 06:15:45 -07:00
co-authored by Claude Opus 4.8
parent 9100532bef
commit 71ba558522
2 changed files with 11 additions and 17 deletions
+11 -16
View File
@@ -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()
}
-1
View File
@@ -208,7 +208,6 @@ export function OpsClient({ spaceId, kbEntries: initial, areaLeads: initialLeads
<AddSecretModal
onClose={() => setShowSecretModal(false)}
onSaved={s => setSecrets(prev => [s, ...prev])}
spaceId={spaceId}
/>
)}
{showLeadModal && (