@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 CHANGED
@@ -1,9 +1,9 @@
1
1
  # @kontrolia/db
2
2
 
3
- Migraciones SQL del schema `kontrolia` (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.
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 `kontrolia`, no toca `public`;
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/kontrolia/custom_access_token_hook` (ver `docker/docker-compose.yml`).
18
- - **Supabase Cloud**: paso manual en el Dashboard → Authentication → Hooks → Custom Access Token → seleccionar `kontrolia.custom_access_token_hook`. No automatizable por API pública.
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 kontrolia.applications.api_key_hash. */
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
@@ -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,4FAA4F;AAC5F,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/D"}
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 kontrolia.applications.api_key_hash. */
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 `kontrolia` schema. */
4
+ * only ever touch the `kontrolia_auth` schema. */
5
5
  connectionString: string;
6
6
  /** Print each statement before running it. */
7
7
  verbose?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"migrate.d.ts","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,cAAc;IAC7B;;iDAE6C;IAC7C,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"}
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. kontrolia.applications/permissions have no insert policy for
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. kontrolia.applications/permissions have no insert policy for
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 kontrolia.applications (name, slug, environment, api_key_hash)
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 kontrolia.permissions (application_id, resource, action, key, description)
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": "1.1.0",
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": [