From 07ca794f1ba6450b3a8fcd975c1ff8da189f1c99 Mon Sep 17 00:00:00 2001 From: Joakim Ahrlin Date: Fri, 7 Nov 2025 17:15:01 +0100 Subject: [PATCH 1/4] add myself to humans.txt (#40236) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index f907179f8664b..45fe7c653f990 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -80,6 +80,7 @@ Jeff Smick Jenny Kibiri Jess Shears Jim Chanco Jr +Joakim Ahrlin John Pena John Schaeffer Jon M From 2415776436927dedb360dae082d03caee607891a Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:24:55 +0200 Subject: [PATCH 2/4] feat: add docs on creating event triggers (#40239) --- .../NavigationMenu.constants.ts | 4 + .../database/postgres/event-triggers.mdx | 135 ++++++++++++++++++ .../database/postgres/roles-superuser.mdx | 2 - 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 apps/docs/content/guides/database/postgres/event-triggers.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index e40c690b7e931..a053d8a96a2cc 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -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}`, + }, ], }, { diff --git a/apps/docs/content/guides/database/postgres/event-triggers.mdx b/apps/docs/content/guides/database/postgres/event-triggers.mdx new file mode 100644 index 0000000000000..0254f74757eba --- /dev/null +++ b/apps/docs/content/guides/database/postgres/event-triggers.mdx @@ -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 + + + +Event triggers run for each DDL command specified above and can consume resources which may cause performance issues if not used carefully. + + + +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) diff --git a/apps/docs/content/guides/database/postgres/roles-superuser.mdx b/apps/docs/content/guides/database/postgres/roles-superuser.mdx index 552ce4b3ba0da..0f34e691799b3 100644 --- a/apps/docs/content/guides/database/postgres/roles-superuser.mdx +++ b/apps/docs/content/guides/database/postgres/roles-superuser.mdx @@ -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` From 5606d07fc2bb817073d5ce5b3d696f736ed2d606 Mon Sep 17 00:00:00 2001 From: Kalleby Santos <105971119+kallebysantos@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:04:25 +0000 Subject: [PATCH 3/4] feat(studio): improve new secret form ux (#40142) Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> --- .../EdgeFunctionSecrets/AddNewSecretForm.tsx | 12 +- .../EdgeFunctionSecret.tsx | 90 ++++-- .../EdgeFunctionSecrets.tsx | 25 +- .../EdgeFunctionSecrets/EditSecretSheet.tsx | 280 ++++++++++++++++++ 4 files changed, 377 insertions(+), 30 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx index 237f6b381a00c..8365f779f0fcd 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx @@ -168,7 +168,7 @@ const AddNewSecretForm = () => {
- Add new secrets + Add or replace secrets {fields.map((fieldItem, index) => ( @@ -178,7 +178,7 @@ const AddNewSecretForm = () => { name={`secrets.${index}.name`} render={({ field }) => ( - Key + Name { Add another - + +

+ Insert or update multiple secrets at once by pasting key-value pairs +

+
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 235f0284c584a..576f19937ff88 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -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 @@ -45,23 +55,63 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
- } - 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, - }, - }} - /> + + +
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index 01938c432b6b7..85bf4695c55f1 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -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() + const [selectedSecret, setSelectedSecret] = useState() const { can: canReadSecrets, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.SECRETS_READ, @@ -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) }, }) @@ -99,7 +105,8 @@ const EdgeFunctionSecrets = () => { setSelectedSecret(secret)} + onSelectEdit={() => setSelectedSecret({ secret, op: 'edit' })} + onSelectDelete={() => setSelectedSecret({ secret, op: 'delete' })} /> )) ) : secrets.length === 0 && searchString.length > 0 ? ( @@ -131,17 +138,23 @@ const EdgeFunctionSecrets = () => { )} + setSelectedSecret(undefined)} + /> + setSelectedSecret(undefined)} onConfirm={() => { if (selectedSecret !== undefined) { - deleteSecret({ projectRef, secrets: [selectedSecret.name] }) + deleteSecret({ projectRef, secrets: [selectedSecret.secret.name] }) } }} > diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx new file mode 100644 index 0000000000000..aec6bc30393dc --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx @@ -0,0 +1,280 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { SubmitHandler, useForm, type UseFormReturn } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useParams } from 'common' +import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation' +import { ProjectSecret } from 'data/secrets/secrets-query' +import { Eye, EyeOff, X } from 'lucide-react' +import { useLatest } from 'react-use' +import { + Button, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input, + Input_Shadcn_, + Separator, + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const FORM_ID = 'edit-secret-sidepanel' + +const FormSchema = z.object({ + name: z.string().min(1, 'Please provide a name for your secret'), + value: z.string().min(1, 'Please provide a value for your secret'), +}) + +type FormSchemaType = z.infer + +interface EditSecretSheetProps { + secret?: ProjectSecret + visible: boolean + onClose: () => void +} + +export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetProps) { + const secretName = useLatest(secret?.name) + const form = useForm({ + resolver: zodResolver(FormSchema), + }) + useEffect(() => { + if (visible) { + form.reset({ + name: secretName.current ?? '', + value: '', + }) + } + }, [form, secretName, visible]) + const isValid = form.formState.isValid + + const { ref: projectRef } = useParams() + const { mutate: updateSecret, isLoading: isUpdating } = useSecretsCreateMutation({ + onSuccess: (_, variables) => { + toast.success(`Successfully updated secret "${variables.secrets[0].name}"`) + onClose() + }, + }) + const onSubmit: SubmitHandler = async ({ name, value }) => { + updateSecret({ + projectRef, + secrets: [{ name, value }], + }) + } + + const { confirmOnClose, modal: closeConfirmationModal } = useConfirmOnClose({ + checkIsDirty: () => form.formState.isDirty, + onClose, + }) + + return ( + + +
+ + + + + + + + {closeConfirmationModal} + + ) +} + +const Header = (): ReactNode => { + return ( + + + + Close + + Edit secret + + ) +} + +type FormBodyProps = { + form: UseFormReturn + onSubmit: SubmitHandler +} + +const FormBody = ({ form, onSubmit }: FormBodyProps): ReactNode => { + return ( + + + + + + + + + + + + + ) +} + +type NameFieldProps = { + form: UseFormReturn +} + +const NameField = ({ form }: NameFieldProps): ReactNode => { + return ( + ( + + + + + + )} + /> + ) +} + +type SecretFieldProps = { + form: UseFormReturn +} + +const SecretField = ({ form }: SecretFieldProps): ReactNode => { + const [showSecretValue, setShowSecretValue] = useState(false) + + return ( + ( + + + + diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx index 769ee3b4bacba..d1ab6e80ac174 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -68,8 +68,7 @@ export const ReportChartV2 = ({ const { plan: orgPlan } = useCurrentOrgPlan() const orgPlanId = orgPlan?.id - const isAvailable = - report.availableIn === undefined || (orgPlanId && report.availableIn.includes(orgPlanId)) + const isAvailable = !report?.availableIn || (orgPlanId && report.availableIn?.includes(orgPlanId)) const canFetch = orgPlanId !== undefined && isAvailable