@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
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**:
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
@@ -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();
|