@kontrolia/db 2.1.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.
- package/migrations/0024_extend_audit_triggers.sql +77 -0
- package/migrations/0025_prevent_last_owner_role_removal.sql +57 -0
- package/migrations/0026_prevent_last_owner_membership_removal.sql +111 -0
- package/migrations/0027_prevent_last_owner_org_reassignment.sql +56 -0
- package/migrations/0028_restrict_owner_grant_and_membership_identity.sql +70 -0
- package/migrations/0029_membership_roles_update_guard.sql +108 -0
- package/migrations/0030_anchor_authority_to_system_roles.sql +392 -0
- package/migrations/0031_allow_owner_bootstrap_grant.sql +121 -0
- package/migrations/0032_application_api_key_lifecycle.sql +66 -0
- package/migrations/0033_prevent_owner_role_invitation.sql +53 -0
- package/migrations/0034_application_ownership_claim.sql +91 -0
- package/migrations/0035_restore_is_platform_admin_claim.sql +102 -0
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
-- Suspending/reactivating a member (organization-members PATCH) and
|
|
2
|
+
-- revoking or resending an invitation (admin-panel's Invitaciones page)
|
|
3
|
+
-- previously left no audit trail: the membership trigger only ran on
|
|
4
|
+
-- INSERT/DELETE (0013), and invitations had no DELETE trigger at all.
|
|
5
|
+
-- Extends both to keep 0013's own promise — "the database logs it, not
|
|
6
|
+
-- application code" — up to date with what the UI can now do.
|
|
7
|
+
|
|
8
|
+
create or replace function kontrolia_auth.log_membership_change()
|
|
9
|
+
returns trigger
|
|
10
|
+
language plpgsql
|
|
11
|
+
security definer
|
|
12
|
+
set search_path = ''
|
|
13
|
+
as $$
|
|
14
|
+
begin
|
|
15
|
+
if tg_op = 'INSERT' then
|
|
16
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
17
|
+
values (new.organization_id, coalesce(auth.uid(), new.user_id), 'membership.created', 'membership', new.id::text, jsonb_build_object('user_id', new.user_id, 'status', new.status));
|
|
18
|
+
return new;
|
|
19
|
+
elsif tg_op = 'UPDATE' then
|
|
20
|
+
if old.status is distinct from new.status then
|
|
21
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
22
|
+
values (new.organization_id, auth.uid(), 'membership.status_changed', 'membership', new.id::text, jsonb_build_object('user_id', new.user_id, 'from_status', old.status, 'to_status', new.status));
|
|
23
|
+
end if;
|
|
24
|
+
return new;
|
|
25
|
+
else
|
|
26
|
+
if exists (select 1 from kontrolia_auth.organizations where id = old.organization_id) then
|
|
27
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
28
|
+
values (old.organization_id, auth.uid(), 'membership.removed', 'membership', old.id::text, jsonb_build_object('user_id', old.user_id));
|
|
29
|
+
end if;
|
|
30
|
+
return old;
|
|
31
|
+
end if;
|
|
32
|
+
end;
|
|
33
|
+
$$;
|
|
34
|
+
|
|
35
|
+
drop trigger if exists audit_membership_change on kontrolia_auth.memberships;
|
|
36
|
+
create trigger audit_membership_change
|
|
37
|
+
after insert or update or delete on kontrolia_auth.memberships
|
|
38
|
+
for each row execute function kontrolia_auth.log_membership_change();
|
|
39
|
+
|
|
40
|
+
create or replace function kontrolia_auth.log_invitation_deleted()
|
|
41
|
+
returns trigger
|
|
42
|
+
language plpgsql
|
|
43
|
+
security definer
|
|
44
|
+
set search_path = ''
|
|
45
|
+
as $$
|
|
46
|
+
begin
|
|
47
|
+
if exists (select 1 from kontrolia_auth.organizations where id = old.organization_id) then
|
|
48
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
49
|
+
values (old.organization_id, auth.uid(), 'invitation.revoked', 'invitation', old.id::text, jsonb_build_object('email', old.email));
|
|
50
|
+
end if;
|
|
51
|
+
return old;
|
|
52
|
+
end;
|
|
53
|
+
$$;
|
|
54
|
+
|
|
55
|
+
create trigger audit_invitation_deleted
|
|
56
|
+
after delete on kontrolia_auth.invitations
|
|
57
|
+
for each row execute function kontrolia_auth.log_invitation_deleted();
|
|
58
|
+
|
|
59
|
+
-- Resend (extend expires_at on a still-pending invitation) shares the same
|
|
60
|
+
-- AFTER UPDATE trigger slot as "accepted" — one function, two cases.
|
|
61
|
+
create or replace function kontrolia_auth.log_invitation_accepted()
|
|
62
|
+
returns trigger
|
|
63
|
+
language plpgsql
|
|
64
|
+
security definer
|
|
65
|
+
set search_path = ''
|
|
66
|
+
as $$
|
|
67
|
+
begin
|
|
68
|
+
if old.accepted_at is null and new.accepted_at is not null then
|
|
69
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
70
|
+
values (new.organization_id, auth.uid(), 'invitation.accepted', 'invitation', new.id::text, jsonb_build_object('email', new.email));
|
|
71
|
+
elsif new.accepted_at is null and old.expires_at is distinct from new.expires_at then
|
|
72
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
73
|
+
values (new.organization_id, auth.uid(), 'invitation.resent', 'invitation', new.id::text, jsonb_build_object('email', new.email, 'new_expires_at', new.expires_at));
|
|
74
|
+
end if;
|
|
75
|
+
return new;
|
|
76
|
+
end;
|
|
77
|
+
$$;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- wouldRemoveLastOwner() (organization-members API route) already stops a
|
|
2
|
+
-- caller from suspending/removing the org's only active Owner — but that
|
|
3
|
+
-- guard lives at the API-route layer, not the DB layer. The RLS policy
|
|
4
|
+
-- actually governing kontrolia_auth.membership_roles ("org admins manage
|
|
5
|
+
-- membership roles", 0010) is a bare is_org_admin() check with no owner
|
|
6
|
+
-- protection at all, so any org Admin can DELETE the sole Owner's role
|
|
7
|
+
-- row directly and reproduce the exact same lockout through a door the
|
|
8
|
+
-- API-layer fix never covered. Enforce it where it can't be bypassed: in
|
|
9
|
+
-- the database, on every DELETE, regardless of which code path issued it.
|
|
10
|
+
|
|
11
|
+
create or replace function kontrolia_auth.prevent_last_owner_role_removal()
|
|
12
|
+
returns trigger
|
|
13
|
+
language plpgsql
|
|
14
|
+
security definer
|
|
15
|
+
set search_path = ''
|
|
16
|
+
as $$
|
|
17
|
+
declare
|
|
18
|
+
v_organization_id uuid;
|
|
19
|
+
v_role_slug text;
|
|
20
|
+
v_active_owner_count int;
|
|
21
|
+
begin
|
|
22
|
+
select organization_id into v_organization_id
|
|
23
|
+
from kontrolia_auth.memberships
|
|
24
|
+
where id = old.membership_id;
|
|
25
|
+
|
|
26
|
+
-- Membership already gone (e.g. cascaded from an org delete, or from a
|
|
27
|
+
-- membership delete the API route already validated before issuing) —
|
|
28
|
+
-- nothing left to protect. Mirrors 0023's org-delete cascade guard.
|
|
29
|
+
if v_organization_id is null or not exists (select 1 from kontrolia_auth.organizations where id = v_organization_id) then
|
|
30
|
+
return old;
|
|
31
|
+
end if;
|
|
32
|
+
|
|
33
|
+
select slug into v_role_slug from kontrolia_auth.roles where id = old.role_id;
|
|
34
|
+
|
|
35
|
+
if v_role_slug is distinct from 'owner' then
|
|
36
|
+
return old;
|
|
37
|
+
end if;
|
|
38
|
+
|
|
39
|
+
select count(*) into v_active_owner_count
|
|
40
|
+
from kontrolia_auth.membership_roles mr
|
|
41
|
+
join kontrolia_auth.memberships m on m.id = mr.membership_id
|
|
42
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
43
|
+
where m.organization_id = v_organization_id
|
|
44
|
+
and m.status = 'active'
|
|
45
|
+
and r.slug = 'owner';
|
|
46
|
+
|
|
47
|
+
if v_active_owner_count <= 1 then
|
|
48
|
+
raise exception 'No puedes quitar el rol de Owner al único Owner activo de la organización.';
|
|
49
|
+
end if;
|
|
50
|
+
|
|
51
|
+
return old;
|
|
52
|
+
end;
|
|
53
|
+
$$;
|
|
54
|
+
|
|
55
|
+
create trigger prevent_last_owner_role_removal
|
|
56
|
+
before delete on kontrolia_auth.membership_roles
|
|
57
|
+
for each row execute function kontrolia_auth.prevent_last_owner_role_removal();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- 0025 closed the last-owner lockout for one specific vector — deleting the
|
|
2
|
+
-- membership_roles row directly. Same-day re-audit found the identical
|
|
3
|
+
-- outcome (an org left with zero active Owners) still reachable through two
|
|
4
|
+
-- sibling doors on kontrolia_auth.memberships itself, neither guarded by
|
|
5
|
+
-- any DB-level check: a direct DELETE of the membership row (RLS policy
|
|
6
|
+
-- "org admins remove memberships" is a bare is_org_admin() check), and a
|
|
7
|
+
-- direct UPDATE of its status to anything other than 'active' (RLS policy
|
|
8
|
+
-- "org admins update memberships", same bare check — this is the exact
|
|
9
|
+
-- suspend-to-zero-Owners bug already fixed once at the API layer in
|
|
10
|
+
-- 4579870, reachable again via raw PostgREST). Close both at the table
|
|
11
|
+
-- that actually matters, the same way 0025 closed membership_roles.
|
|
12
|
+
|
|
13
|
+
create or replace function kontrolia_auth.prevent_last_owner_membership_removal()
|
|
14
|
+
returns trigger
|
|
15
|
+
language plpgsql
|
|
16
|
+
security definer
|
|
17
|
+
set search_path = ''
|
|
18
|
+
as $$
|
|
19
|
+
declare
|
|
20
|
+
v_is_owner boolean;
|
|
21
|
+
v_active_owner_count int;
|
|
22
|
+
begin
|
|
23
|
+
-- Cascaded from the organization's own deletion — nothing left to
|
|
24
|
+
-- protect, and blocking here would make "delete organization" itself
|
|
25
|
+
-- impossible. Mirrors 0023/0025's cascade-safety guard.
|
|
26
|
+
if not exists (select 1 from kontrolia_auth.organizations where id = old.organization_id) then
|
|
27
|
+
return old;
|
|
28
|
+
end if;
|
|
29
|
+
|
|
30
|
+
-- A membership that wasn't active wasn't counted among active Owners
|
|
31
|
+
-- anyway, so removing it can't be what drops the count to zero.
|
|
32
|
+
if old.status is distinct from 'active' then
|
|
33
|
+
return old;
|
|
34
|
+
end if;
|
|
35
|
+
|
|
36
|
+
select exists (
|
|
37
|
+
select 1
|
|
38
|
+
from kontrolia_auth.membership_roles mr
|
|
39
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
40
|
+
where mr.membership_id = old.id and r.slug = 'owner'
|
|
41
|
+
) into v_is_owner;
|
|
42
|
+
|
|
43
|
+
if not v_is_owner then
|
|
44
|
+
return old;
|
|
45
|
+
end if;
|
|
46
|
+
|
|
47
|
+
select count(*) into v_active_owner_count
|
|
48
|
+
from kontrolia_auth.membership_roles mr
|
|
49
|
+
join kontrolia_auth.memberships m on m.id = mr.membership_id
|
|
50
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
51
|
+
where m.organization_id = old.organization_id
|
|
52
|
+
and m.status = 'active'
|
|
53
|
+
and r.slug = 'owner';
|
|
54
|
+
|
|
55
|
+
if v_active_owner_count <= 1 then
|
|
56
|
+
raise exception 'No puedes quitar al único Owner activo de la organización.';
|
|
57
|
+
end if;
|
|
58
|
+
|
|
59
|
+
return old;
|
|
60
|
+
end;
|
|
61
|
+
$$;
|
|
62
|
+
|
|
63
|
+
create trigger prevent_last_owner_membership_delete
|
|
64
|
+
before delete on kontrolia_auth.memberships
|
|
65
|
+
for each row execute function kontrolia_auth.prevent_last_owner_membership_removal();
|
|
66
|
+
|
|
67
|
+
create or replace function kontrolia_auth.prevent_last_owner_deactivation()
|
|
68
|
+
returns trigger
|
|
69
|
+
language plpgsql
|
|
70
|
+
security definer
|
|
71
|
+
set search_path = ''
|
|
72
|
+
as $$
|
|
73
|
+
declare
|
|
74
|
+
v_is_owner boolean;
|
|
75
|
+
v_active_owner_count int;
|
|
76
|
+
begin
|
|
77
|
+
-- Only relevant when a membership is moving OUT of active status.
|
|
78
|
+
if old.status is distinct from 'active' or new.status = 'active' then
|
|
79
|
+
return new;
|
|
80
|
+
end if;
|
|
81
|
+
|
|
82
|
+
select exists (
|
|
83
|
+
select 1
|
|
84
|
+
from kontrolia_auth.membership_roles mr
|
|
85
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
86
|
+
where mr.membership_id = old.id and r.slug = 'owner'
|
|
87
|
+
) into v_is_owner;
|
|
88
|
+
|
|
89
|
+
if not v_is_owner then
|
|
90
|
+
return new;
|
|
91
|
+
end if;
|
|
92
|
+
|
|
93
|
+
select count(*) into v_active_owner_count
|
|
94
|
+
from kontrolia_auth.membership_roles mr
|
|
95
|
+
join kontrolia_auth.memberships m on m.id = mr.membership_id
|
|
96
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
97
|
+
where m.organization_id = old.organization_id
|
|
98
|
+
and m.status = 'active'
|
|
99
|
+
and r.slug = 'owner';
|
|
100
|
+
|
|
101
|
+
if v_active_owner_count <= 1 then
|
|
102
|
+
raise exception 'No puedes suspender al único Owner activo de la organización.';
|
|
103
|
+
end if;
|
|
104
|
+
|
|
105
|
+
return new;
|
|
106
|
+
end;
|
|
107
|
+
$$;
|
|
108
|
+
|
|
109
|
+
create trigger prevent_last_owner_deactivation
|
|
110
|
+
before update on kontrolia_auth.memberships
|
|
111
|
+
for each row execute function kontrolia_auth.prevent_last_owner_deactivation();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- 0026's UPDATE trigger only inspected `status`, never `organization_id` —
|
|
2
|
+
-- a caller with admin rights in the Owner's org AND the destination org
|
|
3
|
+
-- (the RLS policy's implicit WITH CHECK, reusing USING since none is
|
|
4
|
+
-- declared, already requires both) could move the sole active Owner's
|
|
5
|
+
-- membership to a different organization while leaving status untouched,
|
|
6
|
+
-- leaving the source org with zero active Owners the same way a status
|
|
7
|
+
-- flip to 'suspended' would. Narrower precondition than 0026's two fixes
|
|
8
|
+
-- (needs dual-org admin, not just single-org), but the same bug class —
|
|
9
|
+
-- extend the same trigger rather than leave it half-covering "leaving
|
|
10
|
+
-- active status" as the only way to lose the last Owner.
|
|
11
|
+
|
|
12
|
+
create or replace function kontrolia_auth.prevent_last_owner_deactivation()
|
|
13
|
+
returns trigger
|
|
14
|
+
language plpgsql
|
|
15
|
+
security definer
|
|
16
|
+
set search_path = ''
|
|
17
|
+
as $$
|
|
18
|
+
declare
|
|
19
|
+
v_is_owner boolean;
|
|
20
|
+
v_active_owner_count int;
|
|
21
|
+
v_leaving_org boolean;
|
|
22
|
+
begin
|
|
23
|
+
-- "Leaving" now covers both ways a membership stops counting toward its
|
|
24
|
+
-- (original) org's active Owners: no longer active, or reassigned away.
|
|
25
|
+
v_leaving_org := (new.organization_id is distinct from old.organization_id) or (new.status is distinct from 'active');
|
|
26
|
+
|
|
27
|
+
if old.status is distinct from 'active' or not v_leaving_org then
|
|
28
|
+
return new;
|
|
29
|
+
end if;
|
|
30
|
+
|
|
31
|
+
select exists (
|
|
32
|
+
select 1
|
|
33
|
+
from kontrolia_auth.membership_roles mr
|
|
34
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
35
|
+
where mr.membership_id = old.id and r.slug = 'owner'
|
|
36
|
+
) into v_is_owner;
|
|
37
|
+
|
|
38
|
+
if not v_is_owner then
|
|
39
|
+
return new;
|
|
40
|
+
end if;
|
|
41
|
+
|
|
42
|
+
select count(*) into v_active_owner_count
|
|
43
|
+
from kontrolia_auth.membership_roles mr
|
|
44
|
+
join kontrolia_auth.memberships m on m.id = mr.membership_id
|
|
45
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
46
|
+
where m.organization_id = old.organization_id
|
|
47
|
+
and m.status = 'active'
|
|
48
|
+
and r.slug = 'owner';
|
|
49
|
+
|
|
50
|
+
if v_active_owner_count <= 1 then
|
|
51
|
+
raise exception 'No puedes quitar al único Owner activo de la organización.';
|
|
52
|
+
end if;
|
|
53
|
+
|
|
54
|
+
return new;
|
|
55
|
+
end;
|
|
56
|
+
$$;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- Fourth same-day Phase 2 audit found two CRITICALs that fully defeat every
|
|
2
|
+
-- last-owner protection added today (0025-0027), because those all guard
|
|
3
|
+
-- operations against an *existing* Owner's own row — none of them restrict
|
|
4
|
+
-- who can become an Owner in the first place, or what a membership row's
|
|
5
|
+
-- identity means. Both are day-one gaps in migration 0010's RLS policies,
|
|
6
|
+
-- live-exploited this session by a plain org Admin (no dual-org privilege
|
|
7
|
+
-- needed, unlike 0027's narrower finding):
|
|
8
|
+
--
|
|
9
|
+
-- 1. "org admins manage membership roles" is a bare is_org_admin() check
|
|
10
|
+
-- with no restriction on which role_id may be inserted — any Admin can
|
|
11
|
+
-- grant themselves (or anyone) the 'owner' role directly, then use the
|
|
12
|
+
-- now-legitimate 2-owner path to suspend the real Owner.
|
|
13
|
+
-- 2. "org admins update memberships" permits changing ANY column,
|
|
14
|
+
-- including user_id, as long as organization_id is unchanged — an Admin
|
|
15
|
+
-- can silently reassign the sole Owner's membership row to an arbitrary
|
|
16
|
+
-- third-party user, and since the audit trigger only fires on status
|
|
17
|
+
-- changes, this leaves zero trail.
|
|
18
|
+
--
|
|
19
|
+
-- No legitimate code path in this app ever updates memberships.user_id
|
|
20
|
+
-- (grepped: the only UPDATE anywhere is the status PATCH in
|
|
21
|
+
-- organization-members/route.ts) — a membership's identity is created once
|
|
22
|
+
-- via invitation-accept or org-bootstrap and never reassigned, so blocking
|
|
23
|
+
-- any change to it entirely, for every membership, is safe.
|
|
24
|
+
|
|
25
|
+
create or replace function kontrolia_auth.prevent_admin_granting_owner_role()
|
|
26
|
+
returns trigger
|
|
27
|
+
language plpgsql
|
|
28
|
+
security definer
|
|
29
|
+
set search_path = ''
|
|
30
|
+
as $$
|
|
31
|
+
declare
|
|
32
|
+
v_role_slug text;
|
|
33
|
+
v_organization_id uuid;
|
|
34
|
+
begin
|
|
35
|
+
select slug into v_role_slug from kontrolia_auth.roles where id = new.role_id;
|
|
36
|
+
if v_role_slug is distinct from 'owner' then
|
|
37
|
+
return new;
|
|
38
|
+
end if;
|
|
39
|
+
|
|
40
|
+
select organization_id into v_organization_id from kontrolia_auth.memberships where id = new.membership_id;
|
|
41
|
+
|
|
42
|
+
if not kontrolia_auth.is_org_owner(v_organization_id) then
|
|
43
|
+
raise exception 'Solo un Owner puede otorgar el rol de Owner.';
|
|
44
|
+
end if;
|
|
45
|
+
|
|
46
|
+
return new;
|
|
47
|
+
end;
|
|
48
|
+
$$;
|
|
49
|
+
|
|
50
|
+
create trigger prevent_admin_granting_owner_role
|
|
51
|
+
before insert on kontrolia_auth.membership_roles
|
|
52
|
+
for each row execute function kontrolia_auth.prevent_admin_granting_owner_role();
|
|
53
|
+
|
|
54
|
+
create or replace function kontrolia_auth.prevent_membership_identity_change()
|
|
55
|
+
returns trigger
|
|
56
|
+
language plpgsql
|
|
57
|
+
security definer
|
|
58
|
+
set search_path = ''
|
|
59
|
+
as $$
|
|
60
|
+
begin
|
|
61
|
+
if new.user_id is distinct from old.user_id then
|
|
62
|
+
raise exception 'No se puede reasignar la identidad de una membresía existente.';
|
|
63
|
+
end if;
|
|
64
|
+
return new;
|
|
65
|
+
end;
|
|
66
|
+
$$;
|
|
67
|
+
|
|
68
|
+
create trigger prevent_membership_identity_change
|
|
69
|
+
before update on kontrolia_auth.memberships
|
|
70
|
+
for each row execute function kontrolia_auth.prevent_membership_identity_change();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
-- Fifth same-day Phase 2 audit found kontrolia_auth.membership_roles had no
|
|
2
|
+
-- trigger on UPDATE at all — 0025 guards DELETE and 0028 guards INSERT, but
|
|
3
|
+
-- role_id (part of the table's own primary key) could be changed directly
|
|
4
|
+
-- via a plain UPDATE, sidestepping both guards entirely: self-promote to
|
|
5
|
+
-- Owner, demote the sole active Owner, or hijack an existing Owner row
|
|
6
|
+
-- outright, all live-exploited this session by a plain org Admin.
|
|
7
|
+
--
|
|
8
|
+
-- The same audit also found a functional regression 0028 introduced:
|
|
9
|
+
-- is_org_owner() depends on auth.uid(), which is NULL under the
|
|
10
|
+
-- service-role connection apps/auth-server/app/api/invitations/accept/
|
|
11
|
+
-- route.ts uses to grant an invited role — so accepting any invitation
|
|
12
|
+
-- offering the 'owner' role now silently fails its role grant, for every
|
|
13
|
+
-- organization. Service-role connections are already the top of this app's
|
|
14
|
+
-- trust chain (RLS doesn't even apply to them, via BYPASSRLS) — govern them
|
|
15
|
+
-- by application code review the same way the rest of the schema already
|
|
16
|
+
-- does, not by re-deriving auth.uid() a service-role session never has.
|
|
17
|
+
--
|
|
18
|
+
-- Detecting that trust boundary needs auth.role() (reads the
|
|
19
|
+
-- request.jwt.claims GUC), not current_user: these trigger functions are
|
|
20
|
+
-- `security definer`, so current_user inside the function body reports the
|
|
21
|
+
-- function's OWNER, not the caller — confirmed by direct experimentation
|
|
22
|
+
-- against this migration's first draft, which used current_user and still
|
|
23
|
+
-- blocked the legitimate service-role grant.
|
|
24
|
+
|
|
25
|
+
create or replace function kontrolia_auth.prevent_admin_granting_owner_role()
|
|
26
|
+
returns trigger
|
|
27
|
+
language plpgsql
|
|
28
|
+
security definer
|
|
29
|
+
set search_path = ''
|
|
30
|
+
as $$
|
|
31
|
+
declare
|
|
32
|
+
v_role_slug text;
|
|
33
|
+
v_organization_id uuid;
|
|
34
|
+
begin
|
|
35
|
+
if auth.role() = 'service_role' then
|
|
36
|
+
return new;
|
|
37
|
+
end if;
|
|
38
|
+
|
|
39
|
+
select slug into v_role_slug from kontrolia_auth.roles where id = new.role_id;
|
|
40
|
+
if v_role_slug is distinct from 'owner' then
|
|
41
|
+
return new;
|
|
42
|
+
end if;
|
|
43
|
+
|
|
44
|
+
select organization_id into v_organization_id from kontrolia_auth.memberships where id = new.membership_id;
|
|
45
|
+
|
|
46
|
+
if not kontrolia_auth.is_org_owner(v_organization_id) then
|
|
47
|
+
raise exception 'Solo un Owner puede otorgar el rol de Owner.';
|
|
48
|
+
end if;
|
|
49
|
+
|
|
50
|
+
return new;
|
|
51
|
+
end;
|
|
52
|
+
$$;
|
|
53
|
+
|
|
54
|
+
create or replace function kontrolia_auth.prevent_membership_role_update()
|
|
55
|
+
returns trigger
|
|
56
|
+
language plpgsql
|
|
57
|
+
security definer
|
|
58
|
+
set search_path = ''
|
|
59
|
+
as $$
|
|
60
|
+
declare
|
|
61
|
+
v_organization_id uuid;
|
|
62
|
+
v_old_slug text;
|
|
63
|
+
v_new_slug text;
|
|
64
|
+
v_active_owner_count int;
|
|
65
|
+
begin
|
|
66
|
+
-- No legitimate flow ever reassigns a role_id row to a different
|
|
67
|
+
-- membership — same reasoning as 0028's blanket ban on
|
|
68
|
+
-- memberships.user_id. Unlike the owner-grant/removal checks below,
|
|
69
|
+
-- there's no service-role exception: nothing legitimate does this either.
|
|
70
|
+
if new.membership_id is distinct from old.membership_id then
|
|
71
|
+
raise exception 'No se puede reasignar un rol de membresía a otra membresía.';
|
|
72
|
+
end if;
|
|
73
|
+
|
|
74
|
+
-- A true no-op UPDATE (e.g. an ON CONFLICT DO UPDATE upsert that hit an
|
|
75
|
+
-- already-identical row, as invitation-accept's upsert can) changes
|
|
76
|
+
-- nothing here — skip straight through rather than re-running checks
|
|
77
|
+
-- against a role that never actually changed.
|
|
78
|
+
if old.role_id is distinct from new.role_id and auth.role() is distinct from 'service_role' then
|
|
79
|
+
select slug into v_old_slug from kontrolia_auth.roles where id = old.role_id;
|
|
80
|
+
select slug into v_new_slug from kontrolia_auth.roles where id = new.role_id;
|
|
81
|
+
select organization_id into v_organization_id from kontrolia_auth.memberships where id = old.membership_id;
|
|
82
|
+
|
|
83
|
+
if v_new_slug = 'owner' and not kontrolia_auth.is_org_owner(v_organization_id) then
|
|
84
|
+
raise exception 'Solo un Owner puede otorgar el rol de Owner.';
|
|
85
|
+
end if;
|
|
86
|
+
|
|
87
|
+
if v_old_slug = 'owner' and v_new_slug is distinct from 'owner' then
|
|
88
|
+
select count(*) into v_active_owner_count
|
|
89
|
+
from kontrolia_auth.membership_roles mr
|
|
90
|
+
join kontrolia_auth.memberships m on m.id = mr.membership_id
|
|
91
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
92
|
+
where m.organization_id = v_organization_id
|
|
93
|
+
and m.status = 'active'
|
|
94
|
+
and r.slug = 'owner';
|
|
95
|
+
|
|
96
|
+
if v_active_owner_count <= 1 then
|
|
97
|
+
raise exception 'No puedes quitar el rol de Owner al único Owner activo de la organización.';
|
|
98
|
+
end if;
|
|
99
|
+
end if;
|
|
100
|
+
end if;
|
|
101
|
+
|
|
102
|
+
return new;
|
|
103
|
+
end;
|
|
104
|
+
$$;
|
|
105
|
+
|
|
106
|
+
create trigger prevent_membership_role_update
|
|
107
|
+
before update on kontrolia_auth.membership_roles
|
|
108
|
+
for each row execute function kontrolia_auth.prevent_membership_role_update();
|
|
@@ -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();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- Closes INT-API-007/INT-API-008/PQ-TECH-010: applications.owner_organization_id
|
|
2
|
+
-- was never written by any code path in the repo — registerApplication()'s
|
|
3
|
+
-- INSERT omits it, the CLI wizard runs before any organization necessarily
|
|
4
|
+
-- exists and never asks who should own the app, and no INSERT/ownership-claim
|
|
5
|
+
-- RLS policy ever existed for a regular user. Three real, shipped
|
|
6
|
+
-- capabilities (0022's owning-org UPDATE policy, the admin-panel rotate/
|
|
7
|
+
-- revoke API-key UI, the applications/members API) were all correctly built
|
|
8
|
+
-- but unreachable for any application registered the intended way.
|
|
9
|
+
--
|
|
10
|
+
-- The actual fix is POST /api/applications/claim (auth-server), a
|
|
11
|
+
-- platform-admin-gated route that runs as service_role — matching this
|
|
12
|
+
-- table's original design comment (migration 0010: "applications catalog:
|
|
13
|
+
-- platform-level, managed via service_role from the auth-server admin API").
|
|
14
|
+
-- This migration only prepares the database side of that: audit logging for
|
|
15
|
+
-- the new action, and a guard against a related gap found while designing
|
|
16
|
+
-- it (see below).
|
|
17
|
+
|
|
18
|
+
-- Extend the existing "database logs it, not application code" trigger
|
|
19
|
+
-- (0032) to also cover ownership changes, the same way it already covers
|
|
20
|
+
-- api_key_hash changes.
|
|
21
|
+
create or replace function kontrolia_auth.log_application_api_key_change()
|
|
22
|
+
returns trigger
|
|
23
|
+
language plpgsql
|
|
24
|
+
security definer
|
|
25
|
+
set search_path = ''
|
|
26
|
+
as $$
|
|
27
|
+
begin
|
|
28
|
+
if old.api_key_hash is distinct from new.api_key_hash then
|
|
29
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
30
|
+
values (
|
|
31
|
+
new.owner_organization_id,
|
|
32
|
+
auth.uid(),
|
|
33
|
+
case when new.api_key_hash is null then 'application.api_key_revoked' else 'application.api_key_rotated' end,
|
|
34
|
+
'application',
|
|
35
|
+
new.id::text,
|
|
36
|
+
jsonb_build_object('slug', new.slug)
|
|
37
|
+
);
|
|
38
|
+
end if;
|
|
39
|
+
|
|
40
|
+
if old.owner_organization_id is distinct from new.owner_organization_id then
|
|
41
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
42
|
+
values (
|
|
43
|
+
new.owner_organization_id,
|
|
44
|
+
auth.uid(),
|
|
45
|
+
case when old.owner_organization_id is null then 'application.ownership_claimed' else 'application.ownership_transferred' end,
|
|
46
|
+
'application',
|
|
47
|
+
new.id::text,
|
|
48
|
+
jsonb_build_object('slug', new.slug, 'previous_owner_organization_id', old.owner_organization_id)
|
|
49
|
+
);
|
|
50
|
+
end if;
|
|
51
|
+
|
|
52
|
+
return new;
|
|
53
|
+
end;
|
|
54
|
+
$$;
|
|
55
|
+
|
|
56
|
+
-- Found while designing the claim route, reading every existing policy on
|
|
57
|
+
-- this table: 0022's "owning org admins can update their application" UPDATE
|
|
58
|
+
-- policy checks USING/WITH CHECK is_org_admin(owner_organization_id) against
|
|
59
|
+
-- the OLD row and the NEW row independently — it never requires those two
|
|
60
|
+
-- organization ids to be the SAME org. A user who administers two different
|
|
61
|
+
-- organizations, one of which already owns an application, could already
|
|
62
|
+
-- reassign that application to their other org via a plain RLS-authenticated
|
|
63
|
+
-- UPDATE, with neither org's other admins involved — the same dual-org-admin
|
|
64
|
+
-- shape as this session's earlier PQ-SEC-005, on a different table. Since the
|
|
65
|
+
-- product has no legitimate "transfer ownership" capability (only "claim an
|
|
66
|
+
-- unowned application", which the new route performs as service_role), the
|
|
67
|
+
-- simplest correct fix is to remove the capability entirely for any other
|
|
68
|
+
-- caller: once owner_organization_id is set, only service_role may change it.
|
|
69
|
+
create or replace function kontrolia_auth.prevent_application_ownership_reassignment()
|
|
70
|
+
returns trigger
|
|
71
|
+
language plpgsql
|
|
72
|
+
security definer
|
|
73
|
+
set search_path = ''
|
|
74
|
+
as $$
|
|
75
|
+
begin
|
|
76
|
+
if auth.role() = 'service_role' then
|
|
77
|
+
return new;
|
|
78
|
+
end if;
|
|
79
|
+
|
|
80
|
+
if old.owner_organization_id is not null and new.owner_organization_id is distinct from old.owner_organization_id then
|
|
81
|
+
raise exception 'No se puede reasignar la propiedad de una aplicación ya reclamada.';
|
|
82
|
+
end if;
|
|
83
|
+
|
|
84
|
+
return new;
|
|
85
|
+
end;
|
|
86
|
+
$$;
|
|
87
|
+
|
|
88
|
+
drop trigger if exists prevent_application_ownership_reassignment on kontrolia_auth.applications;
|
|
89
|
+
create trigger prevent_application_ownership_reassignment
|
|
90
|
+
before update on kontrolia_auth.applications
|
|
91
|
+
for each row execute function kontrolia_auth.prevent_application_ownership_reassignment();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
-- CRITICAL regression found live while testing the new applications/claim
|
|
2
|
+
-- route (migration 0034): migration 0030's `create or replace function
|
|
3
|
+
-- kontrolia_auth.custom_access_token_hook` fully replaced the function body
|
|
4
|
+
-- to anchor roles/permissions to is_system_role, but its jsonb_set calls only
|
|
5
|
+
-- covered organization_id/roles/permissions — it silently dropped the
|
|
6
|
+
-- is_platform_admin claim that 0020's version set. `create or replace`
|
|
7
|
+
-- replaces the whole body, so anything not repeated in the new version is
|
|
8
|
+
-- gone, not merged.
|
|
9
|
+
--
|
|
10
|
+
-- Live-reproduced: generated a real magic-link session for a genuine
|
|
11
|
+
-- platform_admins row (raul.dolores@gmail.com) against the running local
|
|
12
|
+
-- sandbox and decoded the resulting JWT — no is_platform_admin claim present
|
|
13
|
+
-- at all. Every platform-admin-gated route (POST/DELETE /api/platform-admins,
|
|
14
|
+
-- GET/POST/PUT /api/oauth-clients, and the new POST /api/applications/claim)
|
|
15
|
+
-- has been checking `claims.is_platform_admin`, which has been `undefined`
|
|
16
|
+
-- (falsy, so at least fails closed — no privilege escalation, just a broken
|
|
17
|
+
-- feature) for every real user since 0030 shipped earlier today. Nothing
|
|
18
|
+
-- caught this because verification since 0030 used direct-SQL/service-role
|
|
19
|
+
-- testing for those routes rather than a real fresh login.
|
|
20
|
+
|
|
21
|
+
create or replace function kontrolia_auth.custom_access_token_hook(event jsonb)
|
|
22
|
+
returns jsonb
|
|
23
|
+
language plpgsql
|
|
24
|
+
stable
|
|
25
|
+
security definer
|
|
26
|
+
set search_path = ''
|
|
27
|
+
as $$
|
|
28
|
+
declare
|
|
29
|
+
claims jsonb;
|
|
30
|
+
target_user_id uuid;
|
|
31
|
+
active_org_id uuid;
|
|
32
|
+
active_membership_id uuid;
|
|
33
|
+
role_names text[];
|
|
34
|
+
permission_keys text[];
|
|
35
|
+
platform_admin boolean;
|
|
36
|
+
begin
|
|
37
|
+
claims := coalesce(event->'claims', '{}'::jsonb);
|
|
38
|
+
target_user_id := (event->>'user_id')::uuid;
|
|
39
|
+
|
|
40
|
+
select active_organization_id into active_org_id
|
|
41
|
+
from kontrolia_auth.sessions_context
|
|
42
|
+
where user_id = target_user_id;
|
|
43
|
+
|
|
44
|
+
if active_org_id is null then
|
|
45
|
+
select organization_id into active_org_id
|
|
46
|
+
from kontrolia_auth.memberships
|
|
47
|
+
where user_id = target_user_id and status = 'active'
|
|
48
|
+
order by created_at asc
|
|
49
|
+
limit 1;
|
|
50
|
+
end if;
|
|
51
|
+
|
|
52
|
+
if active_org_id is not null then
|
|
53
|
+
select id into active_membership_id
|
|
54
|
+
from kontrolia_auth.memberships
|
|
55
|
+
where user_id = target_user_id
|
|
56
|
+
and organization_id = active_org_id
|
|
57
|
+
and status = 'active';
|
|
58
|
+
end if;
|
|
59
|
+
|
|
60
|
+
if active_membership_id is not null then
|
|
61
|
+
select coalesce(array_agg(distinct r.slug), '{}')
|
|
62
|
+
into role_names
|
|
63
|
+
from kontrolia_auth.membership_roles mr
|
|
64
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
65
|
+
where mr.membership_id = active_membership_id and r.is_system_role;
|
|
66
|
+
|
|
67
|
+
select coalesce(array_agg(distinct p.key), '{}')
|
|
68
|
+
into permission_keys
|
|
69
|
+
from (
|
|
70
|
+
select p.id, p.key
|
|
71
|
+
from kontrolia_auth.membership_roles mr
|
|
72
|
+
join kontrolia_auth.role_permissions rp on rp.role_id = mr.role_id
|
|
73
|
+
join kontrolia_auth.permissions p on p.id = rp.permission_id
|
|
74
|
+
where mr.membership_id = active_membership_id
|
|
75
|
+
union
|
|
76
|
+
select p.id, p.key
|
|
77
|
+
from kontrolia_auth.user_permissions up
|
|
78
|
+
join kontrolia_auth.permissions p on p.id = up.permission_id
|
|
79
|
+
where up.membership_id = active_membership_id and up.effect = 'allow'
|
|
80
|
+
) p
|
|
81
|
+
where not exists (
|
|
82
|
+
select 1 from kontrolia_auth.user_permissions up_deny
|
|
83
|
+
where up_deny.membership_id = active_membership_id
|
|
84
|
+
and up_deny.permission_id = p.id
|
|
85
|
+
and up_deny.effect = 'deny'
|
|
86
|
+
);
|
|
87
|
+
else
|
|
88
|
+
role_names := '{}';
|
|
89
|
+
permission_keys := '{}';
|
|
90
|
+
end if;
|
|
91
|
+
|
|
92
|
+
select exists(select 1 from kontrolia_auth.platform_admins where user_id = target_user_id) into platform_admin;
|
|
93
|
+
|
|
94
|
+
claims := jsonb_set(claims, '{organization_id}', coalesce(to_jsonb(active_org_id), 'null'::jsonb));
|
|
95
|
+
claims := jsonb_set(claims, '{roles}', to_jsonb(coalesce(role_names, '{}')));
|
|
96
|
+
claims := jsonb_set(claims, '{permissions}', to_jsonb(coalesce(permission_keys, '{}')));
|
|
97
|
+
claims := jsonb_set(claims, '{is_platform_admin}', to_jsonb(coalesce(platform_admin, false)));
|
|
98
|
+
|
|
99
|
+
event := jsonb_set(event, '{claims}', claims);
|
|
100
|
+
return event;
|
|
101
|
+
end;
|
|
102
|
+
$$;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontrolia/db",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "KontrolIA Auth database layer: SQL migrations for the `kontrolia` schema (organizations, RBAC, Custom Access Token Hook) and a connection-string-agnostic migration runner.",
|
|
6
6
|
"keywords": [
|