@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.
- package/README.md +1 -1
- package/dist/grant-platform-admin.d.ts +21 -0
- package/dist/grant-platform-admin.d.ts.map +1 -0
- package/dist/grant-platform-admin.js +21 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/migrations/0021_org_owner_delete.sql +31 -0
- package/migrations/0022_application_homepage_url.sql +17 -0
- package/migrations/0023_fix_audit_triggers_on_org_delete.sql +60 -0
- 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,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.
|
|
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": [
|