fix(secrets): honor ops.secrets.read permission for reveal/list

A regular member granted reveal access either via the ops.secrets.read
role permission or a per-secret ops_acl entry could not actually see or
reveal secrets: revealSecret hard-gated on built-in role admin/board
(ignoring both the permission and the ACL), and secrets_select RLS never
consulted the permission, and the Ops page short-circuited the secrets
fetch by built-in role.

Migration 031 additively adds an OR user_has_permission(...,
'ops.secrets.read') branch to secrets_select; the admin/board and
per-secret ops_acl branches are unchanged so deployment is access-neutral
unless an admin has granted the permission. revealSecret now resolves
the member and lets RLS be the boundary (a denied row simply does not
return). The Ops page always queries secrets (RLS filters the set) and
shows the section to admin/board, a permission holder, or anyone with an
ACL-granted secret. Write paths (create/update/delete) are unchanged.
Mirrored in schema.sql; migration docs and HANDOFF updated.
This commit is contained in:
Moheeb Zara
2026-05-16 19:07:06 -07:00
parent b2ac0e0271
commit 2687c1578d
7 changed files with 74 additions and 15 deletions
+9 -4
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { ADMIN_ROLES } from '@/lib/permissions'
import { createSecretSchema, updateSecretSchema, uuidSchema } from '@/lib/validations'
import { encryptSecret, decryptSecret, encryptionAvailable } from '@/lib/secrets/crypto'
@@ -110,7 +110,11 @@ export async function revealSecret(secretId: string) {
if (!idCheck.ok) return { error: 'Invalid secret ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, SECRETS_RW_ROLES, 'Board or admin access required')
// RLS (secrets_select) is the authority: admin/board, OR the
// ops.secrets.read role permission, OR a per-secret ops_acl entry. We no
// longer pre-block on built-in role here, because that ignored both the
// permission and the ACL path. A denied row simply does not come back.
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: { user } } = await supabase.auth.getUser()
@@ -120,9 +124,10 @@ export async function revealSecret(secretId: string) {
.select('id, encryption_version, encrypted_value, value, title')
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
.single()
.maybeSingle()
if (error || !data) return { error: error?.message ?? 'Not found' }
if (error) return { error: error.message }
if (!data) return { error: 'You do not have access to this secret.' }
// A row marked encrypted (version > 0) MUST have ciphertext. If it does
// not, the row is corrupt: do not silently fall back to the (empty/stale)