Service principals
Non-human organization members that can't sign in, hold a role through a membership, and run organization-owned workflows and service webhook endpoints.
service-principals gives an organization members that aren't people: an
automation account, an integration, a nightly job. Each one is an
auth.users row that can't sign in and holds its role through a tenant
membership, so RLS, member_can and your authorization provider treat it
like any member with that role, and the audit log names it.
better-supabase sql add service-principalsimport { createServicePrincipals } from "better-supabase/blocks/service-principals";Create one
create(organizationId, name, role) creates the user through the Auth admin
API, then registers it. The user is banned for about 100 years, has no
password, gets an address under service-principals.invalid and carries
app_metadata.bs_service_principal, so nobody can sign in as it. If the
registration is refused, the user is deleted again.
"use server";
import {
createServicePrincipals,
sqlTransport,
} from "better-supabase/blocks/service-principals";
const principals = createServicePrincipals({
transport: sqlTransport(postgres.admin),
admin: bs.admin().$client.auth.admin,
});
export async function addAutomationAccount(organizationId: string) {
const ctx = await bs.context();
return principals
.create(organizationId, "Automations", "member", {
default: true,
createdBy: ctx.auth.claims.sub,
})
.orThrow();
}Every function except list is service-role only. Pass createdBy when a
member asks for the change: that member needs service_principals.manage
and must be allowed to assign the role (can_assign_as), so nobody hands a
principal more than they hold. Without createdBy the service acts on its
own, for setup scripts and migrations.
| Method | What it does |
|---|---|
create(org, name, role, opts?) | Creates and registers a principal; default: true makes it the default |
setRole(id, role, opts?) | Changes its role and its membership |
disable(id, opts?) | Removes its membership and its default; the user row stays for the audit log |
defaultFor(org) | The organization's default principal, or null |
list(org) | The organization's principals, through the service_principals.read policy |
register_service_principal refuses a user without the
bs_service_principal flag or without a ban, so an existing account never
becomes a principal.
What runs as the default principal
Each organization has at most one default principal. Two features resolve
organization-owned work to it, so you don't need an automation.runAs hook
for the common case:
- Workflow builder. A version published by the service (owner
organization) runs as the default principal.workflow_run_principalchecks on every step that its role still holds every permission the version stored, and refuses the step withWORKFLOW_PUBLISHER_REVOKEDotherwise. Without arunAshook,versions.runAsreturns{ role: "authenticated", sub, tenant_id, bs_permission_cap }, the principal capped to the stored permissions. Anautomation.runAshook still overrides it. See Workflow builder. - Outgoing webhooks. A service endpoint (one without
created_by) gets an event only when the default principal holds the event's read permission. Without a default principal, the endpoint's declaredpermissionsdecide, as before. See Outgoing webhooks.
Disabling the default principal stops both: workflow steps fail with "This organization has no default service principal" and service endpoints fall back to their declared permissions.
Permissions
| Operation | Key | Default roles |
|---|---|---|
service-principals.read | service_principals.read | admin, owner |
service-principals.manage | service_principals.manage | admin, owner |
Under the provider model, the membership row the module writes is what your
provider reads, so a provider that keeps roles elsewhere must give the
principal its role there too. With sql.modules.tenant.mode: "custom" the
module writes no membership at all. If you switch users off through
sql.modules.access.disabled.user with an active-row table (such as
profiles), add a row for each principal; a user without one is disabled.
Events
service_principal.created, service_principal.role_changed and
service_principal.disabled go to the outbox and the audit log under the
access category. See Events.
SQL
| Function | Who |
|---|---|
register_service_principal(principal, tenant, name, role, is_default, created_by) | service_role |
set_service_principal_role(principal, role, created_by) | service_role |
disable_service_principal(principal, created_by) | service_role |
default_service_principal(tenant) | service_role |
service_principals_list(tenant) | members |
Last updated on
Identities
Link an email address or a chat user to an app user or a contact, through a one-time code the signed-in user issues or a confirmed address.
Chat SDK
Run Chat SDK bots on Supabase with a Postgres state adapter, an inbox adapter for the in-app widget, and helpers that record Slack, WhatsApp and SMS messages in the inbox.