snipe-auth-rbac 0.1.0 → 0.2.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/bin/cli.mjs +140 -0
- package/dist/admin/index.cjs +30 -12
- package/dist/admin/index.cjs.map +1 -1
- package/dist/admin/index.d.cts +37 -6
- package/dist/admin/index.d.ts +37 -6
- package/dist/admin/index.js +30 -13
- package/dist/admin/index.js.map +1 -1
- package/dist/{chunk-4WTV6J44.js → chunk-C76JHCKM.js} +1 -1
- package/dist/chunk-C76JHCKM.js.map +1 -0
- package/dist/{chunk-BRCJUCDG.js → chunk-NRDW233A.js} +17 -3
- package/dist/chunk-NRDW233A.js.map +1 -0
- package/dist/index.cjs +17 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -7
- package/dist/index.d.ts +35 -7
- package/dist/index.js +5 -3
- package/dist/react/index.cjs +17 -2
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +3 -3
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +5 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{types-BEc5SCIo.d.cts → types-DxvFudPF.d.cts} +1 -1
- package/dist/{types-BEc5SCIo.d.ts → types-DxvFudPF.d.ts} +1 -1
- package/package.json +8 -2
- package/sql/0001_initial.sql +512 -0
- package/sql/0002_seed_defaults.sql +57 -0
- package/dist/chunk-4WTV6J44.js.map +0 -1
- package/dist/chunk-BRCJUCDG.js.map +0 -1
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
-- snipe-auth-rbac — initial schema and resolver
|
|
2
|
+
--
|
|
3
|
+
-- Two-layer RBAC: system roles + per-company roles, sharing one
|
|
4
|
+
-- resource registry and one resolver function.
|
|
5
|
+
--
|
|
6
|
+
-- Schema isolation
|
|
7
|
+
-- ────────────────
|
|
8
|
+
-- All package objects live under a dedicated `rbac` schema instead
|
|
9
|
+
-- of the `public` namespace. Adopters get a clean separation from
|
|
10
|
+
-- their own tables and zero risk of name collisions.
|
|
11
|
+
--
|
|
12
|
+
-- For Supabase / PostgREST adopters: add `rbac` to the project's
|
|
13
|
+
-- "Exposed schemas" setting (Studio → Settings → API → Exposed
|
|
14
|
+
-- schemas → add `rbac`). The TS + Python clients address the
|
|
15
|
+
-- functions via `supabase.schema('rbac').rpc('user_can', …)`.
|
|
16
|
+
--
|
|
17
|
+
-- Idempotent: every statement uses CREATE … IF NOT EXISTS or
|
|
18
|
+
-- CREATE OR REPLACE so the file can be re-applied on an existing
|
|
19
|
+
-- DB without errors.
|
|
20
|
+
|
|
21
|
+
BEGIN;
|
|
22
|
+
|
|
23
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
24
|
+
-- 0. The schema itself
|
|
25
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
CREATE SCHEMA IF NOT EXISTS rbac;
|
|
28
|
+
|
|
29
|
+
-- Make the rbac schema usable by Supabase's roles. anon needs
|
|
30
|
+
-- USAGE so PostgREST can introspect; authenticated needs USAGE +
|
|
31
|
+
-- SELECT/INSERT/UPDATE/DELETE rights are *not* granted by default
|
|
32
|
+
-- — every read goes through SECURITY DEFINER functions, so the
|
|
33
|
+
-- role only needs EXECUTE on those (granted at the bottom).
|
|
34
|
+
DO $$ BEGIN
|
|
35
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
36
|
+
EXECUTE 'GRANT USAGE ON SCHEMA rbac TO anon';
|
|
37
|
+
END IF;
|
|
38
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
39
|
+
EXECUTE 'GRANT USAGE ON SCHEMA rbac TO authenticated';
|
|
40
|
+
END IF;
|
|
41
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
|
42
|
+
EXECUTE 'GRANT ALL ON SCHEMA rbac TO service_role';
|
|
43
|
+
END IF;
|
|
44
|
+
END $$;
|
|
45
|
+
|
|
46
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
47
|
+
-- 1. Companies (the team / tenant unit)
|
|
48
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS rbac.companies (
|
|
51
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
52
|
+
name text NOT NULL,
|
|
53
|
+
slug text UNIQUE,
|
|
54
|
+
type text,
|
|
55
|
+
parent_id uuid REFERENCES rbac.companies(id),
|
|
56
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
57
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
58
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
62
|
+
-- 2. Resource registry — the only place a "permissionable thing" is
|
|
63
|
+
-- declared. Two scopes: 'system' (platform-wide) or 'company'
|
|
64
|
+
-- (per-tenant). The resolver and the matrix UI both read from here.
|
|
65
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS rbac.resources (
|
|
68
|
+
resource text PRIMARY KEY,
|
|
69
|
+
scope text NOT NULL CHECK (scope IN ('system', 'company')),
|
|
70
|
+
label text NOT NULL,
|
|
71
|
+
description text,
|
|
72
|
+
group_label text,
|
|
73
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
77
|
+
-- 3. Roles — one table for both layers, distinguished by scope.
|
|
78
|
+
-- system: platform-wide role (no company_id)
|
|
79
|
+
-- company + company_id NOT NULL: a role inside that company
|
|
80
|
+
-- company + company_id NULL: a *role template* — cloned per company
|
|
81
|
+
-- at company creation
|
|
82
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS rbac.roles (
|
|
85
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
86
|
+
scope text NOT NULL CHECK (scope IN ('system', 'company')),
|
|
87
|
+
company_id uuid REFERENCES rbac.companies(id) ON DELETE CASCADE,
|
|
88
|
+
name text NOT NULL,
|
|
89
|
+
description text,
|
|
90
|
+
is_system boolean NOT NULL DEFAULT false,
|
|
91
|
+
is_super boolean NOT NULL DEFAULT false,
|
|
92
|
+
frontend_config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
93
|
+
-- Permission template — what `apply_template_defaults(role_id)`
|
|
94
|
+
-- pre-fills when the host calls it after sync_resources(). Shape:
|
|
95
|
+
-- {
|
|
96
|
+
-- "default": ["read"],
|
|
97
|
+
-- "groups": { "Stammdaten": ["read","write","update"], … },
|
|
98
|
+
-- "resources": { "tenants": ["read","write","update","delete"] }
|
|
99
|
+
-- }
|
|
100
|
+
-- Only meaningful on template rows (company_id IS NULL). Real
|
|
101
|
+
-- per-company roles ignore the column.
|
|
102
|
+
default_permissions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
103
|
+
created_by uuid,
|
|
104
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
105
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
106
|
+
CONSTRAINT roles_scope_company_chk CHECK (
|
|
107
|
+
(scope = 'system' AND company_id IS NULL)
|
|
108
|
+
OR scope = 'company'
|
|
109
|
+
),
|
|
110
|
+
CONSTRAINT roles_super_only_system_chk CHECK (
|
|
111
|
+
NOT is_super OR scope = 'system'
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
-- Backfill for adopters running this against an existing v0.2.0 DB.
|
|
116
|
+
-- The IF NOT EXISTS guard makes it safe on a fresh install too.
|
|
117
|
+
DO $$ BEGIN
|
|
118
|
+
IF NOT EXISTS (
|
|
119
|
+
SELECT 1 FROM information_schema.columns
|
|
120
|
+
WHERE table_schema = 'rbac' AND table_name = 'roles'
|
|
121
|
+
AND column_name = 'default_permissions'
|
|
122
|
+
) THEN
|
|
123
|
+
EXECUTE 'ALTER TABLE rbac.roles ADD COLUMN default_permissions jsonb NOT NULL DEFAULT ''{}''::jsonb';
|
|
124
|
+
END IF;
|
|
125
|
+
END $$;
|
|
126
|
+
|
|
127
|
+
CREATE UNIQUE INDEX IF NOT EXISTS roles_unique_name
|
|
128
|
+
ON rbac.roles (scope, coalesce(company_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
|
129
|
+
|
|
130
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
131
|
+
-- 4. Role permissions — the actual checkbox grid
|
|
132
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS rbac.role_permissions (
|
|
135
|
+
role_id uuid NOT NULL REFERENCES rbac.roles(id) ON DELETE CASCADE,
|
|
136
|
+
resource text NOT NULL REFERENCES rbac.resources(resource),
|
|
137
|
+
can_read boolean NOT NULL DEFAULT false,
|
|
138
|
+
can_write boolean NOT NULL DEFAULT false,
|
|
139
|
+
can_update boolean NOT NULL DEFAULT false,
|
|
140
|
+
can_delete boolean NOT NULL DEFAULT false,
|
|
141
|
+
PRIMARY KEY (role_id, resource)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
CREATE OR REPLACE FUNCTION rbac.check_role_resource_scope()
|
|
145
|
+
RETURNS trigger
|
|
146
|
+
LANGUAGE plpgsql
|
|
147
|
+
SET search_path = rbac, public
|
|
148
|
+
AS $$
|
|
149
|
+
DECLARE
|
|
150
|
+
v_role_scope text;
|
|
151
|
+
v_resource_scope text;
|
|
152
|
+
BEGIN
|
|
153
|
+
SELECT scope INTO v_role_scope FROM rbac.roles WHERE id = NEW.role_id;
|
|
154
|
+
SELECT scope INTO v_resource_scope FROM rbac.resources WHERE resource = NEW.resource;
|
|
155
|
+
IF v_role_scope IS DISTINCT FROM v_resource_scope THEN
|
|
156
|
+
RAISE EXCEPTION
|
|
157
|
+
'rbac: role scope (%) does not match resource scope (%) for resource %',
|
|
158
|
+
v_role_scope, v_resource_scope, NEW.resource;
|
|
159
|
+
END IF;
|
|
160
|
+
RETURN NEW;
|
|
161
|
+
END $$;
|
|
162
|
+
|
|
163
|
+
DROP TRIGGER IF EXISTS role_resource_scope_trg
|
|
164
|
+
ON rbac.role_permissions;
|
|
165
|
+
CREATE TRIGGER role_resource_scope_trg
|
|
166
|
+
BEFORE INSERT OR UPDATE ON rbac.role_permissions
|
|
167
|
+
FOR EACH ROW EXECUTE FUNCTION rbac.check_role_resource_scope();
|
|
168
|
+
|
|
169
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
170
|
+
-- 5. Assignments — system + company memberships
|
|
171
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS rbac.user_system_roles (
|
|
174
|
+
user_id uuid NOT NULL,
|
|
175
|
+
role_id uuid NOT NULL REFERENCES rbac.roles(id),
|
|
176
|
+
assigned_at timestamptz NOT NULL DEFAULT now(),
|
|
177
|
+
assigned_by uuid,
|
|
178
|
+
PRIMARY KEY (user_id, role_id)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE TABLE IF NOT EXISTS rbac.user_company_roles (
|
|
182
|
+
user_id uuid NOT NULL,
|
|
183
|
+
company_id uuid NOT NULL REFERENCES rbac.companies(id) ON DELETE CASCADE,
|
|
184
|
+
role_id uuid NOT NULL REFERENCES rbac.roles(id),
|
|
185
|
+
assigned_at timestamptz NOT NULL DEFAULT now(),
|
|
186
|
+
assigned_by uuid,
|
|
187
|
+
PRIMARY KEY (user_id, company_id, role_id)
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
CREATE INDEX IF NOT EXISTS user_company_roles_user_idx
|
|
191
|
+
ON rbac.user_company_roles (user_id);
|
|
192
|
+
|
|
193
|
+
CREATE INDEX IF NOT EXISTS user_company_roles_company_idx
|
|
194
|
+
ON rbac.user_company_roles (company_id);
|
|
195
|
+
|
|
196
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
197
|
+
-- 6. Resolver function — the single source of truth for "can?"
|
|
198
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
CREATE OR REPLACE FUNCTION rbac.user_can(
|
|
201
|
+
p_user_id uuid,
|
|
202
|
+
p_resource text,
|
|
203
|
+
p_action text,
|
|
204
|
+
p_company_id uuid DEFAULT NULL
|
|
205
|
+
) RETURNS boolean
|
|
206
|
+
LANGUAGE plpgsql
|
|
207
|
+
STABLE
|
|
208
|
+
SECURITY DEFINER
|
|
209
|
+
SET search_path = rbac, public
|
|
210
|
+
AS $$
|
|
211
|
+
DECLARE
|
|
212
|
+
v_resource_scope text;
|
|
213
|
+
BEGIN
|
|
214
|
+
-- Super-admin bypass: any system role with is_super=true grants
|
|
215
|
+
-- every permission everywhere.
|
|
216
|
+
IF EXISTS (
|
|
217
|
+
SELECT 1
|
|
218
|
+
FROM rbac.user_system_roles usr
|
|
219
|
+
JOIN rbac.roles r ON r.id = usr.role_id
|
|
220
|
+
WHERE usr.user_id = p_user_id
|
|
221
|
+
AND r.is_super
|
|
222
|
+
) THEN
|
|
223
|
+
RETURN true;
|
|
224
|
+
END IF;
|
|
225
|
+
|
|
226
|
+
SELECT scope INTO v_resource_scope
|
|
227
|
+
FROM rbac.resources
|
|
228
|
+
WHERE resource = p_resource;
|
|
229
|
+
|
|
230
|
+
IF v_resource_scope IS NULL THEN
|
|
231
|
+
RETURN false;
|
|
232
|
+
END IF;
|
|
233
|
+
|
|
234
|
+
IF p_action NOT IN ('read', 'write', 'update', 'delete') THEN
|
|
235
|
+
RETURN false;
|
|
236
|
+
END IF;
|
|
237
|
+
|
|
238
|
+
IF v_resource_scope = 'system' THEN
|
|
239
|
+
RETURN EXISTS (
|
|
240
|
+
SELECT 1
|
|
241
|
+
FROM rbac.user_system_roles usr
|
|
242
|
+
JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
|
|
243
|
+
WHERE usr.user_id = p_user_id
|
|
244
|
+
AND rp.resource = p_resource
|
|
245
|
+
AND CASE p_action
|
|
246
|
+
WHEN 'read' THEN rp.can_read
|
|
247
|
+
WHEN 'write' THEN rp.can_write
|
|
248
|
+
WHEN 'update' THEN rp.can_update
|
|
249
|
+
WHEN 'delete' THEN rp.can_delete
|
|
250
|
+
END
|
|
251
|
+
);
|
|
252
|
+
END IF;
|
|
253
|
+
|
|
254
|
+
IF p_company_id IS NULL THEN
|
|
255
|
+
RETURN false;
|
|
256
|
+
END IF;
|
|
257
|
+
|
|
258
|
+
RETURN EXISTS (
|
|
259
|
+
SELECT 1
|
|
260
|
+
FROM rbac.user_company_roles ucr
|
|
261
|
+
JOIN rbac.role_permissions rp ON rp.role_id = ucr.role_id
|
|
262
|
+
WHERE ucr.user_id = p_user_id
|
|
263
|
+
AND ucr.company_id = p_company_id
|
|
264
|
+
AND rp.resource = p_resource
|
|
265
|
+
AND CASE p_action
|
|
266
|
+
WHEN 'read' THEN rp.can_read
|
|
267
|
+
WHEN 'write' THEN rp.can_write
|
|
268
|
+
WHEN 'update' THEN rp.can_update
|
|
269
|
+
WHEN 'delete' THEN rp.can_delete
|
|
270
|
+
END
|
|
271
|
+
);
|
|
272
|
+
END $$;
|
|
273
|
+
|
|
274
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
275
|
+
-- 7. Convenience: hydrate a user's full profile in one call
|
|
276
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
CREATE OR REPLACE FUNCTION rbac.user_profile(p_user_id uuid)
|
|
279
|
+
RETURNS jsonb
|
|
280
|
+
LANGUAGE sql
|
|
281
|
+
STABLE
|
|
282
|
+
SECURITY DEFINER
|
|
283
|
+
SET search_path = rbac, public
|
|
284
|
+
AS $$
|
|
285
|
+
WITH
|
|
286
|
+
system_roles AS (
|
|
287
|
+
SELECT r.id, r.name, r.is_super, r.frontend_config
|
|
288
|
+
FROM rbac.user_system_roles usr
|
|
289
|
+
JOIN rbac.roles r ON r.id = usr.role_id
|
|
290
|
+
WHERE usr.user_id = p_user_id
|
|
291
|
+
),
|
|
292
|
+
system_perms AS (
|
|
293
|
+
SELECT rp.resource,
|
|
294
|
+
bool_or(rp.can_read) AS can_read,
|
|
295
|
+
bool_or(rp.can_write) AS can_write,
|
|
296
|
+
bool_or(rp.can_update) AS can_update,
|
|
297
|
+
bool_or(rp.can_delete) AS can_delete
|
|
298
|
+
FROM rbac.user_system_roles usr
|
|
299
|
+
JOIN rbac.role_permissions rp ON rp.role_id = usr.role_id
|
|
300
|
+
WHERE usr.user_id = p_user_id
|
|
301
|
+
GROUP BY rp.resource
|
|
302
|
+
),
|
|
303
|
+
memberships AS (
|
|
304
|
+
SELECT c.id AS company_id,
|
|
305
|
+
c.name AS company_name,
|
|
306
|
+
c.slug AS company_slug,
|
|
307
|
+
jsonb_agg(DISTINCT jsonb_build_object(
|
|
308
|
+
'id', r.id, 'name', r.name, 'is_system', r.is_system,
|
|
309
|
+
'frontend_config', r.frontend_config
|
|
310
|
+
)) AS roles
|
|
311
|
+
FROM rbac.user_company_roles ucr
|
|
312
|
+
JOIN rbac.companies c ON c.id = ucr.company_id
|
|
313
|
+
JOIN rbac.roles r ON r.id = ucr.role_id
|
|
314
|
+
WHERE ucr.user_id = p_user_id
|
|
315
|
+
GROUP BY c.id, c.name, c.slug
|
|
316
|
+
),
|
|
317
|
+
company_perms AS (
|
|
318
|
+
SELECT ucr.company_id,
|
|
319
|
+
rp.resource,
|
|
320
|
+
bool_or(rp.can_read) AS can_read,
|
|
321
|
+
bool_or(rp.can_write) AS can_write,
|
|
322
|
+
bool_or(rp.can_update) AS can_update,
|
|
323
|
+
bool_or(rp.can_delete) AS can_delete
|
|
324
|
+
FROM rbac.user_company_roles ucr
|
|
325
|
+
JOIN rbac.role_permissions rp ON rp.role_id = ucr.role_id
|
|
326
|
+
WHERE ucr.user_id = p_user_id
|
|
327
|
+
GROUP BY ucr.company_id, rp.resource
|
|
328
|
+
)
|
|
329
|
+
SELECT jsonb_build_object(
|
|
330
|
+
'user_id', p_user_id,
|
|
331
|
+
'is_super_admin', EXISTS (SELECT 1 FROM system_roles WHERE is_super),
|
|
332
|
+
'system_roles', coalesce(
|
|
333
|
+
(SELECT jsonb_agg(jsonb_build_object('id', id, 'name', name)) FROM system_roles),
|
|
334
|
+
'[]'::jsonb
|
|
335
|
+
),
|
|
336
|
+
'system_permissions', coalesce(
|
|
337
|
+
(SELECT jsonb_object_agg(resource, jsonb_build_object(
|
|
338
|
+
'read', can_read, 'write', can_write,
|
|
339
|
+
'update', can_update, 'delete', can_delete
|
|
340
|
+
)) FROM system_perms),
|
|
341
|
+
'{}'::jsonb
|
|
342
|
+
),
|
|
343
|
+
'memberships', coalesce(
|
|
344
|
+
(SELECT jsonb_agg(jsonb_build_object(
|
|
345
|
+
'company_id', m.company_id,
|
|
346
|
+
'company_name', m.company_name,
|
|
347
|
+
'company_slug', m.company_slug,
|
|
348
|
+
'roles', m.roles,
|
|
349
|
+
'permissions', coalesce(
|
|
350
|
+
(SELECT jsonb_object_agg(cp.resource, jsonb_build_object(
|
|
351
|
+
'read', cp.can_read, 'write', cp.can_write,
|
|
352
|
+
'update', cp.can_update, 'delete', cp.can_delete
|
|
353
|
+
))
|
|
354
|
+
FROM company_perms cp
|
|
355
|
+
WHERE cp.company_id = m.company_id),
|
|
356
|
+
'{}'::jsonb
|
|
357
|
+
)
|
|
358
|
+
)) FROM memberships m),
|
|
359
|
+
'[]'::jsonb
|
|
360
|
+
)
|
|
361
|
+
);
|
|
362
|
+
$$;
|
|
363
|
+
|
|
364
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
365
|
+
-- 7b. Template defaults — pre-fill role_permissions from a JSONB
|
|
366
|
+
-- pattern carried on the role row. Run after sync_resources() so
|
|
367
|
+
-- newly registered resources pick up the right cells without an
|
|
368
|
+
-- admin opening the matrix UI.
|
|
369
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
CREATE OR REPLACE FUNCTION rbac.apply_template_defaults(
|
|
372
|
+
p_role_id uuid,
|
|
373
|
+
p_only_missing boolean DEFAULT true
|
|
374
|
+
) RETURNS int
|
|
375
|
+
LANGUAGE plpgsql
|
|
376
|
+
SECURITY DEFINER
|
|
377
|
+
SET search_path = rbac, public
|
|
378
|
+
AS $$
|
|
379
|
+
DECLARE
|
|
380
|
+
v_role rbac.roles;
|
|
381
|
+
v_dp jsonb;
|
|
382
|
+
v_default jsonb;
|
|
383
|
+
v_groups jsonb;
|
|
384
|
+
v_resources jsonb;
|
|
385
|
+
v_resource record;
|
|
386
|
+
v_actions jsonb;
|
|
387
|
+
v_count int := 0;
|
|
388
|
+
BEGIN
|
|
389
|
+
SELECT * INTO v_role FROM rbac.roles WHERE id = p_role_id;
|
|
390
|
+
IF NOT FOUND THEN
|
|
391
|
+
RETURN 0;
|
|
392
|
+
END IF;
|
|
393
|
+
|
|
394
|
+
v_dp := COALESCE(v_role.default_permissions, '{}'::jsonb);
|
|
395
|
+
v_default := COALESCE(v_dp -> 'default', '[]'::jsonb);
|
|
396
|
+
v_groups := COALESCE(v_dp -> 'groups', '{}'::jsonb);
|
|
397
|
+
v_resources := COALESCE(v_dp -> 'resources', '{}'::jsonb);
|
|
398
|
+
|
|
399
|
+
FOR v_resource IN
|
|
400
|
+
SELECT resource, group_label
|
|
401
|
+
FROM rbac.resources
|
|
402
|
+
WHERE scope = v_role.scope
|
|
403
|
+
LOOP
|
|
404
|
+
-- Skip if the matrix already has any cell for this (role, resource)
|
|
405
|
+
IF p_only_missing AND EXISTS (
|
|
406
|
+
SELECT 1 FROM rbac.role_permissions
|
|
407
|
+
WHERE role_id = p_role_id
|
|
408
|
+
AND resource = v_resource.resource
|
|
409
|
+
) THEN
|
|
410
|
+
CONTINUE;
|
|
411
|
+
END IF;
|
|
412
|
+
|
|
413
|
+
-- Pick action set: explicit resource override → group default → fallback default
|
|
414
|
+
v_actions := COALESCE(
|
|
415
|
+
v_resources -> v_resource.resource,
|
|
416
|
+
CASE WHEN v_resource.group_label IS NOT NULL
|
|
417
|
+
THEN v_groups -> v_resource.group_label
|
|
418
|
+
ELSE NULL END,
|
|
419
|
+
v_default
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
IF v_actions IS NULL OR jsonb_array_length(v_actions) = 0 THEN
|
|
423
|
+
CONTINUE;
|
|
424
|
+
END IF;
|
|
425
|
+
|
|
426
|
+
INSERT INTO rbac.role_permissions
|
|
427
|
+
(role_id, resource, can_read, can_write, can_update, can_delete)
|
|
428
|
+
VALUES (
|
|
429
|
+
p_role_id,
|
|
430
|
+
v_resource.resource,
|
|
431
|
+
v_actions ? 'read',
|
|
432
|
+
v_actions ? 'write',
|
|
433
|
+
v_actions ? 'update',
|
|
434
|
+
v_actions ? 'delete'
|
|
435
|
+
)
|
|
436
|
+
ON CONFLICT (role_id, resource) DO UPDATE
|
|
437
|
+
SET can_read = EXCLUDED.can_read OR rbac.role_permissions.can_read,
|
|
438
|
+
can_write = EXCLUDED.can_write OR rbac.role_permissions.can_write,
|
|
439
|
+
can_update = EXCLUDED.can_update OR rbac.role_permissions.can_update,
|
|
440
|
+
can_delete = EXCLUDED.can_delete OR rbac.role_permissions.can_delete;
|
|
441
|
+
|
|
442
|
+
v_count := v_count + 1;
|
|
443
|
+
END LOOP;
|
|
444
|
+
|
|
445
|
+
RETURN v_count;
|
|
446
|
+
END $$;
|
|
447
|
+
|
|
448
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
449
|
+
-- 8. Block delete/rename of system rows
|
|
450
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
CREATE OR REPLACE FUNCTION rbac.protect_system_role()
|
|
453
|
+
RETURNS trigger
|
|
454
|
+
LANGUAGE plpgsql
|
|
455
|
+
SET search_path = rbac, public
|
|
456
|
+
AS $$
|
|
457
|
+
BEGIN
|
|
458
|
+
IF TG_OP = 'DELETE' THEN
|
|
459
|
+
IF OLD.is_system THEN
|
|
460
|
+
RAISE EXCEPTION 'rbac: cannot delete system role %', OLD.name;
|
|
461
|
+
END IF;
|
|
462
|
+
RETURN OLD;
|
|
463
|
+
END IF;
|
|
464
|
+
IF OLD.is_system AND OLD.name IS DISTINCT FROM NEW.name THEN
|
|
465
|
+
RAISE EXCEPTION 'rbac: cannot rename system role %', OLD.name;
|
|
466
|
+
END IF;
|
|
467
|
+
IF OLD.is_super AND NOT NEW.is_super THEN
|
|
468
|
+
RAISE EXCEPTION 'rbac: cannot revoke super-admin flag';
|
|
469
|
+
END IF;
|
|
470
|
+
RETURN NEW;
|
|
471
|
+
END $$;
|
|
472
|
+
|
|
473
|
+
DROP TRIGGER IF EXISTS protect_system_role_trg ON rbac.roles;
|
|
474
|
+
CREATE TRIGGER protect_system_role_trg
|
|
475
|
+
BEFORE UPDATE OR DELETE ON rbac.roles
|
|
476
|
+
FOR EACH ROW EXECUTE FUNCTION rbac.protect_system_role();
|
|
477
|
+
|
|
478
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
479
|
+
-- 9. updated_at touchers
|
|
480
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
CREATE OR REPLACE FUNCTION rbac.touch_updated_at()
|
|
483
|
+
RETURNS trigger
|
|
484
|
+
LANGUAGE plpgsql AS $$
|
|
485
|
+
BEGIN
|
|
486
|
+
NEW.updated_at = now();
|
|
487
|
+
RETURN NEW;
|
|
488
|
+
END $$;
|
|
489
|
+
|
|
490
|
+
DROP TRIGGER IF EXISTS companies_touch_trg ON rbac.companies;
|
|
491
|
+
CREATE TRIGGER companies_touch_trg
|
|
492
|
+
BEFORE UPDATE ON rbac.companies
|
|
493
|
+
FOR EACH ROW EXECUTE FUNCTION rbac.touch_updated_at();
|
|
494
|
+
|
|
495
|
+
DROP TRIGGER IF EXISTS roles_touch_trg ON rbac.roles;
|
|
496
|
+
CREATE TRIGGER roles_touch_trg
|
|
497
|
+
BEFORE UPDATE ON rbac.roles
|
|
498
|
+
FOR EACH ROW EXECUTE FUNCTION rbac.touch_updated_at();
|
|
499
|
+
|
|
500
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
501
|
+
-- 10. EXECUTE on the resolver/hydrator for authenticated users
|
|
502
|
+
-- ─────────────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
DO $$ BEGIN
|
|
505
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
506
|
+
EXECUTE 'GRANT EXECUTE ON FUNCTION rbac.user_can(uuid, text, text, uuid) TO authenticated';
|
|
507
|
+
EXECUTE 'GRANT EXECUTE ON FUNCTION rbac.user_profile(uuid) TO authenticated';
|
|
508
|
+
EXECUTE 'GRANT EXECUTE ON FUNCTION rbac.apply_template_defaults(uuid, boolean) TO authenticated';
|
|
509
|
+
END IF;
|
|
510
|
+
END $$;
|
|
511
|
+
|
|
512
|
+
COMMIT;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- snipe-auth-rbac — optional default seed
|
|
2
|
+
--
|
|
3
|
+
-- Companion to 0001_initial.sql that seeds:
|
|
4
|
+
-- * Two system roles (System Admin with is_super=true, System Support).
|
|
5
|
+
-- * Four generic company-role templates (Owner / Manager / Member /
|
|
6
|
+
-- Viewer) with sensible default_permissions patterns.
|
|
7
|
+
--
|
|
8
|
+
-- The four templates use only the `default` action set — they don't
|
|
9
|
+
-- reference specific resources or groups, since those are defined by
|
|
10
|
+
-- the host. After registering host resources, run
|
|
11
|
+
-- ``rbac.apply_template_defaults(role_id)`` to materialise the matrix.
|
|
12
|
+
--
|
|
13
|
+
-- Domain-specific templates (Property Manager, Tenant Manager,
|
|
14
|
+
-- Sales, …) belong in the host's own seed migration where their
|
|
15
|
+
-- group/resource defaults can reference real registered resources.
|
|
16
|
+
--
|
|
17
|
+
-- Idempotent: every INSERT uses ON CONFLICT DO NOTHING. Re-running
|
|
18
|
+
-- the file leaves an existing deployment untouched.
|
|
19
|
+
|
|
20
|
+
BEGIN;
|
|
21
|
+
|
|
22
|
+
-- System roles
|
|
23
|
+
INSERT INTO rbac.roles (id, scope, company_id, name, description, is_system, is_super, default_permissions)
|
|
24
|
+
VALUES
|
|
25
|
+
(gen_random_uuid(), 'system', NULL, 'System Admin',
|
|
26
|
+
'Plattform-Vollzugriff. Setzt jede Berechtigungsprüfung außer Kraft.',
|
|
27
|
+
true, true,
|
|
28
|
+
'{"default": ["read", "write", "update", "delete"]}'::jsonb),
|
|
29
|
+
(gen_random_uuid(), 'system', NULL, 'System Support',
|
|
30
|
+
'Lesezugriff auf systemweite Ressourcen für Support-Aufgaben.',
|
|
31
|
+
true, false,
|
|
32
|
+
'{"default": ["read"]}'::jsonb)
|
|
33
|
+
ON CONFLICT DO NOTHING;
|
|
34
|
+
|
|
35
|
+
-- Company-role templates (company_id IS NULL = template).
|
|
36
|
+
-- Generic shapes only; domain-specific defaults are the host's job.
|
|
37
|
+
INSERT INTO rbac.roles (id, scope, company_id, name, description, is_system, is_super, default_permissions)
|
|
38
|
+
VALUES
|
|
39
|
+
(gen_random_uuid(), 'company', NULL, 'Owner',
|
|
40
|
+
'Vollzugriff innerhalb der eigenen Company.',
|
|
41
|
+
true, false,
|
|
42
|
+
'{"default": ["read", "write", "update", "delete"]}'::jsonb),
|
|
43
|
+
(gen_random_uuid(), 'company', NULL, 'Manager',
|
|
44
|
+
'Verwaltet Daten der Company, kann Rollen ändern. Kein Löschen.',
|
|
45
|
+
true, false,
|
|
46
|
+
'{"default": ["read", "write", "update"]}'::jsonb),
|
|
47
|
+
(gen_random_uuid(), 'company', NULL, 'Member',
|
|
48
|
+
'Standard-Mitarbeiter mit Lese- und Schreibzugriff.',
|
|
49
|
+
true, false,
|
|
50
|
+
'{"default": ["read", "write"]}'::jsonb),
|
|
51
|
+
(gen_random_uuid(), 'company', NULL, 'Viewer',
|
|
52
|
+
'Nur Lesezugriff.',
|
|
53
|
+
true, false,
|
|
54
|
+
'{"default": ["read"]}'::jsonb)
|
|
55
|
+
ON CONFLICT DO NOTHING;
|
|
56
|
+
|
|
57
|
+
COMMIT;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client.ts"],"sourcesContent":["/**\n * Transport-agnostic client: turns an adopter-supplied\n * `AuthRbacFetcher` into a permission resolver. The React provider\n * wraps this; non-React consumers (Node scripts, edge functions)\n * can use it directly.\n */\n\nimport type {\n Action,\n AuthRbacFetcher,\n PermissionMap,\n ResourceDescriptor,\n ResourceRegistry,\n ResourceScope,\n UserProfile,\n} from \"./types.js\";\n\nexport interface AuthRbacClientOptions {\n fetcher: AuthRbacFetcher;\n /**\n * The host project's full resource list. Required so the resolver\n * can look up a resource's scope without a DB round-trip per call.\n * Re-using the same array the host syncs into the\n * `auth_rbac_resources` table at boot keeps everything in lockstep.\n */\n resources: ResourceRegistry;\n}\n\nexport interface CanOptions {\n /**\n * Override the active company. Omit to use the company the\n * caller has currently activated (the React provider tracks\n * this; for direct client use you must pass it).\n */\n companyId?: string | null;\n}\n\n/**\n * Pure resolver. Given a hydrated profile it answers boolean\n * questions instantly — no I/O. The `resourceMap` is built once at\n * construction so per-call work is two map lookups.\n */\nexport function buildPermissionResolver(\n resources: ResourceRegistry,\n profile: UserProfile,\n defaultCompanyId: string | null,\n) {\n const scopeByResource = new Map<string, ResourceScope>(\n resources.map((r) => [r.resource, r.scope]),\n );\n\n const can = (\n resource: string,\n action: Action,\n options?: CanOptions,\n ): boolean => {\n if (profile.is_super_admin) {\n return true;\n }\n const scope = scopeByResource.get(resource);\n if (!scope) {\n // Unknown resource — fail closed.\n return false;\n }\n if (scope === \"system\") {\n return readGrid(profile.system_permissions, resource, action);\n }\n const companyId = options?.companyId ?? defaultCompanyId;\n if (!companyId) {\n return false;\n }\n const membership = profile.memberships.find(\n (m) => m.company_id === companyId,\n );\n if (!membership) {\n return false;\n }\n return readGrid(membership.permissions, resource, action);\n };\n\n return {\n can,\n /** Permission map for the active (or specified) company. */\n activePermissions: (companyId?: string | null): PermissionMap => {\n const id = companyId ?? defaultCompanyId;\n if (!id) {\n return {};\n }\n return (\n profile.memberships.find((m) => m.company_id === id)?.permissions ?? {}\n );\n },\n systemPermissions: (): PermissionMap => profile.system_permissions,\n };\n}\n\nfunction readGrid(\n map: PermissionMap,\n resource: string,\n action: Action,\n): boolean {\n const grid = map[resource];\n if (!grid) {\n return false;\n }\n return grid[action];\n}\n\n/**\n * Helper: groups a resource registry by `group` for the matrix UI.\n * Returns groups in insertion order with their resources.\n */\nexport function groupResources(\n registry: ResourceRegistry,\n): Array<{ group: string; resources: ResourceDescriptor[] }> {\n const order: string[] = [];\n const buckets = new Map<string, ResourceDescriptor[]>();\n for (const r of registry) {\n const key = r.group ?? \"Sonstige\";\n if (!buckets.has(key)) {\n buckets.set(key, []);\n order.push(key);\n }\n buckets.get(key)!.push(r);\n }\n return order.map((g) => ({ group: g, resources: buckets.get(g)! }));\n}\n\nexport type AuthRbacClient = ReturnType<typeof buildPermissionResolver>;\nexport type { AuthRbacClientOptions as ClientOptions };\n"],"mappings":";AA0CO,SAAS,wBACd,WACA,SACA,kBACA;AACA,QAAM,kBAAkB,IAAI;AAAA,IAC1B,UAAU,IAAI,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,KAAK,CAAC;AAAA,EAC5C;AAEA,QAAM,MAAM,CACV,UACA,QACA,YACY;AACZ,QAAI,QAAQ,gBAAgB;AAC1B,aAAO;AAAA,IACT;AACA,UAAM,QAAQ,gBAAgB,IAAI,QAAQ;AAC1C,QAAI,CAAC,OAAO;AAEV,aAAO;AAAA,IACT;AACA,QAAI,UAAU,UAAU;AACtB,aAAO,SAAS,QAAQ,oBAAoB,UAAU,MAAM;AAAA,IAC9D;AACA,UAAM,YAAY,SAAS,aAAa;AACxC,QAAI,CAAC,WAAW;AACd,aAAO;AAAA,IACT;AACA,UAAM,aAAa,QAAQ,YAAY;AAAA,MACrC,CAAC,MAAM,EAAE,eAAe;AAAA,IAC1B;AACA,QAAI,CAAC,YAAY;AACf,aAAO;AAAA,IACT;AACA,WAAO,SAAS,WAAW,aAAa,UAAU,MAAM;AAAA,EAC1D;AAEA,SAAO;AAAA,IACL;AAAA;AAAA,IAEA,mBAAmB,CAAC,cAA6C;AAC/D,YAAM,KAAK,aAAa;AACxB,UAAI,CAAC,IAAI;AACP,eAAO,CAAC;AAAA,MACV;AACA,aACE,QAAQ,YAAY,KAAK,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,eAAe,CAAC;AAAA,IAE1E;AAAA,IACA,mBAAmB,MAAqB,QAAQ;AAAA,EAClD;AACF;AAEA,SAAS,SACP,KACA,UACA,QACS;AACT,QAAM,OAAO,IAAI,QAAQ;AACzB,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AACA,SAAO,KAAK,MAAM;AACpB;AAMO,SAAS,eACd,UAC2D;AAC3D,QAAM,QAAkB,CAAC;AACzB,QAAM,UAAU,oBAAI,IAAkC;AACtD,aAAW,KAAK,UAAU;AACxB,UAAM,MAAM,EAAE,SAAS;AACvB,QAAI,CAAC,QAAQ,IAAI,GAAG,GAAG;AACrB,cAAQ,IAAI,KAAK,CAAC,CAAC;AACnB,YAAM,KAAK,GAAG;AAAA,IAChB;AACA,YAAQ,IAAI,GAAG,EAAG,KAAK,CAAC;AAAA,EAC1B;AACA,SAAO,MAAM,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,WAAW,QAAQ,IAAI,CAAC,EAAG,EAAE;AACpE;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/fetchers.ts"],"sourcesContent":["/**\n * Built-in fetchers — adopters can use these or pass their own\n * implementation of `AuthRbacFetcher`.\n */\n\nimport type { AuthRbacFetcher, UserProfile } from \"./types.js\";\n\n/**\n * Calls the package's SQL function `auth_rbac_user_profile(uuid)` via\n * a Supabase JS client. Easiest path when the host project already\n * uses Supabase.\n *\n * @example\n * createSupabaseFetcher({ supabase, userId: session.user.id })\n */\nexport function createSupabaseFetcher(opts: {\n supabase: {\n rpc: (\n fn: string,\n args: Record<string, unknown>,\n ) => Promise<{ data: unknown; error: { message: string } | null }>;\n };\n userId: string;\n}): AuthRbacFetcher {\n return {\n async fetchProfile(): Promise<UserProfile> {\n const { data, error } = await opts.supabase.rpc(\n \"auth_rbac_user_profile\",\n { p_user_id: opts.userId },\n );\n if (error) {\n throw new Error(\n `auth-rbac: failed to load user profile via Supabase RPC: ${error.message}`,\n );\n }\n return normalizeProfile(data);\n },\n };\n}\n\n/**\n * Calls a regular HTTP endpoint that returns a `UserProfile` JSON\n * payload. Use this when the host project has its own backend that\n * wraps the package's Python helpers (or any equivalent).\n *\n * @example\n * createHttpFetcher({ url: \"/api/users/me/profile\" })\n */\nexport function createHttpFetcher(opts: {\n url: string;\n /** Forwarded as-is to `fetch`. Use this to attach auth headers. */\n init?: RequestInit;\n /** Override the global `fetch` if you're in a non-browser env. */\n fetch?: typeof fetch;\n}): AuthRbacFetcher {\n const fetchImpl = opts.fetch ?? globalThis.fetch;\n return {\n async fetchProfile(): Promise<UserProfile> {\n const res = await fetchImpl(opts.url, opts.init);\n if (!res.ok) {\n throw new Error(\n `auth-rbac: profile endpoint ${opts.url} returned ${res.status}`,\n );\n }\n const json = (await res.json()) as unknown;\n return normalizeProfile(json);\n },\n };\n}\n\n/**\n * Defensive normalisation: the Supabase RPC returns whatever the SQL\n * function emitted. We coerce missing fields into the empty defaults\n * so consumers can iterate without null checks. Throws if the shape\n * is unrecognisable.\n */\nfunction normalizeProfile(raw: unknown): UserProfile {\n if (!raw || typeof raw !== \"object\") {\n throw new Error(\"auth-rbac: profile payload is not an object\");\n }\n const p = raw as Partial<UserProfile> & Record<string, unknown>;\n if (typeof p.user_id !== \"string\") {\n throw new Error(\"auth-rbac: profile payload missing user_id\");\n }\n return {\n user_id: p.user_id,\n is_super_admin: !!p.is_super_admin,\n system_roles: Array.isArray(p.system_roles) ? p.system_roles : [],\n system_permissions:\n p.system_permissions && typeof p.system_permissions === \"object\"\n ? (p.system_permissions as UserProfile[\"system_permissions\"])\n : {},\n system_frontend_config:\n p.system_frontend_config && typeof p.system_frontend_config === \"object\"\n ? (p.system_frontend_config as UserProfile[\"system_frontend_config\"])\n : {},\n memberships: Array.isArray(p.memberships)\n ? (p.memberships as UserProfile[\"memberships\"])\n : [],\n };\n}\n"],"mappings":";AAeO,SAAS,sBAAsB,MAQlB;AAClB,SAAO;AAAA,IACL,MAAM,eAAqC;AACzC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,KAAK,SAAS;AAAA,QAC1C;AAAA,QACA,EAAE,WAAW,KAAK,OAAO;AAAA,MAC3B;AACA,UAAI,OAAO;AACT,cAAM,IAAI;AAAA,UACR,4DAA4D,MAAM,OAAO;AAAA,QAC3E;AAAA,MACF;AACA,aAAO,iBAAiB,IAAI;AAAA,IAC9B;AAAA,EACF;AACF;AAUO,SAAS,kBAAkB,MAMd;AAClB,QAAM,YAAY,KAAK,SAAS,WAAW;AAC3C,SAAO;AAAA,IACL,MAAM,eAAqC;AACzC,YAAM,MAAM,MAAM,UAAU,KAAK,KAAK,KAAK,IAAI;AAC/C,UAAI,CAAC,IAAI,IAAI;AACX,cAAM,IAAI;AAAA,UACR,+BAA+B,KAAK,GAAG,aAAa,IAAI,MAAM;AAAA,QAChE;AAAA,MACF;AACA,YAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,aAAO,iBAAiB,IAAI;AAAA,IAC9B;AAAA,EACF;AACF;AAQA,SAAS,iBAAiB,KAA2B;AACnD,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,UAAM,IAAI,MAAM,6CAA6C;AAAA,EAC/D;AACA,QAAM,IAAI;AACV,MAAI,OAAO,EAAE,YAAY,UAAU;AACjC,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AACA,SAAO;AAAA,IACL,SAAS,EAAE;AAAA,IACX,gBAAgB,CAAC,CAAC,EAAE;AAAA,IACpB,cAAc,MAAM,QAAQ,EAAE,YAAY,IAAI,EAAE,eAAe,CAAC;AAAA,IAChE,oBACE,EAAE,sBAAsB,OAAO,EAAE,uBAAuB,WACnD,EAAE,qBACH,CAAC;AAAA,IACP,wBACE,EAAE,0BAA0B,OAAO,EAAE,2BAA2B,WAC3D,EAAE,yBACH,CAAC;AAAA,IACP,aAAa,MAAM,QAAQ,EAAE,WAAW,IACnC,EAAE,cACH,CAAC;AAAA,EACP;AACF;","names":[]}
|