@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 CHANGED
@@ -15,4 +15,4 @@ DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" pnpm --filte
15
15
  ## Activar el hook
16
16
 
17
17
  - **Self-hosted**: variable de entorno de GoTrue `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/kontrolia_auth/custom_access_token_hook` (ver `docker/docker-compose.yml`).
18
- - **Supabase Cloud**: paso manual en el Dashboard → Authentication → Hooks → Custom Access Token → seleccionar `kontrolia_auth.custom_access_token_hook`. No automatizable por API pública.
18
+ - **Supabase Cloud**: `npx create-kontrolia-auth` (o `deploy`) lo automatiza vía la Management API de Supabase, pidiendo un Personal Access Token de la cuenta. A mano: Dashboard → Authentication → Hooks → Custom Access Token → seleccionar `kontrolia_auth.custom_access_token_hook`.
@@ -0,0 +1,21 @@
1
+ export interface GrantPlatformAdminOptions {
2
+ /** Postgres connection string — same trust model as migrate(): whoever has
3
+ * this can already do anything in the database, so this bypasses the
4
+ * app's own is_platform_admin authorization check entirely. That's the
5
+ * point — it's the recovery path for when there are zero platform admins
6
+ * (e.g. bootstrap_first_platform_admin() didn't fire because auth.users
7
+ * already had rows from something else sharing the same Supabase
8
+ * project), which the admin-panel UI can never self-serve since its API
9
+ * requires an existing platform admin to call it. */
10
+ connectionString: string;
11
+ email: string;
12
+ }
13
+ export interface GrantPlatformAdminResult {
14
+ userId: string;
15
+ email: string;
16
+ /** True if this user already was a platform admin — grant is idempotent. */
17
+ alreadyGranted: boolean;
18
+ }
19
+ /** Grants platform admin directly via SQL — no HTTP layer, no JWT required. */
20
+ export declare function grantPlatformAdmin({ connectionString, email }: GrantPlatformAdminOptions): Promise<GrantPlatformAdminResult>;
21
+ //# sourceMappingURL=grant-platform-admin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grant-platform-admin.d.ts","sourceRoot":"","sources":["../src/grant-platform-admin.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,yBAAyB;IACxC;;;;;;;yDAOqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,+EAA+E;AAC/E,wBAAsB,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,KAAK,EAAE,EAAE,yBAAyB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CA0BlI"}
@@ -0,0 +1,21 @@
1
+ import { Client } from "pg";
2
+ /** Grants platform admin directly via SQL — no HTTP layer, no JWT required. */
3
+ export async function grantPlatformAdmin({ connectionString, email }) {
4
+ const client = new Client({ connectionString });
5
+ await client.connect();
6
+ try {
7
+ const { rows: users } = await client.query("select id, email from auth.users where lower(email) = lower($1) limit 1", [email]);
8
+ const user = users[0];
9
+ if (!user) {
10
+ throw new Error(`No hay ningún usuario registrado con el correo "${email}". Regístrate primero desde auth-server.`);
11
+ }
12
+ const { rows: inserted } = await client.query(`insert into kontrolia_auth.platform_admins (user_id)
13
+ values ($1)
14
+ on conflict (user_id) do nothing
15
+ returning user_id`, [user.id]);
16
+ return { userId: user.id, email: user.email, alreadyGranted: inserted.length === 0 };
17
+ }
18
+ finally {
19
+ await client.end();
20
+ }
21
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { migrate, type MigrateOptions } from "./migrate.js";
2
+ export { grantPlatformAdmin, type GrantPlatformAdminOptions, type GrantPlatformAdminResult } from "./grant-platform-admin.js";
2
3
  export { registerApplication, type RegisterApplicationOptions, type RegisteredApplication, type PermissionInput, } from "./register-application.js";
3
4
  export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAC1B,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,KAAK,yBAAyB,EAAE,KAAK,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC9H,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAC1B,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { migrate } from "./migrate.js";
2
+ export { grantPlatformAdmin } from "./grant-platform-admin.js";
2
3
  export { registerApplication, } from "./register-application.js";
3
4
  export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
@@ -0,0 +1,31 @@
1
+ -- Deleting an organization is irreversible and cascades through every
2
+ -- membership/role/invitation/audit-log row for it (see the FKs in
3
+ -- 0002/0004/0005/0006_*.sql) — permission_denied by default today, since no
4
+ -- DELETE policy exists on kontrolia_auth.organizations at all. Reserved for
5
+ -- the org's Owner specifically, not any Admin: is_org_admin() (0009/0020)
6
+ -- deliberately treats Owner and Admin the same for day-to-day management,
7
+ -- but wiping the whole organization (including its audit trail) is a
8
+ -- different level of consequence.
9
+ create or replace function kontrolia_auth.is_org_owner(org_id uuid)
10
+ returns boolean
11
+ language sql
12
+ stable
13
+ security definer
14
+ set search_path = ''
15
+ as $$
16
+ select exists (
17
+ select 1
18
+ from kontrolia_auth.memberships m
19
+ join kontrolia_auth.membership_roles mr on mr.membership_id = m.id
20
+ join kontrolia_auth.roles r on r.id = mr.role_id
21
+ where m.organization_id = org_id
22
+ and m.user_id = auth.uid()
23
+ and m.status = 'active'
24
+ and r.slug = 'owner'
25
+ );
26
+ $$;
27
+
28
+ grant execute on function kontrolia_auth.is_org_owner(uuid) to authenticated;
29
+
30
+ create policy "org owners can delete their organization" on kontrolia_auth.organizations
31
+ for delete using (kontrolia_auth.is_org_owner(id));
@@ -0,0 +1,17 @@
1
+ -- Nothing in the schema records where a registered application is actually
2
+ -- reachable for end users — redirect_urls is OAuth callback URIs, not a
3
+ -- home page. Needed for an "apps you have access to" launcher (auth-server)
4
+ -- to have somewhere to link. Nullable: not always known at registration
5
+ -- time via the CLI wizard.
6
+ alter table kontrolia_auth.applications add column homepage_url text;
7
+
8
+ comment on column kontrolia_auth.applications.homepage_url is
9
+ 'Where this application is reachable for end users (its actual URL, not an OAuth redirect URI) — what an "apps you can access" launcher links to.';
10
+
11
+ -- No UPDATE policy existed on applications at all before this (0018 only
12
+ -- added a SELECT policy widening catalog browsing). Scoped to the
13
+ -- registering org's admins, not every org that merely enabled the app.
14
+ create policy "owning org admins can update their application" on kontrolia_auth.applications
15
+ for update
16
+ using (owner_organization_id is not null and kontrolia_auth.is_org_admin(owner_organization_id))
17
+ with check (owner_organization_id is not null and kontrolia_auth.is_org_admin(owner_organization_id));
@@ -0,0 +1,60 @@
1
+ -- Deleting an organization (0021) cascades to memberships, which cascades
2
+ -- to membership_roles — and the AFTER DELETE audit triggers on both
3
+ -- (0013_audit_log_triggers.sql) tried to INSERT a new audit_logs row
4
+ -- referencing that same organization_id mid-cascade, violating
5
+ -- audit_logs_organization_id_fkey (audit_logs itself also cascades on
6
+ -- organization delete, so by the time these triggers fire the parent
7
+ -- organization row is gone). There's no point logging "membership removed"
8
+ -- for a membership that's disappearing because its whole organization —
9
+ -- audit trail included — is being wiped a moment later anyway. Guard both
10
+ -- triggers: only log when the organization is still around, i.e. this is a
11
+ -- real standalone removal, not a side effect of deleting the org itself.
12
+ create or replace function kontrolia_auth.log_membership_change()
13
+ returns trigger
14
+ language plpgsql
15
+ security definer
16
+ set search_path = ''
17
+ as $$
18
+ begin
19
+ if tg_op = 'INSERT' then
20
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
21
+ 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));
22
+ return new;
23
+ else
24
+ if exists (select 1 from kontrolia_auth.organizations where id = old.organization_id) then
25
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
26
+ values (old.organization_id, auth.uid(), 'membership.removed', 'membership', old.id::text, jsonb_build_object('user_id', old.user_id));
27
+ end if;
28
+ return old;
29
+ end if;
30
+ end;
31
+ $$;
32
+
33
+ create or replace function kontrolia_auth.log_role_assignment_change()
34
+ returns trigger
35
+ language plpgsql
36
+ security definer
37
+ set search_path = ''
38
+ as $$
39
+ declare
40
+ target_membership record;
41
+ begin
42
+ select organization_id, user_id into target_membership
43
+ from kontrolia_auth.memberships
44
+ where id = coalesce(new.membership_id, old.membership_id);
45
+
46
+ if not exists (select 1 from kontrolia_auth.organizations where id = target_membership.organization_id) then
47
+ if tg_op = 'INSERT' then return new; else return old; end if;
48
+ end if;
49
+
50
+ if tg_op = 'INSERT' then
51
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
52
+ values (target_membership.organization_id, coalesce(auth.uid(), target_membership.user_id), 'role.assigned', 'membership_role', new.membership_id::text, jsonb_build_object('role_id', new.role_id));
53
+ return new;
54
+ else
55
+ insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
56
+ values (target_membership.organization_id, auth.uid(), 'role.unassigned', 'membership_role', old.membership_id::text, jsonb_build_object('role_id', old.role_id));
57
+ return old;
58
+ end if;
59
+ end;
60
+ $$;
@@ -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();