diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0935839..7adfa47 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -103,7 +103,8 @@ jobs: ARN=$(terraform output -raw db_cluster_arn 2>/dev/null || true) SECRET=$(terraform output -raw db_secret_arn 2>/dev/null || true) if [ -n "$ARN" ] && [ -n "$SECRET" ]; then - cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate + cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" \ + SUPERADMIN_EMAIL="${{ vars.SUPERADMIN_EMAIL }}" go run ./cmd/migrate echo "done=true" >> "$GITHUB_OUTPUT" else echo "done=false" >> "$GITHUB_OUTPUT" # first deploy: the cluster doesn't exist yet @@ -122,7 +123,8 @@ jobs: working-directory: infra/terraform/prod run: | ARN=$(terraform output -raw db_cluster_arn) SECRET=$(terraform output -raw db_secret_arn) - cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate + cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" \ + SUPERADMIN_EMAIL="${{ vars.SUPERADMIN_EMAIL }}" go run ./cmd/migrate - name: Smoke test if: steps.ready.outputs.ready == 'true' diff --git a/README.md b/README.md index de22931..5bce61d 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ The steps above assume these exist. They're part of [PLAN.md → Phase 0](PLAN.m 3. In GitHub: create the `production` environment (deployments from `main` only), add the `main` ruleset, and set repository **variables** from the bootstrap outputs: `AWS_PLAN_ROLE_ARN`, `AWS_DEPLOY_ROLE_ARN`, `AWS_REGION`, `TF_STATE_BUCKET`, `ARTIFACT_BUCKET`, plus `API_DOMAIN` (`api.substratalapps.com`). None are secrets; OIDC needs no stored credentials. 4. Merge into `main`. The first deploy creates the stack and then migrates the new cluster. 5. Put the Stripe keys into the `substratal-prod/app` secret (`stripe_secret_key`, `stripe_webhook_secret`; Terraform never overwrites them), run `make stripe-catalog` against test mode first, and register `https://api.substratalapps.com/internal/stripe/webhook` in Stripe. Request SES production access (about a day). -6. Seed the first `superadmin`, which the public API can't create by design: `DATABASE_BACKEND=dataapi DB_CLUSTER_ARN=… DB_SECRET_ARN=… go run ./cmd/admin bootstrap -email you@example.com` (ARNs from `terraform output`). +6. The first `superadmin` is the platform owner's account, which the public API can't create by design. Set the repository variable `SUPERADMIN_EMAIL`: every deploy then makes sure that account exists as a **protected** superadmin that can't be suspended, deleted, erased, or demoted ([Users → The protected superadmin](https://compositecode.github.io/substratalapps.com/api-reference/users/#the-protected-superadmin)). It's created without a password. Set one once, from your machine: `DATABASE_BACKEND=dataapi DB_CLUSTER_ARN=… DB_SECRET_ARN=… go run ./cmd/admin bootstrap -email ` (ARNs from `terraform output`, AWS credentials for the production account). 7. Optional: seed a smoke-test User and Application, and set `SMOKE_USER_ID`, `SMOKE_APP_ID` (variables) and `SMOKE_API_KEY` (secret) so every deploy also checks `effective-permissions` through the Data API. `main` stays the repository's default branch. It's what's in production, and links throughout the docs point at `blob/main/...`. Local checkouts and day-to-day work use `dev`. diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 0769a51..7eb744d 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -3,6 +3,9 @@ // go run ./cmd/migrate # local Postgres (DATABASE_URL) // DATABASE_BACKEND=dataapi go run ./cmd/migrate # Aurora via the Data API (deploy.yml) // go run ./cmd/migrate -new add_widgets # create the next numbered file +// +// With SUPERADMIN_EMAIL set, it then makes sure that account exists as the +// protected superadmin (migration 0004), so every deploy re-asserts it. package main import ( @@ -66,4 +69,19 @@ func main() { if len(done) == 0 { fmt.Println("up to date") } + if email := os.Getenv("SUPERADMIN_EMAIL"); email != "" { + base, err := wire.NewBase(ctx) + if err != nil { + log.Fatal(err) + } + id, created, err := base.Server(nil).EnsureProtectedSuperadmin(ctx, email) + if err != nil { + log.Fatal(err) + } + if created { + fmt.Println("superadmin created", id, "(no password yet: run cmd/admin bootstrap or use password reset)") + } else { + fmt.Println("superadmin ensured", id) + } + } } diff --git a/docs/api-reference/users.md b/docs/api-reference/users.md index af0df69..f051f0b 100644 --- a/docs/api-reference/users.md +++ b/docs/api-reference/users.md @@ -154,6 +154,16 @@ Rejected transitions return `409 invalid_status_transition`. A self-service call Soft-delete: sets `status: deleted` and `deleted_at`, revokes every session, deactivates every push [device](../notifications/#sessions-and-device-lifecycle), fires `access.revoked` (reason `user_deleted`) for every Application the user had active access to, and from then on `GET /v1/users/{id}` 404s for everyone but `users.manage`, who can still fetch, restore, or request erasure for the account. Self, or `users.manage`. Entitlements, Roles, Profile, and Settings are retained, not removed — hard deletion is the separate erasure process below. The email address becomes reusable by a new signup immediately. Writes `user.deleted`. +## The protected superadmin + +The platform owner's account is **protected**. Every deploy makes sure it exists as an active, verified `superadmin` (the `SUPERADMIN_EMAIL` deploy variable), and nothing can lock it out: + +- `DELETE /v1/users/{id}`, `POST /v1/users/{id}/suspend`, a `PATCH` that changes `status` away from `active` or changes `email`, `POST /v1/users/{id}/erasure-requests`, and `DELETE /v1/users/{id}/roles/role_platform_superadmin` all return `409 user_protected`. +- The database enforces the same rule for every caller, including a direct SQL session (see [Database Schema → users](../../domain-model/database-schema/#users)). Ops can lift it for one transaction to hand ownership to another account. +- If the account is somehow missing, demoted, or unprotected, the next deploy restores it. Its password is never changed by a deploy. + +Everything else works normally: it can change its own password, profile, and settings, enable MFA, and sign out. + ## `POST /v1/users/{id}/suspend` ```json @@ -264,5 +274,6 @@ Calling this for an already-scheduled user returns the existing request (`202`, | `invalid_status_transition` | 409 | A `status` change not in the table above, or an invitation re-send for a non-`invited` user. | | `erasure_not_scheduled` | 404 | Cancelling an erasure that isn't pending. | | `erasure_scheduled` | 409 | Restoring a soft-deleted User while their erasure request is still scheduled. | +| `user_protected` | 409 | Suspending, deleting, erasing, re-addressing, or demoting the [protected superadmin](#the-protected-superadmin). | | `user_token_required` | 400 | `me` used with an API Key. | | `validation_failed` | 422 | Malformed email, or an unknown `status` value. | diff --git a/docs/domain-model/database-schema.md b/docs/domain-model/database-schema.md index edc8dd8..8164862 100644 --- a/docs/domain-model/database-schema.md +++ b/docs/domain-model/database-schema.md @@ -42,9 +42,12 @@ The entity pages describe *what* each field means to an API caller. This page is | `created_at`, `updated_at`, `last_login_at` | `timestamptz` | `last_login_at` nullable | | `version` | `integer` | not null, default `1` | | `deleted_at` | `timestamptz` | nullable — soft-delete | +| `protected` | `boolean` | not null, default `false` — the platform owner's account; see below | **Indexes:** `unique (email, test_mode) where deleted_at is null` (a deleted user's email should be reusable by a new signup, which a plain unique index would block, and test-mode signups mustn't collide with live ones); `(lower(email) text_pattern_ops)` for the `q` prefix search; `(status)` for admin filtering. No `organization_id` here — see `organization_memberships` below; a single column on `users` would cap a User at one Organization. No `auth`/password column here either — see `user_identities` below; a User can hold more than one login method, per [Auth → Native auth and per-Organization SSO](../../api-reference/auth/#native-auth-and-per-organization-sso). +**Protected superadmin.** Triggers keep a `protected` User (the platform owner, re-asserted by every deploy) from being locked out, for every caller: an `UPDATE` that would clear `protected`, change `status` from `active`, set `deleted_at`, or change `email` or `test_mode` is rejected, as is a `DELETE`; `user_role_assignments` refuses to drop its `role_platform_superadmin` row; and `erasure_requests` refuses a scheduled erasure for it. Setting `app.protected_user_maintenance = 'on'` for one transaction lifts all three, for ops handing ownership to another account. See [Users → The protected superadmin](../../api-reference/users/#the-protected-superadmin). + ## user_identities, sso_connections ```sql diff --git a/docs/non-functional-requirements.md b/docs/non-functional-requirements.md index bfe6232..3462edf 100644 --- a/docs/non-functional-requirements.md +++ b/docs/non-functional-requirements.md @@ -20,7 +20,7 @@ nav_order: 10 - **MFA:** optional TOTP (RFC 6238) for `password` identities, with 10 single-use recovery codes. TOTP secrets are encrypted with KMS at rest. An SSO identity's MFA belongs to its IdP. - **Service-to-service:** billing → hub, app → hub, and any other backend caller authenticates with a scoped [API Key](../api-reference/api-keys/), never user credentials. mTLS isn't offered. - **Secrets at rest:** every bearer credential (refresh tokens, API Key secrets, emailed tokens, recovery codes) is stored only as a hash. Webhook signing secrets, TOTP secrets, and [push credentials](../api-reference/notifications/#push-credentials) must be usable by the server, so they're KMS-encrypted instead. So are push device tokens: they aren't credentials, but they're personal data the server has to send to. -- **Bootstrapping:** the very first `superadmin` cannot be created through the public API — nothing can call `POST /v1/users/{id}/roles/{roleId}` with `roles.manage` before a `superadmin` exists to grant it. It's seeded directly against the database (or via a one-time, non-API admin CLI command) as part of standing up a new environment, not specified as an endpoint. +- **Bootstrapping:** the very first `superadmin` cannot be created through the public API — nothing can call `POST /v1/users/{id}/roles/{roleId}` with `roles.manage` before a `superadmin` exists to grant it. It's seeded directly against the database, not specified as an endpoint. Every deploy makes sure the platform owner's account (the `SUPERADMIN_EMAIL` deploy variable) exists as a **protected superadmin**: active, verified, holding `superadmin`, and impossible to suspend, delete, erase, re-address, or demote, through the API ([`409 user_protected`](../api-reference/users/#the-protected-superadmin)) or directly in the database. Its password is set with the one-time admin CLI (`cmd/admin bootstrap`) or the password-reset flow. - **MCP:** the [MCP Server](../mcp-server/) is not a separate auth surface — it carries and forwards the same Bearer credential (user token or API Key) as any other caller. No new rule in this section applies to it that doesn't already apply here. ## CORS diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2e6608e..c65b12d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2695,7 +2695,7 @@ components: application/json: schema: { $ref: "#/components/schemas/Error" } Conflict: - description: "State conflict (version_conflict, idempotency_key_reused, entitlement_already_exists, entitlement_platform_managed, subscription_exists, invalid_status_transition, …)" + description: "State conflict (version_conflict, idempotency_key_reused, entitlement_already_exists, entitlement_platform_managed, subscription_exists, user_protected, invalid_status_transition, …)" content: application/json: schema: { $ref: "#/components/schemas/Error" } diff --git a/internal/core/bootstrap.go b/internal/core/bootstrap.go index be294aa..e99056a 100644 --- a/internal/core/bootstrap.go +++ b/internal/core/bootstrap.go @@ -58,4 +58,53 @@ func (s *Server) BootstrapSuperadmin(ctx context.Context, email, password string return userID, err } +// EnsureProtectedSuperadmin makes the platform's owner account exist, as an +// active superadmin that can never be removed (migration 0004). It runs on +// every deploy, after migrations (cmd/migrate with SUPERADMIN_EMAIL set), so +// the account is always there and always protected: +// +// - If no live User has the email, it's created active and verified, with +// no password. Set one with cmd/admin bootstrap, or the password-reset +// flow. +// - If the User exists, it's made active and verified again, marked +// protected, and given back the superadmin Role if it was missing. +// +// It never changes an existing password, and writes audit events only for +// what it actually changed. It reports whether it created the User. +func (s *Server) EnsureProtectedSuperadmin(ctx context.Context, email string) (userID string, created bool, err error) { + addr, ok := validEmail(email) + if !ok { + return "", false, fmt.Errorf("superadmin: invalid email %q", email) + } + err = s.system(ctx, false, db.Settings{}, func(c *Call) error { + u, err := c.findUser("u.email = :e and u.deleted_at is null", db.Args{"e": addr}) + switch err { + case nil: + userID = u.ID + case db.ErrNotFound: + nu, err := c.createUser(newUser{Email: addr, Status: "active", EmailVerify: true, + DisplayName: "Superadmin", ActorType: "system", ActorID: "system"}) + if err != nil { + return err + } + userID, created = nu.ID, true + default: + return err + } + if _, err := c.q.Exec(ctx, `update users set status = 'active', email_verified = true, protected = true + where id = :u and (status <> 'active' or not email_verified or not protected)`, db.Args{"u": userID}); err != nil { + return err + } + n, err := c.q.Exec(ctx, `insert into user_role_assignments (user_id, role_id, application_id, assigned_by_type, assigned_by_id) + values (:u, 'role_platform_superadmin', null, 'system', 'system') on conflict do nothing`, db.Args{"u": userID}) + if err != nil || n == 0 { + return err + } + return c.audit(Audit{Action: "role.assigned", TargetType: "role_assignment", TargetID: userID + ":role_platform_superadmin", + TargetUserID: &userID, ActorType: "system", ActorID: "system", + After: map[string]any{"user_id": userID, "role_id": "role_platform_superadmin", "application_id": nil}}) + }) + return userID, created, err +} + var _ = ids.New diff --git a/internal/core/roles.go b/internal/core/roles.go index 10b86ff..77a6920 100644 --- a/internal/core/roles.go +++ b/internal/core/roles.go @@ -646,6 +646,11 @@ func (s *Server) RolesRemove(w http.ResponseWriter, r *http.Request, id gen.User if x.ID == "role_platform_member" { return httpx.Conflict("member_role_required", "Every User holds the member platform Role.") } + if x.ID == "role_platform_superadmin" { + if err := c.guardProtected(uid); err != nil { + return err + } + } n, err := c.q.Exec(c.ctx, `delete from user_role_assignments where user_id = :u and role_id = :r`, db.Args{"u": uid, "r": x.ID}) if err != nil { diff --git a/internal/core/superadmin_test.go b/internal/core/superadmin_test.go new file mode 100644 index 0000000..c3f1051 --- /dev/null +++ b/internal/core/superadmin_test.go @@ -0,0 +1,84 @@ +package core_test + +import ( + "context" + "testing" + + "github.com/CompositeCode/substratalapps.com/internal/db" + "github.com/CompositeCode/substratalapps.com/internal/testenv" +) + +// The protected superadmin (migration 0004) can't be locked out through the +// API or directly in the database, and every deploy re-asserts it. +func TestProtectedSuperadmin(t *testing.T) { + e := testenv.NewWith(t, testenv.Options{OwnDatabase: true}) + ctx := context.Background() + addr := "owner+" + testenv.Unique() + "@example.com" + + id, created, err := e.Core.EnsureProtectedSuperadmin(ctx, addr) + if err != nil || !created { + t.Fatalf("first ensure: %s %v %v", id, created, err) + } + again, created, err := e.Core.EnsureProtectedSuperadmin(ctx, addr) + if err != nil || created || again != id { + t.Fatalf("second ensure should be a no-op: %s %v %v", again, created, err) + } + roles := e.Get("/v1/users/"+id+"/roles", e.Admin).Expect(200).JSON()["data"].([]any) + hasSuper := false + for _, r := range roles { + hasSuper = hasSuper || r.(map[string]any)["role_id"] == "role_platform_superadmin" + } + if !hasSuper { + t.Fatalf("roles = %v", roles) + } + if u := e.Get("/v1/users/"+id, e.Admin).JSON(); u["status"] != "active" || u["email_verified"] != true { + t.Fatalf("user = %v", u) + } + + // Every API path that could lock the account out is refused. + up := "/v1/users/" + id + e.Delete(up, e.Admin).ExpectErr(409, "user_protected") + e.Post(up+"/suspend", e.Admin, map[string]any{"reason": "x"}).ExpectErr(409, "user_protected") + e.Patch(up, e.Admin, map[string]any{"status": "suspended"}).ExpectErr(409, "user_protected") + e.Patch(up, e.Admin, map[string]any{"email": "elsewhere+" + testenv.Unique() + "@example.com"}).ExpectErr(409, "user_protected") + e.Post(up+"/erasure-requests", e.Admin, map[string]any{}).ExpectErr(409, "user_protected") + e.Delete(up+"/roles/role_platform_superadmin", e.Admin).ExpectErr(409, "user_protected") + // Harmless changes still work. + e.Patch(up, e.Admin, map[string]any{"status": "active"}).Expect(200) + + // The database refuses the same changes from any caller. + for _, q := range []string{ + `update users set status = 'suspended' where id = :u`, + `update users set deleted_at = now() where id = :u`, + `update users set protected = false where id = :u`, + `delete from user_role_assignments where user_id = :u and role_id = 'role_platform_superadmin'`, + `insert into erasure_requests (user_id, requested_by_type, requested_by_id, scheduled_for) + values (:u, 'system', 'system', now())`, + } { + err := e.DB.Tx(ctx, db.Settings{}, func(tx db.Querier) error { + _, err := tx.Exec(ctx, q, db.Args{"u": id}) + return err + }) + if err == nil { + t.Fatalf("database allowed: %s", q) + } + } + // Ops can lift the guard for one transaction to hand ownership over. + err = e.DB.Tx(ctx, db.Settings{}, func(tx db.Querier) error { + if _, err := tx.Query(ctx, `select to_jsonb(set_config('app.protected_user_maintenance', 'on', true))`, nil); err != nil { + return err + } + _, err := tx.Exec(ctx, `update users set protected = false where id = :u`, db.Args{"u": id}) + return err + }) + if err != nil { + t.Fatalf("maintenance override: %v", err) + } + e.Delete(up+"/roles/role_platform_superadmin", e.Admin).Expect(204) + + // The next deploy's ensure restores the role and the protection. + if _, _, err := e.Core.EnsureProtectedSuperadmin(ctx, addr); err != nil { + t.Fatal(err) + } + e.Delete(up+"/roles/role_platform_superadmin", e.Admin).ExpectErr(409, "user_protected") +} diff --git a/internal/core/users.go b/internal/core/users.go index 7285c7a..ddfd3de 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -240,6 +240,11 @@ func (s *Server) UsersUpdate(w http.ResponseWriter, r *http.Request, id gen.User if err := f.Err(); err != nil { return err } + if (newStatus != "" && newStatus != "active") || (newEmail != "" && !strings.EqualFold(newEmail, u.Email)) { + if err := c.guardProtected(u.ID); err != nil { + return err + } + } if err := c.destructiveIf(newStatus == "suspended" && u.Status != "suspended"); err != nil { return err } @@ -297,6 +302,22 @@ func (s *Server) UsersUpdate(w http.ResponseWriter, r *http.Request, id gen.User // transitionUser applies a users.manage status change (Users → PATCH // transition table), with its sessions, events, and audit. +// guardProtected refuses any change that would lock out the protected +// superadmin (migration 0004): suspend, delete, erase, re-address, or +// demote it. The database enforces the same rule for every caller; this +// turns it into a clear API error instead of a failed write. +func (c *Call) guardProtected(userID string) error { + rows, err := c.q.Query(c.ctx, `select to_jsonb(true) from users where id = :u and protected`, db.Args{"u": userID}) + if err != nil { + return err + } + if len(rows) > 0 { + return httpx.Conflict("user_protected", + "This User is the platform's protected superadmin; it can't be suspended, deleted, erased, re-addressed, or demoted.") + } + return nil +} + func (c *Call) transitionUser(u userRow, to, restoreEmail string) error { bad := func() error { return httpx.Conflict("invalid_status_transition", "The status change from "+u.Status+" to "+to+" isn't allowed.") @@ -449,6 +470,9 @@ func (s *Server) UsersDelete(w http.ResponseWriter, r *http.Request, id gen.User if u.Status == "deleted" { return c.NoContent() } + if err := c.guardProtected(u.ID); err != nil { + return err + } if err := c.softDeleteUser(u); err != nil { return err } @@ -480,6 +504,9 @@ func (s *Server) UsersSuspend(w http.ResponseWriter, r *http.Request, id gen.Use if err != nil { return err } + if err := c.guardProtected(u.ID); err != nil { + return err + } switch u.Status { case "suspended": case "active": @@ -549,6 +576,9 @@ func (s *Server) UsersRequestErasure(w http.ResponseWriter, r *http.Request, id if err != nil { return err } + if err := c.guardProtected(u.ID); err != nil { + return err + } var existing erasureRow err = db.One(c.ctx, c.q, &existing, `select jsonb_build_object('user_id', user_id, 'status', status, 'requested_at', requested_at, 'scheduled_for', scheduled_for) from erasure_requests where user_id = :u`, diff --git a/migrations/0004_protected_superadmin.sql b/migrations/0004_protected_superadmin.sql new file mode 100644 index 0000000..963541f --- /dev/null +++ b/migrations/0004_protected_superadmin.sql @@ -0,0 +1,71 @@ +-- 0004_protected_superadmin: a superadmin that can never be removed. +-- +-- A protected User is the platform's owner account. Every deploy re-asserts +-- it (cmd/migrate, SUPERADMIN_EMAIL), and the database refuses anything +-- that would lock it out, for every caller, including a direct SQL session: +-- it stays active and undeleted, keeps its email and live mode, keeps the +-- superadmin Role, and can't be erased. +-- +-- The guard can be lifted for one transaction with +-- SET LOCAL app.protected_user_maintenance = 'on' (ops only, never the API), +-- for example to hand ownership to a different account. +-- +-- Numbered 0004 so it can ship ahead of 0003_push; the two are independent. + +alter table users add column protected boolean not null default false; + +create function substratal_util.protected_user_maintenance() returns boolean +language sql stable as $$ + select coalesce(current_setting('app.protected_user_maintenance', true), '') = 'on' +$$; + +create function substratal_util.protect_users() returns trigger language plpgsql as $$ +begin + if substratal_util.protected_user_maintenance() then + if tg_op = 'DELETE' then return old; end if; + return new; + end if; + if tg_op = 'DELETE' then + if old.protected then + raise exception 'user % is protected and can''t be deleted', old.id using errcode = '42501'; + end if; + return old; + end if; + if old.protected and (not new.protected or new.status <> 'active' or new.deleted_at is not null + or new.email is distinct from old.email or new.test_mode <> old.test_mode) then + raise exception 'user % is protected: it must stay an active, undeleted superadmin with the same email', old.id + using errcode = '42501'; + end if; + return new; +end $$; + +create trigger users_protected before update or delete on users + for each row execute function substratal_util.protect_users(); + +create function substratal_util.protect_superadmin_role() returns trigger language plpgsql as $$ +begin + if not substratal_util.protected_user_maintenance() + and old.role_id = 'role_platform_superadmin' + and (tg_op = 'DELETE' or new.role_id <> old.role_id or new.user_id <> old.user_id) + and exists (select 1 from users u where u.id = old.user_id and u.protected) then + raise exception 'user % is protected and keeps the superadmin role', old.user_id using errcode = '42501'; + end if; + if tg_op = 'DELETE' then return old; end if; + return new; +end $$; + +create trigger user_role_assignments_protected before update or delete on user_role_assignments + for each row execute function substratal_util.protect_superadmin_role(); + +create function substratal_util.protect_erasure() returns trigger language plpgsql as $$ +begin + if not substratal_util.protected_user_maintenance() + and new.status = 'scheduled' + and exists (select 1 from users u where u.id = new.user_id and u.protected) then + raise exception 'user % is protected and can''t be erased', new.user_id using errcode = '42501'; + end if; + return new; +end $$; + +create trigger erasure_requests_protected before insert or update on erasure_requests + for each row execute function substratal_util.protect_erasure();