Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,10 @@ export const database: NavMenuConstant = {
name: 'Managing connections',
url: '/guides/database/connection-management' as `/${string}`,
},
{
name: 'Managing event triggers',
url: '/guides/database/postgres/event-triggers' as `/${string}`,
},
],
},
{
Expand Down
135 changes: 135 additions & 0 deletions apps/docs/content/guides/database/postgres/event-triggers.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
id: 'postgres-event-triggers'
title: 'Event Triggers'
description: 'Automatically execute SQL on database events.'
subtitle: 'Automatically execute SQL on database events.'
---

In Postgres, an [event trigger](https://www.postgresql.org/docs/current/event-triggers.html) is similar to a [trigger](/docs/guides/database/postgres/triggers), except that it is triggered by database level events (and is usually reserved for [superusers](/docs/guides/database/postgres/roles-superuser))

With our `Supautils` extension (installed automatically for all Supabase projects), the `postgres` user has the ability to create and manage event triggers.

Some use cases for event triggers are:

- Capturing Data Definition Language (DDL) changes - these are changes to your database schema (though the [pgAudit](/docs/guides/database/extensions/pgaudit) extension provides a more complete solution)
- Enforcing/monitoring/preventing actions - such as preventing tables from being dropped in Production or enforcing RLS on all new tables

The guide covers two example event triggers:

1. Preventing accidental dropping of a table
2. Automatically enabling Row Level Security on new tables in the `public` schema

## Creating an event trigger

Only the `postgres` user can create event triggers, so make sure you are authenticated as them. As with triggers, event triggers consist of 2 parts

1. A [Function](/docs/guides/database/functions) which will be executed when the triggering event occurs
2. The actual Event Trigger object, with parameters around when the trigger should be run

### Example trigger function - prevent dropping tables

This example protects any table from being dropped. You can override it by temporarily disabling the event trigger: `ALTER EVENT TRIGGER dont_drop_trigger DISABLE;`

```sql
-- Function
CREATE OR REPLACE FUNCTION dont_drop_function()
RETURNS event_trigger LANGUAGE plpgsql AS $$
DECLARE
obj record;
tbl_name text;
BEGIN
FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects()
LOOP
IF obj.object_type = 'table' THEN
RAISE EXCEPTION 'ERROR: All tables in this schema are protected and cannot be dropped';
END IF;
END LOOP;
END;
$$;

-- Event trigger
CREATE EVENT TRIGGER dont_drop_trigger
ON sql_drop
EXECUTE FUNCTION dont_drop_function();
```

### Example trigger function - auto enable Row Level Security

```sql
CREATE OR REPLACE FUNCTION rls_auto_enable()
RETURNS EVENT_TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
DECLARE
cmd record;
BEGIN
FOR cmd IN
SELECT *
FROM pg_event_trigger_ddl_commands()
WHERE command_tag IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
AND object_type IN ('table','partitioned table')
LOOP
IF cmd.schema_name IS NOT NULL AND cmd.schema_name IN ('public') AND cmd.schema_name NOT IN ('pg_catalog','information_schema') AND cmd.schema_name NOT LIKE 'pg_toast%' AND cmd.schema_name NOT LIKE 'pg_temp%' THEN
BEGIN
EXECUTE format('alter table if exists %s enable row level security', cmd.object_identity);
RAISE LOG 'rls_auto_enable: enabled RLS on %', cmd.object_identity;
EXCEPTION
WHEN OTHERS THEN
RAISE LOG 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity;
END;
ELSE
RAISE LOG 'rls_auto_enable: skip % (either system schema or not in enforced list: %.)', cmd.object_identity, cmd.schema_name;
END IF;
END LOOP;
END;
$$;

DROP EVENT TRIGGER IF EXISTS ensure_rls;
CREATE EVENT TRIGGER ensure_rls
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
EXECUTE FUNCTION rls_auto_enable();
```

### Event trigger Functions and firing events

Event triggers can be triggered on:

- `ddl_command_start` - occurs just before a DDL command for almost all objects within a schema
- `ddl_command_end` - occurs just after a DDL command for almost all objects within a schema
- `sql_drop` - occurs just before `ddl_command_end` for any DDL commands that `DROP` a database object (note that altering a table can cause it to be dropped)
- `table_rewrite` - occurs just before a table is rewritten using the `ALTER TABLE` command

<Admonition type="caution">

Event triggers run for each DDL command specified above and can consume resources which may cause performance issues if not used carefully.

</Admonition>

Within each event trigger, helper functions exist to view the objects being modified or the command being run. For example, our example calls `pg_event_trigger_dropped_objects()` to view the object(s) being dropped. For a more comprehensive overview of these functions, read the [official event trigger definition documentation](https://www.postgresql.org/docs/current/event-trigger-definition.html)

To view the matrix commands that cause an event trigger to fire, read the [official event trigger matrix documentation](https://www.postgresql.org/docs/current/event-trigger-matrix.html)

## Disabling an event trigger

You can disable an event trigger using the `alter event trigger` command:

```sql
ALTER EVENT TRIGGER dont_drop_trigger DISABLE;
```

## Dropping an event trigger

You can delete a trigger using the `drop event trigger` command:

```sql
DROP EVENT TRIGGER dont_drop_trigger;
```

## Resources

- Official Postgres Docs: [Event Trigger Behaviours](https://www.postgresql.org/docs/current/event-trigger-definition.html)
- Official Postgres Docs: [Event Trigger Firing Matrix](https://www.postgresql.org/docs/current/event-trigger-matrix.html)
- Supabase blog: [Postgres Event Triggers without superuser access](/blog/event-triggers-wo-superuser)
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,5 @@ However, this does mean that some operations, that typically require `superuser`

## Unsupported operations

- `CREATE SUBSCRIPTION`
- `CREATE EVENT TRIGGER`
- `COPY ... FROM PROGRAM`
- `ALTER USER ... WITH SUPERUSER`
1 change: 1 addition & 0 deletions apps/docs/public/humans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Jeff Smick
Jenny Kibiri
Jess Shears
Jim Chanco Jr
Joakim Ahrlin
John Pena
John Schaeffer
Jon M
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const AddNewSecretForm = () => {
<form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Add new secrets</CardTitle>
<CardTitle>Add or replace secrets</CardTitle>
</CardHeader>
<CardContent>
{fields.map((fieldItem, index) => (
Expand All @@ -178,7 +178,7 @@ const AddNewSecretForm = () => {
name={`secrets.${index}.name`}
render={({ field }) => (
<FormItem_Shadcn_ className="w-full">
<FormLabel_Shadcn_>Key</FormLabel_Shadcn_>
<FormLabel_Shadcn_>Name</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input
{...field}
Expand Down Expand Up @@ -246,9 +246,13 @@ const AddNewSecretForm = () => {
Add another
</Button>
</CardContent>
<CardFooter className="justify-end space-x-2">
<CardFooter className="justify-between space-x-2">
<p className="text-sm text-foreground-lighter">
Insert or update multiple secrets at once by pasting key-value pairs
</p>

<Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
{isCreating ? 'Saving...' : 'Save'}
{isCreating ? 'Saving...' : fields.length > 1 ? 'Bulk save' : 'Save'}
</Button>
</CardFooter>
</Card>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Trash } from 'lucide-react'
import { Edit2, MoreVertical, Trash } from 'lucide-react'

import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import type { ProjectSecret } from 'data/secrets/secrets-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { TableCell, TableRow } from 'ui'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
TableCell,
TableRow,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'

interface EdgeFunctionSecretProps {
secret: ProjectSecret
onSelectDelete: () => void
onSelectEdit: () => void
}

const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) => {
const EdgeFunctionSecret = ({ secret, onSelectEdit, onSelectDelete }: EdgeFunctionSecretProps) => {
const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*')
// [Joshen] Following API's validation:
// https://github.com/supabase/infrastructure/blob/develop/api/src/routes/v1/projects/ref/secrets/secrets.controller.ts#L106
Expand Down Expand Up @@ -45,23 +55,63 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
</TableCell>
<TableCell>
<div className="flex items-center justify-end">
<ButtonTooltip
type="text"
icon={<Trash />}
className="px-1"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectDelete()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be deleted'
: !canUpdateSecrets
? 'You need additional permissions to delete edge function secrets'
: undefined,
},
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More options"
type="default"
className="px-1"
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem asChild>
<ButtonTooltip
type="text"
icon={<Edit2 size={14} />}
className="w-full justify-start group text-inherit"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectEdit()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be changed'
: !canUpdateSecrets
? 'You need additional permissions to edit edge function secrets'
: undefined,
},
}}
>
Edit secret
</ButtonTooltip>
</DropdownMenuItem>

<DropdownMenuSeparator />

<DropdownMenuItem asChild>
<ButtonTooltip
type="text"
icon={<Trash size={14} className="group-[&:not(:disabled)]:text-destructive" />}
className="w-full justify-start group text-inherit"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectDelete()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be deleted'
: !canUpdateSecrets
? 'You need additional permissions to delete edge function secrets'
: undefined,
},
}}
>
Delete secret
</ButtonTooltip>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import AddNewSecretForm from './AddNewSecretForm'
import EdgeFunctionSecret from './EdgeFunctionSecret'
import { EditSecretSheet } from './EditSecretSheet'

type SelectedProjectSecret = {
secret: ProjectSecret
op: 'delete' | 'edit'
}

const EdgeFunctionSecrets = () => {
const { ref: projectRef } = useParams()
const [searchString, setSearchString] = useState('')
const [selectedSecret, setSelectedSecret] = useState<ProjectSecret>()
const [selectedSecret, setSelectedSecret] = useState<SelectedProjectSecret>()

const { can: canReadSecrets, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
Expand All @@ -33,7 +39,7 @@ const EdgeFunctionSecrets = () => {

const { mutate: deleteSecret, isLoading: isDeleting } = useSecretsDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted ${selectedSecret?.name}`)
toast.success(`Successfully deleted ${selectedSecret?.secret.name}`)
setSelectedSecret(undefined)
},
})
Expand Down Expand Up @@ -99,7 +105,8 @@ const EdgeFunctionSecrets = () => {
<EdgeFunctionSecret
key={secret.name}
secret={secret}
onSelectDelete={() => setSelectedSecret(secret)}
onSelectEdit={() => setSelectedSecret({ secret, op: 'edit' })}
onSelectDelete={() => setSelectedSecret({ secret, op: 'delete' })}
/>
))
) : secrets.length === 0 && searchString.length > 0 ? (
Expand Down Expand Up @@ -131,17 +138,23 @@ const EdgeFunctionSecrets = () => {
</>
)}

<EditSecretSheet
secret={selectedSecret?.secret}
visible={selectedSecret?.op === 'edit'}
onClose={() => setSelectedSecret(undefined)}
/>

<ConfirmationModal
variant="destructive"
loading={isDeleting}
visible={selectedSecret !== undefined}
visible={selectedSecret?.op === 'delete'}
confirmLabel="Delete secret"
confirmLabelLoading="Deleting secret"
title={`Confirm to delete secret "${selectedSecret?.name}"`}
title={`Confirm to delete secret "${selectedSecret?.secret.name}"`}
onCancel={() => setSelectedSecret(undefined)}
onConfirm={() => {
if (selectedSecret !== undefined) {
deleteSecret({ projectRef, secrets: [selectedSecret.name] })
deleteSecret({ projectRef, secrets: [selectedSecret.secret.name] })
}
}}
>
Expand Down
Loading
Loading