@plutocms/supabase 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +7 -0
  3. package/FEATURES.md +5 -1
  4. package/README.md +66 -0
  5. package/app/components/access/CopyButton.vue +24 -0
  6. package/app/components/access/DeleteRoleModal.vue +73 -0
  7. package/app/components/access/EditRolesSlideover.vue +134 -0
  8. package/app/components/access/InviteModal.vue +288 -0
  9. package/app/components/access/LinkField.vue +19 -0
  10. package/app/components/access/LinkModal.vue +29 -0
  11. package/app/components/access/RevokeModal.vue +83 -0
  12. package/app/components/access/RoleSlideover.vue +251 -0
  13. package/app/components/mail/AuthEmailsPanel.vue +108 -0
  14. package/app/pages/admin/(auth)/accept-invite.vue +155 -0
  15. package/app/pages/admin/settings/roles.vue +187 -0
  16. package/app/pages/admin/settings/users.vue +372 -0
  17. package/app/plugins/pluto-extension.ts +38 -1
  18. package/app/utils/access-ui.ts +100 -0
  19. package/app/utils/auth-driver.ts +3 -0
  20. package/app/utils/invite-link.ts +70 -0
  21. package/app/utils/supabase-project.ts +54 -0
  22. package/db/migrations/008_users_and_roles_admin.sql +462 -0
  23. package/i18n/locales/en.json +194 -1
  24. package/i18n/locales/pt-BR.json +194 -1
  25. package/package.json +3 -2
  26. package/server/api/access/capabilities.get.ts +17 -0
  27. package/server/api/access/roles/[key].delete.ts +18 -0
  28. package/server/api/access/roles/[key].put.ts +26 -0
  29. package/server/api/access/roles.get.ts +17 -0
  30. package/server/api/access/roles.post.ts +25 -0
  31. package/server/api/access/users/[id]/resend.post.ts +172 -0
  32. package/server/api/access/users/[id]/roles.delete.ts +22 -0
  33. package/server/api/access/users/[id]/roles.put.ts +24 -0
  34. package/server/api/access/users/invite-link.post.ts +50 -0
  35. package/server/api/access/users/invite.post.ts +201 -0
  36. package/server/api/access/users.get.ts +17 -0
  37. package/server/utils/access-errors.ts +148 -0
  38. package/server/utils/access-invite.ts +66 -0
  39. package/server/utils/access-mail.ts +354 -0
  40. package/server/utils/admin-guard.ts +4 -3
  41. package/server/utils/admin-role-guard.ts +44 -0
  42. package/server/utils/rate-limit.ts +84 -0
  43. package/server/utils/service-client.ts +18 -0
  44. package/server/utils/sign-in-link.ts +129 -0
  45. package/shared/types/access.d.ts +41 -0
  46. package/shared/types/supabase.ts +50 -9
@@ -0,0 +1,100 @@
1
+ // Pure helpers of the Users and Roles pages. This file does not use Nuxt
2
+ // auto-imports, so plain Vitest can import it.
3
+ import type { AccessRole, AccessUser } from '../../shared/types/access'
4
+
5
+ /** The role key of the administrator. It cannot be edited or removed. */
6
+ export const ADMIN_ROLE_KEY = 'admin'
7
+
8
+ /** The role key pattern of the access API. */
9
+ export const ROLE_KEY_PATTERN = /^[a-z0-9_]+$/
10
+
11
+ /** The longest role key the API accepts. */
12
+ export const ROLE_KEY_MAX = 50
13
+
14
+ /**
15
+ * Makes a role key from a label: lower case, a-z and 0-9, words joined by an
16
+ * underscore, accents removed. Returns an empty string when nothing is left.
17
+ */
18
+ export function slugifyRoleKey(label: string): string {
19
+ return label
20
+ .normalize('NFD')
21
+ .replace(/\p{M}/gu, '')
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, '_')
24
+ .replace(/^_+|_+$/g, '')
25
+ .slice(0, ROLE_KEY_MAX)
26
+ .replace(/_+$/g, '')
27
+ }
28
+
29
+ export function isValidRoleKey(key: string): boolean {
30
+ return key.length > 0 && key.length <= ROLE_KEY_MAX && ROLE_KEY_PATTERN.test(key)
31
+ }
32
+
33
+ /** `invited` until the user accepts the invite, then `active`. */
34
+ export function userStatus(user: Pick<AccessUser, 'email_confirmed_at'>): 'active' | 'invited' {
35
+ return user.email_confirmed_at ? 'active' : 'invited'
36
+ }
37
+
38
+ /**
39
+ * True when the user is an administrator. The `admin` role or the profile flag
40
+ * counts, the same as `public.is_admin()`.
41
+ */
42
+ export function isAdminUser(user: Pick<AccessUser, 'is_admin' | 'roles'>): boolean {
43
+ return user.is_admin || user.roles.includes(ADMIN_ROLE_KEY)
44
+ }
45
+
46
+ /** The name to show for a user: the display name, the username, else the email. */
47
+ export function userDisplayName(user: Pick<AccessUser, 'display_name' | 'username' | 'email'>): string {
48
+ return user.display_name?.trim() || user.username?.trim() || user.email?.trim() || ''
49
+ }
50
+
51
+ /** Looks a role label up by key. A key with no role stays as it is. */
52
+ export function roleLabelOf(roles: AccessRole[], key: string): string {
53
+ return roles.find((role) => role.key === key)?.label ?? key
54
+ }
55
+
56
+ /** The two lists hold the same keys, in any order. */
57
+ export function sameKeys(a: string[], b: string[]): boolean {
58
+ const left = [...a].toSorted()
59
+ const right = [...b].toSorted()
60
+
61
+ return left.length === right.length && left.every((key, index) => key === right[index])
62
+ }
63
+
64
+ /**
65
+ * The error `i18n` key that the server sent (`createPlutoError`), or
66
+ * undefined. An `ofetch` error holds the body in `data`.
67
+ */
68
+ export function errorKeyOf(error: unknown): string | undefined {
69
+ const body = (error as { data?: { data?: { i18n?: { key?: unknown } } } } | null)?.data
70
+ const key = body?.data?.i18n?.key
71
+
72
+ return typeof key === 'string' ? key : undefined
73
+ }
74
+
75
+ /**
76
+ * The keys of the invite errors that come from the mailer. The invite modal
77
+ * shows the cause, and offers a link to copy or the email setup.
78
+ */
79
+ export const INVITE_MAIL_ERROR_KEYS = [
80
+ 'supabase.access.errors.emailNotAuthorized',
81
+ 'supabase.access.errors.emailRateLimited',
82
+ 'supabase.access.errors.emailInvalid',
83
+ ] as const
84
+
85
+ export function isInviteMailError(error: unknown): boolean {
86
+ const key = errorKeyOf(error)
87
+
88
+ return key !== undefined && (INVITE_MAIL_ERROR_KEYS as readonly string[]).includes(key)
89
+ }
90
+
91
+ /** The name of a language in that language, for example `Português (Brasil)`. */
92
+ export function languageName(code: string): string {
93
+ try {
94
+ const name = new Intl.DisplayNames([code], { type: 'language' }).of(code)
95
+
96
+ return name ? name.charAt(0).toLocaleUpperCase(code) + name.slice(1) : code
97
+ } catch {
98
+ return code
99
+ }
100
+ }
@@ -50,6 +50,8 @@ export function mapSupabaseUser(
50
50
  * - `signup` and `forgot-password` send a signed-in user to the admin home.
51
51
  * - `confirm` and `update-password` stay open: a recovery or confirm link
52
52
  * signs the user in, and the page must still show.
53
+ * - `accept-invite` stays open: an invite link signs the user in, and the page
54
+ * must still show the password form.
53
55
  * - `setup` stays open for the auth guard. The setup guard
54
56
  * (`guard` in `pluto-extension.ts`) blocks it when the setup is done.
55
57
  */
@@ -58,6 +60,7 @@ export const SUPABASE_PUBLIC_PATHS: PlutoAuthPublicPath[] = [
58
60
  { path: '/admin/forgot-password', signedIn: 'redirect' },
59
61
  { path: '/admin/confirm', signedIn: 'allow' },
60
62
  { path: '/admin/update-password', signedIn: 'allow' },
63
+ { path: '/admin/accept-invite', signedIn: 'allow' },
61
64
  { path: '/admin/setup', signedIn: 'allow' },
62
65
  ]
63
66
 
@@ -0,0 +1,70 @@
1
+ // Pure helper of the accept-invite page. This file does not use Nuxt
2
+ // auto-imports, so plain Vitest can import it.
3
+
4
+ /** The link types that the page can verify. */
5
+ export type InviteOtpType = 'invite' | 'magiclink' | 'signup'
6
+
7
+ const OTP_TYPES: readonly string[] = ['invite', 'magiclink', 'signup']
8
+
9
+ /**
10
+ * What an invite link holds.
11
+ *
12
+ * - `otp`: `?token_hash=...&type=invite`. It comes from the custom invite
13
+ * email template, or from the link that the admin copies or Pluto mail
14
+ * sends. The type is `invite`, or `magiclink` for an account that
15
+ * exists (`signup` for an account that did not confirm). The page calls
16
+ * `verifyOtp` with that type.
17
+ * - `session`: `#access_token=...&refresh_token=...`. It comes from the
18
+ * default invite email (the implicit flow). The page calls `setSession`.
19
+ * - `error`: `#error_code=otp_expired`. GoTrue could not accept the link.
20
+ * - `none`: the link holds nothing. The page then looks for a session.
21
+ */
22
+ export type InviteLink =
23
+ | { kind: 'otp', tokenHash: string, type: InviteOtpType }
24
+ | { kind: 'session', accessToken: string, refreshToken: string }
25
+ | { kind: 'error', code: string, description?: string }
26
+ | { kind: 'none' }
27
+
28
+ type QueryValue = string | null | undefined | Array<string | null>
29
+
30
+ function first(value: QueryValue): string | undefined {
31
+ const item = Array.isArray(value) ? value[0] : value
32
+
33
+ return typeof item === 'string' && item !== '' ? item : undefined
34
+ }
35
+
36
+ /**
37
+ * Reads an invite link. `query` is the route query. `hash` is the URL hash,
38
+ * with or without the leading `#`. The query wins over the hash.
39
+ */
40
+ export function parseInviteLink(
41
+ query: Record<string, QueryValue> | null | undefined,
42
+ hash: string | null | undefined
43
+ ): InviteLink {
44
+ const fragment = new URLSearchParams((hash ?? '').replace(/^#/, ''))
45
+ const q = query ?? {}
46
+
47
+ const tokenHash = first(q.token_hash)
48
+ const type = first(q.type)
49
+ if (tokenHash && type && OTP_TYPES.includes(type)) {
50
+ return { kind: 'otp', tokenHash, type: type as InviteOtpType }
51
+ }
52
+
53
+ const accessToken = fragment.get('access_token')
54
+ const refreshToken = fragment.get('refresh_token')
55
+ if (accessToken && refreshToken) {
56
+ return { kind: 'session', accessToken, refreshToken }
57
+ }
58
+
59
+ const code = fragment.get('error_code') ?? first(q.error_code)
60
+ if (code) {
61
+ return {
62
+ kind: 'error',
63
+ code,
64
+ description:
65
+ fragment.get('error_description') ?? first(q.error_description),
66
+ }
67
+ }
68
+
69
+ return { kind: 'none' }
70
+ }
@@ -0,0 +1,54 @@
1
+ // Pure helpers of the "Supabase sign-in emails" panel. This file does not use
2
+ // Nuxt auto-imports, so plain Vitest can import it.
3
+
4
+ /** The SMTP values of Resend, for the Supabase custom SMTP form. */
5
+ export const RESEND_SMTP = {
6
+ host: 'smtp.resend.com',
7
+ port: '465',
8
+ username: 'resend',
9
+ } as const
10
+
11
+ /** The page where Resend connects itself to a Supabase project. */
12
+ export const RESEND_SUPABASE_INTEGRATION_URL = 'https://resend.com/settings/integrations'
13
+
14
+ /**
15
+ * Reads the project ref from the Supabase URL: `https://<ref>.supabase.co`
16
+ * (and `.supabase.red`). A custom domain, a local URL or a bad value gives
17
+ * null.
18
+ */
19
+ export function supabaseProjectRef(url: string | null | undefined): string | null {
20
+ if (!url) {
21
+ return null
22
+ }
23
+
24
+ try {
25
+ const { hostname } = new URL(url)
26
+ const match = /^([a-z0-9]{10,})\.supabase\.(?:co|red)$/.exec(hostname.toLowerCase())
27
+
28
+ return match?.[1] ?? null
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ export interface SupabaseDashboardLinks {
35
+ project: string
36
+ smtp: string
37
+ templates: string
38
+ rateLimits: string
39
+ }
40
+
41
+ /**
42
+ * The dashboard pages of a project. The deep links are best effort: Supabase
43
+ * can move a page, and the panel says so.
44
+ */
45
+ export function supabaseDashboardLinks(ref: string): SupabaseDashboardLinks {
46
+ const base = `https://supabase.com/dashboard/project/${ref}`
47
+
48
+ return {
49
+ project: base,
50
+ smtp: `${base}/auth/smtp`,
51
+ templates: `${base}/auth/templates`,
52
+ rateLimits: `${base}/auth/rate-limits`,
53
+ }
54
+ }
@@ -0,0 +1,462 @@
1
+ -- Users and roles admin
2
+ --
3
+ -- Functions for the access API (server/api/access/**). They let an admin
4
+ -- list staff and roles, change the roles of a user, and manage roles.
5
+ -- This file creates no table, so it needs no table grant.
6
+ --
7
+ -- Rules:
8
+ -- - Every function is security definer with an empty search_path.
9
+ -- - Every write needs public.is_admin(). There is no users:manage
10
+ -- capability. A user with users:read may list staff and roles.
11
+ -- - A rule error is P0001 with the message 'pluto:<code>'. A missing right
12
+ -- is 42501 with the message 'pluto:forbidden'.
13
+ -- - set_user_roles and add_user_roles keep profiles.is_admin in step with
14
+ -- the 'admin' role. So a revoke removes the first admin too, and
15
+ -- requireAdmin (it reads the column) sees an admin that has the role only.
16
+ --
17
+ -- Every statement is safe to run twice.
18
+
19
+ -- Staff: a user with at least one role, or with profiles.is_admin.
20
+ create or replace function public.list_staff_users()
21
+ returns table (
22
+ id uuid,
23
+ email text,
24
+ display_name text,
25
+ username text,
26
+ is_admin boolean,
27
+ roles text[],
28
+ invited_at timestamptz,
29
+ email_confirmed_at timestamptz,
30
+ last_sign_in_at timestamptz
31
+ )
32
+ language plpgsql
33
+ security definer
34
+ set search_path = ''
35
+ stable
36
+ as $$
37
+ begin
38
+ if not public.has_capability('users:read') then
39
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
40
+ end if;
41
+
42
+ return query
43
+ select
44
+ u.id,
45
+ u.email::text,
46
+ p.display_name,
47
+ p.username,
48
+ (
49
+ coalesce(p.is_admin, false)
50
+ or exists (
51
+ select 1 from public.user_roles a
52
+ where a.user_id = u.id and a.role_key = 'admin'
53
+ )
54
+ ),
55
+ coalesce(
56
+ array(
57
+ select ur.role_key from public.user_roles ur
58
+ where ur.user_id = u.id
59
+ order by ur.role_key
60
+ ),
61
+ '{}'::text[]
62
+ ),
63
+ u.invited_at,
64
+ u.email_confirmed_at,
65
+ u.last_sign_in_at
66
+ from auth.users u
67
+ left join public.profiles p on p.id = u.id
68
+ where coalesce(p.is_admin, false)
69
+ or exists (select 1 from public.user_roles s where s.user_id = u.id)
70
+ order by u.created_at;
71
+ end;
72
+ $$;
73
+
74
+ create or replace function public.list_roles()
75
+ returns table (
76
+ key text,
77
+ label text,
78
+ description text,
79
+ is_builtin boolean,
80
+ capabilities text[],
81
+ user_count integer
82
+ )
83
+ language plpgsql
84
+ security definer
85
+ set search_path = ''
86
+ stable
87
+ as $$
88
+ begin
89
+ if not public.has_capability('users:read') then
90
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
91
+ end if;
92
+
93
+ return query
94
+ select
95
+ r.key,
96
+ r.label,
97
+ r.description,
98
+ r.is_builtin,
99
+ coalesce(
100
+ array(
101
+ select rc.capability from public.role_capabilities rc
102
+ where rc.role_key = r.key
103
+ order by rc.capability
104
+ ),
105
+ '{}'::text[]
106
+ ),
107
+ (select count(*)::integer from public.user_roles ur where ur.role_key = r.key)
108
+ from public.roles r
109
+ order by r.is_builtin desc, r.key;
110
+ end;
111
+ $$;
112
+
113
+ create or replace function public.find_user_id_by_email(p_email text)
114
+ returns uuid
115
+ language plpgsql
116
+ security definer
117
+ set search_path = ''
118
+ stable
119
+ as $$
120
+ declare
121
+ found_id uuid;
122
+ begin
123
+ if not public.is_admin() then
124
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
125
+ end if;
126
+
127
+ select u.id into found_id
128
+ from auth.users u
129
+ where lower(u.email) = lower(trim(p_email))
130
+ limit 1;
131
+
132
+ return found_id;
133
+ end;
134
+ $$;
135
+
136
+ -- Replaces the roles of a user. An empty array removes every role.
137
+ create or replace function public.set_user_roles(target uuid, role_keys text[])
138
+ returns void
139
+ language plpgsql
140
+ security definer
141
+ set search_path = ''
142
+ as $$
143
+ declare
144
+ keys text[];
145
+ target_is_admin boolean;
146
+ begin
147
+ if not public.is_admin() then
148
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
149
+ end if;
150
+
151
+ perform pg_advisory_xact_lock(hashtext('pluto.user_roles'));
152
+
153
+ if not exists (select 1 from auth.users u where u.id = target) then
154
+ raise exception using errcode = 'P0001', message = 'pluto:user_not_found';
155
+ end if;
156
+
157
+ select coalesce(array(select distinct k from unnest(role_keys) as k), '{}'::text[])
158
+ into keys;
159
+
160
+ if exists (
161
+ select 1 from unnest(keys) as k
162
+ where not exists (select 1 from public.roles r where r.key = k)
163
+ ) then
164
+ raise exception using errcode = 'P0001', message = 'pluto:unknown_role';
165
+ end if;
166
+
167
+ target_is_admin := coalesce(
168
+ (select p.is_admin from public.profiles p where p.id = target),
169
+ false
170
+ ) or exists (
171
+ select 1 from public.user_roles ur
172
+ where ur.user_id = target and ur.role_key = 'admin'
173
+ );
174
+
175
+ -- Do not remove the last admin.
176
+ if target_is_admin and not ('admin' = any(keys)) then
177
+ if not exists (
178
+ select 1 from public.profiles p
179
+ where p.is_admin and p.id <> target
180
+ union all
181
+ select 1 from public.user_roles ur
182
+ where ur.role_key = 'admin' and ur.user_id <> target
183
+ ) then
184
+ raise exception using errcode = 'P0001', message = 'pluto:last_admin';
185
+ end if;
186
+ end if;
187
+
188
+ delete from public.user_roles ur
189
+ where ur.user_id = target and ur.role_key <> all (keys);
190
+
191
+ insert into public.user_roles (user_id, role_key)
192
+ select target, k from unnest(keys) as k
193
+ on conflict do nothing;
194
+
195
+ update public.profiles p
196
+ set is_admin = ('admin' = any(keys))
197
+ where p.id = target;
198
+ end;
199
+ $$;
200
+
201
+ -- Adds roles to a user. It keeps the roles that the user has.
202
+ create or replace function public.add_user_roles(target uuid, role_keys text[])
203
+ returns void
204
+ language plpgsql
205
+ security definer
206
+ set search_path = ''
207
+ as $$
208
+ declare
209
+ keys text[];
210
+ begin
211
+ if not public.is_admin() then
212
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
213
+ end if;
214
+
215
+ perform pg_advisory_xact_lock(hashtext('pluto.user_roles'));
216
+
217
+ if not exists (select 1 from auth.users u where u.id = target) then
218
+ raise exception using errcode = 'P0001', message = 'pluto:user_not_found';
219
+ end if;
220
+
221
+ select coalesce(array(select distinct k from unnest(role_keys) as k), '{}'::text[])
222
+ into keys;
223
+
224
+ if exists (
225
+ select 1 from unnest(keys) as k
226
+ where not exists (select 1 from public.roles r where r.key = k)
227
+ ) then
228
+ raise exception using errcode = 'P0001', message = 'pluto:unknown_role';
229
+ end if;
230
+
231
+ insert into public.user_roles (user_id, role_key)
232
+ select target, k from unnest(keys) as k
233
+ on conflict do nothing;
234
+
235
+ if 'admin' = any(keys) then
236
+ update public.profiles p set is_admin = true where p.id = target;
237
+ end if;
238
+ end;
239
+ $$;
240
+
241
+ create or replace function public.create_role(
242
+ p_key text,
243
+ p_label text,
244
+ p_description text,
245
+ p_capabilities text[]
246
+ )
247
+ returns void
248
+ language plpgsql
249
+ security definer
250
+ set search_path = ''
251
+ as $$
252
+ declare
253
+ caps text[];
254
+ begin
255
+ if not public.is_admin() then
256
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
257
+ end if;
258
+
259
+ perform pg_advisory_xact_lock(hashtext('pluto.user_roles'));
260
+
261
+ if p_key is null or p_key !~ '^[a-z0-9_]+$' then
262
+ raise exception using errcode = 'P0001', message = 'pluto:invalid_key';
263
+ end if;
264
+
265
+ if p_label is null or trim(p_label) = '' then
266
+ raise exception using errcode = 'P0001', message = 'pluto:invalid_label';
267
+ end if;
268
+
269
+ select coalesce(array(select distinct c from unnest(p_capabilities) as c), '{}'::text[])
270
+ into caps;
271
+
272
+ if '*' = any(caps) then
273
+ raise exception using errcode = 'P0001', message = 'pluto:wildcard';
274
+ end if;
275
+
276
+ if cardinality(caps) = 0 then
277
+ raise exception using errcode = 'P0001', message = 'pluto:empty_role';
278
+ end if;
279
+
280
+ if exists (
281
+ select 1 from unnest(caps) as c where c !~ '^[a-z0-9_]+:[a-z0-9_]+$'
282
+ ) then
283
+ raise exception using errcode = 'P0001', message = 'pluto:invalid_capability';
284
+ end if;
285
+
286
+ if exists (select 1 from public.roles r where r.key = p_key) then
287
+ raise exception using errcode = 'P0001', message = 'pluto:role_exists';
288
+ end if;
289
+
290
+ insert into public.roles (key, label, description, is_builtin)
291
+ values (p_key, trim(p_label), nullif(trim(coalesce(p_description, '')), ''), false);
292
+
293
+ insert into public.role_capabilities (role_key, capability)
294
+ select p_key, c from unnest(caps) as c;
295
+ end;
296
+ $$;
297
+
298
+ -- Edits a role and replaces its capabilities in one transaction.
299
+ create or replace function public.update_role(
300
+ p_key text,
301
+ p_label text,
302
+ p_description text,
303
+ p_capabilities text[]
304
+ )
305
+ returns void
306
+ language plpgsql
307
+ security definer
308
+ set search_path = ''
309
+ as $$
310
+ declare
311
+ caps text[];
312
+ begin
313
+ if not public.is_admin() then
314
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
315
+ end if;
316
+
317
+ perform pg_advisory_xact_lock(hashtext('pluto.user_roles'));
318
+
319
+ if p_key = 'admin' then
320
+ raise exception using errcode = 'P0001', message = 'pluto:builtin_role';
321
+ end if;
322
+
323
+ if not exists (select 1 from public.roles r where r.key = p_key) then
324
+ raise exception using errcode = 'P0001', message = 'pluto:role_not_found';
325
+ end if;
326
+
327
+ if p_label is null or trim(p_label) = '' then
328
+ raise exception using errcode = 'P0001', message = 'pluto:invalid_label';
329
+ end if;
330
+
331
+ select coalesce(array(select distinct c from unnest(p_capabilities) as c), '{}'::text[])
332
+ into caps;
333
+
334
+ if '*' = any(caps) then
335
+ raise exception using errcode = 'P0001', message = 'pluto:wildcard';
336
+ end if;
337
+
338
+ if cardinality(caps) = 0 then
339
+ raise exception using errcode = 'P0001', message = 'pluto:empty_role';
340
+ end if;
341
+
342
+ if exists (
343
+ select 1 from unnest(caps) as c where c !~ '^[a-z0-9_]+:[a-z0-9_]+$'
344
+ ) then
345
+ raise exception using errcode = 'P0001', message = 'pluto:invalid_capability';
346
+ end if;
347
+
348
+ update public.roles r
349
+ set label = trim(p_label),
350
+ description = nullif(trim(coalesce(p_description, '')), '')
351
+ where r.key = p_key;
352
+
353
+ delete from public.role_capabilities rc where rc.role_key = p_key;
354
+
355
+ insert into public.role_capabilities (role_key, capability)
356
+ select p_key, c from unnest(caps) as c;
357
+ end;
358
+ $$;
359
+
360
+ -- Deletes a role. It returns the number of users who lost the role.
361
+ create or replace function public.delete_role(p_key text)
362
+ returns integer
363
+ language plpgsql
364
+ security definer
365
+ set search_path = ''
366
+ as $$
367
+ declare
368
+ builtin boolean;
369
+ affected integer;
370
+ begin
371
+ if not public.is_admin() then
372
+ raise exception using errcode = '42501', message = 'pluto:forbidden';
373
+ end if;
374
+
375
+ perform pg_advisory_xact_lock(hashtext('pluto.user_roles'));
376
+
377
+ select r.is_builtin into builtin from public.roles r where r.key = p_key;
378
+
379
+ if builtin is null then
380
+ raise exception using errcode = 'P0001', message = 'pluto:role_not_found';
381
+ end if;
382
+
383
+ if builtin or p_key = 'admin' then
384
+ raise exception using errcode = 'P0001', message = 'pluto:builtin_role';
385
+ end if;
386
+
387
+ select count(*)::integer into affected
388
+ from public.user_roles ur where ur.role_key = p_key;
389
+
390
+ -- The cascade removes the user_roles and role_capabilities rows.
391
+ delete from public.roles r where r.key = p_key;
392
+
393
+ return affected;
394
+ end;
395
+ $$;
396
+
397
+ -- Execute rights. Only a signed-in user and the service role may call them.
398
+ -- Each function checks the right of the caller inside.
399
+ revoke execute on function public.list_staff_users() from public, anon;
400
+ grant execute on function public.list_staff_users() to authenticated, service_role;
401
+
402
+ revoke execute on function public.list_roles() from public, anon;
403
+ grant execute on function public.list_roles() to authenticated, service_role;
404
+
405
+ revoke execute on function public.find_user_id_by_email(text) from public, anon;
406
+ grant execute on function public.find_user_id_by_email(text) to authenticated, service_role;
407
+
408
+ revoke execute on function public.set_user_roles(uuid, text[]) from public, anon;
409
+ grant execute on function public.set_user_roles(uuid, text[]) to authenticated, service_role;
410
+
411
+ revoke execute on function public.add_user_roles(uuid, text[]) from public, anon;
412
+ grant execute on function public.add_user_roles(uuid, text[]) to authenticated, service_role;
413
+
414
+ revoke execute on function public.create_role(text, text, text, text[]) from public, anon;
415
+ grant execute on function public.create_role(text, text, text, text[]) to authenticated, service_role;
416
+
417
+ revoke execute on function public.update_role(text, text, text, text[]) from public, anon;
418
+ grant execute on function public.update_role(text, text, text, text[]) to authenticated, service_role;
419
+
420
+ revoke execute on function public.delete_role(text) from public, anon;
421
+ grant execute on function public.delete_role(text) to authenticated, service_role;
422
+
423
+ -- Policy tweaks. The functions above are the way to edit roles. These
424
+ -- policies stop a direct write through the Data API from breaking the
425
+ -- rules: the built-in roles stay, 'admin' keeps its wildcard, and no other
426
+ -- role gets the wildcard.
427
+ drop policy if exists "Enable delete for admins on roles" on public.roles;
428
+
429
+ create policy "Enable delete for admins on roles"
430
+ on public.roles
431
+ for delete
432
+ to authenticated
433
+ using (public.is_admin() and not is_builtin);
434
+
435
+ drop policy if exists "Enable insert for admins on role_capabilities" on public.role_capabilities;
436
+
437
+ create policy "Enable insert for admins on role_capabilities"
438
+ on public.role_capabilities
439
+ for insert
440
+ to authenticated
441
+ with check (
442
+ public.is_admin() and role_key <> 'admin' and capability <> '*'
443
+ );
444
+
445
+ drop policy if exists "Enable update for admins on role_capabilities" on public.role_capabilities;
446
+
447
+ create policy "Enable update for admins on role_capabilities"
448
+ on public.role_capabilities
449
+ for update
450
+ to authenticated
451
+ using (public.is_admin() and role_key <> 'admin')
452
+ with check (
453
+ public.is_admin() and role_key <> 'admin' and capability <> '*'
454
+ );
455
+
456
+ drop policy if exists "Enable delete for admins on role_capabilities" on public.role_capabilities;
457
+
458
+ create policy "Enable delete for admins on role_capabilities"
459
+ on public.role_capabilities
460
+ for delete
461
+ to authenticated
462
+ using (public.is_admin() and role_key <> 'admin');