@kontrolia/db 2.0.0 → 2.1.1

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.
@@ -0,0 +1,392 @@
1
+ -- Sixth same-day audit found the real root cause underlying every
2
+ -- last-owner fix today (0025-0029): is_org_owner()/is_org_admin() only
3
+ -- ever checked roles.slug — a column no trigger and no RLS policy on
4
+ -- kontrolia_auth.roles actually protects. "org admins can update custom
5
+ -- roles" (0019) never inspects slug at all. Any org Admin could create an
6
+ -- ordinary custom role, grant it to themselves, then UPDATE its slug to
7
+ -- 'owner', becoming recognized as Owner by every check in the system —
8
+ -- live-exploited this session, a complete bypass of migrations 0025-0029
9
+ -- through a table none of them touch.
10
+ --
11
+ -- The actual invariant that matters isn't the slug string, it's
12
+ -- is_system_role: 0019's INSERT/UPDATE policies on kontrolia_auth.roles
13
+ -- already guarantee a custom role can never be created with, or updated
14
+ -- to, is_system_role = true (the policies' implicit WITH CHECK — reusing
15
+ -- USING, since neither specifies one — requires "not is_system_role" on
16
+ -- the row both before AND after the write). That flag is only ever set by
17
+ -- the original global-role seed data and the SECURITY DEFINER
18
+ -- handle_application_enabled() trigger (0019), both outside any
19
+ -- RLS-writable path. Anchor every authority check to that flag instead of
20
+ -- the freely-writable slug string it was never actually protecting.
21
+
22
+ create or replace function kontrolia_auth.is_org_admin(org_id uuid)
23
+ returns boolean
24
+ language sql
25
+ stable
26
+ security definer
27
+ set search_path = ''
28
+ as $$
29
+ select exists (
30
+ select 1
31
+ from kontrolia_auth.memberships m
32
+ join kontrolia_auth.membership_roles mr on mr.membership_id = m.id
33
+ join kontrolia_auth.roles r on r.id = mr.role_id
34
+ where m.organization_id = org_id
35
+ and m.user_id = auth.uid()
36
+ and m.status = 'active'
37
+ and r.is_system_role
38
+ and r.slug in ('owner', 'admin')
39
+ );
40
+ $$;
41
+
42
+ create or replace function kontrolia_auth.is_org_owner(org_id uuid)
43
+ returns boolean
44
+ language sql
45
+ stable
46
+ security definer
47
+ set search_path = ''
48
+ as $$
49
+ select exists (
50
+ select 1
51
+ from kontrolia_auth.memberships m
52
+ join kontrolia_auth.membership_roles mr on mr.membership_id = m.id
53
+ join kontrolia_auth.roles r on r.id = mr.role_id
54
+ where m.organization_id = org_id
55
+ and m.user_id = auth.uid()
56
+ and m.status = 'active'
57
+ and r.is_system_role
58
+ and r.slug = 'owner'
59
+ );
60
+ $$;
61
+
62
+ -- Same gap in the JWT's "roles" claim: a hijacked custom role's slug
63
+ -- would show up as "owner"/"admin" in the token itself, fooling any
64
+ -- client-side hasRole(['owner']) UI gate even though the DB-level fix
65
+ -- above already stops it from granting any real privilege. Restricting
66
+ -- this to system roles only doesn't remove any real capability — no
67
+ -- caller in this codebase checks hasRole() against a custom role's slug
68
+ -- (grepped: only 'owner'/'admin' are ever checked), and a custom role's
69
+ -- actual permissions are still fully exposed via the separate
70
+ -- "permissions" claim below, unaffected.
71
+ create or replace function kontrolia_auth.custom_access_token_hook(event jsonb)
72
+ returns jsonb
73
+ language plpgsql
74
+ stable
75
+ security definer
76
+ set search_path = ''
77
+ as $$
78
+ declare
79
+ claims jsonb;
80
+ target_user_id uuid;
81
+ active_org_id uuid;
82
+ active_membership_id uuid;
83
+ role_names text[];
84
+ permission_keys text[];
85
+ begin
86
+ claims := coalesce(event->'claims', '{}'::jsonb);
87
+ target_user_id := (event->>'user_id')::uuid;
88
+
89
+ select active_organization_id into active_org_id
90
+ from kontrolia_auth.sessions_context
91
+ where user_id = target_user_id;
92
+
93
+ if active_org_id is null then
94
+ select organization_id into active_org_id
95
+ from kontrolia_auth.memberships
96
+ where user_id = target_user_id and status = 'active'
97
+ order by created_at asc
98
+ limit 1;
99
+ end if;
100
+
101
+ if active_org_id is not null then
102
+ select id into active_membership_id
103
+ from kontrolia_auth.memberships
104
+ where user_id = target_user_id
105
+ and organization_id = active_org_id
106
+ and status = 'active';
107
+ end if;
108
+
109
+ if active_membership_id is not null then
110
+ select coalesce(array_agg(distinct r.slug), '{}')
111
+ into role_names
112
+ from kontrolia_auth.membership_roles mr
113
+ join kontrolia_auth.roles r on r.id = mr.role_id
114
+ where mr.membership_id = active_membership_id and r.is_system_role;
115
+
116
+ select coalesce(array_agg(distinct p.key), '{}')
117
+ into permission_keys
118
+ from (
119
+ select p.id, p.key
120
+ from kontrolia_auth.membership_roles mr
121
+ join kontrolia_auth.role_permissions rp on rp.role_id = mr.role_id
122
+ join kontrolia_auth.permissions p on p.id = rp.permission_id
123
+ where mr.membership_id = active_membership_id
124
+ union
125
+ select p.id, p.key
126
+ from kontrolia_auth.user_permissions up
127
+ join kontrolia_auth.permissions p on p.id = up.permission_id
128
+ where up.membership_id = active_membership_id and up.effect = 'allow'
129
+ ) p
130
+ where not exists (
131
+ select 1 from kontrolia_auth.user_permissions up_deny
132
+ where up_deny.membership_id = active_membership_id
133
+ and up_deny.permission_id = p.id
134
+ and up_deny.effect = 'deny'
135
+ );
136
+ else
137
+ role_names := '{}';
138
+ permission_keys := '{}';
139
+ end if;
140
+
141
+ claims := jsonb_set(claims, '{organization_id}', coalesce(to_jsonb(active_org_id), 'null'::jsonb));
142
+ claims := jsonb_set(claims, '{roles}', to_jsonb(coalesce(role_names, '{}')));
143
+ claims := jsonb_set(claims, '{permissions}', to_jsonb(coalesce(permission_keys, '{}')));
144
+
145
+ event := jsonb_set(event, '{claims}', claims);
146
+ return event;
147
+ end;
148
+ $$;
149
+
150
+ -- The last-owner "how many active Owners remain" counting queries in
151
+ -- 0025-0029 have the exact same blind-slug-trust gap: a hijacked custom
152
+ -- role sitting in membership_roles with slug = 'owner' would inflate the
153
+ -- count, letting the real Owner be removed right alongside it — live-
154
+ -- chained and exploited this session. Add "and r.is_system_role" to every
155
+ -- one of them, redefining each function in place (same names/triggers,
156
+ -- only the queries change).
157
+
158
+ create or replace function kontrolia_auth.prevent_last_owner_role_removal()
159
+ returns trigger
160
+ language plpgsql
161
+ security definer
162
+ set search_path = ''
163
+ as $$
164
+ declare
165
+ v_organization_id uuid;
166
+ v_role_slug text;
167
+ v_is_system_role boolean;
168
+ v_active_owner_count int;
169
+ begin
170
+ select organization_id into v_organization_id
171
+ from kontrolia_auth.memberships
172
+ where id = old.membership_id;
173
+
174
+ if v_organization_id is null or not exists (select 1 from kontrolia_auth.organizations where id = v_organization_id) then
175
+ return old;
176
+ end if;
177
+
178
+ select slug, is_system_role into v_role_slug, v_is_system_role from kontrolia_auth.roles where id = old.role_id;
179
+
180
+ if v_role_slug is distinct from 'owner' or not v_is_system_role then
181
+ return old;
182
+ end if;
183
+
184
+ select count(*) into v_active_owner_count
185
+ from kontrolia_auth.membership_roles mr
186
+ join kontrolia_auth.memberships m on m.id = mr.membership_id
187
+ join kontrolia_auth.roles r on r.id = mr.role_id
188
+ where m.organization_id = v_organization_id
189
+ and m.status = 'active'
190
+ and r.slug = 'owner'
191
+ and r.is_system_role;
192
+
193
+ if v_active_owner_count <= 1 then
194
+ raise exception 'No puedes quitar el rol de Owner al único Owner activo de la organización.';
195
+ end if;
196
+
197
+ return old;
198
+ end;
199
+ $$;
200
+
201
+ create or replace function kontrolia_auth.prevent_last_owner_membership_removal()
202
+ returns trigger
203
+ language plpgsql
204
+ security definer
205
+ set search_path = ''
206
+ as $$
207
+ declare
208
+ v_is_owner boolean;
209
+ v_active_owner_count int;
210
+ begin
211
+ if not exists (select 1 from kontrolia_auth.organizations where id = old.organization_id) then
212
+ return old;
213
+ end if;
214
+
215
+ if old.status is distinct from 'active' then
216
+ return old;
217
+ end if;
218
+
219
+ select exists (
220
+ select 1
221
+ from kontrolia_auth.membership_roles mr
222
+ join kontrolia_auth.roles r on r.id = mr.role_id
223
+ where mr.membership_id = old.id and r.slug = 'owner' and r.is_system_role
224
+ ) into v_is_owner;
225
+
226
+ if not v_is_owner then
227
+ return old;
228
+ end if;
229
+
230
+ select count(*) into v_active_owner_count
231
+ from kontrolia_auth.membership_roles mr
232
+ join kontrolia_auth.memberships m on m.id = mr.membership_id
233
+ join kontrolia_auth.roles r on r.id = mr.role_id
234
+ where m.organization_id = old.organization_id
235
+ and m.status = 'active'
236
+ and r.slug = 'owner'
237
+ and r.is_system_role;
238
+
239
+ if v_active_owner_count <= 1 then
240
+ raise exception 'No puedes quitar al único Owner activo de la organización.';
241
+ end if;
242
+
243
+ return old;
244
+ end;
245
+ $$;
246
+
247
+ create or replace function kontrolia_auth.prevent_last_owner_deactivation()
248
+ returns trigger
249
+ language plpgsql
250
+ security definer
251
+ set search_path = ''
252
+ as $$
253
+ declare
254
+ v_is_owner boolean;
255
+ v_active_owner_count int;
256
+ v_leaving_org boolean;
257
+ begin
258
+ v_leaving_org := (new.organization_id is distinct from old.organization_id) or (new.status is distinct from 'active');
259
+
260
+ if old.status is distinct from 'active' or not v_leaving_org then
261
+ return new;
262
+ end if;
263
+
264
+ select exists (
265
+ select 1
266
+ from kontrolia_auth.membership_roles mr
267
+ join kontrolia_auth.roles r on r.id = mr.role_id
268
+ where mr.membership_id = old.id and r.slug = 'owner' and r.is_system_role
269
+ ) into v_is_owner;
270
+
271
+ if not v_is_owner then
272
+ return new;
273
+ end if;
274
+
275
+ select count(*) into v_active_owner_count
276
+ from kontrolia_auth.membership_roles mr
277
+ join kontrolia_auth.memberships m on m.id = mr.membership_id
278
+ join kontrolia_auth.roles r on r.id = mr.role_id
279
+ where m.organization_id = old.organization_id
280
+ and m.status = 'active'
281
+ and r.slug = 'owner'
282
+ and r.is_system_role;
283
+
284
+ if v_active_owner_count <= 1 then
285
+ raise exception 'No puedes quitar al único Owner activo de la organización.';
286
+ end if;
287
+
288
+ return new;
289
+ end;
290
+ $$;
291
+
292
+ create or replace function kontrolia_auth.prevent_admin_granting_owner_role()
293
+ returns trigger
294
+ language plpgsql
295
+ security definer
296
+ set search_path = ''
297
+ as $$
298
+ declare
299
+ v_role_slug text;
300
+ v_is_system_role boolean;
301
+ v_organization_id uuid;
302
+ begin
303
+ if auth.role() = 'service_role' then
304
+ return new;
305
+ end if;
306
+
307
+ select slug, is_system_role into v_role_slug, v_is_system_role from kontrolia_auth.roles where id = new.role_id;
308
+ if v_role_slug is distinct from 'owner' or not v_is_system_role then
309
+ return new;
310
+ end if;
311
+
312
+ select organization_id into v_organization_id from kontrolia_auth.memberships where id = new.membership_id;
313
+
314
+ if not kontrolia_auth.is_org_owner(v_organization_id) then
315
+ raise exception 'Solo un Owner puede otorgar el rol de Owner.';
316
+ end if;
317
+
318
+ return new;
319
+ end;
320
+ $$;
321
+
322
+ create or replace function kontrolia_auth.prevent_membership_role_update()
323
+ returns trigger
324
+ language plpgsql
325
+ security definer
326
+ set search_path = ''
327
+ as $$
328
+ declare
329
+ v_organization_id uuid;
330
+ v_old_slug text;
331
+ v_old_is_system boolean;
332
+ v_new_slug text;
333
+ v_new_is_system boolean;
334
+ v_active_owner_count int;
335
+ begin
336
+ if new.membership_id is distinct from old.membership_id then
337
+ raise exception 'No se puede reasignar un rol de membresía a otra membresía.';
338
+ end if;
339
+
340
+ if old.role_id is distinct from new.role_id and auth.role() is distinct from 'service_role' then
341
+ select slug, is_system_role into v_old_slug, v_old_is_system from kontrolia_auth.roles where id = old.role_id;
342
+ select slug, is_system_role into v_new_slug, v_new_is_system from kontrolia_auth.roles where id = new.role_id;
343
+ select organization_id into v_organization_id from kontrolia_auth.memberships where id = old.membership_id;
344
+
345
+ if v_new_slug = 'owner' and v_new_is_system and not kontrolia_auth.is_org_owner(v_organization_id) then
346
+ raise exception 'Solo un Owner puede otorgar el rol de Owner.';
347
+ end if;
348
+
349
+ if v_old_slug = 'owner' and v_old_is_system and (v_new_slug is distinct from 'owner' or not v_new_is_system) then
350
+ select count(*) into v_active_owner_count
351
+ from kontrolia_auth.membership_roles mr
352
+ join kontrolia_auth.memberships m on m.id = mr.membership_id
353
+ join kontrolia_auth.roles r on r.id = mr.role_id
354
+ where m.organization_id = v_organization_id
355
+ and m.status = 'active'
356
+ and r.slug = 'owner'
357
+ and r.is_system_role;
358
+
359
+ if v_active_owner_count <= 1 then
360
+ raise exception 'No puedes quitar el rol de Owner al único Owner activo de la organización.';
361
+ end if;
362
+ end if;
363
+ end if;
364
+
365
+ return new;
366
+ end;
367
+ $$;
368
+
369
+ -- roles: neither the INSERT nor the UPDATE policy (0019) has ever
370
+ -- inspected `slug` — "not is_system_role" only ever governed
371
+ -- is_system_role itself, never the string a custom role's slug could be
372
+ -- set to. Defense in depth on top of the is_system_role anchoring above:
373
+ -- even if some future check forgets to require is_system_role, a custom
374
+ -- role can no longer be given a reserved slug in the first place.
375
+ create or replace function kontrolia_auth.prevent_custom_role_reserved_slug()
376
+ returns trigger
377
+ language plpgsql
378
+ security definer
379
+ set search_path = ''
380
+ as $$
381
+ begin
382
+ if not new.is_system_role and new.slug in ('owner', 'admin', 'member') then
383
+ raise exception 'No puedes usar "%" como slug de un rol personalizado — está reservado.', new.slug;
384
+ end if;
385
+ return new;
386
+ end;
387
+ $$;
388
+
389
+ drop trigger if exists prevent_custom_role_reserved_slug on kontrolia_auth.roles;
390
+ create trigger prevent_custom_role_reserved_slug
391
+ before insert or update on kontrolia_auth.roles
392
+ for each row execute function kontrolia_auth.prevent_custom_role_reserved_slug();
@@ -0,0 +1,121 @@
1
+ -- 0028's owner-grant guard ("Solo un Owner puede otorgar el rol de Owner")
2
+ -- broke organization creation itself: 0011's bootstrap trigger auto-enrolls
3
+ -- a brand-new organization's creator as its first Owner, in the same
4
+ -- transaction as the org's own insert — at that exact moment
5
+ -- is_org_owner() correctly returns false (there is, by definition, no
6
+ -- Owner yet), so 0028 rejected the very grant it needed to allow. Every
7
+ -- "create organization" request has been failing with "Solo un Owner
8
+ -- puede otorgar el rol de Owner" since 0028 shipped today.
9
+ --
10
+ -- The fix: also allow the grant when the target organization currently has
11
+ -- zero active Owners. That can only be genuinely true for a brand-new org
12
+ -- (0025-0027 already block every path that would let an *existing* org's
13
+ -- active-Owner count reach zero), so this reopens nothing PQ-SEC-006
14
+ -- closed — it only restores the one case those very fixes made
15
+ -- impossible to reach any other way: establishing an org's first Owner.
16
+
17
+ create or replace function kontrolia_auth.prevent_admin_granting_owner_role()
18
+ returns trigger
19
+ language plpgsql
20
+ security definer
21
+ set search_path = ''
22
+ as $$
23
+ declare
24
+ v_role_slug text;
25
+ v_is_system_role boolean;
26
+ v_organization_id uuid;
27
+ v_existing_owner_count int;
28
+ begin
29
+ if auth.role() = 'service_role' then
30
+ return new;
31
+ end if;
32
+
33
+ select slug, is_system_role into v_role_slug, v_is_system_role from kontrolia_auth.roles where id = new.role_id;
34
+ if v_role_slug is distinct from 'owner' or not v_is_system_role then
35
+ return new;
36
+ end if;
37
+
38
+ select organization_id into v_organization_id from kontrolia_auth.memberships where id = new.membership_id;
39
+
40
+ if kontrolia_auth.is_org_owner(v_organization_id) then
41
+ return new;
42
+ end if;
43
+
44
+ select count(*) into v_existing_owner_count
45
+ from kontrolia_auth.membership_roles mr
46
+ join kontrolia_auth.memberships m on m.id = mr.membership_id
47
+ join kontrolia_auth.roles r on r.id = mr.role_id
48
+ where m.organization_id = v_organization_id
49
+ and m.status = 'active'
50
+ and r.slug = 'owner'
51
+ and r.is_system_role;
52
+
53
+ if v_existing_owner_count > 0 then
54
+ raise exception 'Solo un Owner puede otorgar el rol de Owner.';
55
+ end if;
56
+
57
+ return new;
58
+ end;
59
+ $$;
60
+
61
+ -- Same latent bug in 0029's UPDATE-path owner-grant check — not currently
62
+ -- reachable by any shipped code path (nothing upserts a role_id change to
63
+ -- 'owner' outside the service-role-exempt invitation-accept flow), but
64
+ -- fixing it for the same reason: a zero-Owner org has nothing to bypass.
65
+ create or replace function kontrolia_auth.prevent_membership_role_update()
66
+ returns trigger
67
+ language plpgsql
68
+ security definer
69
+ set search_path = ''
70
+ as $$
71
+ declare
72
+ v_organization_id uuid;
73
+ v_old_slug text;
74
+ v_old_is_system boolean;
75
+ v_new_slug text;
76
+ v_new_is_system boolean;
77
+ v_active_owner_count int;
78
+ begin
79
+ if new.membership_id is distinct from old.membership_id then
80
+ raise exception 'No se puede reasignar un rol de membresía a otra membresía.';
81
+ end if;
82
+
83
+ if old.role_id is distinct from new.role_id and auth.role() is distinct from 'service_role' then
84
+ select slug, is_system_role into v_old_slug, v_old_is_system from kontrolia_auth.roles where id = old.role_id;
85
+ select slug, is_system_role into v_new_slug, v_new_is_system from kontrolia_auth.roles where id = new.role_id;
86
+ select organization_id into v_organization_id from kontrolia_auth.memberships where id = old.membership_id;
87
+
88
+ if v_new_slug = 'owner' and v_new_is_system and not kontrolia_auth.is_org_owner(v_organization_id) then
89
+ select count(*) into v_active_owner_count
90
+ from kontrolia_auth.membership_roles mr
91
+ join kontrolia_auth.memberships m on m.id = mr.membership_id
92
+ join kontrolia_auth.roles r on r.id = mr.role_id
93
+ where m.organization_id = v_organization_id
94
+ and m.status = 'active'
95
+ and r.slug = 'owner'
96
+ and r.is_system_role;
97
+
98
+ if v_active_owner_count > 0 then
99
+ raise exception 'Solo un Owner puede otorgar el rol de Owner.';
100
+ end if;
101
+ end if;
102
+
103
+ if v_old_slug = 'owner' and v_old_is_system and (v_new_slug is distinct from 'owner' or not v_new_is_system) then
104
+ select count(*) into v_active_owner_count
105
+ from kontrolia_auth.membership_roles mr
106
+ join kontrolia_auth.memberships m on m.id = mr.membership_id
107
+ join kontrolia_auth.roles r on r.id = mr.role_id
108
+ where m.organization_id = v_organization_id
109
+ and m.status = 'active'
110
+ and r.slug = 'owner'
111
+ and r.is_system_role;
112
+
113
+ if v_active_owner_count <= 1 then
114
+ raise exception 'No puedes quitar el rol de Owner al único Owner activo de la organización.';
115
+ end if;
116
+ end if;
117
+ end if;
118
+
119
+ return new;
120
+ end;
121
+ $$;
@@ -0,0 +1,66 @@
1
+ -- kontrolia-integration-surface audit (INT-KEY-001, HIGH): the application
2
+ -- sync API key had no rotation, no revocation UI, no "last used" signal, and
3
+ -- no logging — a leak was both undetectable and recoverable only by
4
+ -- destructively deleting and re-registering the whole application. Also
5
+ -- found while implementing the fix: api_key_hash was readable by ANY
6
+ -- authenticated user for ANY application. RLS is row-level only — 0018's
7
+ -- "browse the application catalog" SELECT policy (`using (true)`) makes
8
+ -- every application row visible to every authenticated user, and 0008's
9
+ -- blanket `grant select ... on all tables ... to authenticated` never
10
+ -- carved out an exception for this one sensitive column. The stored value
11
+ -- is a plain, unsalted sha256 digest (packages/db/src/api-key.ts) — not
12
+ -- practically reversible given the key's 192 bits of entropy, but a hash
13
+ -- that any user can read for any organization's application is needless
14
+ -- exposure regardless, and worth closing at the same time as the rest of
15
+ -- this key's lifecycle.
16
+
17
+ alter table kontrolia_auth.applications add column api_key_last_used_at timestamptz;
18
+
19
+ comment on column kontrolia_auth.applications.api_key_last_used_at is
20
+ 'Updated on every successful POST /api/applications/sync auth — lets an operator tell an active integration from an abandoned one before rotating/revoking its key.';
21
+
22
+ -- Column-level ACL, layered under the existing row-level policies. Table-
23
+ -- level `grant select` (0008) makes every column readable regardless of a
24
+ -- later per-column `revoke` — Postgres's column privileges are additive on
25
+ -- top of the table-level grant, not a restriction of it, so blocking one
26
+ -- column means revoking the table-level grant entirely and re-granting an
27
+ -- explicit column list for everything else. Rotating/revoking the key
28
+ -- still works (that's an UPDATE, untouched by this). The sync route's own
29
+ -- lookup runs as service_role, which needs its own explicit grant since
30
+ -- column privileges aren't inherited from a table-level grant either.
31
+ revoke select on kontrolia_auth.applications from authenticated;
32
+ grant select (
33
+ id, name, slug, owner_organization_id, environment, redirect_urls,
34
+ created_at, updated_at, homepage_url, api_key_last_used_at
35
+ ) on kontrolia_auth.applications to authenticated;
36
+ grant select (id, api_key_hash) on kontrolia_auth.applications to service_role;
37
+
38
+ -- Same "the database logs it, not application code" pattern as every other
39
+ -- audit trigger in this schema (0013, 0024) — rotation/revocation are
40
+ -- exactly the kind of security-relevant event that should never depend on
41
+ -- whichever code path happened to perform the UPDATE remembering to log it.
42
+ create or replace function kontrolia_auth.log_application_api_key_change()
43
+ returns trigger
44
+ language plpgsql
45
+ security definer
46
+ set search_path = ''
47
+ as $$
48
+ begin
49
+ if old.api_key_hash is distinct from new.api_key_hash then
50
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
51
+ values (
52
+ new.owner_organization_id,
53
+ auth.uid(),
54
+ case when new.api_key_hash is null then 'application.api_key_revoked' else 'application.api_key_rotated' end,
55
+ 'application',
56
+ new.id::text,
57
+ jsonb_build_object('slug', new.slug)
58
+ );
59
+ end if;
60
+ return new;
61
+ end;
62
+ $$;
63
+
64
+ create trigger audit_application_api_key_change
65
+ after update on kontrolia_auth.applications
66
+ for each row execute function kontrolia_auth.log_application_api_key_change();
@@ -0,0 +1,53 @@
1
+ -- Found while designing the external applications/members API (which
2
+ -- creates invitations via a service-role client, same as invitation-accept
3
+ -- already does): invitation-accept (apps/auth-server/app/api/invitations/
4
+ -- accept/route.ts) grants invitation.role_id through a service_role admin
5
+ -- client, and prevent_admin_granting_owner_role's own `auth.role() =
6
+ -- 'service_role' then return new` bypass (0028) means it never checked
7
+ -- whether that role is 'owner'. An org Admin has always been able to create
8
+ -- an invitation with role_id pointing at the Owner role (POST
9
+ -- /api/invitations only enforces "org admins manage invitations" RLS, which
10
+ -- never inspects role_id), and accepting it would silently grant Owner with
11
+ -- no is_org_owner() check at all — a live, reachable bypass of every
12
+ -- owner-grant protection built in 0025-0031, through a channel none of them
13
+ -- touch.
14
+ --
15
+ -- Rather than trying to teach the service-role-exempt accept flow to tell a
16
+ -- legitimate bootstrap grant apart from this, remove the capability at its
17
+ -- source: an invitation can never carry the Owner role. This is also just a
18
+ -- reasonable product invariant on its own — you can't invite someone
19
+ -- straight into ownership, an existing Owner has to promote them after they
20
+ -- join. No legitimate code path creates an owner-role invitation today
21
+ -- (grepped: POST /api/invitations never special-cases role slugs), so this
22
+ -- forecloses nothing that worked before.
23
+
24
+ create or replace function kontrolia_auth.prevent_owner_role_invitation()
25
+ returns trigger
26
+ language plpgsql
27
+ security definer
28
+ set search_path = ''
29
+ as $$
30
+ declare
31
+ v_role_slug text;
32
+ v_is_system_role boolean;
33
+ begin
34
+ if new.role_id is null then
35
+ return new;
36
+ end if;
37
+
38
+ select slug, is_system_role into v_role_slug, v_is_system_role
39
+ from kontrolia_auth.roles
40
+ where id = new.role_id;
41
+
42
+ if v_role_slug = 'owner' and v_is_system_role then
43
+ raise exception 'No se puede invitar directamente con el rol de Owner.';
44
+ end if;
45
+
46
+ return new;
47
+ end;
48
+ $$;
49
+
50
+ drop trigger if exists prevent_owner_role_invitation on kontrolia_auth.invitations;
51
+ create trigger prevent_owner_role_invitation
52
+ before insert or update on kontrolia_auth.invitations
53
+ for each row execute function kontrolia_auth.prevent_owner_role_invitation();