@plutocms/supabase 0.6.0 → 0.7.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/CHANGELOG.md +7 -0
- package/FEATURES.md +2 -0
- package/app/pages/admin.vue +14 -0
- package/app/plugins/pluto-extension.ts +12 -0
- package/db/migrations/004_roles_and_capabilities.sql +298 -0
- package/package.json +2 -2
- package/server/api/migrations/run.post.ts +2 -2
- package/server/api/migrations/status.get.ts +2 -2
- package/server/api/permissions/me.get.ts +24 -0
- package/server/api/settings/update.post.ts +2 -2
- package/server/api/users/[id].get.ts +2 -2
- package/server/api/users/index.get.ts +2 -2
- package/server/utils/admin-guard.ts +11 -38
- package/server/utils/capability-guard.ts +52 -0
- package/shared/types/supabase.ts +81 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0](https://github.com/plutocms/supabase/compare/v0.6.0...v0.7.0) (2026-09-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **permissions:** add role-based capabilities, replacing single-bit admin ([#53](https://github.com/plutocms/supabase/issues/53)) ([72f2eb0](https://github.com/plutocms/supabase/commit/72f2eb051efe15e261d3c7fcef95bdf867e11f5a))
|
|
9
|
+
|
|
3
10
|
## [0.6.0](https://github.com/plutocms/supabase/compare/v0.5.0...v0.6.0) (2026-09-12)
|
|
4
11
|
|
|
5
12
|
|
package/FEATURES.md
CHANGED
|
@@ -8,3 +8,5 @@ policies shared by every project here.
|
|
|
8
8
|
|
|
9
9
|
- Layer migrations: apply pending, versioned, per-file database migrations from the admin UI,
|
|
10
10
|
after initial setup. @.claude/skills/layer-migrations/SKILL.md
|
|
11
|
+
- Permissions storage: role-based capabilities backing server route guards and RLS policies.
|
|
12
|
+
@.claude/skills/permissions-storage/SKILL.md
|
package/app/pages/admin.vue
CHANGED
|
@@ -8,6 +8,20 @@ const route = useRoute()
|
|
|
8
8
|
const { isLoggedIn, logout, allowedUnauthenticatedPaths } = await useAuth()
|
|
9
9
|
const toast = useToast()
|
|
10
10
|
|
|
11
|
+
const { load: loadPermissions, clear: clearPermissions } = usePlutoPermissions()
|
|
12
|
+
|
|
13
|
+
watch(
|
|
14
|
+
isLoggedIn,
|
|
15
|
+
(loggedIn) => {
|
|
16
|
+
if (loggedIn) {
|
|
17
|
+
loadPermissions()
|
|
18
|
+
} else {
|
|
19
|
+
clearPermissions()
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{ immediate: true }
|
|
23
|
+
)
|
|
24
|
+
|
|
11
25
|
const visibility = useDocumentVisibility()
|
|
12
26
|
|
|
13
27
|
watch(visibility, async (current, previous) => {
|
|
@@ -36,5 +36,17 @@ export default defineNuxtPlugin(() => {
|
|
|
36
36
|
hasSettingsModified.value = Date.now()
|
|
37
37
|
},
|
|
38
38
|
},
|
|
39
|
+
capabilities: [
|
|
40
|
+
{ id: 'settings-manage', key: 'settings:manage', label: 'Manage site settings' },
|
|
41
|
+
{ id: 'users-read', key: 'users:read', label: 'View all user accounts' },
|
|
42
|
+
{ id: 'system-migrate', key: 'system:migrate', label: 'Apply pending layer migrations' },
|
|
43
|
+
],
|
|
44
|
+
permissionsDriver: {
|
|
45
|
+
id: 'supabase',
|
|
46
|
+
load: async () => {
|
|
47
|
+
const response = await $fetch<{ capabilities: string[] }>('/api/permissions/me')
|
|
48
|
+
return response.capabilities
|
|
49
|
+
},
|
|
50
|
+
},
|
|
39
51
|
})
|
|
40
52
|
})
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
-- Roles and capabilities
|
|
2
|
+
--
|
|
3
|
+
-- Adds a role-based capability system. A role (public.roles) holds a set
|
|
4
|
+
-- of capabilities (public.role_capabilities); a user (public.user_roles)
|
|
5
|
+
-- holds a set of roles. public.has_capability(cap) answers "can the
|
|
6
|
+
-- calling user do this?" for any layer's server routes and RLS policies.
|
|
7
|
+
--
|
|
8
|
+
-- Standalone and self-sufficient: it does not depend on running as a
|
|
9
|
+
-- diff against another file, only on 001_baseline.sql's and
|
|
10
|
+
-- 002_admin_hardening.sql's objects (public.profiles, public.settings,
|
|
11
|
+
-- public.healthcheck, public.is_admin(), public.handle_new_user())
|
|
12
|
+
-- already existing.
|
|
13
|
+
|
|
14
|
+
-- Free text, never an enum. An enum would stop a layer, or an operator,
|
|
15
|
+
-- from adding a role or a capability — the exact problem
|
|
16
|
+
-- 003_settings_generic_keys.sql already fixed for settings.
|
|
17
|
+
create table if not exists public.roles (
|
|
18
|
+
key text primary key,
|
|
19
|
+
label text not null,
|
|
20
|
+
description text,
|
|
21
|
+
is_builtin boolean not null default false,
|
|
22
|
+
created_at timestamptz not null default now(),
|
|
23
|
+
constraint roles_key_format check (key ~ '^[a-z0-9_]+$')
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
create table if not exists public.role_capabilities (
|
|
27
|
+
role_key text not null references public.roles (key) on delete cascade,
|
|
28
|
+
capability text not null,
|
|
29
|
+
primary key (role_key, capability),
|
|
30
|
+
-- '<namespace>:<action>', or the literal '*' for every capability.
|
|
31
|
+
constraint role_capabilities_format
|
|
32
|
+
check (capability = '*' or capability ~ '^[a-z0-9_]+:[a-z0-9_]+$')
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
create table if not exists public.user_roles (
|
|
36
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
37
|
+
role_key text not null references public.roles (key) on delete cascade,
|
|
38
|
+
granted_at timestamptz not null default now(),
|
|
39
|
+
primary key (user_id, role_key)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
create index if not exists user_roles_user_id_idx on public.user_roles (user_id);
|
|
43
|
+
|
|
44
|
+
-- Seed the built-in admin role, with the wildcard capability.
|
|
45
|
+
insert into public.roles (key, label, description, is_builtin) values
|
|
46
|
+
('admin', 'Administrator', 'Full access to every feature.', true)
|
|
47
|
+
on conflict (key) do nothing;
|
|
48
|
+
|
|
49
|
+
insert into public.role_capabilities (role_key, capability) values ('admin', '*')
|
|
50
|
+
on conflict do nothing;
|
|
51
|
+
|
|
52
|
+
-- Every existing admin gets the admin role with no manual step.
|
|
53
|
+
insert into public.user_roles (user_id, role_key)
|
|
54
|
+
select p.id, 'admin' from public.profiles p where p.is_admin = true
|
|
55
|
+
on conflict do nothing;
|
|
56
|
+
|
|
57
|
+
-- Returns true when the calling user holds the given capability, either
|
|
58
|
+
-- directly through a role, or through public.is_admin(). SECURITY
|
|
59
|
+
-- DEFINER for the same reason public.is_admin() is: the lookup must not
|
|
60
|
+
-- be subject to the RLS policies it is being used to decide.
|
|
61
|
+
create or replace function public.has_capability(cap text)
|
|
62
|
+
returns boolean
|
|
63
|
+
language sql security definer set search_path = '' stable
|
|
64
|
+
as $$
|
|
65
|
+
select public.is_admin() or exists (
|
|
66
|
+
select 1
|
|
67
|
+
from public.user_roles ur
|
|
68
|
+
join public.role_capabilities rc on rc.role_key = ur.role_key
|
|
69
|
+
where ur.user_id = auth.uid()
|
|
70
|
+
and (rc.capability = cap or rc.capability = '*')
|
|
71
|
+
);
|
|
72
|
+
$$;
|
|
73
|
+
|
|
74
|
+
-- The flat capability list for the calling user. Backs /api/permissions/me.
|
|
75
|
+
create or replace function public.my_capabilities()
|
|
76
|
+
returns setof text
|
|
77
|
+
language sql security definer set search_path = '' stable
|
|
78
|
+
as $$
|
|
79
|
+
select '*'::text where public.is_admin()
|
|
80
|
+
union
|
|
81
|
+
select rc.capability
|
|
82
|
+
from public.user_roles ur
|
|
83
|
+
join public.role_capabilities rc on rc.role_key = ur.role_key
|
|
84
|
+
where ur.user_id = auth.uid();
|
|
85
|
+
$$;
|
|
86
|
+
|
|
87
|
+
grant execute on function public.has_capability(text) to authenticated;
|
|
88
|
+
grant execute on function public.my_capabilities() to authenticated;
|
|
89
|
+
|
|
90
|
+
-- Reimplements public.is_admin() to also recognize the 'admin' role, so
|
|
91
|
+
-- every existing admin passes every new capability check automatically,
|
|
92
|
+
-- with zero data migration. Kept as create or replace, not a new
|
|
93
|
+
-- function: its name and signature (returns boolean, no arguments,
|
|
94
|
+
-- language sql security definer set search_path = '', stable) never
|
|
95
|
+
-- change. 30 RLS policies across 4 repos call it by name.
|
|
96
|
+
create or replace function public.is_admin()
|
|
97
|
+
returns boolean
|
|
98
|
+
language sql
|
|
99
|
+
security definer
|
|
100
|
+
set search_path = ''
|
|
101
|
+
stable
|
|
102
|
+
as $$
|
|
103
|
+
select coalesce(
|
|
104
|
+
(select p.is_admin from public.profiles p where p.id = auth.uid()),
|
|
105
|
+
false
|
|
106
|
+
) or exists (
|
|
107
|
+
select 1 from public.user_roles ur
|
|
108
|
+
where ur.user_id = auth.uid() and ur.role_key = 'admin'
|
|
109
|
+
);
|
|
110
|
+
$$;
|
|
111
|
+
|
|
112
|
+
-- Also grant the 'admin' role to a newly-created first-admin account, so
|
|
113
|
+
-- has_capability() sees it the same way is_admin() does. Keeps the exact
|
|
114
|
+
-- existing signature; on_auth_user_created (from 001_baseline.sql)
|
|
115
|
+
-- already points at this function by name, and create or replace is
|
|
116
|
+
-- enough to update its behavior.
|
|
117
|
+
create or replace function public.handle_new_user()
|
|
118
|
+
returns trigger
|
|
119
|
+
language plpgsql
|
|
120
|
+
security definer set search_path = ''
|
|
121
|
+
as $$
|
|
122
|
+
declare
|
|
123
|
+
profile_count int;
|
|
124
|
+
should_be_admin boolean;
|
|
125
|
+
begin
|
|
126
|
+
select count(*) into profile_count from public.profiles;
|
|
127
|
+
|
|
128
|
+
-- Only the very first account is auto-promoted. Never trust
|
|
129
|
+
-- raw_user_meta_data for this: it is fully client-controlled — anyone can
|
|
130
|
+
-- pass an arbitrary `options.data` straight to Supabase's own
|
|
131
|
+
-- /auth/v1/signup endpoint, bypassing this project's own /api/auth/signup
|
|
132
|
+
-- route entirely — so honoring an `is_admin` key there let any new
|
|
133
|
+
-- signup grant itself admin.
|
|
134
|
+
should_be_admin := profile_count = 0;
|
|
135
|
+
|
|
136
|
+
insert into public.profiles (id, email, username, display_name, is_admin)
|
|
137
|
+
values (
|
|
138
|
+
new.id,
|
|
139
|
+
new.email,
|
|
140
|
+
new.raw_user_meta_data ->> 'username',
|
|
141
|
+
new.raw_user_meta_data ->> 'display_name',
|
|
142
|
+
should_be_admin
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if should_be_admin then
|
|
146
|
+
insert into public.user_roles (user_id, role_key) values (new.id, 'admin')
|
|
147
|
+
on conflict do nothing;
|
|
148
|
+
end if;
|
|
149
|
+
|
|
150
|
+
return new;
|
|
151
|
+
end;
|
|
152
|
+
$$;
|
|
153
|
+
|
|
154
|
+
-- RLS on the three new tables.
|
|
155
|
+
alter table public.roles enable row level security;
|
|
156
|
+
alter table public.role_capabilities enable row level security;
|
|
157
|
+
alter table public.user_roles enable row level security;
|
|
158
|
+
|
|
159
|
+
drop policy if exists "Enable read access for authenticated users on roles" on public.roles;
|
|
160
|
+
|
|
161
|
+
create policy "Enable read access for authenticated users on roles"
|
|
162
|
+
on public.roles
|
|
163
|
+
for select
|
|
164
|
+
to authenticated
|
|
165
|
+
using (true);
|
|
166
|
+
|
|
167
|
+
drop policy if exists "Enable insert for admins on roles" on public.roles;
|
|
168
|
+
|
|
169
|
+
create policy "Enable insert for admins on roles"
|
|
170
|
+
on public.roles
|
|
171
|
+
for insert
|
|
172
|
+
to authenticated
|
|
173
|
+
with check (public.is_admin());
|
|
174
|
+
|
|
175
|
+
drop policy if exists "Enable update for admins on roles" on public.roles;
|
|
176
|
+
|
|
177
|
+
create policy "Enable update for admins on roles"
|
|
178
|
+
on public.roles
|
|
179
|
+
for update
|
|
180
|
+
to authenticated
|
|
181
|
+
using (public.is_admin())
|
|
182
|
+
with check (public.is_admin());
|
|
183
|
+
|
|
184
|
+
drop policy if exists "Enable delete for admins on roles" on public.roles;
|
|
185
|
+
|
|
186
|
+
create policy "Enable delete for admins on roles"
|
|
187
|
+
on public.roles
|
|
188
|
+
for delete
|
|
189
|
+
to authenticated
|
|
190
|
+
using (public.is_admin());
|
|
191
|
+
|
|
192
|
+
drop policy if exists "Enable read access for authenticated users on role_capabilities" on public.role_capabilities;
|
|
193
|
+
|
|
194
|
+
create policy "Enable read access for authenticated users on role_capabilities"
|
|
195
|
+
on public.role_capabilities
|
|
196
|
+
for select
|
|
197
|
+
to authenticated
|
|
198
|
+
using (true);
|
|
199
|
+
|
|
200
|
+
drop policy if exists "Enable insert for admins on role_capabilities" on public.role_capabilities;
|
|
201
|
+
|
|
202
|
+
create policy "Enable insert for admins on role_capabilities"
|
|
203
|
+
on public.role_capabilities
|
|
204
|
+
for insert
|
|
205
|
+
to authenticated
|
|
206
|
+
with check (public.is_admin());
|
|
207
|
+
|
|
208
|
+
drop policy if exists "Enable update for admins on role_capabilities" on public.role_capabilities;
|
|
209
|
+
|
|
210
|
+
create policy "Enable update for admins on role_capabilities"
|
|
211
|
+
on public.role_capabilities
|
|
212
|
+
for update
|
|
213
|
+
to authenticated
|
|
214
|
+
using (public.is_admin())
|
|
215
|
+
with check (public.is_admin());
|
|
216
|
+
|
|
217
|
+
drop policy if exists "Enable delete for admins on role_capabilities" on public.role_capabilities;
|
|
218
|
+
|
|
219
|
+
create policy "Enable delete for admins on role_capabilities"
|
|
220
|
+
on public.role_capabilities
|
|
221
|
+
for delete
|
|
222
|
+
to authenticated
|
|
223
|
+
using (public.is_admin());
|
|
224
|
+
|
|
225
|
+
-- A user may always read their own role grants; only an admin reads
|
|
226
|
+
-- everyone's. Every write is admin-only, with no exception: with check
|
|
227
|
+
-- (true) here would let any authenticated user grant themselves the
|
|
228
|
+
-- 'admin' role, a privilege-escalation hole identical in shape to the
|
|
229
|
+
-- one closed in 002_admin_hardening.sql.
|
|
230
|
+
drop policy if exists "Enable read access for own or admin on user_roles" on public.user_roles;
|
|
231
|
+
|
|
232
|
+
create policy "Enable read access for own or admin on user_roles"
|
|
233
|
+
on public.user_roles
|
|
234
|
+
for select
|
|
235
|
+
to authenticated
|
|
236
|
+
using (user_id = auth.uid() or public.is_admin());
|
|
237
|
+
|
|
238
|
+
drop policy if exists "Enable insert for admins on user_roles" on public.user_roles;
|
|
239
|
+
|
|
240
|
+
create policy "Enable insert for admins on user_roles"
|
|
241
|
+
on public.user_roles
|
|
242
|
+
for insert
|
|
243
|
+
to authenticated
|
|
244
|
+
with check (public.is_admin());
|
|
245
|
+
|
|
246
|
+
drop policy if exists "Enable update for admins on user_roles" on public.user_roles;
|
|
247
|
+
|
|
248
|
+
create policy "Enable update for admins on user_roles"
|
|
249
|
+
on public.user_roles
|
|
250
|
+
for update
|
|
251
|
+
to authenticated
|
|
252
|
+
using (public.is_admin())
|
|
253
|
+
with check (public.is_admin());
|
|
254
|
+
|
|
255
|
+
drop policy if exists "Enable delete for admins on user_roles" on public.user_roles;
|
|
256
|
+
|
|
257
|
+
create policy "Enable delete for admins on user_roles"
|
|
258
|
+
on public.user_roles
|
|
259
|
+
for delete
|
|
260
|
+
to authenticated
|
|
261
|
+
using (public.is_admin());
|
|
262
|
+
|
|
263
|
+
-- Rewrite this repo's own three policies to named capabilities. Since
|
|
264
|
+
-- has_capability() folds in is_admin() through the 'admin' role check,
|
|
265
|
+
-- every existing admin is unaffected by this change.
|
|
266
|
+
drop policy if exists "Enable read access for authenticated users" on public.profiles;
|
|
267
|
+
|
|
268
|
+
create policy "Enable read access for authenticated users"
|
|
269
|
+
on public.profiles
|
|
270
|
+
for select
|
|
271
|
+
to authenticated, dashboard_user
|
|
272
|
+
using (auth.uid() = id or public.has_capability('users:read'));
|
|
273
|
+
|
|
274
|
+
drop policy if exists "Enable insert for authenticated users only" on public.settings;
|
|
275
|
+
|
|
276
|
+
create policy "Enable insert for authenticated users only"
|
|
277
|
+
on public.settings
|
|
278
|
+
for insert
|
|
279
|
+
to authenticated, dashboard_user
|
|
280
|
+
with check (public.has_capability('settings:manage'));
|
|
281
|
+
|
|
282
|
+
drop policy if exists "Enable update for authenticated users on settings" on public.settings;
|
|
283
|
+
|
|
284
|
+
create policy "Enable update for authenticated users on settings"
|
|
285
|
+
on public.settings
|
|
286
|
+
for update
|
|
287
|
+
to authenticated, dashboard_user
|
|
288
|
+
using (public.has_capability('settings:manage'))
|
|
289
|
+
with check (public.has_capability('settings:manage'));
|
|
290
|
+
|
|
291
|
+
drop policy if exists "Enable update for authenticated users on healthcheck" on public.healthcheck;
|
|
292
|
+
|
|
293
|
+
create policy "Enable update for authenticated users on healthcheck"
|
|
294
|
+
on public.healthcheck
|
|
295
|
+
for update
|
|
296
|
+
to authenticated, dashboard_user
|
|
297
|
+
using (public.has_capability('settings:manage'))
|
|
298
|
+
with check (public.has_capability('settings:manage'));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"trustedDependencies": [
|
|
6
6
|
"@parcel/watcher",
|
|
7
7
|
"@plutocms/pluto",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@nuxt/eslint": "^1.16.0",
|
|
40
40
|
"@nuxt/ui": "^4.9.0",
|
|
41
41
|
"@nuxtjs/supabase": "^2.0.9",
|
|
42
|
-
"@plutocms/pluto": "^0.
|
|
42
|
+
"@plutocms/pluto": "^0.5.0",
|
|
43
43
|
"@plutocms/utils": "^0.2.1",
|
|
44
44
|
"postgres": "^3.4.9",
|
|
45
45
|
"supabase": "^2.109.1"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PlutoMigrationFile } from '../../../shared/types/migrations'
|
|
2
2
|
import type { MigrationFileResult } from '../../utils/migrations'
|
|
3
|
-
import {
|
|
3
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
4
4
|
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
5
5
|
import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
|
|
6
6
|
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
@@ -11,7 +11,7 @@ interface Payload {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export default defineEventHandler(async (event) => {
|
|
14
|
-
await
|
|
14
|
+
await requireCapability(event, 'system:migrate')
|
|
15
15
|
|
|
16
16
|
const body = await readBody<Payload | undefined>(event)
|
|
17
17
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
2
2
|
import { getMigrationStatus } from '../../utils/pending-migrations'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
-
await
|
|
5
|
+
await requireCapability(event, 'system:migrate')
|
|
6
6
|
|
|
7
7
|
const status = await getMigrationStatus(event)
|
|
8
8
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The calling user's own capability list. No admin gate — every signed-in
|
|
5
|
+
* user needs this to know what their own UI should show. A signed-out
|
|
6
|
+
* caller gets an empty list, not a 401, so the client composable never
|
|
7
|
+
* has to special-case this endpoint's error path.
|
|
8
|
+
*/
|
|
9
|
+
export default defineEventHandler(async (event) => {
|
|
10
|
+
const user = await serverSupabaseUser(event)
|
|
11
|
+
|
|
12
|
+
if (!user) {
|
|
13
|
+
return { capabilities: [] as string[] }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
17
|
+
const { data, error } = await client.rpc('my_capabilities')
|
|
18
|
+
|
|
19
|
+
if (error) {
|
|
20
|
+
throw createError({ statusCode: 500, statusMessage: error.message })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { capabilities: data ?? [] }
|
|
24
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import {
|
|
2
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
3
3
|
|
|
4
4
|
// A key is lowercase segments separated by dots, e.g. 'website_title' or
|
|
5
5
|
// 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
|
|
@@ -11,7 +11,7 @@ const SETTING_KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/
|
|
|
11
11
|
const MAX_SETTING_VALUE_LENGTH = 10_000
|
|
12
12
|
|
|
13
13
|
export default defineEventHandler(async (event) => {
|
|
14
|
-
await
|
|
14
|
+
await requireCapability(event, 'settings:manage')
|
|
15
15
|
|
|
16
16
|
const client = await serverSupabaseClient<Database>(event)
|
|
17
17
|
const body = await readBody<Record<string, unknown>>(event)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import {
|
|
2
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
-
await
|
|
5
|
+
await requireCapability(event, 'users:read')
|
|
6
6
|
|
|
7
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
8
8
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
-
import {
|
|
2
|
+
import { requireCapability } from '../../utils/capability-guard'
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
-
await
|
|
5
|
+
await requireCapability(event, 'users:read')
|
|
6
6
|
|
|
7
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
8
8
|
|
|
@@ -1,46 +1,19 @@
|
|
|
1
1
|
import type { H3Event } from 'h3'
|
|
2
|
-
import {
|
|
2
|
+
import { ALL_CAPABILITIES, requireCapability } from './capability-guard'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Guards a server route so only
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `User` row. The claims object has no `id` field — the user's id is the
|
|
10
|
-
* `sub` claim. Using `user.id` here silently queries `eq('id', undefined)`,
|
|
11
|
-
* which matches zero rows and looks exactly like "not an admin" even for a
|
|
12
|
-
* real admin. Always read `user.sub`, never `user.id`.
|
|
13
|
-
*
|
|
14
|
-
* Reads `is_admin` from `public.profiles`, never from `user_metadata`. The
|
|
15
|
-
* `handle_new_user` trigger never writes `is_admin` back to
|
|
16
|
-
* `auth.users.raw_user_meta_data`, so `user_metadata.is_admin` can be unset
|
|
17
|
-
* even for the first admin. `public.profiles.is_admin` is the source of
|
|
18
|
-
* truth.
|
|
5
|
+
* Guards a server route so only an admin (or a holder of every
|
|
6
|
+
* capability, which is what the built-in admin role grants) can call it.
|
|
7
|
+
* A thin alias over requireCapability, kept for the routes that ask for
|
|
8
|
+
* "admin, full stop" rather than one named capability.
|
|
19
9
|
*
|
|
20
10
|
* Throws a 401 if there is no logged-in user, or a 403 if the user is not
|
|
21
|
-
* an admin. Returns the claims on success.
|
|
11
|
+
* an admin. Returns the claims on success. See capability-guard.ts for
|
|
12
|
+
* the user.sub vs user.id note: this function forwards straight into
|
|
13
|
+
* requireCapability, which never reads either field.
|
|
22
14
|
*/
|
|
23
15
|
export async function requireAdmin(event: H3Event) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const client = await serverSupabaseClient<Database>(event)
|
|
31
|
-
|
|
32
|
-
const { data: profile, error } = await client
|
|
33
|
-
.from('profiles')
|
|
34
|
-
.select('is_admin')
|
|
35
|
-
.eq('id', user.sub)
|
|
36
|
-
.single()
|
|
37
|
-
|
|
38
|
-
if (error || !profile?.is_admin) {
|
|
39
|
-
throw createError({
|
|
40
|
-
statusCode: 403,
|
|
41
|
-
statusMessage: 'Your account is not an admin.',
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return user
|
|
16
|
+
return requireCapability(event, ALL_CAPABILITIES, {
|
|
17
|
+
message: 'Your account is not an admin.',
|
|
18
|
+
})
|
|
46
19
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
3
|
+
|
|
4
|
+
/** The wildcard capability. A holder passes every capability check. */
|
|
5
|
+
export const ALL_CAPABILITIES = '*'
|
|
6
|
+
|
|
7
|
+
interface RequireCapabilityOptions {
|
|
8
|
+
/** Overrides the 403 message. */
|
|
9
|
+
message?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Guards a server route on a named capability.
|
|
14
|
+
*
|
|
15
|
+
* `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
|
|
16
|
+
* `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
|
|
17
|
+
* `User` row — used here only to distinguish "not logged in" (401) from
|
|
18
|
+
* "logged in but missing the capability" (403). The capability check
|
|
19
|
+
* itself runs in the database, through public.has_capability(), a
|
|
20
|
+
* SECURITY DEFINER function that reads auth.uid() directly from the same
|
|
21
|
+
* session — user.sub/user.id is never passed to it.
|
|
22
|
+
*
|
|
23
|
+
* public.has_capability() folds in public.is_admin(), so every existing
|
|
24
|
+
* admin passes every capability check with no data migration.
|
|
25
|
+
*
|
|
26
|
+
* Throws a 401 if there is no logged-in user, or a 403 if the user is
|
|
27
|
+
* missing the capability. Returns the claims on success.
|
|
28
|
+
*/
|
|
29
|
+
export async function requireCapability(
|
|
30
|
+
event: H3Event,
|
|
31
|
+
capability: string,
|
|
32
|
+
options?: RequireCapabilityOptions
|
|
33
|
+
) {
|
|
34
|
+
const user = await serverSupabaseUser(event)
|
|
35
|
+
|
|
36
|
+
if (!user) {
|
|
37
|
+
throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
41
|
+
const { data, error } = await client.rpc('has_capability', { cap: capability })
|
|
42
|
+
|
|
43
|
+
if (error || data !== true) {
|
|
44
|
+
throw createError({
|
|
45
|
+
statusCode: 403,
|
|
46
|
+
statusMessage:
|
|
47
|
+
options?.message ?? `Your account is missing the "${capability}" permission.`,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return user
|
|
52
|
+
}
|
package/shared/types/supabase.ts
CHANGED
|
@@ -253,6 +253,53 @@ export type Database = {
|
|
|
253
253
|
}
|
|
254
254
|
Relationships: []
|
|
255
255
|
}
|
|
256
|
+
role_capabilities: {
|
|
257
|
+
Row: {
|
|
258
|
+
capability: string
|
|
259
|
+
role_key: string
|
|
260
|
+
}
|
|
261
|
+
Insert: {
|
|
262
|
+
capability: string
|
|
263
|
+
role_key: string
|
|
264
|
+
}
|
|
265
|
+
Update: {
|
|
266
|
+
capability?: string
|
|
267
|
+
role_key?: string
|
|
268
|
+
}
|
|
269
|
+
Relationships: [
|
|
270
|
+
{
|
|
271
|
+
foreignKeyName: "role_capabilities_role_key_fkey"
|
|
272
|
+
columns: ["role_key"]
|
|
273
|
+
isOneToOne: false
|
|
274
|
+
referencedRelation: "roles"
|
|
275
|
+
referencedColumns: ["key"]
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
roles: {
|
|
280
|
+
Row: {
|
|
281
|
+
created_at: string
|
|
282
|
+
description: string | null
|
|
283
|
+
is_builtin: boolean
|
|
284
|
+
key: string
|
|
285
|
+
label: string
|
|
286
|
+
}
|
|
287
|
+
Insert: {
|
|
288
|
+
created_at?: string
|
|
289
|
+
description?: string | null
|
|
290
|
+
is_builtin?: boolean
|
|
291
|
+
key: string
|
|
292
|
+
label: string
|
|
293
|
+
}
|
|
294
|
+
Update: {
|
|
295
|
+
created_at?: string
|
|
296
|
+
description?: string | null
|
|
297
|
+
is_builtin?: boolean
|
|
298
|
+
key?: string
|
|
299
|
+
label?: string
|
|
300
|
+
}
|
|
301
|
+
Relationships: []
|
|
302
|
+
}
|
|
256
303
|
settings: {
|
|
257
304
|
Row: {
|
|
258
305
|
id: number
|
|
@@ -274,12 +321,45 @@ export type Database = {
|
|
|
274
321
|
}
|
|
275
322
|
Relationships: []
|
|
276
323
|
}
|
|
324
|
+
user_roles: {
|
|
325
|
+
Row: {
|
|
326
|
+
granted_at: string
|
|
327
|
+
role_key: string
|
|
328
|
+
user_id: string
|
|
329
|
+
}
|
|
330
|
+
Insert: {
|
|
331
|
+
granted_at?: string
|
|
332
|
+
role_key: string
|
|
333
|
+
user_id: string
|
|
334
|
+
}
|
|
335
|
+
Update: {
|
|
336
|
+
granted_at?: string
|
|
337
|
+
role_key?: string
|
|
338
|
+
user_id?: string
|
|
339
|
+
}
|
|
340
|
+
Relationships: [
|
|
341
|
+
{
|
|
342
|
+
foreignKeyName: "user_roles_role_key_fkey"
|
|
343
|
+
columns: ["role_key"]
|
|
344
|
+
isOneToOne: false
|
|
345
|
+
referencedRelation: "roles"
|
|
346
|
+
referencedColumns: ["key"]
|
|
347
|
+
},
|
|
348
|
+
]
|
|
349
|
+
}
|
|
277
350
|
}
|
|
278
351
|
Views: {
|
|
279
352
|
[_ in never]: never
|
|
280
353
|
}
|
|
281
354
|
Functions: {
|
|
282
|
-
|
|
355
|
+
has_capability: {
|
|
356
|
+
Args: { cap: string }
|
|
357
|
+
Returns: boolean
|
|
358
|
+
}
|
|
359
|
+
my_capabilities: {
|
|
360
|
+
Args: Record<PropertyKey, never>
|
|
361
|
+
Returns: string[]
|
|
362
|
+
}
|
|
283
363
|
}
|
|
284
364
|
Enums: {
|
|
285
365
|
thealthcheck: "first_setup"
|