notifications(P4f): audit fixes, lock down 047 + best-effort enqueue paths

Two findings from an ultrathink audit of the Phase 4 work.

(1) members_with_permission was callable by any PostgREST client. Postgres
grants EXECUTE on new functions to PUBLIC by default, and this codebase
has no existing REVOKE pattern. The function returns a SET of member_ids
for any (space, permission) pair, so without a lockdown ANY authenticated
caller could enumerate "who has permission X in space Y" for spaces they
are not even members of, learning member_ids and the permission
distribution. user_has_permission has a narrower exposure (per-uid
boolean) and is needed by anon/authenticated for RLS-policy evaluation,
so it stays callable; the new function is locked to service_role since
the only legitimate caller (the form-submission fan-out) goes through
the admin client. Migration 047 + schema.sql now REVOKE EXECUTE FROM
PUBLIC and GRANT TO service_role.

(2) The Phase 4 enqueue blocks in equipment.ts (reserve, cancel) and
classes.ts (signUp, cancelMySignup waitlist promotion, updateSession
session-cancellation fan-out) used the shared helpers, but the helpers
themselves were not internally best-effort. The Stripe webhook's
original enqueueDues wrapped its entire body in try/catch so a thrown
member-lookup error could never bubble into the money path; my refactor
removed that inner wrap and relied on enqueueNotification's
best-effort. resolveMemberContact and getSpaceName could still throw on
a transient supabase network error, which would surface to the action
return value after the underlying mutation already committed: the
client would see an error even though their booking, signup, or
session-cancellation succeeded. Fixed at both layers: the helpers now
swallow throws internally (returning null / empty), and each call site
in equipment/classes is wrapped in try/catch around the lookup chain so
secondary action-specific queries (equipment name+location lookup,
promoted-signup session lookup, session-cancellation signup fan-out)
also cannot leak. forms.submitForm already had the wrap from P4d.

2 new helper tests pin the best-effort behavior; suite 567, build
clean.
This commit is contained in:
Moheeb Zara
2026-05-19 16:12:03 -07:00
parent 6abac53380
commit bd8f22a3ff
6 changed files with 260 additions and 181 deletions
+20 -3
View File
@@ -15,6 +15,7 @@ function mockAdmin(opts: {
selectResult?: { data: Row | null; error?: { message: string } | null } selectResult?: { data: Row | null; error?: { message: string } | null }
upsertResult?: { error: { message: string } | null } upsertResult?: { error: { message: string } | null }
throwOnUpsert?: boolean throwOnUpsert?: boolean
throwOnMaybeSingle?: boolean
}) { }) {
const upsert = vi.fn(async () => { const upsert = vi.fn(async () => {
if (opts.throwOnUpsert) throw new Error('boom') if (opts.throwOnUpsert) throw new Error('boom')
@@ -24,9 +25,10 @@ function mockAdmin(opts: {
const builder = { const builder = {
select: vi.fn().mockReturnThis(), select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn(async () => maybeSingle: vi.fn(async () => {
opts.selectResult ?? { data: null, error: null }, if (opts.throwOnMaybeSingle) throw new Error('network down')
), return opts.selectResult ?? { data: null, error: null }
}),
upsert, upsert,
} }
const from = vi.fn(() => builder) const from = vi.fn(() => builder)
@@ -62,6 +64,14 @@ describe('resolveMemberContact', () => {
const r = await resolveMemberContact(admin as never, 'space-1', 'member-1') const r = await resolveMemberContact(admin as never, 'space-1', 'member-1')
expect(r).toEqual({ email: null, displayName: null }) expect(r).toEqual({ email: null, displayName: null })
}) })
it('returns null and never throws when the lookup itself throws (best-effort)', async () => {
const admin = mockAdmin({ throwOnMaybeSingle: true })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(resolveMemberContact(admin as never, 'space-1', 'member-1')).resolves.toBeNull()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
}) })
describe('enqueueNotification', () => { describe('enqueueNotification', () => {
@@ -155,4 +165,11 @@ describe('getSpaceName', () => {
const admin = mockAdmin({ selectResult: { data: null, error: null } }) const admin = mockAdmin({ selectResult: { data: null, error: null } })
expect(await getSpaceName(admin as never, 'ghost')).toBe('') expect(await getSpaceName(admin as never, 'ghost')).toBe('')
}) })
it('returns empty string when the lookup itself throws (best-effort)', async () => {
const admin = mockAdmin({ throwOnMaybeSingle: true })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(getSpaceName(admin as never, 'space-1')).resolves.toBe('')
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
}) })
+128 -112
View File
@@ -318,59 +318,65 @@ export async function updateSession(input: unknown) {
// to 'cancelled', every signed-up member needs to know (the only other // to 'cancelled', every signed-up member needs to know (the only other
// place that lives is /me, and they probably aren't checking it). One // place that lives is /me, and they probably aren't checking it). One
// outbox row per (session, member); dedupe collapses a re-cancel. // outbox row per (session, member); dedupe collapses a re-cancel.
// Wrapped: the session update is committed; transient DB errors in the
// fan-out must never surface to the action's result.
if (u.status === 'cancelled') { if (u.status === 'cancelled') {
const admin = createAdminClient() try {
const { data: sessRow } = await admin const admin = createAdminClient()
.from('class_sessions') const { data: sessRow } = await admin
.select('starts_at, ends_at, location, classes(title)') .from('class_sessions')
.eq('id', u.sessionId) .select('starts_at, ends_at, location, classes(title)')
.eq('space_id', member.space_id) .eq('id', u.sessionId)
.maybeSingle() .eq('space_id', member.space_id)
const sessShape = sessRow as { .maybeSingle()
starts_at: string | null const sessShape = sessRow as {
ends_at: string | null starts_at: string | null
location: string | null ends_at: string | null
classes?: { title: string | null } | null location: string | null
} | null classes?: { title: string | null } | null
const { data: signups } = await admin } | null
.from('class_signups') const { data: signups } = await admin
.select('member_id') .from('class_signups')
.eq('space_id', member.space_id) .select('member_id')
.eq('session_id', u.sessionId) .eq('space_id', member.space_id)
.neq('status', 'cancelled') .eq('session_id', u.sessionId)
const memberIds = Array.from( .neq('status', 'cancelled')
new Set(((signups ?? []) as Array<{ member_id: string }>).map(s => s.member_id)), const memberIds = Array.from(
) new Set(((signups ?? []) as Array<{ member_id: string }>).map(s => s.member_id)),
if (memberIds.length > 0) { )
const spaceName = await getSpaceName(admin, member.space_id) if (memberIds.length > 0) {
const manageUrl = buildManageUrl(null) const spaceName = await getSpaceName(admin, member.space_id)
for (const affectedMemberId of memberIds) { const manageUrl = buildManageUrl(null)
const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId) for (const affectedMemberId of memberIds) {
if (!contact?.email) continue const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId)
const { subject, html, text } = renderClassEmail({ if (!contact?.email) continue
type: 'class_session_cancelled', const { subject, html, text } = renderClassEmail({
spaceName, type: 'class_session_cancelled',
memberName: contact.displayName, spaceName,
className: sessShape?.classes?.title ?? '', memberName: contact.displayName,
location: sessShape?.location ?? null, className: sessShape?.classes?.title ?? '',
startsAt: sessShape?.starts_at ?? null, location: sessShape?.location ?? null,
endsAt: sessShape?.ends_at ?? null, startsAt: sessShape?.starts_at ?? null,
manageUrl, endsAt: sessShape?.ends_at ?? null,
}) manageUrl,
await enqueueNotification(admin, { })
spaceId: member.space_id, await enqueueNotification(admin, {
memberId: affectedMemberId, spaceId: member.space_id,
type: 'class_session_cancelled',
recipient: contact.email,
subject,
bodyHtml: html,
bodyText: text,
dedupeKey: classDedupeKey('class_session_cancelled', {
sessionId: u.sessionId,
memberId: affectedMemberId, memberId: affectedMemberId,
}), type: 'class_session_cancelled',
}) recipient: contact.email,
subject,
bodyHtml: html,
bodyText: text,
dedupeKey: classDedupeKey('class_session_cancelled', {
sessionId: u.sessionId,
memberId: affectedMemberId,
}),
})
}
} }
} catch (e) {
console.error('[updateSession] class_session_cancelled fan-out failed:', e instanceof Error ? e.message : e)
} }
} }
@@ -603,7 +609,8 @@ export async function signUpForClass(input: unknown) {
// Signup confirmation to the affected member (target, not actor): a manager // Signup confirmation to the affected member (target, not actor): a manager
// signing someone else up still emails the booked-for member. Registered // signing someone else up still emails the booked-for member. Registered
// and waitlisted use different copy; the type derives from the RPC's // and waitlisted use different copy; the type derives from the RPC's
// signup_status. Best-effort: never throws into this action. // signup_status. Wrapped: the signup is already written by the RPC, so the
// email path must never surface an error to the action's result.
const signupType = const signupType =
status === 'registered' status === 'registered'
? ('class_signup_registered' as const) ? ('class_signup_registered' as const)
@@ -611,28 +618,32 @@ export async function signUpForClass(input: unknown) {
? ('class_signup_waitlisted' as const) ? ('class_signup_waitlisted' as const)
: null : null
if (signupType) { if (signupType) {
const contact = await resolveMemberContact(admin, member.space_id, targetMemberId) try {
if (contact?.email) { const contact = await resolveMemberContact(admin, member.space_id, targetMemberId)
const { subject, html, text } = renderClassEmail({ if (contact?.email) {
type: signupType, const { subject, html, text } = renderClassEmail({
spaceName: await getSpaceName(admin, member.space_id), type: signupType,
memberName: contact.displayName, spaceName: await getSpaceName(admin, member.space_id),
className: cls?.title ?? '', memberName: contact.displayName,
location: (session.location as string | null) ?? null, className: cls?.title ?? '',
startsAt: session.starts_at as string, location: (session.location as string | null) ?? null,
endsAt: (session.ends_at as string | null) ?? null, startsAt: session.starts_at as string,
manageUrl: buildManageUrl(null), endsAt: (session.ends_at as string | null) ?? null,
}) manageUrl: buildManageUrl(null),
await enqueueNotification(admin, { })
spaceId: member.space_id, await enqueueNotification(admin, {
memberId: targetMemberId, spaceId: member.space_id,
type: signupType, memberId: targetMemberId,
recipient: contact.email, type: signupType,
subject, recipient: contact.email,
bodyHtml: html, subject,
bodyText: text, bodyHtml: html,
dedupeKey: classDedupeKey(signupType, { signupId: row.signup_id }), bodyText: text,
}) dedupeKey: classDedupeKey(signupType, { signupId: row.signup_id }),
})
}
} catch (e) {
console.error(`[signUpForClass] ${signupType} enqueue failed:`, e instanceof Error ? e.message : e)
} }
} }
@@ -673,47 +684,52 @@ export async function cancelMySignup(input: unknown) {
// Waitlist promotion: someone else was bumped from waitlist into the // Waitlist promotion: someone else was bumped from waitlist into the
// session by this cancel. Tell them, since the only place that information // session by this cancel. Tell them, since the only place that information
// lives otherwise is /me. Best-effort: never throws into this action. // lives otherwise is /me. Wrapped: cancel + promotion are already committed
// by class_cancel_tx; the email path must never surface an error.
if (row.promoted_id) { if (row.promoted_id) {
const { data: promo } = await admin try {
.from('class_signups') const { data: promo } = await admin
.select( .from('class_signups')
'id, member_id, class_sessions(starts_at, ends_at, location, classes(title))', .select(
) 'id, member_id, class_sessions(starts_at, ends_at, location, classes(title))',
.eq('id', row.promoted_id) )
.eq('space_id', member.space_id) .eq('id', row.promoted_id)
.maybeSingle() .eq('space_id', member.space_id)
const promotedMemberId = (promo?.member_id as string | null) ?? null .maybeSingle()
const promoSession = (promo as { class_sessions?: { const promotedMemberId = (promo?.member_id as string | null) ?? null
starts_at: string | null const promoSession = (promo as { class_sessions?: {
ends_at: string | null starts_at: string | null
location: string | null ends_at: string | null
classes?: { title: string | null } | null location: string | null
} | null } | null)?.class_sessions classes?: { title: string | null } | null
if (promotedMemberId) { } | null } | null)?.class_sessions
const contact = await resolveMemberContact(admin, member.space_id, promotedMemberId) if (promotedMemberId) {
if (contact?.email) { const contact = await resolveMemberContact(admin, member.space_id, promotedMemberId)
const { subject, html, text } = renderClassEmail({ if (contact?.email) {
type: 'class_signup_promoted', const { subject, html, text } = renderClassEmail({
spaceName: await getSpaceName(admin, member.space_id), type: 'class_signup_promoted',
memberName: contact.displayName, spaceName: await getSpaceName(admin, member.space_id),
className: promoSession?.classes?.title ?? '', memberName: contact.displayName,
location: promoSession?.location ?? null, className: promoSession?.classes?.title ?? '',
startsAt: promoSession?.starts_at ?? null, location: promoSession?.location ?? null,
endsAt: promoSession?.ends_at ?? null, startsAt: promoSession?.starts_at ?? null,
manageUrl: buildManageUrl(null), endsAt: promoSession?.ends_at ?? null,
}) manageUrl: buildManageUrl(null),
await enqueueNotification(admin, { })
spaceId: member.space_id, await enqueueNotification(admin, {
memberId: promotedMemberId, spaceId: member.space_id,
type: 'class_signup_promoted', memberId: promotedMemberId,
recipient: contact.email, type: 'class_signup_promoted',
subject, recipient: contact.email,
bodyHtml: html, subject,
bodyText: text, bodyHtml: html,
dedupeKey: classDedupeKey('class_signup_promoted', { signupId: row.promoted_id }), bodyText: text,
}) dedupeKey: classDedupeKey('class_signup_promoted', { signupId: row.promoted_id }),
})
}
} }
} catch (e) {
console.error('[cancelMySignup] class_signup_promoted enqueue failed:', e instanceof Error ? e.message : e)
} }
} }
+65 -52
View File
@@ -311,29 +311,36 @@ export async function reserveEquipment(input: unknown) {
// Booking confirmation goes to the affected member (target), not the actor. // Booking confirmation goes to the affected member (target), not the actor.
// A manager booking on behalf of someone else still emails the booked-for // A manager booking on behalf of someone else still emails the booked-for
// member. Best-effort: the enqueue helper never throws into this action. // member. Wrapped: the reservation row is already committed, so a transient
const contact = await resolveMemberContact(admin, member.space_id, targetMemberId) // DB error in the email path must NOT surface as an action error to the
if (contact?.email) { // client. enqueueNotification is internally best-effort; the wrap covers
const { subject, html, text } = renderBookingEmail({ // resolveMemberContact, getSpaceName, and any other lookup that could throw.
type: 'booking_confirmed', try {
spaceName: await getSpaceName(admin, member.space_id), const contact = await resolveMemberContact(admin, member.space_id, targetMemberId)
memberName: contact.displayName, if (contact?.email) {
equipmentName: (equip.name as string | null) ?? 'equipment', const { subject, html, text } = renderBookingEmail({
location: (equip.location as string | null) ?? null, type: 'booking_confirmed',
startsAt: r.starts_at, spaceName: await getSpaceName(admin, member.space_id),
endsAt: r.ends_at, memberName: contact.displayName,
manageUrl: buildManageUrl(null), equipmentName: (equip.name as string | null) ?? 'equipment',
}) location: (equip.location as string | null) ?? null,
await enqueueNotification(admin, { startsAt: r.starts_at,
spaceId: member.space_id, endsAt: r.ends_at,
memberId: targetMemberId, manageUrl: buildManageUrl(null),
type: 'booking_confirmed', })
recipient: contact.email, await enqueueNotification(admin, {
subject, spaceId: member.space_id,
bodyHtml: html, memberId: targetMemberId,
bodyText: text, type: 'booking_confirmed',
dedupeKey: bookingDedupeKey('booking_confirmed', data.id as string), recipient: contact.email,
}) subject,
bodyHtml: html,
bodyText: text,
dedupeKey: bookingDedupeKey('booking_confirmed', data.id as string),
})
}
} catch (e) {
console.error('[reserveEquipment] booking_confirmed enqueue failed:', e instanceof Error ? e.message : e)
} }
revalidatePath('/equipment') revalidatePath('/equipment')
@@ -382,36 +389,42 @@ export async function cancelReservation(input: unknown) {
// Cancel emails fire ONLY when someone other than the affected member // Cancel emails fire ONLY when someone other than the affected member
// cancelled (a manager cancelling on the member's behalf). A self-cancel is // cancelled (a manager cancelling on the member's behalf). A self-cancel is
// silent: the actor already saw the UI confirm and an email would be noise. // silent: the actor already saw the UI confirm and an email would be noise.
// Wrapped: cancel is committed; transient errors in the email path must not
// surface to the client.
if (!ownIt) { if (!ownIt) {
const affectedMemberId = res.member_id as string try {
const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId) const affectedMemberId = res.member_id as string
if (contact?.email) { const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId)
const { data: equip } = await admin if (contact?.email) {
.from('equipment') const { data: equip } = await admin
.select('name, location') .from('equipment')
.eq('id', res.equipment_id as string) .select('name, location')
.eq('space_id', member.space_id) .eq('id', res.equipment_id as string)
.maybeSingle() .eq('space_id', member.space_id)
const { subject, html, text } = renderBookingEmail({ .maybeSingle()
type: 'booking_cancelled', const { subject, html, text } = renderBookingEmail({
spaceName: await getSpaceName(admin, member.space_id), type: 'booking_cancelled',
memberName: contact.displayName, spaceName: await getSpaceName(admin, member.space_id),
equipmentName: (equip?.name as string | null) ?? 'equipment', memberName: contact.displayName,
location: (equip?.location as string | null) ?? null, equipmentName: (equip?.name as string | null) ?? 'equipment',
startsAt: res.starts_at as string, location: (equip?.location as string | null) ?? null,
endsAt: res.ends_at as string, startsAt: res.starts_at as string,
manageUrl: buildManageUrl(null), endsAt: res.ends_at as string,
}) manageUrl: buildManageUrl(null),
await enqueueNotification(admin, { })
spaceId: member.space_id, await enqueueNotification(admin, {
memberId: affectedMemberId, spaceId: member.space_id,
type: 'booking_cancelled', memberId: affectedMemberId,
recipient: contact.email, type: 'booking_cancelled',
subject, recipient: contact.email,
bodyHtml: html, subject,
bodyText: text, bodyHtml: html,
dedupeKey: bookingDedupeKey('booking_cancelled', v.data.reservationId), bodyText: text,
}) dedupeKey: bookingDedupeKey('booking_cancelled', v.data.reservationId),
})
}
} catch (e) {
console.error('[cancelReservation] booking_cancelled enqueue failed:', e instanceof Error ? e.message : e)
} }
} }
+28 -14
View File
@@ -22,22 +22,29 @@ export type MemberContact = {
// Look up a member's email + display_name, scoped to the given space. Used // Look up a member's email + display_name, scoped to the given space. Used
// by every enqueue site to derive a recipient from a member id. Returns null // by every enqueue site to derive a recipient from a member id. Returns null
// if the member is not in that space (cross-tenant safety: the (id, space_id) // if the member is not in that space (cross-tenant safety: the (id, space_id)
// filter pins the lookup). // filter pins the lookup) OR if the lookup itself fails (best-effort: never
// throws into the calling action, so a transient DB error cannot turn a
// successful domain mutation into a client-visible error).
export async function resolveMemberContact( export async function resolveMemberContact(
admin: AdminClient, admin: AdminClient,
spaceId: string, spaceId: string,
memberId: string, memberId: string,
): Promise<MemberContact | null> { ): Promise<MemberContact | null> {
const { data } = await admin try {
.from('space_members') const { data } = await admin
.select('email, display_name') .from('space_members')
.eq('id', memberId) .select('email, display_name')
.eq('space_id', spaceId) .eq('id', memberId)
.maybeSingle() .eq('space_id', spaceId)
if (!data) return null .maybeSingle()
return { if (!data) return null
email: (data.email as string | null) ?? null, return {
displayName: (data.display_name as string | null) ?? null, email: (data.email as string | null) ?? null,
displayName: (data.display_name as string | null) ?? null,
}
} catch (e) {
console.error('[notifications] resolveMemberContact threw:', e instanceof Error ? e.message : e)
return null
} }
} }
@@ -91,10 +98,17 @@ export async function enqueueNotification(
// Read the space's name once per caller. Used for brand-neutral copy that // Read the space's name once per caller. Used for brand-neutral copy that
// injects the space name (this is a generic multi-space platform; never // injects the space name (this is a generic multi-space platform; never
// hard-code a tenant). Returns empty string on miss so renders still work. // hard-code a tenant). Returns empty string on miss or on lookup failure
// (best-effort, same rationale as resolveMemberContact); the renderers all
// fall back to "your hackerspace" so the email still composes.
export async function getSpaceName(admin: AdminClient, spaceId: string): Promise<string> { export async function getSpaceName(admin: AdminClient, spaceId: string): Promise<string> {
const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle() try {
return (data?.name as string | null) ?? '' const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle()
return (data?.name as string | null) ?? ''
} catch (e) {
console.error('[notifications] getSpaceName threw:', e instanceof Error ? e.message : e)
return ''
}
} }
// Build the member portal URL the way every enqueue site does. Header host // Build the member portal URL the way every enqueue site does. Header host
+11
View File
@@ -35,3 +35,14 @@ AS $$
) )
); );
$$; $$;
-- LOCKDOWN: unlike user_has_permission (a per-user boolean that is hard to
-- enumerate without already knowing target uids), members_with_permission
-- returns a SET of member_ids for any (space, permission) pair. By default
-- Postgres grants EXECUTE on a new function to PUBLIC, so without an explicit
-- REVOKE this function would let any authenticated PostgREST caller enumerate
-- "who has permission X in space Y" across spaces they are not even members
-- of. Lock it to service_role: the notifications fan-out goes through the
-- admin (service-role) client; no client should call this directly.
REVOKE EXECUTE ON FUNCTION public.members_with_permission(uuid, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.members_with_permission(uuid, text) TO service_role;
+8
View File
@@ -1775,6 +1775,12 @@ $$;
-- user_effective_roles fallback. Used by notification fan-outs (e.g. the -- user_effective_roles fallback. Used by notification fan-outs (e.g. the
-- forms.manage admin alert) where N user_has_permission calls would be N -- forms.manage admin alert) where N user_has_permission calls would be N
-- round-trips. Equivalent to scripts/047_members_with_permission.sql. -- round-trips. Equivalent to scripts/047_members_with_permission.sql.
-- LOCKDOWN: unlike user_has_permission (a per-user boolean that is hard to
-- enumerate without already knowing target uids), this function returns a SET
-- of member_ids for any (space, permission) pair. Postgres grants EXECUTE on
-- new functions to PUBLIC by default, so an explicit REVOKE is required to
-- prevent any authenticated PostgREST caller from enumerating permission
-- holders across spaces they are not members of. Locked to service_role.
CREATE OR REPLACE FUNCTION public.members_with_permission(sid uuid, perm text) CREATE OR REPLACE FUNCTION public.members_with_permission(sid uuid, perm text)
RETURNS TABLE(member_id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public RETURNS TABLE(member_id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$ AS $$
@@ -1791,6 +1797,8 @@ AS $$
) )
); );
$$; $$;
REVOKE EXECUTE ON FUNCTION public.members_with_permission(uuid, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.members_with_permission(uuid, text) TO service_role;
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY; ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY; ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;