@plutocms/supabase 0.11.3 → 0.13.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.
- package/.env.example +14 -0
- package/CHANGELOG.md +14 -0
- package/FEATURES.md +5 -1
- package/README.md +76 -14
- package/app/components/access/CopyButton.vue +24 -0
- package/app/components/access/DeleteRoleModal.vue +73 -0
- package/app/components/access/EditRolesSlideover.vue +134 -0
- package/app/components/access/InviteModal.vue +288 -0
- package/app/components/access/LinkField.vue +19 -0
- package/app/components/access/LinkModal.vue +29 -0
- package/app/components/access/RevokeModal.vue +83 -0
- package/app/components/access/RoleSlideover.vue +251 -0
- package/app/components/mail/AuthEmailsPanel.vue +108 -0
- package/app/pages/admin/(auth)/accept-invite.vue +155 -0
- package/app/pages/admin/settings/roles.vue +187 -0
- package/app/pages/admin/settings/users.vue +372 -0
- package/app/plugins/pluto-extension.ts +38 -1
- package/app/utils/access-ui.ts +100 -0
- package/app/utils/auth-driver.ts +3 -0
- package/app/utils/invite-link.ts +70 -0
- package/app/utils/supabase-project.ts +54 -0
- package/db/migrations/008_users_and_roles_admin.sql +462 -0
- package/i18n/locales/en.json +194 -1
- package/i18n/locales/pt-BR.json +194 -1
- package/package.json +3 -2
- package/server/api/access/capabilities.get.ts +17 -0
- package/server/api/access/roles/[key].delete.ts +18 -0
- package/server/api/access/roles/[key].put.ts +26 -0
- package/server/api/access/roles.get.ts +17 -0
- package/server/api/access/roles.post.ts +25 -0
- package/server/api/access/users/[id]/resend.post.ts +172 -0
- package/server/api/access/users/[id]/roles.delete.ts +22 -0
- package/server/api/access/users/[id]/roles.put.ts +24 -0
- package/server/api/access/users/invite-link.post.ts +50 -0
- package/server/api/access/users/invite.post.ts +201 -0
- package/server/api/access/users.get.ts +17 -0
- package/server/utils/access-errors.ts +148 -0
- package/server/utils/access-invite.ts +66 -0
- package/server/utils/access-mail.ts +354 -0
- package/server/utils/admin-guard.ts +4 -3
- package/server/utils/admin-role-guard.ts +44 -0
- package/server/utils/rate-limit.ts +84 -0
- package/server/utils/service-client.ts +18 -0
- package/server/utils/sign-in-link.ts +129 -0
- package/shared/types/access.d.ts +41 -0
- 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
|
+
}
|
package/app/utils/auth-driver.ts
CHANGED
|
@@ -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');
|