@kontrolia/db 1.1.0 → 2.0.0
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 +4 -4
- package/dist/api-key.d.ts +1 -1
- package/dist/api-key.d.ts.map +1 -1
- package/dist/api-key.js +1 -1
- package/dist/migrate.d.ts +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/register-application.d.ts +1 -1
- package/dist/register-application.js +3 -3
- package/migrations/0016_platform_admins.sql +117 -0
- package/migrations/0017_bootstrap_first_platform_admin.sql +35 -0
- package/migrations/0018_org_admins_manage_applications.sql +25 -0
- package/migrations/0019_app_scoped_custom_roles.sql +194 -0
- package/migrations/0020_rename_schema_to_kontrolia_auth.sql +379 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# @kontrolia/db
|
|
2
2
|
|
|
3
|
-
Migraciones SQL del schema `
|
|
3
|
+
Migraciones SQL del schema `kontrolia_auth` (organizaciones, RBAC jerárquico, invitaciones, dispositivos, audit log) y el Custom Access Token Hook que enriquece el JWT con `organization_id`, `roles` y `permissions` de la organización activa del usuario.
|
|
4
4
|
|
|
5
5
|
Diseñado para aplicarse igual sobre:
|
|
6
|
-
- un proyecto Supabase **existente** (Cloud o self-hosted) — solo aísla sus tablas en el schema `
|
|
6
|
+
- un proyecto Supabase **existente** (Cloud o self-hosted) — solo aísla sus tablas en el schema `kontrolia_auth`, no toca `public`;
|
|
7
7
|
- un proyecto Supabase **nuevo** levantado localmente por `docker/docker-compose.yml`.
|
|
8
8
|
|
|
9
9
|
## Uso
|
|
@@ -14,5 +14,5 @@ DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" pnpm --filte
|
|
|
14
14
|
|
|
15
15
|
## Activar el hook
|
|
16
16
|
|
|
17
|
-
- **Self-hosted**: variable de entorno de GoTrue `GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/
|
|
18
|
-
- **Supabase Cloud**: paso manual en el Dashboard → Authentication → Hooks → Custom Access Token → seleccionar `
|
|
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.
|
package/dist/api-key.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Generates a new plaintext application sync API key. Shown once, never stored. */
|
|
2
2
|
export declare function generateApplicationApiKey(): string;
|
|
3
|
-
/** sha256 hex digest — what actually gets stored in
|
|
3
|
+
/** sha256 hex digest — what actually gets stored in kontrolia_auth.applications.api_key_hash. */
|
|
4
4
|
export declare function hashApplicationApiKey(plaintext: string): string;
|
|
5
5
|
//# sourceMappingURL=api-key.d.ts.map
|
package/dist/api-key.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-key.d.ts","sourceRoot":"","sources":["../src/api-key.ts"],"names":[],"mappings":"AAIA,oFAAoF;AACpF,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,
|
|
1
|
+
{"version":3,"file":"api-key.d.ts","sourceRoot":"","sources":["../src/api-key.ts"],"names":[],"mappings":"AAIA,oFAAoF;AACpF,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,iGAAiG;AACjG,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/D"}
|
package/dist/api-key.js
CHANGED
|
@@ -4,7 +4,7 @@ const API_KEY_PREFIX = "kapp_";
|
|
|
4
4
|
export function generateApplicationApiKey() {
|
|
5
5
|
return `${API_KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
|
|
6
6
|
}
|
|
7
|
-
/** sha256 hex digest — what actually gets stored in
|
|
7
|
+
/** sha256 hex digest — what actually gets stored in kontrolia_auth.applications.api_key_hash. */
|
|
8
8
|
export function hashApplicationApiKey(plaintext) {
|
|
9
9
|
return createHash("sha256").update(plaintext).digest("hex");
|
|
10
10
|
}
|
package/dist/migrate.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface MigrateOptions {
|
|
2
2
|
/** Postgres connection string. Works against a fresh self-hosted instance
|
|
3
3
|
* or an existing Supabase project (Cloud or self-hosted) — the migrations
|
|
4
|
-
* only ever touch the `
|
|
4
|
+
* only ever touch the `kontrolia_auth` schema. */
|
|
5
5
|
connectionString: string;
|
|
6
6
|
/** Print each statement before running it. */
|
|
7
7
|
verbose?: boolean;
|
package/dist/migrate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,cAAc;IAC7B;;
|
|
1
|
+
{"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,cAAc;IAC7B;;sDAEkD;IAClD,gBAAgB,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAWD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,EAAE,gBAAgB,EAAE,OAAe,EAAE,EAAE,cAAc,iBAgClF"}
|
|
@@ -25,7 +25,7 @@ export interface RegisteredApplication {
|
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Inserts an application and its permission catalog directly against
|
|
28
|
-
* Postgres.
|
|
28
|
+
* Postgres. kontrolia_auth.applications/permissions have no insert policy for
|
|
29
29
|
* regular users (see migrations/0010_rls_policies.sql — writes are meant to
|
|
30
30
|
* go through a platform-admin path), so this is that path: a direct,
|
|
31
31
|
* service-role-equivalent write, the same way migrate() bypasses RLS to
|
|
@@ -2,7 +2,7 @@ import { Client } from "pg";
|
|
|
2
2
|
import { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
|
|
3
3
|
/**
|
|
4
4
|
* Inserts an application and its permission catalog directly against
|
|
5
|
-
* Postgres.
|
|
5
|
+
* Postgres. kontrolia_auth.applications/permissions have no insert policy for
|
|
6
6
|
* regular users (see migrations/0010_rls_policies.sql — writes are meant to
|
|
7
7
|
* go through a platform-admin path), so this is that path: a direct,
|
|
8
8
|
* service-role-equivalent write, the same way migrate() bypasses RLS to
|
|
@@ -14,7 +14,7 @@ export async function registerApplication(options) {
|
|
|
14
14
|
await client.connect();
|
|
15
15
|
try {
|
|
16
16
|
const candidateApiKey = generateApplicationApiKey();
|
|
17
|
-
const { rows: [application], } = await client.query(`insert into
|
|
17
|
+
const { rows: [application], } = await client.query(`insert into kontrolia_auth.applications (name, slug, environment, api_key_hash)
|
|
18
18
|
values ($1, $2, $3, $4)
|
|
19
19
|
on conflict (slug) do update set name = excluded.name, environment = excluded.environment
|
|
20
20
|
returning id, (xmax = 0) as inserted`, [options.name, options.slug, options.environment, hashApplicationApiKey(candidateApiKey)]);
|
|
@@ -23,7 +23,7 @@ export async function registerApplication(options) {
|
|
|
23
23
|
const permissionKeys = [];
|
|
24
24
|
for (const permission of options.permissions) {
|
|
25
25
|
const key = `${options.slug}.${permission.resource}.${permission.action}`;
|
|
26
|
-
await client.query(`insert into
|
|
26
|
+
await client.query(`insert into kontrolia_auth.permissions (application_id, resource, action, key, description)
|
|
27
27
|
values ($1, $2, $3, $4, $5)
|
|
28
28
|
on conflict (key) do update set description = excluded.description`, [application.id, permission.resource, permission.action, key, permission.description ?? null]);
|
|
29
29
|
permissionKeys.push(key);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
-- A "platform admin" needs to see across every organization (support/ops
|
|
2
|
+
-- consoles, cross-tenant monitoring) — something the rest of the model
|
|
3
|
+
-- deliberately can't do, since roles/permissions are always scoped to one
|
|
4
|
+
-- active organization at a time (see custom_access_token_hook). Rather than
|
|
5
|
+
-- have every app invent its own "<app>.admin.ver_todo" escape hatch, this
|
|
6
|
+
-- is one reserved claim outside the app-permission-key namespace, backed by
|
|
7
|
+
-- its own table so it's a single source of truth across the whole install.
|
|
8
|
+
--
|
|
9
|
+
-- No self-service UI/API for granting this on purpose — same "manual step
|
|
10
|
+
-- for now" pattern as application_organizations enablement. Grant with:
|
|
11
|
+
-- insert into kontrolia.platform_admins (user_id) values ('<user-uuid>');
|
|
12
|
+
|
|
13
|
+
create table kontrolia.platform_admins (
|
|
14
|
+
user_id uuid primary key references auth.users (id) on delete cascade,
|
|
15
|
+
granted_by uuid references auth.users (id) on delete set null,
|
|
16
|
+
granted_at timestamptz not null default now()
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
comment on table kontrolia.platform_admins is 'Users who can see across every organization — a single reserved claim (is_platform_admin), not an app-defined permission. Granted by inserting directly, no UI yet.';
|
|
20
|
+
|
|
21
|
+
alter table kontrolia.platform_admins enable row level security;
|
|
22
|
+
|
|
23
|
+
-- Only platform admins can even see who else is one — nobody self-grants
|
|
24
|
+
-- via RLS (writes require direct database access, same as this table's own
|
|
25
|
+
-- comment says).
|
|
26
|
+
create policy "platform admins can view the platform admin list" on kontrolia.platform_admins
|
|
27
|
+
for select using (exists (select 1 from kontrolia.platform_admins pa where pa.user_id = auth.uid()));
|
|
28
|
+
|
|
29
|
+
grant select on kontrolia.platform_admins to authenticated;
|
|
30
|
+
|
|
31
|
+
create or replace function kontrolia.custom_access_token_hook(event jsonb)
|
|
32
|
+
returns jsonb
|
|
33
|
+
language plpgsql
|
|
34
|
+
stable
|
|
35
|
+
security definer
|
|
36
|
+
set search_path = ''
|
|
37
|
+
as $$
|
|
38
|
+
declare
|
|
39
|
+
claims jsonb;
|
|
40
|
+
target_user_id uuid;
|
|
41
|
+
active_org_id uuid;
|
|
42
|
+
active_membership_id uuid;
|
|
43
|
+
role_names text[];
|
|
44
|
+
permission_keys text[];
|
|
45
|
+
platform_admin boolean;
|
|
46
|
+
begin
|
|
47
|
+
claims := coalesce(event->'claims', '{}'::jsonb);
|
|
48
|
+
target_user_id := (event->>'user_id')::uuid;
|
|
49
|
+
|
|
50
|
+
select exists(select 1 from kontrolia.platform_admins where user_id = target_user_id) into platform_admin;
|
|
51
|
+
|
|
52
|
+
select active_organization_id into active_org_id
|
|
53
|
+
from kontrolia.sessions_context
|
|
54
|
+
where user_id = target_user_id;
|
|
55
|
+
|
|
56
|
+
if active_org_id is null then
|
|
57
|
+
select organization_id into active_org_id
|
|
58
|
+
from kontrolia.memberships
|
|
59
|
+
where user_id = target_user_id and status = 'active'
|
|
60
|
+
order by created_at asc
|
|
61
|
+
limit 1;
|
|
62
|
+
end if;
|
|
63
|
+
|
|
64
|
+
if active_org_id is not null then
|
|
65
|
+
select id into active_membership_id
|
|
66
|
+
from kontrolia.memberships
|
|
67
|
+
where user_id = target_user_id
|
|
68
|
+
and organization_id = active_org_id
|
|
69
|
+
and status = 'active';
|
|
70
|
+
end if;
|
|
71
|
+
|
|
72
|
+
if active_membership_id is not null then
|
|
73
|
+
select coalesce(array_agg(distinct r.slug), '{}')
|
|
74
|
+
into role_names
|
|
75
|
+
from kontrolia.membership_roles mr
|
|
76
|
+
join kontrolia.roles r on r.id = mr.role_id
|
|
77
|
+
where mr.membership_id = active_membership_id;
|
|
78
|
+
|
|
79
|
+
select coalesce(array_agg(distinct p.key), '{}')
|
|
80
|
+
into permission_keys
|
|
81
|
+
from (
|
|
82
|
+
select p.id, p.key
|
|
83
|
+
from kontrolia.membership_roles mr
|
|
84
|
+
join kontrolia.role_permissions rp on rp.role_id = mr.role_id
|
|
85
|
+
join kontrolia.permissions p on p.id = rp.permission_id
|
|
86
|
+
where mr.membership_id = active_membership_id
|
|
87
|
+
union
|
|
88
|
+
select p.id, p.key
|
|
89
|
+
from kontrolia.user_permissions up
|
|
90
|
+
join kontrolia.permissions p on p.id = up.permission_id
|
|
91
|
+
where up.membership_id = active_membership_id and up.effect = 'allow'
|
|
92
|
+
) p
|
|
93
|
+
where not exists (
|
|
94
|
+
select 1 from kontrolia.user_permissions up_deny
|
|
95
|
+
where up_deny.membership_id = active_membership_id
|
|
96
|
+
and up_deny.permission_id = p.id
|
|
97
|
+
and up_deny.effect = 'deny'
|
|
98
|
+
);
|
|
99
|
+
else
|
|
100
|
+
role_names := '{}';
|
|
101
|
+
permission_keys := '{}';
|
|
102
|
+
end if;
|
|
103
|
+
|
|
104
|
+
-- to_jsonb(NULL::uuid) is SQL NULL, not a jsonb null — and jsonb_set is
|
|
105
|
+
-- strict, so passing it straight through would collapse the whole
|
|
106
|
+
-- function's result to NULL for any user without an organization yet
|
|
107
|
+
-- (i.e. every brand-new signup). coalesce() converts that to a real
|
|
108
|
+
-- jsonb null instead.
|
|
109
|
+
claims := jsonb_set(claims, '{organization_id}', coalesce(to_jsonb(active_org_id), 'null'::jsonb));
|
|
110
|
+
claims := jsonb_set(claims, '{roles}', to_jsonb(coalesce(role_names, '{}')));
|
|
111
|
+
claims := jsonb_set(claims, '{permissions}', to_jsonb(coalesce(permission_keys, '{}')));
|
|
112
|
+
claims := jsonb_set(claims, '{is_platform_admin}', to_jsonb(coalesce(platform_admin, false)));
|
|
113
|
+
|
|
114
|
+
event := jsonb_set(event, '{claims}', claims);
|
|
115
|
+
return event;
|
|
116
|
+
end;
|
|
117
|
+
$$;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Closes the platform-admin bootstrapping problem: /oauth-clients and any
|
|
2
|
+
-- future platform-admin-only screen are useless on a fresh install if
|
|
3
|
+
-- granting the very first one requires direct database access — exactly
|
|
4
|
+
-- the kind of step this product is supposed to eliminate for non-technical
|
|
5
|
+
-- operators. Same idea as kontrolia.handle_new_organization() (the creator
|
|
6
|
+
-- of the first organization becomes its Owner automatically): the very
|
|
7
|
+
-- first user of a fresh installation becomes its first platform admin
|
|
8
|
+
-- automatically, regardless of how they signed up (password, Google,
|
|
9
|
+
-- Microsoft — this fires on auth.users itself, not any one auth method).
|
|
10
|
+
--
|
|
11
|
+
-- Guarded by two conditions together, not just "platform_admins is empty":
|
|
12
|
+
-- if the sole platform admin is later revoked, this must NOT silently hand
|
|
13
|
+
-- the role to whoever happens to sign up next on an otherwise-live
|
|
14
|
+
-- installation. Checking auth.users count = 1 pins this to a genuinely
|
|
15
|
+
-- fresh install — it can only ever fire once, on the very first signup.
|
|
16
|
+
|
|
17
|
+
create or replace function kontrolia.bootstrap_first_platform_admin()
|
|
18
|
+
returns trigger
|
|
19
|
+
language plpgsql
|
|
20
|
+
security definer
|
|
21
|
+
set search_path = ''
|
|
22
|
+
as $$
|
|
23
|
+
begin
|
|
24
|
+
if not exists (select 1 from kontrolia.platform_admins limit 1)
|
|
25
|
+
and (select count(*) from auth.users) = 1 then
|
|
26
|
+
insert into kontrolia.platform_admins (user_id) values (new.id);
|
|
27
|
+
end if;
|
|
28
|
+
return new;
|
|
29
|
+
end;
|
|
30
|
+
$$;
|
|
31
|
+
|
|
32
|
+
create trigger on_first_user_created
|
|
33
|
+
after insert on auth.users
|
|
34
|
+
for each row
|
|
35
|
+
execute function kontrolia.bootstrap_first_platform_admin();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Closes a real gap: kontrolia.application_organizations had a SELECT
|
|
2
|
+
-- policy but nothing let anyone actually enable an application for their
|
|
3
|
+
-- organization — the wizard/API registers an application globally, but
|
|
4
|
+
-- nothing ever connected it to an org, so Aplicaciones/Permisos/Roles all
|
|
5
|
+
-- stayed empty even after registering a real application. Enabling an app
|
|
6
|
+
-- for your org is an org-level decision (same model as installing a Slack
|
|
7
|
+
-- app into your workspace), so this is org-admin self-service, not
|
|
8
|
+
-- platform-admin gated.
|
|
9
|
+
--
|
|
10
|
+
-- Also widens who can even see the application *catalog*: the original
|
|
11
|
+
-- applications SELECT policy only showed a row once it was already enabled
|
|
12
|
+
-- for one of your orgs — a chicken-and-egg problem for browsing what's
|
|
13
|
+
-- available to enable in the first place. Nothing in the catalog itself is
|
|
14
|
+
-- per-org secret data, so any authenticated user can browse it now; what's
|
|
15
|
+
-- still gated is enabling one for a specific organization.
|
|
16
|
+
|
|
17
|
+
drop policy if exists "anyone can view applications enabled for their org" on kontrolia.applications;
|
|
18
|
+
|
|
19
|
+
create policy "authenticated users can browse the application catalog" on kontrolia.applications
|
|
20
|
+
for select to authenticated using (true);
|
|
21
|
+
|
|
22
|
+
create policy "org admins enable/disable applications for their org" on kontrolia.application_organizations
|
|
23
|
+
for all
|
|
24
|
+
using (kontrolia.is_org_admin(organization_id))
|
|
25
|
+
with check (kontrolia.is_org_admin(organization_id));
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
-- Custom roles now belong to exactly one application, so each app can define
|
|
2
|
+
-- its own catalog of roles (e.g. "Facturación → Contador") instead of one
|
|
3
|
+
-- role potentially spanning several unrelated apps. The 3 global system
|
|
4
|
+
-- roles (Owner/Admin/Member) are untouched — they stay organization-wide,
|
|
5
|
+
-- shared, and immutable exactly as before.
|
|
6
|
+
--
|
|
7
|
+
-- Enabling an application for an org now also auto-creates an "Administrador
|
|
8
|
+
-- de <app>" role scoped to that (org, app) pair, holding every permission the
|
|
9
|
+
-- app currently declares. Its permissions are never toggled by hand — a
|
|
10
|
+
-- trigger keeps it in sync whenever the app registers a new permission, so it
|
|
11
|
+
-- never drifts out of date the way a manually-curated role would.
|
|
12
|
+
|
|
13
|
+
alter table kontrolia.roles
|
|
14
|
+
add column application_id uuid references kontrolia.applications (id) on delete cascade,
|
|
15
|
+
add column grants_all_permissions boolean not null default false;
|
|
16
|
+
|
|
17
|
+
-- One unused test role predates this constraint and can't be backfilled
|
|
18
|
+
-- (zero permissions, zero members, and its organization has no application
|
|
19
|
+
-- enabled to infer one from) — safe to drop.
|
|
20
|
+
delete from kontrolia.roles
|
|
21
|
+
where slug = 'facturacion-solo-lectura' and organization_id is not null and not is_system_role
|
|
22
|
+
and not exists (select 1 from kontrolia.role_permissions rp where rp.role_id = roles.id)
|
|
23
|
+
and not exists (select 1 from kontrolia.membership_roles mr where mr.role_id = roles.id);
|
|
24
|
+
|
|
25
|
+
-- The other pre-existing custom role already only references one
|
|
26
|
+
-- application's permissions — infer application_id from that.
|
|
27
|
+
update kontrolia.roles r
|
|
28
|
+
set application_id = inferred.application_id
|
|
29
|
+
from (
|
|
30
|
+
select rp.role_id, p.application_id
|
|
31
|
+
from kontrolia.role_permissions rp
|
|
32
|
+
join kontrolia.permissions p on p.id = rp.permission_id
|
|
33
|
+
group by rp.role_id, p.application_id
|
|
34
|
+
having count(distinct p.application_id) = 1
|
|
35
|
+
) inferred
|
|
36
|
+
where inferred.role_id = r.id and r.application_id is null and r.organization_id is not null and not r.is_system_role;
|
|
37
|
+
|
|
38
|
+
-- A custom (non-system) role now must belong to one application. The 3
|
|
39
|
+
-- global system roles (organization_id null) and any future org-wide system
|
|
40
|
+
-- role are exempt. Added after the cleanup/backfill above so it only ever
|
|
41
|
+
-- validates the steady state.
|
|
42
|
+
alter table kontrolia.roles
|
|
43
|
+
add constraint roles_custom_roles_require_application
|
|
44
|
+
check (organization_id is null or is_system_role or application_id is not null);
|
|
45
|
+
|
|
46
|
+
-- roles: reopen browsing (already true for applications, mirror it here so
|
|
47
|
+
-- picking an application for a new custom role can list its permission
|
|
48
|
+
-- count) and require an application on insert, matching the constraint
|
|
49
|
+
-- above; system roles (including the auto-created "Administrador de <app>")
|
|
50
|
+
-- can never be renamed or deleted through the API.
|
|
51
|
+
drop policy if exists "org admins can manage custom roles" on kontrolia.roles;
|
|
52
|
+
create policy "org admins can create app-scoped custom roles" on kontrolia.roles
|
|
53
|
+
for insert to authenticated
|
|
54
|
+
with check (
|
|
55
|
+
organization_id is not null and application_id is not null and not is_system_role
|
|
56
|
+
and kontrolia.is_org_admin(organization_id)
|
|
57
|
+
and exists (
|
|
58
|
+
select 1 from kontrolia.application_organizations ao
|
|
59
|
+
where ao.organization_id = roles.organization_id and ao.application_id = roles.application_id
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
drop policy if exists "org admins can update custom roles" on kontrolia.roles;
|
|
64
|
+
create policy "org admins can update custom roles" on kontrolia.roles
|
|
65
|
+
for update using (organization_id is not null and not is_system_role and kontrolia.is_org_admin(organization_id));
|
|
66
|
+
|
|
67
|
+
drop policy if exists "org admins can delete custom roles" on kontrolia.roles;
|
|
68
|
+
create policy "org admins can delete custom roles" on kontrolia.roles
|
|
69
|
+
for delete using (organization_id is not null and not is_system_role and kontrolia.is_org_admin(organization_id));
|
|
70
|
+
|
|
71
|
+
-- role_permissions: "Administrador de <app>" roles are excluded from manual
|
|
72
|
+
-- writes — their permission set is only ever changed by the sync trigger
|
|
73
|
+
-- below, so hand-editing them would just get overwritten on the next catalog
|
|
74
|
+
-- sync anyway.
|
|
75
|
+
drop policy if exists "org admins manage role permissions" on kontrolia.role_permissions;
|
|
76
|
+
create policy "org admins manage role permissions" on kontrolia.role_permissions
|
|
77
|
+
for all using (
|
|
78
|
+
exists (
|
|
79
|
+
select 1 from kontrolia.roles r
|
|
80
|
+
where r.id = role_id and r.organization_id is not null and not r.grants_all_permissions
|
|
81
|
+
and kontrolia.is_org_admin(r.organization_id)
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- Auto-create the "Administrador de <app>" role (+ its full permission set)
|
|
86
|
+
-- whenever an application gets enabled for an organization.
|
|
87
|
+
create or replace function kontrolia.handle_application_enabled()
|
|
88
|
+
returns trigger
|
|
89
|
+
language plpgsql
|
|
90
|
+
security definer
|
|
91
|
+
set search_path = ''
|
|
92
|
+
as $$
|
|
93
|
+
declare
|
|
94
|
+
app_name text;
|
|
95
|
+
app_slug text;
|
|
96
|
+
new_role_id uuid;
|
|
97
|
+
begin
|
|
98
|
+
select name, slug into app_name, app_slug from kontrolia.applications where id = new.application_id;
|
|
99
|
+
|
|
100
|
+
insert into kontrolia.roles (organization_id, application_id, name, slug, is_system_role, grants_all_permissions)
|
|
101
|
+
values (new.organization_id, new.application_id, 'Administrador de ' || app_name, 'admin-' || app_slug, true, true)
|
|
102
|
+
on conflict (organization_id, slug) do nothing
|
|
103
|
+
returning id into new_role_id;
|
|
104
|
+
|
|
105
|
+
if new_role_id is not null then
|
|
106
|
+
insert into kontrolia.role_permissions (role_id, permission_id)
|
|
107
|
+
select new_role_id, p.id from kontrolia.permissions p where p.application_id = new.application_id;
|
|
108
|
+
end if;
|
|
109
|
+
|
|
110
|
+
return new;
|
|
111
|
+
end;
|
|
112
|
+
$$;
|
|
113
|
+
|
|
114
|
+
drop trigger if exists on_application_enabled on kontrolia.application_organizations;
|
|
115
|
+
create trigger on_application_enabled
|
|
116
|
+
after insert on kontrolia.application_organizations
|
|
117
|
+
for each row
|
|
118
|
+
execute function kontrolia.handle_application_enabled();
|
|
119
|
+
|
|
120
|
+
-- Backfill: create the "Administrador de <app>" role for every
|
|
121
|
+
-- (org, application) pair that was already enabled before this migration.
|
|
122
|
+
insert into kontrolia.roles (organization_id, application_id, name, slug, is_system_role, grants_all_permissions)
|
|
123
|
+
select ao.organization_id, ao.application_id, 'Administrador de ' || a.name, 'admin-' || a.slug, true, true
|
|
124
|
+
from kontrolia.application_organizations ao
|
|
125
|
+
join kontrolia.applications a on a.id = ao.application_id
|
|
126
|
+
on conflict (organization_id, slug) do nothing;
|
|
127
|
+
|
|
128
|
+
insert into kontrolia.role_permissions (role_id, permission_id)
|
|
129
|
+
select r.id, p.id
|
|
130
|
+
from kontrolia.roles r
|
|
131
|
+
join kontrolia.permissions p on p.application_id = r.application_id
|
|
132
|
+
where r.grants_all_permissions
|
|
133
|
+
on conflict do nothing;
|
|
134
|
+
|
|
135
|
+
-- Keep every "Administrador de <app>" role in sync automatically whenever
|
|
136
|
+
-- the app declares a new permission (e.g. its next deploy syncs a new
|
|
137
|
+
-- resource.action pair) — nobody should have to remember to go re-grant it.
|
|
138
|
+
create or replace function kontrolia.handle_permission_added()
|
|
139
|
+
returns trigger
|
|
140
|
+
language plpgsql
|
|
141
|
+
security definer
|
|
142
|
+
set search_path = ''
|
|
143
|
+
as $$
|
|
144
|
+
begin
|
|
145
|
+
insert into kontrolia.role_permissions (role_id, permission_id)
|
|
146
|
+
select r.id, new.id
|
|
147
|
+
from kontrolia.roles r
|
|
148
|
+
where r.application_id = new.application_id and r.grants_all_permissions
|
|
149
|
+
on conflict do nothing;
|
|
150
|
+
return new;
|
|
151
|
+
end;
|
|
152
|
+
$$;
|
|
153
|
+
|
|
154
|
+
drop trigger if exists on_permission_added on kontrolia.permissions;
|
|
155
|
+
create trigger on_permission_added
|
|
156
|
+
after insert on kontrolia.permissions
|
|
157
|
+
for each row
|
|
158
|
+
execute function kontrolia.handle_permission_added();
|
|
159
|
+
|
|
160
|
+
-- A membership can hold at most one role per application (org-wide system
|
|
161
|
+
-- roles are exempt — a user is always exactly one of Owner/Admin/Member,
|
|
162
|
+
-- plus at most one role per app they've been granted access to).
|
|
163
|
+
create or replace function kontrolia.enforce_one_role_per_application()
|
|
164
|
+
returns trigger
|
|
165
|
+
language plpgsql
|
|
166
|
+
security definer
|
|
167
|
+
set search_path = ''
|
|
168
|
+
as $$
|
|
169
|
+
declare
|
|
170
|
+
target_app_id uuid;
|
|
171
|
+
conflict_count int;
|
|
172
|
+
begin
|
|
173
|
+
select application_id into target_app_id from kontrolia.roles where id = new.role_id;
|
|
174
|
+
|
|
175
|
+
if target_app_id is not null then
|
|
176
|
+
select count(*) into conflict_count
|
|
177
|
+
from kontrolia.membership_roles mr
|
|
178
|
+
join kontrolia.roles r on r.id = mr.role_id
|
|
179
|
+
where mr.membership_id = new.membership_id and r.application_id = target_app_id and mr.role_id <> new.role_id;
|
|
180
|
+
|
|
181
|
+
if conflict_count > 0 then
|
|
182
|
+
raise exception 'Este usuario ya tiene un rol asignado para esta aplicación. Quita el rol anterior antes de asignar uno nuevo.';
|
|
183
|
+
end if;
|
|
184
|
+
end if;
|
|
185
|
+
|
|
186
|
+
return new;
|
|
187
|
+
end;
|
|
188
|
+
$$;
|
|
189
|
+
|
|
190
|
+
drop trigger if exists before_membership_role_insert on kontrolia.membership_roles;
|
|
191
|
+
create trigger before_membership_role_insert
|
|
192
|
+
before insert on kontrolia.membership_roles
|
|
193
|
+
for each row
|
|
194
|
+
execute function kontrolia.enforce_one_role_per_application();
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
-- Renames the schema from kontrolia to kontrolia_auth. ALTER SCHEMA RENAME
|
|
2
|
+
-- moves every table/function/trigger/policy/grant along with it — object
|
|
3
|
+
-- identities (OIDs) don't change, so RLS policies, views, CHECK constraints,
|
|
4
|
+
-- foreign keys and grants (all of which Postgres compiles to OID references
|
|
5
|
+
-- at creation time) keep working untouched.
|
|
6
|
+
--
|
|
7
|
+
-- The one thing that does NOT survive a schema rename automatically: a
|
|
8
|
+
-- plpgsql/sql function's own BODY is stored as literal source text and
|
|
9
|
+
-- re-resolves every identifier by name each time it runs — so every
|
|
10
|
+
-- function below that references another kontrolia.<object> internally has
|
|
11
|
+
-- to be redefined with the new schema name, or it starts raising "schema
|
|
12
|
+
-- kontrolia does not exist" the next time it's called. Verified empirically
|
|
13
|
+
-- against a throwaway transaction before writing this migration: RLS
|
|
14
|
+
-- policies survive unmodified, function bodies do not.
|
|
15
|
+
|
|
16
|
+
alter schema kontrolia rename to kontrolia_auth;
|
|
17
|
+
|
|
18
|
+
-- packages/db/migrations/0009_helper_functions.sql
|
|
19
|
+
create or replace function kontrolia_auth.is_org_member(org_id uuid)
|
|
20
|
+
returns boolean
|
|
21
|
+
language sql
|
|
22
|
+
stable
|
|
23
|
+
security definer
|
|
24
|
+
set search_path = ''
|
|
25
|
+
as $$
|
|
26
|
+
select exists (
|
|
27
|
+
select 1 from kontrolia_auth.memberships m
|
|
28
|
+
where m.organization_id = org_id
|
|
29
|
+
and m.user_id = auth.uid()
|
|
30
|
+
and m.status = 'active'
|
|
31
|
+
);
|
|
32
|
+
$$;
|
|
33
|
+
|
|
34
|
+
create or replace function kontrolia_auth.is_org_admin(org_id uuid)
|
|
35
|
+
returns boolean
|
|
36
|
+
language sql
|
|
37
|
+
stable
|
|
38
|
+
security definer
|
|
39
|
+
set search_path = ''
|
|
40
|
+
as $$
|
|
41
|
+
select exists (
|
|
42
|
+
select 1
|
|
43
|
+
from kontrolia_auth.memberships m
|
|
44
|
+
join kontrolia_auth.membership_roles mr on mr.membership_id = m.id
|
|
45
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
46
|
+
where m.organization_id = org_id
|
|
47
|
+
and m.user_id = auth.uid()
|
|
48
|
+
and m.status = 'active'
|
|
49
|
+
and r.slug in ('owner', 'admin')
|
|
50
|
+
);
|
|
51
|
+
$$;
|
|
52
|
+
|
|
53
|
+
grant execute on function kontrolia_auth.is_org_member(uuid) to authenticated;
|
|
54
|
+
grant execute on function kontrolia_auth.is_org_admin(uuid) to authenticated;
|
|
55
|
+
|
|
56
|
+
-- packages/db/migrations/0011_org_owner_bootstrap_trigger.sql
|
|
57
|
+
create or replace function kontrolia_auth.handle_new_organization()
|
|
58
|
+
returns trigger
|
|
59
|
+
language plpgsql
|
|
60
|
+
security definer
|
|
61
|
+
set search_path = ''
|
|
62
|
+
as $$
|
|
63
|
+
declare
|
|
64
|
+
owner_role_id uuid;
|
|
65
|
+
new_membership_id uuid;
|
|
66
|
+
begin
|
|
67
|
+
select id into owner_role_id from kontrolia_auth.roles where slug = 'owner' and organization_id is null;
|
|
68
|
+
|
|
69
|
+
insert into kontrolia_auth.memberships (user_id, organization_id, status)
|
|
70
|
+
values (auth.uid(), new.id, 'active')
|
|
71
|
+
returning id into new_membership_id;
|
|
72
|
+
|
|
73
|
+
insert into kontrolia_auth.membership_roles (membership_id, role_id)
|
|
74
|
+
values (new_membership_id, owner_role_id);
|
|
75
|
+
|
|
76
|
+
return new;
|
|
77
|
+
end;
|
|
78
|
+
$$;
|
|
79
|
+
|
|
80
|
+
-- packages/db/migrations/0012_devices_and_session_revoke.sql
|
|
81
|
+
create or replace function kontrolia_auth.revoke_session(target_session_id uuid)
|
|
82
|
+
returns void
|
|
83
|
+
language plpgsql
|
|
84
|
+
security definer
|
|
85
|
+
set search_path = ''
|
|
86
|
+
as $$
|
|
87
|
+
begin
|
|
88
|
+
if not exists (
|
|
89
|
+
select 1 from kontrolia_auth.devices
|
|
90
|
+
where session_id = target_session_id and user_id = auth.uid()
|
|
91
|
+
) then
|
|
92
|
+
raise exception 'not authorized to revoke this session';
|
|
93
|
+
end if;
|
|
94
|
+
|
|
95
|
+
delete from auth.sessions where id = target_session_id;
|
|
96
|
+
delete from kontrolia_auth.devices where session_id = target_session_id;
|
|
97
|
+
end;
|
|
98
|
+
$$;
|
|
99
|
+
|
|
100
|
+
grant execute on function kontrolia_auth.revoke_session(uuid) to authenticated;
|
|
101
|
+
revoke execute on function kontrolia_auth.revoke_session(uuid) from anon, public;
|
|
102
|
+
|
|
103
|
+
-- packages/db/migrations/0013_audit_log_triggers.sql
|
|
104
|
+
create or replace function kontrolia_auth.log_organization_created()
|
|
105
|
+
returns trigger
|
|
106
|
+
language plpgsql
|
|
107
|
+
security definer
|
|
108
|
+
set search_path = ''
|
|
109
|
+
as $$
|
|
110
|
+
begin
|
|
111
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
112
|
+
values (new.id, auth.uid(), 'organization.created', 'organization', new.id::text, jsonb_build_object('name', new.name, 'slug', new.slug));
|
|
113
|
+
return new;
|
|
114
|
+
end;
|
|
115
|
+
$$;
|
|
116
|
+
|
|
117
|
+
create or replace function kontrolia_auth.log_membership_change()
|
|
118
|
+
returns trigger
|
|
119
|
+
language plpgsql
|
|
120
|
+
security definer
|
|
121
|
+
set search_path = ''
|
|
122
|
+
as $$
|
|
123
|
+
begin
|
|
124
|
+
if tg_op = 'INSERT' then
|
|
125
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
126
|
+
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));
|
|
127
|
+
return new;
|
|
128
|
+
else
|
|
129
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
130
|
+
values (old.organization_id, auth.uid(), 'membership.removed', 'membership', old.id::text, jsonb_build_object('user_id', old.user_id));
|
|
131
|
+
return old;
|
|
132
|
+
end if;
|
|
133
|
+
end;
|
|
134
|
+
$$;
|
|
135
|
+
|
|
136
|
+
create or replace function kontrolia_auth.log_invitation_created()
|
|
137
|
+
returns trigger
|
|
138
|
+
language plpgsql
|
|
139
|
+
security definer
|
|
140
|
+
set search_path = ''
|
|
141
|
+
as $$
|
|
142
|
+
begin
|
|
143
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
144
|
+
values (new.organization_id, auth.uid(), 'invitation.created', 'invitation', new.id::text, jsonb_build_object('email', new.email));
|
|
145
|
+
return new;
|
|
146
|
+
end;
|
|
147
|
+
$$;
|
|
148
|
+
|
|
149
|
+
create or replace function kontrolia_auth.log_invitation_accepted()
|
|
150
|
+
returns trigger
|
|
151
|
+
language plpgsql
|
|
152
|
+
security definer
|
|
153
|
+
set search_path = ''
|
|
154
|
+
as $$
|
|
155
|
+
begin
|
|
156
|
+
if old.accepted_at is null and new.accepted_at is not null then
|
|
157
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
158
|
+
values (new.organization_id, auth.uid(), 'invitation.accepted', 'invitation', new.id::text, jsonb_build_object('email', new.email));
|
|
159
|
+
end if;
|
|
160
|
+
return new;
|
|
161
|
+
end;
|
|
162
|
+
$$;
|
|
163
|
+
|
|
164
|
+
create or replace function kontrolia_auth.log_role_assignment_change()
|
|
165
|
+
returns trigger
|
|
166
|
+
language plpgsql
|
|
167
|
+
security definer
|
|
168
|
+
set search_path = ''
|
|
169
|
+
as $$
|
|
170
|
+
declare
|
|
171
|
+
target_membership record;
|
|
172
|
+
begin
|
|
173
|
+
select organization_id, user_id into target_membership
|
|
174
|
+
from kontrolia_auth.memberships
|
|
175
|
+
where id = coalesce(new.membership_id, old.membership_id);
|
|
176
|
+
|
|
177
|
+
if tg_op = 'INSERT' then
|
|
178
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
179
|
+
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));
|
|
180
|
+
return new;
|
|
181
|
+
else
|
|
182
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
183
|
+
values (target_membership.organization_id, auth.uid(), 'role.unassigned', 'membership_role', old.membership_id::text, jsonb_build_object('role_id', old.role_id));
|
|
184
|
+
return old;
|
|
185
|
+
end if;
|
|
186
|
+
end;
|
|
187
|
+
$$;
|
|
188
|
+
|
|
189
|
+
-- packages/db/migrations/0014_fix_device_revoked_actor_fk.sql (latest version of log_device_revoked)
|
|
190
|
+
create or replace function kontrolia_auth.log_device_revoked()
|
|
191
|
+
returns trigger
|
|
192
|
+
language plpgsql
|
|
193
|
+
security definer
|
|
194
|
+
set search_path = ''
|
|
195
|
+
as $$
|
|
196
|
+
declare
|
|
197
|
+
fallback_actor uuid;
|
|
198
|
+
begin
|
|
199
|
+
select id into fallback_actor from auth.users where id = old.user_id;
|
|
200
|
+
|
|
201
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
202
|
+
values (null, coalesce(auth.uid(), fallback_actor), 'device.revoked', 'device', old.session_id::text, jsonb_build_object('label', old.label));
|
|
203
|
+
return old;
|
|
204
|
+
end;
|
|
205
|
+
$$;
|
|
206
|
+
|
|
207
|
+
-- packages/db/migrations/0016_platform_admins.sql (latest version of custom_access_token_hook)
|
|
208
|
+
create or replace function kontrolia_auth.custom_access_token_hook(event jsonb)
|
|
209
|
+
returns jsonb
|
|
210
|
+
language plpgsql
|
|
211
|
+
stable
|
|
212
|
+
security definer
|
|
213
|
+
set search_path = ''
|
|
214
|
+
as $$
|
|
215
|
+
declare
|
|
216
|
+
claims jsonb;
|
|
217
|
+
target_user_id uuid;
|
|
218
|
+
active_org_id uuid;
|
|
219
|
+
active_membership_id uuid;
|
|
220
|
+
role_names text[];
|
|
221
|
+
permission_keys text[];
|
|
222
|
+
platform_admin boolean;
|
|
223
|
+
begin
|
|
224
|
+
claims := coalesce(event->'claims', '{}'::jsonb);
|
|
225
|
+
target_user_id := (event->>'user_id')::uuid;
|
|
226
|
+
|
|
227
|
+
select exists(select 1 from kontrolia_auth.platform_admins where user_id = target_user_id) into platform_admin;
|
|
228
|
+
|
|
229
|
+
select active_organization_id into active_org_id
|
|
230
|
+
from kontrolia_auth.sessions_context
|
|
231
|
+
where user_id = target_user_id;
|
|
232
|
+
|
|
233
|
+
if active_org_id is null then
|
|
234
|
+
select organization_id into active_org_id
|
|
235
|
+
from kontrolia_auth.memberships
|
|
236
|
+
where user_id = target_user_id and status = 'active'
|
|
237
|
+
order by created_at asc
|
|
238
|
+
limit 1;
|
|
239
|
+
end if;
|
|
240
|
+
|
|
241
|
+
if active_org_id is not null then
|
|
242
|
+
select id into active_membership_id
|
|
243
|
+
from kontrolia_auth.memberships
|
|
244
|
+
where user_id = target_user_id
|
|
245
|
+
and organization_id = active_org_id
|
|
246
|
+
and status = 'active';
|
|
247
|
+
end if;
|
|
248
|
+
|
|
249
|
+
if active_membership_id is not null then
|
|
250
|
+
select coalesce(array_agg(distinct r.slug), '{}')
|
|
251
|
+
into role_names
|
|
252
|
+
from kontrolia_auth.membership_roles mr
|
|
253
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
254
|
+
where mr.membership_id = active_membership_id;
|
|
255
|
+
|
|
256
|
+
select coalesce(array_agg(distinct p.key), '{}')
|
|
257
|
+
into permission_keys
|
|
258
|
+
from (
|
|
259
|
+
select p.id, p.key
|
|
260
|
+
from kontrolia_auth.membership_roles mr
|
|
261
|
+
join kontrolia_auth.role_permissions rp on rp.role_id = mr.role_id
|
|
262
|
+
join kontrolia_auth.permissions p on p.id = rp.permission_id
|
|
263
|
+
where mr.membership_id = active_membership_id
|
|
264
|
+
union
|
|
265
|
+
select p.id, p.key
|
|
266
|
+
from kontrolia_auth.user_permissions up
|
|
267
|
+
join kontrolia_auth.permissions p on p.id = up.permission_id
|
|
268
|
+
where up.membership_id = active_membership_id and up.effect = 'allow'
|
|
269
|
+
) p
|
|
270
|
+
where not exists (
|
|
271
|
+
select 1 from kontrolia_auth.user_permissions up_deny
|
|
272
|
+
where up_deny.membership_id = active_membership_id
|
|
273
|
+
and up_deny.permission_id = p.id
|
|
274
|
+
and up_deny.effect = 'deny'
|
|
275
|
+
);
|
|
276
|
+
else
|
|
277
|
+
role_names := '{}';
|
|
278
|
+
permission_keys := '{}';
|
|
279
|
+
end if;
|
|
280
|
+
|
|
281
|
+
claims := jsonb_set(claims, '{organization_id}', coalesce(to_jsonb(active_org_id), 'null'::jsonb));
|
|
282
|
+
claims := jsonb_set(claims, '{roles}', to_jsonb(coalesce(role_names, '{}')));
|
|
283
|
+
claims := jsonb_set(claims, '{permissions}', to_jsonb(coalesce(permission_keys, '{}')));
|
|
284
|
+
claims := jsonb_set(claims, '{is_platform_admin}', to_jsonb(coalesce(platform_admin, false)));
|
|
285
|
+
|
|
286
|
+
event := jsonb_set(event, '{claims}', claims);
|
|
287
|
+
return event;
|
|
288
|
+
end;
|
|
289
|
+
$$;
|
|
290
|
+
|
|
291
|
+
grant execute on function kontrolia_auth.custom_access_token_hook(jsonb) to supabase_auth_admin;
|
|
292
|
+
revoke execute on function kontrolia_auth.custom_access_token_hook(jsonb) from authenticated, anon, public;
|
|
293
|
+
|
|
294
|
+
-- packages/db/migrations/0017_bootstrap_first_platform_admin.sql
|
|
295
|
+
create or replace function kontrolia_auth.bootstrap_first_platform_admin()
|
|
296
|
+
returns trigger
|
|
297
|
+
language plpgsql
|
|
298
|
+
security definer
|
|
299
|
+
set search_path = ''
|
|
300
|
+
as $$
|
|
301
|
+
begin
|
|
302
|
+
if not exists (select 1 from kontrolia_auth.platform_admins limit 1)
|
|
303
|
+
and (select count(*) from auth.users) = 1 then
|
|
304
|
+
insert into kontrolia_auth.platform_admins (user_id) values (new.id);
|
|
305
|
+
end if;
|
|
306
|
+
return new;
|
|
307
|
+
end;
|
|
308
|
+
$$;
|
|
309
|
+
|
|
310
|
+
-- packages/db/migrations/0019_app_scoped_custom_roles.sql
|
|
311
|
+
create or replace function kontrolia_auth.handle_application_enabled()
|
|
312
|
+
returns trigger
|
|
313
|
+
language plpgsql
|
|
314
|
+
security definer
|
|
315
|
+
set search_path = ''
|
|
316
|
+
as $$
|
|
317
|
+
declare
|
|
318
|
+
app_name text;
|
|
319
|
+
app_slug text;
|
|
320
|
+
new_role_id uuid;
|
|
321
|
+
begin
|
|
322
|
+
select name, slug into app_name, app_slug from kontrolia_auth.applications where id = new.application_id;
|
|
323
|
+
|
|
324
|
+
insert into kontrolia_auth.roles (organization_id, application_id, name, slug, is_system_role, grants_all_permissions)
|
|
325
|
+
values (new.organization_id, new.application_id, 'Administrador de ' || app_name, 'admin-' || app_slug, true, true)
|
|
326
|
+
on conflict (organization_id, slug) do nothing
|
|
327
|
+
returning id into new_role_id;
|
|
328
|
+
|
|
329
|
+
if new_role_id is not null then
|
|
330
|
+
insert into kontrolia_auth.role_permissions (role_id, permission_id)
|
|
331
|
+
select new_role_id, p.id from kontrolia_auth.permissions p where p.application_id = new.application_id;
|
|
332
|
+
end if;
|
|
333
|
+
|
|
334
|
+
return new;
|
|
335
|
+
end;
|
|
336
|
+
$$;
|
|
337
|
+
|
|
338
|
+
create or replace function kontrolia_auth.handle_permission_added()
|
|
339
|
+
returns trigger
|
|
340
|
+
language plpgsql
|
|
341
|
+
security definer
|
|
342
|
+
set search_path = ''
|
|
343
|
+
as $$
|
|
344
|
+
begin
|
|
345
|
+
insert into kontrolia_auth.role_permissions (role_id, permission_id)
|
|
346
|
+
select r.id, new.id
|
|
347
|
+
from kontrolia_auth.roles r
|
|
348
|
+
where r.application_id = new.application_id and r.grants_all_permissions
|
|
349
|
+
on conflict do nothing;
|
|
350
|
+
return new;
|
|
351
|
+
end;
|
|
352
|
+
$$;
|
|
353
|
+
|
|
354
|
+
create or replace function kontrolia_auth.enforce_one_role_per_application()
|
|
355
|
+
returns trigger
|
|
356
|
+
language plpgsql
|
|
357
|
+
security definer
|
|
358
|
+
set search_path = ''
|
|
359
|
+
as $$
|
|
360
|
+
declare
|
|
361
|
+
target_app_id uuid;
|
|
362
|
+
conflict_count int;
|
|
363
|
+
begin
|
|
364
|
+
select application_id into target_app_id from kontrolia_auth.roles where id = new.role_id;
|
|
365
|
+
|
|
366
|
+
if target_app_id is not null then
|
|
367
|
+
select count(*) into conflict_count
|
|
368
|
+
from kontrolia_auth.membership_roles mr
|
|
369
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
370
|
+
where mr.membership_id = new.membership_id and r.application_id = target_app_id and mr.role_id <> new.role_id;
|
|
371
|
+
|
|
372
|
+
if conflict_count > 0 then
|
|
373
|
+
raise exception 'Este usuario ya tiene un rol asignado para esta aplicación. Quita el rol anterior antes de asignar uno nuevo.';
|
|
374
|
+
end if;
|
|
375
|
+
end if;
|
|
376
|
+
|
|
377
|
+
return new;
|
|
378
|
+
end;
|
|
379
|
+
$$;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontrolia/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
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": [
|