@kontrolia/db 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KontrolIA Auth Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @kontrolia/db
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.
4
+
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`;
7
+ - un proyecto Supabase **nuevo** levantado localmente por `docker/docker-compose.yml`.
8
+
9
+ ## Uso
10
+
11
+ ```bash
12
+ DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" pnpm --filter @kontrolia/db migrate
13
+ ```
14
+
15
+ ## Activar el hook
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.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { migrate } from "./migrate.js";
3
+ const connectionString = process.env.DATABASE_URL;
4
+ if (!connectionString) {
5
+ console.error("[kontrolia-db] DATABASE_URL is not set. Point it at your existing Supabase project's Postgres connection string, or at the local docker-compose Postgres.");
6
+ process.exit(1);
7
+ }
8
+ migrate({ connectionString, verbose: true })
9
+ .then(() => {
10
+ console.log("[kontrolia-db] migrations applied successfully.");
11
+ })
12
+ .catch((error) => {
13
+ console.error("[kontrolia-db] migration failed:", error);
14
+ process.exit(1);
15
+ });
@@ -0,0 +1,3 @@
1
+ export { migrate, type MigrateOptions } from "./migrate.js";
2
+ export { registerApplication, type RegisterApplicationOptions, type RegisteredApplication, type PermissionInput, } from "./register-application.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { migrate } from "./migrate.js";
2
+ export { registerApplication, } from "./register-application.js";
@@ -0,0 +1,14 @@
1
+ export interface MigrateOptions {
2
+ /** Postgres connection string. Works against a fresh self-hosted instance
3
+ * or an existing Supabase project (Cloud or self-hosted) — the migrations
4
+ * only ever touch the `kontrolia` schema. */
5
+ connectionString: string;
6
+ /** Print each statement before running it. */
7
+ verbose?: boolean;
8
+ }
9
+ /**
10
+ * Applies every .sql file under migrations/ that hasn't run yet, in
11
+ * filename order, inside its own transaction. Safe to re-run.
12
+ */
13
+ export declare function migrate({ connectionString, verbose }: MigrateOptions): Promise<void>;
14
+ //# sourceMappingURL=migrate.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,48 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Client } from "pg";
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const MIGRATIONS_DIR = join(__dirname, "..", "migrations");
7
+ async function ensureMigrationsTable(client) {
8
+ await client.query(`
9
+ create table if not exists kontrolia_migrations (
10
+ filename text primary key,
11
+ applied_at timestamptz not null default now()
12
+ )
13
+ `);
14
+ }
15
+ /**
16
+ * Applies every .sql file under migrations/ that hasn't run yet, in
17
+ * filename order, inside its own transaction. Safe to re-run.
18
+ */
19
+ export async function migrate({ connectionString, verbose = false }) {
20
+ const client = new Client({ connectionString });
21
+ await client.connect();
22
+ try {
23
+ await ensureMigrationsTable(client);
24
+ const { rows: applied } = await client.query("select filename from kontrolia_migrations");
25
+ const appliedSet = new Set(applied.map((r) => r.filename));
26
+ const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort();
27
+ for (const file of files) {
28
+ if (appliedSet.has(file))
29
+ continue;
30
+ const sql = await readFile(join(MIGRATIONS_DIR, file), "utf8");
31
+ if (verbose)
32
+ console.log(`[kontrolia-db] applying ${file}`);
33
+ await client.query("begin");
34
+ try {
35
+ await client.query(sql);
36
+ await client.query("insert into kontrolia_migrations (filename) values ($1)", [file]);
37
+ await client.query("commit");
38
+ }
39
+ catch (error) {
40
+ await client.query("rollback");
41
+ throw new Error(`Migration ${file} failed: ${error.message}`, { cause: error });
42
+ }
43
+ }
44
+ }
45
+ finally {
46
+ await client.end();
47
+ }
48
+ }
@@ -0,0 +1,28 @@
1
+ export interface PermissionInput {
2
+ resource: string;
3
+ action: string;
4
+ description?: string;
5
+ }
6
+ export interface RegisterApplicationOptions {
7
+ /** Postgres connection string — same one migrate() uses. */
8
+ connectionString: string;
9
+ name: string;
10
+ slug: string;
11
+ environment: "development" | "staging" | "production";
12
+ permissions: PermissionInput[];
13
+ }
14
+ export interface RegisteredApplication {
15
+ applicationId: string;
16
+ permissionKeys: string[];
17
+ }
18
+ /**
19
+ * Inserts an application and its permission catalog directly against
20
+ * Postgres. kontrolia.applications/permissions have no insert policy for
21
+ * regular users (see migrations/0010_rls_policies.sql — writes are meant to
22
+ * go through a platform-admin path) and no such admin API exists yet, so
23
+ * this is that path: a direct, service-role-equivalent write, the same way
24
+ * migrate() bypasses RLS to apply schema changes. Safe to re-run — the slug
25
+ * and permission key are both upserted.
26
+ */
27
+ export declare function registerApplication(options: RegisterApplicationOptions): Promise<RegisteredApplication>;
28
+ //# sourceMappingURL=register-application.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register-application.d.ts","sourceRoot":"","sources":["../src/register-application.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,aAAa,GAAG,SAAS,GAAG,YAAY,CAAC;IACtD,WAAW,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAgC7G"}
@@ -0,0 +1,34 @@
1
+ import { Client } from "pg";
2
+ /**
3
+ * Inserts an application and its permission catalog directly against
4
+ * Postgres. kontrolia.applications/permissions have no insert policy for
5
+ * regular users (see migrations/0010_rls_policies.sql — writes are meant to
6
+ * go through a platform-admin path) and no such admin API exists yet, so
7
+ * this is that path: a direct, service-role-equivalent write, the same way
8
+ * migrate() bypasses RLS to apply schema changes. Safe to re-run — the slug
9
+ * and permission key are both upserted.
10
+ */
11
+ export async function registerApplication(options) {
12
+ const client = new Client({ connectionString: options.connectionString });
13
+ await client.connect();
14
+ try {
15
+ const { rows: [application], } = await client.query(`insert into kontrolia.applications (name, slug, environment)
16
+ values ($1, $2, $3)
17
+ on conflict (slug) do update set name = excluded.name, environment = excluded.environment
18
+ returning id`, [options.name, options.slug, options.environment]);
19
+ if (!application)
20
+ throw new Error(`Failed to upsert application "${options.slug}"`);
21
+ const permissionKeys = [];
22
+ for (const permission of options.permissions) {
23
+ const key = `${options.slug}.${permission.resource}.${permission.action}`;
24
+ await client.query(`insert into kontrolia.permissions (application_id, resource, action, key, description)
25
+ values ($1, $2, $3, $4, $5)
26
+ on conflict (key) do update set description = excluded.description`, [application.id, permission.resource, permission.action, key, permission.description ?? null]);
27
+ permissionKeys.push(key);
28
+ }
29
+ return { applicationId: application.id, permissionKeys };
30
+ }
31
+ finally {
32
+ await client.end();
33
+ }
34
+ }
@@ -0,0 +1,10 @@
1
+ -- KontrolIA Auth — bootstrap schema
2
+ -- All KontrolIA tables live in a dedicated `kontrolia` schema so this migration
3
+ -- is safe to run against a Supabase project that already has application
4
+ -- tables in `public` (existing-project install path).
5
+
6
+ create schema if not exists kontrolia;
7
+
8
+ create extension if not exists pgcrypto with schema public;
9
+
10
+ comment on schema kontrolia is 'KontrolIA Auth — organizations, RBAC and application registry. Isolated from the host project public schema.';
@@ -0,0 +1,12 @@
1
+ create table kontrolia.organizations (
2
+ id uuid primary key default gen_random_uuid(),
3
+ name text not null,
4
+ slug text not null unique,
5
+ settings jsonb not null default '{}'::jsonb,
6
+ created_at timestamptz not null default now(),
7
+ updated_at timestamptz not null default now()
8
+ );
9
+
10
+ comment on table kontrolia.organizations is 'Tenants. A user can belong to many organizations via kontrolia.memberships.';
11
+
12
+ create index organizations_slug_idx on kontrolia.organizations (slug);
@@ -0,0 +1,41 @@
1
+ -- An "application" (Facturación, CRM, Inventarios...) declares its own
2
+ -- permission catalog when registered. Organizations can only assign, in their
3
+ -- roles, permissions from applications they have enabled.
4
+
5
+ create table kontrolia.applications (
6
+ id uuid primary key default gen_random_uuid(),
7
+ name text not null,
8
+ slug text not null unique,
9
+ owner_organization_id uuid references kontrolia.organizations (id) on delete set null,
10
+ environment text not null default 'production' check (environment in ('development', 'staging', 'production')),
11
+ redirect_urls text[] not null default '{}',
12
+ created_at timestamptz not null default now(),
13
+ updated_at timestamptz not null default now()
14
+ );
15
+
16
+ comment on table kontrolia.applications is 'Registered client applications. oauth_clients (v2) attach real OAuth2 credentials to a row here.';
17
+
18
+ create table kontrolia.application_organizations (
19
+ application_id uuid not null references kontrolia.applications (id) on delete cascade,
20
+ organization_id uuid not null references kontrolia.organizations (id) on delete cascade,
21
+ enabled_at timestamptz not null default now(),
22
+ primary key (application_id, organization_id)
23
+ );
24
+
25
+ comment on table kontrolia.application_organizations is 'Which organizations have which applications enabled — gates which permissions can be assigned in that org''s roles.';
26
+
27
+ create table kontrolia.permissions (
28
+ id uuid primary key default gen_random_uuid(),
29
+ application_id uuid not null references kontrolia.applications (id) on delete cascade,
30
+ resource text not null,
31
+ action text not null,
32
+ key text not null unique,
33
+ description text,
34
+ created_at timestamptz not null default now(),
35
+ unique (application_id, resource, action)
36
+ );
37
+
38
+ comment on table kontrolia.permissions is 'Hierarchical permission catalog, e.g. key = "facturacion.facturas.crear" (resource = "facturas", action = "crear").';
39
+
40
+ create index permissions_application_id_idx on kontrolia.permissions (application_id);
41
+ create index permissions_key_idx on kontrolia.permissions (key);
@@ -0,0 +1,30 @@
1
+ -- organization_id = null marks a system role (Owner/Admin/Member), seeded once
2
+ -- and assignable in every organization. Non-null organization_id marks a
3
+ -- custom role scoped to that organization only.
4
+
5
+ create table kontrolia.roles (
6
+ id uuid primary key default gen_random_uuid(),
7
+ organization_id uuid references kontrolia.organizations (id) on delete cascade,
8
+ name text not null,
9
+ slug text not null,
10
+ is_system_role boolean not null default false,
11
+ created_at timestamptz not null default now(),
12
+ updated_at timestamptz not null default now(),
13
+ unique (organization_id, slug)
14
+ );
15
+
16
+ comment on table kontrolia.roles is 'organization_id NULL = system role (Owner/Admin/Member), shared across all orgs. Non-null = org-custom role.';
17
+
18
+ create table kontrolia.role_permissions (
19
+ role_id uuid not null references kontrolia.roles (id) on delete cascade,
20
+ permission_id uuid not null references kontrolia.permissions (id) on delete cascade,
21
+ primary key (role_id, permission_id)
22
+ );
23
+
24
+ -- Seed the three system roles KontrolIA ships out of the box.
25
+ insert into kontrolia.roles (organization_id, name, slug, is_system_role)
26
+ values
27
+ (null, 'Owner', 'owner', true),
28
+ (null, 'Admin', 'admin', true),
29
+ (null, 'Member', 'member', true)
30
+ on conflict do nothing;
@@ -0,0 +1,42 @@
1
+ create table kontrolia.memberships (
2
+ id uuid primary key default gen_random_uuid(),
3
+ user_id uuid not null references auth.users (id) on delete cascade,
4
+ organization_id uuid not null references kontrolia.organizations (id) on delete cascade,
5
+ status text not null default 'active' check (status in ('active', 'invited', 'suspended')),
6
+ invited_by uuid references auth.users (id) on delete set null,
7
+ created_at timestamptz not null default now(),
8
+ updated_at timestamptz not null default now(),
9
+ unique (user_id, organization_id)
10
+ );
11
+
12
+ comment on table kontrolia.memberships is 'A user''s membership in one organization. A user can hold many memberships (multi-org).';
13
+
14
+ create index memberships_user_id_idx on kontrolia.memberships (user_id);
15
+ create index memberships_organization_id_idx on kontrolia.memberships (organization_id);
16
+
17
+ create table kontrolia.membership_roles (
18
+ membership_id uuid not null references kontrolia.memberships (id) on delete cascade,
19
+ role_id uuid not null references kontrolia.roles (id) on delete cascade,
20
+ primary key (membership_id, role_id)
21
+ );
22
+
23
+ -- Point-in-time allow/deny overrides on top of the roles' permissions,
24
+ -- e.g. granting one extra permission to a Member without creating a new role.
25
+ create table kontrolia.user_permissions (
26
+ membership_id uuid not null references kontrolia.memberships (id) on delete cascade,
27
+ permission_id uuid not null references kontrolia.permissions (id) on delete cascade,
28
+ effect text not null check (effect in ('allow', 'deny')),
29
+ primary key (membership_id, permission_id)
30
+ );
31
+
32
+ comment on table kontrolia.user_permissions is 'Per-membership overrides. "deny" always wins over any role-granted permission.';
33
+
34
+ -- Which organization is "active" for a user's current session — the Custom
35
+ -- Access Token Hook reads this to decide which org''s roles/permissions to
36
+ -- embed in the JWT. switchOrganization() in the SDK updates this row and
37
+ -- forces a session refresh.
38
+ create table kontrolia.sessions_context (
39
+ user_id uuid primary key references auth.users (id) on delete cascade,
40
+ active_organization_id uuid references kontrolia.organizations (id) on delete set null,
41
+ updated_at timestamptz not null default now()
42
+ );
@@ -0,0 +1,41 @@
1
+ create table kontrolia.invitations (
2
+ id uuid primary key default gen_random_uuid(),
3
+ organization_id uuid not null references kontrolia.organizations (id) on delete cascade,
4
+ email text not null,
5
+ role_id uuid references kontrolia.roles (id) on delete set null,
6
+ token text not null unique default encode(gen_random_bytes(24), 'hex'),
7
+ invited_by uuid references auth.users (id) on delete set null,
8
+ accepted_at timestamptz,
9
+ expires_at timestamptz not null default (now() + interval '7 days'),
10
+ created_at timestamptz not null default now()
11
+ );
12
+
13
+ create index invitations_organization_id_idx on kontrolia.invitations (organization_id);
14
+ create index invitations_email_idx on kontrolia.invitations (email);
15
+
16
+ create table kontrolia.devices (
17
+ id uuid primary key default gen_random_uuid(),
18
+ user_id uuid not null references auth.users (id) on delete cascade,
19
+ session_id uuid,
20
+ label text,
21
+ user_agent text,
22
+ ip inet,
23
+ last_seen_at timestamptz not null default now(),
24
+ created_at timestamptz not null default now()
25
+ );
26
+
27
+ create index devices_user_id_idx on kontrolia.devices (user_id);
28
+
29
+ create table kontrolia.audit_logs (
30
+ id uuid primary key default gen_random_uuid(),
31
+ organization_id uuid references kontrolia.organizations (id) on delete cascade,
32
+ actor_user_id uuid references auth.users (id) on delete set null,
33
+ action text not null,
34
+ target_type text,
35
+ target_id text,
36
+ metadata jsonb not null default '{}'::jsonb,
37
+ created_at timestamptz not null default now()
38
+ );
39
+
40
+ create index audit_logs_organization_id_idx on kontrolia.audit_logs (organization_id);
41
+ create index audit_logs_created_at_idx on kontrolia.audit_logs (created_at desc);
@@ -0,0 +1,93 @@
1
+ -- Custom Access Token Hook: runs inside GoTrue's own transaction whenever a
2
+ -- JWT is issued (login, refresh). Embeds organization_id, roles and
3
+ -- permissions for the user's *active* organization only — never for every
4
+ -- organization the user belongs to, to keep the token small and avoid
5
+ -- leaking permissions across tenants. switchOrganization() in the SDK
6
+ -- updates kontrolia.sessions_context and triggers a session refresh so this
7
+ -- hook re-runs with the new active organization.
8
+
9
+ create or replace function kontrolia.custom_access_token_hook(event jsonb)
10
+ returns jsonb
11
+ language plpgsql
12
+ stable
13
+ security definer
14
+ set search_path = ''
15
+ as $$
16
+ declare
17
+ claims jsonb;
18
+ target_user_id uuid;
19
+ active_org_id uuid;
20
+ active_membership_id uuid;
21
+ role_names text[];
22
+ permission_keys text[];
23
+ begin
24
+ claims := coalesce(event->'claims', '{}'::jsonb);
25
+ target_user_id := (event->>'user_id')::uuid;
26
+
27
+ select active_organization_id into active_org_id
28
+ from kontrolia.sessions_context
29
+ where user_id = target_user_id;
30
+
31
+ if active_org_id is null then
32
+ select organization_id into active_org_id
33
+ from kontrolia.memberships
34
+ where user_id = target_user_id and status = 'active'
35
+ order by created_at asc
36
+ limit 1;
37
+ end if;
38
+
39
+ if active_org_id is not null then
40
+ select id into active_membership_id
41
+ from kontrolia.memberships
42
+ where user_id = target_user_id
43
+ and organization_id = active_org_id
44
+ and status = 'active';
45
+ end if;
46
+
47
+ if active_membership_id is not null then
48
+ select coalesce(array_agg(distinct r.slug), '{}')
49
+ into role_names
50
+ from kontrolia.membership_roles mr
51
+ join kontrolia.roles r on r.id = mr.role_id
52
+ where mr.membership_id = active_membership_id;
53
+
54
+ select coalesce(array_agg(distinct p.key), '{}')
55
+ into permission_keys
56
+ from (
57
+ select p.id, p.key
58
+ from kontrolia.membership_roles mr
59
+ join kontrolia.role_permissions rp on rp.role_id = mr.role_id
60
+ join kontrolia.permissions p on p.id = rp.permission_id
61
+ where mr.membership_id = active_membership_id
62
+ union
63
+ select p.id, p.key
64
+ from kontrolia.user_permissions up
65
+ join kontrolia.permissions p on p.id = up.permission_id
66
+ where up.membership_id = active_membership_id and up.effect = 'allow'
67
+ ) p
68
+ where not exists (
69
+ select 1 from kontrolia.user_permissions up_deny
70
+ where up_deny.membership_id = active_membership_id
71
+ and up_deny.permission_id = p.id
72
+ and up_deny.effect = 'deny'
73
+ );
74
+ else
75
+ role_names := '{}';
76
+ permission_keys := '{}';
77
+ end if;
78
+
79
+ -- to_jsonb(NULL::uuid) is SQL NULL, not a jsonb null — and jsonb_set is
80
+ -- strict, so passing it straight through would collapse the whole
81
+ -- function's result to NULL for any user without an organization yet
82
+ -- (i.e. every brand-new signup). coalesce() converts that to a real
83
+ -- jsonb null instead.
84
+ claims := jsonb_set(claims, '{organization_id}', coalesce(to_jsonb(active_org_id), 'null'::jsonb));
85
+ claims := jsonb_set(claims, '{roles}', to_jsonb(coalesce(role_names, '{}')));
86
+ claims := jsonb_set(claims, '{permissions}', to_jsonb(coalesce(permission_keys, '{}')));
87
+
88
+ event := jsonb_set(event, '{claims}', claims);
89
+ return event;
90
+ end;
91
+ $$;
92
+
93
+ comment on function kontrolia.custom_access_token_hook is 'Supabase Auth Custom Access Token Hook. Wire via GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/kontrolia/custom_access_token_hook (self-hosted) or Dashboard > Authentication > Hooks (Supabase Cloud — manual step, cannot be automated by the CLI).';
@@ -0,0 +1,33 @@
1
+ -- Least-privilege wiring for the hook, per Supabase's guidance: only
2
+ -- supabase_auth_admin (GoTrue's own role) may execute it.
3
+
4
+ grant usage on schema kontrolia to supabase_auth_admin;
5
+ grant execute on function kontrolia.custom_access_token_hook(jsonb) to supabase_auth_admin;
6
+ revoke execute on function kontrolia.custom_access_token_hook(jsonb) from authenticated, anon, public;
7
+
8
+ -- The hook reads these tables inside the SECURITY DEFINER function above, so
9
+ -- supabase_auth_admin needs SELECT even though end users never query them
10
+ -- directly (RLS below blocks that path).
11
+ grant select on kontrolia.sessions_context to supabase_auth_admin;
12
+ grant select on kontrolia.memberships to supabase_auth_admin;
13
+ grant select on kontrolia.membership_roles to supabase_auth_admin;
14
+ grant select on kontrolia.roles to supabase_auth_admin;
15
+ grant select on kontrolia.role_permissions to supabase_auth_admin;
16
+ grant select on kontrolia.permissions to supabase_auth_admin;
17
+ grant select on kontrolia.user_permissions to supabase_auth_admin;
18
+
19
+ -- Expose the schema to PostgREST for authenticated/anon access, gated by RLS.
20
+ grant usage on schema kontrolia to authenticated, anon;
21
+ grant select, insert, update, delete on all tables in schema kontrolia to authenticated;
22
+ grant select on all tables in schema kontrolia to anon;
23
+ alter default privileges in schema kontrolia grant select, insert, update, delete on tables to authenticated;
24
+
25
+ -- service_role bypasses RLS (Supabase grants it BYPASSRLS), but table-level
26
+ -- and schema-level GRANTs are separate from RLS and still apply — without
27
+ -- these, server-only code using the service-role key (e.g. the invitation
28
+ -- acceptance flow, which must read/write before the visitor is even
29
+ -- authenticated) gets "permission denied for schema kontrolia" from
30
+ -- PostgREST.
31
+ grant usage on schema kontrolia to service_role;
32
+ grant select, insert, update, delete on all tables in schema kontrolia to service_role;
33
+ alter default privileges in schema kontrolia grant select, insert, update, delete on tables to service_role;
@@ -0,0 +1,39 @@
1
+ -- Helpers reused by RLS policies below. security definer + fixed search_path
2
+ -- so they can safely read kontrolia tables regardless of the caller's RLS.
3
+
4
+ create or replace function kontrolia.is_org_member(org_id uuid)
5
+ returns boolean
6
+ language sql
7
+ stable
8
+ security definer
9
+ set search_path = ''
10
+ as $$
11
+ select exists (
12
+ select 1 from kontrolia.memberships m
13
+ where m.organization_id = org_id
14
+ and m.user_id = auth.uid()
15
+ and m.status = 'active'
16
+ );
17
+ $$;
18
+
19
+ create or replace function kontrolia.is_org_admin(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
28
+ from kontrolia.memberships m
29
+ join kontrolia.membership_roles mr on mr.membership_id = m.id
30
+ join kontrolia.roles r on r.id = mr.role_id
31
+ where m.organization_id = org_id
32
+ and m.user_id = auth.uid()
33
+ and m.status = 'active'
34
+ and r.slug in ('owner', 'admin')
35
+ );
36
+ $$;
37
+
38
+ grant execute on function kontrolia.is_org_member(uuid) to authenticated;
39
+ grant execute on function kontrolia.is_org_admin(uuid) to authenticated;
@@ -0,0 +1,103 @@
1
+ alter table kontrolia.organizations enable row level security;
2
+ alter table kontrolia.applications enable row level security;
3
+ alter table kontrolia.application_organizations enable row level security;
4
+ alter table kontrolia.permissions enable row level security;
5
+ alter table kontrolia.roles enable row level security;
6
+ alter table kontrolia.role_permissions enable row level security;
7
+ alter table kontrolia.memberships enable row level security;
8
+ alter table kontrolia.membership_roles enable row level security;
9
+ alter table kontrolia.user_permissions enable row level security;
10
+ alter table kontrolia.invitations enable row level security;
11
+ alter table kontrolia.sessions_context enable row level security;
12
+ alter table kontrolia.devices enable row level security;
13
+ alter table kontrolia.audit_logs enable row level security;
14
+
15
+ -- organizations
16
+ create policy "members can view their organizations" on kontrolia.organizations
17
+ for select using (kontrolia.is_org_member(id));
18
+ create policy "authenticated users can create organizations" on kontrolia.organizations
19
+ for insert to authenticated with check (true);
20
+ create policy "org admins can update their organization" on kontrolia.organizations
21
+ for update using (kontrolia.is_org_admin(id));
22
+
23
+ -- applications / catalog: platform-level, managed via service_role from the
24
+ -- auth-server admin API (which enforces its own platform-admin check).
25
+ create policy "anyone can view applications enabled for their org" on kontrolia.applications
26
+ for select using (
27
+ exists (
28
+ select 1 from kontrolia.application_organizations ao
29
+ where ao.application_id = id and kontrolia.is_org_member(ao.organization_id)
30
+ )
31
+ );
32
+ create policy "org members can view enabled applications" on kontrolia.application_organizations
33
+ for select using (kontrolia.is_org_member(organization_id));
34
+ create policy "org members can view permissions of their enabled apps" on kontrolia.permissions
35
+ for select using (
36
+ exists (
37
+ select 1 from kontrolia.application_organizations ao
38
+ where ao.application_id = permissions.application_id and kontrolia.is_org_member(ao.organization_id)
39
+ )
40
+ );
41
+
42
+ -- roles
43
+ create policy "org members can view roles" on kontrolia.roles
44
+ for select using (organization_id is null or kontrolia.is_org_member(organization_id));
45
+ create policy "org admins can manage custom roles" on kontrolia.roles
46
+ for insert to authenticated with check (organization_id is not null and kontrolia.is_org_admin(organization_id));
47
+ create policy "org admins can update custom roles" on kontrolia.roles
48
+ for update using (organization_id is not null and kontrolia.is_org_admin(organization_id));
49
+ create policy "org admins can delete custom roles" on kontrolia.roles
50
+ for delete using (organization_id is not null and kontrolia.is_org_admin(organization_id));
51
+
52
+ create policy "org members can view role permissions" on kontrolia.role_permissions
53
+ for select using (
54
+ exists (
55
+ select 1 from kontrolia.roles r
56
+ where r.id = role_id and (r.organization_id is null or kontrolia.is_org_member(r.organization_id))
57
+ )
58
+ );
59
+ create policy "org admins manage role permissions" on kontrolia.role_permissions
60
+ for all using (
61
+ exists (select 1 from kontrolia.roles r where r.id = role_id and r.organization_id is not null and kontrolia.is_org_admin(r.organization_id))
62
+ );
63
+
64
+ -- memberships
65
+ create policy "members can view memberships in their orgs" on kontrolia.memberships
66
+ for select using (user_id = auth.uid() or kontrolia.is_org_member(organization_id));
67
+ create policy "org admins manage memberships" on kontrolia.memberships
68
+ for insert to authenticated with check (kontrolia.is_org_admin(organization_id));
69
+ create policy "org admins update memberships" on kontrolia.memberships
70
+ for update using (kontrolia.is_org_admin(organization_id));
71
+ create policy "org admins remove memberships" on kontrolia.memberships
72
+ for delete using (kontrolia.is_org_admin(organization_id));
73
+
74
+ create policy "org members can view membership roles" on kontrolia.membership_roles
75
+ for select using (
76
+ exists (select 1 from kontrolia.memberships m where m.id = membership_id and (m.user_id = auth.uid() or kontrolia.is_org_member(m.organization_id)))
77
+ );
78
+ create policy "org admins manage membership roles" on kontrolia.membership_roles
79
+ for all using (
80
+ exists (select 1 from kontrolia.memberships m where m.id = membership_id and kontrolia.is_org_admin(m.organization_id))
81
+ );
82
+
83
+ create policy "org admins manage user permission overrides" on kontrolia.user_permissions
84
+ for all using (
85
+ exists (select 1 from kontrolia.memberships m where m.id = membership_id and kontrolia.is_org_admin(m.organization_id))
86
+ );
87
+
88
+ -- invitations — org admins only; invited-user acceptance flow goes through
89
+ -- the auth-server API (service role), not direct table access.
90
+ create policy "org admins manage invitations" on kontrolia.invitations
91
+ for all using (kontrolia.is_org_admin(organization_id));
92
+
93
+ -- sessions_context — a user only ever sees/updates their own active org
94
+ create policy "users manage their own session context" on kontrolia.sessions_context
95
+ for all using (user_id = auth.uid()) with check (user_id = auth.uid());
96
+
97
+ -- devices
98
+ create policy "users manage their own devices" on kontrolia.devices
99
+ for all using (user_id = auth.uid()) with check (user_id = auth.uid());
100
+
101
+ -- audit_logs — read-only for org admins, writes come from service_role only
102
+ create policy "org admins can view audit logs" on kontrolia.audit_logs
103
+ for select using (organization_id is not null and kontrolia.is_org_admin(organization_id));
@@ -0,0 +1,32 @@
1
+ -- Bootstraps the chicken-and-egg problem of RLS-gated membership writes:
2
+ -- creating an organization has no admin membership yet to satisfy
3
+ -- kontrolia.is_org_admin(). Instead, the creator is auto-enrolled as Owner
4
+ -- via trigger, in the same transaction as the insert.
5
+
6
+ create or replace function kontrolia.handle_new_organization()
7
+ returns trigger
8
+ language plpgsql
9
+ security definer
10
+ set search_path = ''
11
+ as $$
12
+ declare
13
+ owner_role_id uuid;
14
+ new_membership_id uuid;
15
+ begin
16
+ select id into owner_role_id from kontrolia.roles where slug = 'owner' and organization_id is null;
17
+
18
+ insert into kontrolia.memberships (user_id, organization_id, status)
19
+ values (auth.uid(), new.id, 'active')
20
+ returning id into new_membership_id;
21
+
22
+ insert into kontrolia.membership_roles (membership_id, role_id)
23
+ values (new_membership_id, owner_role_id);
24
+
25
+ return new;
26
+ end;
27
+ $$;
28
+
29
+ create trigger on_organization_created
30
+ after insert on kontrolia.organizations
31
+ for each row
32
+ execute function kontrolia.handle_new_organization();
@@ -0,0 +1,37 @@
1
+ -- One device row per session, upserted on each login/touch.
2
+ alter table kontrolia.devices add constraint devices_session_id_key unique (session_id);
3
+
4
+ -- Revokes one specific Supabase session (device) for the calling user.
5
+ --
6
+ -- There is no supabase-js admin method to revoke an arbitrary session by
7
+ -- session_id — signOut() only affects the caller's own current/other
8
+ -- sessions. The documented approach is to correlate the JWT's session_id
9
+ -- claim with auth.sessions.id and delete that row directly: access tokens
10
+ -- already issued stay valid until they expire (short-lived, see
11
+ -- GOTRUE_JWT_EXP / config.toml's auth.jwt_expiry), but the session can no
12
+ -- longer be refreshed once its auth.sessions row is gone.
13
+ --
14
+ -- SECURITY DEFINER is what makes touching auth.sessions from an
15
+ -- `authenticated`-role caller possible at all; ownership is checked
16
+ -- explicitly inside the function since RLS doesn't apply here.
17
+ create or replace function kontrolia.revoke_session(target_session_id uuid)
18
+ returns void
19
+ language plpgsql
20
+ security definer
21
+ set search_path = ''
22
+ as $$
23
+ begin
24
+ if not exists (
25
+ select 1 from kontrolia.devices
26
+ where session_id = target_session_id and user_id = auth.uid()
27
+ ) then
28
+ raise exception 'not authorized to revoke this session';
29
+ end if;
30
+
31
+ delete from auth.sessions where id = target_session_id;
32
+ delete from kontrolia.devices where session_id = target_session_id;
33
+ end;
34
+ $$;
35
+
36
+ grant execute on function kontrolia.revoke_session(uuid) to authenticated;
37
+ revoke execute on function kontrolia.revoke_session(uuid) from anon, public;
@@ -0,0 +1,133 @@
1
+ -- Trigger-based audit logging: writes happen inside the database, not
2
+ -- because application code remembered to also call an "insertAuditLog()"
3
+ -- helper. That's the entire point of an audit trail — it has to keep
4
+ -- recording even when a future API route forgets to, or a change comes
5
+ -- from Studio/psql directly.
6
+ --
7
+ -- actor_user_id is auth.uid() where available. Some of these writes go
8
+ -- through service-role code paths (e.g. invitation acceptance, which needs
9
+ -- to run before the visitor even has an org membership) where auth.uid()
10
+ -- isn't meaningful — those fall back to the affected row's own user_id, or
11
+ -- are left null when there truly isn't one to infer.
12
+
13
+ create or replace function kontrolia.log_organization_created()
14
+ returns trigger
15
+ language plpgsql
16
+ security definer
17
+ set search_path = ''
18
+ as $$
19
+ begin
20
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
21
+ values (new.id, auth.uid(), 'organization.created', 'organization', new.id::text, jsonb_build_object('name', new.name, 'slug', new.slug));
22
+ return new;
23
+ end;
24
+ $$;
25
+
26
+ create trigger audit_organization_created
27
+ after insert on kontrolia.organizations
28
+ for each row execute function kontrolia.log_organization_created();
29
+
30
+ create or replace function kontrolia.log_membership_change()
31
+ returns trigger
32
+ language plpgsql
33
+ security definer
34
+ set search_path = ''
35
+ as $$
36
+ begin
37
+ if tg_op = 'INSERT' then
38
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
39
+ 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));
40
+ return new;
41
+ else
42
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
43
+ values (old.organization_id, auth.uid(), 'membership.removed', 'membership', old.id::text, jsonb_build_object('user_id', old.user_id));
44
+ return old;
45
+ end if;
46
+ end;
47
+ $$;
48
+
49
+ create trigger audit_membership_change
50
+ after insert or delete on kontrolia.memberships
51
+ for each row execute function kontrolia.log_membership_change();
52
+
53
+ create or replace function kontrolia.log_invitation_created()
54
+ returns trigger
55
+ language plpgsql
56
+ security definer
57
+ set search_path = ''
58
+ as $$
59
+ begin
60
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
61
+ values (new.organization_id, auth.uid(), 'invitation.created', 'invitation', new.id::text, jsonb_build_object('email', new.email));
62
+ return new;
63
+ end;
64
+ $$;
65
+
66
+ create trigger audit_invitation_created
67
+ after insert on kontrolia.invitations
68
+ for each row execute function kontrolia.log_invitation_created();
69
+
70
+ create or replace function kontrolia.log_invitation_accepted()
71
+ returns trigger
72
+ language plpgsql
73
+ security definer
74
+ set search_path = ''
75
+ as $$
76
+ begin
77
+ if old.accepted_at is null and new.accepted_at is not null then
78
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
79
+ values (new.organization_id, auth.uid(), 'invitation.accepted', 'invitation', new.id::text, jsonb_build_object('email', new.email));
80
+ end if;
81
+ return new;
82
+ end;
83
+ $$;
84
+
85
+ create trigger audit_invitation_accepted
86
+ after update on kontrolia.invitations
87
+ for each row execute function kontrolia.log_invitation_accepted();
88
+
89
+ create or replace function kontrolia.log_role_assignment_change()
90
+ returns trigger
91
+ language plpgsql
92
+ security definer
93
+ set search_path = ''
94
+ as $$
95
+ declare
96
+ target_membership record;
97
+ begin
98
+ select organization_id, user_id into target_membership
99
+ from kontrolia.memberships
100
+ where id = coalesce(new.membership_id, old.membership_id);
101
+
102
+ if tg_op = 'INSERT' then
103
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
104
+ 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));
105
+ return new;
106
+ else
107
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
108
+ values (target_membership.organization_id, auth.uid(), 'role.unassigned', 'membership_role', old.membership_id::text, jsonb_build_object('role_id', old.role_id));
109
+ return old;
110
+ end if;
111
+ end;
112
+ $$;
113
+
114
+ create trigger audit_role_assignment_change
115
+ after insert or delete on kontrolia.membership_roles
116
+ for each row execute function kontrolia.log_role_assignment_change();
117
+
118
+ create or replace function kontrolia.log_device_revoked()
119
+ returns trigger
120
+ language plpgsql
121
+ security definer
122
+ set search_path = ''
123
+ as $$
124
+ begin
125
+ insert into kontrolia.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
126
+ values (null, coalesce(auth.uid(), old.user_id), 'device.revoked', 'device', old.session_id::text, jsonb_build_object('label', old.label));
127
+ return old;
128
+ end;
129
+ $$;
130
+
131
+ create trigger audit_device_revoked
132
+ after delete on kontrolia.devices
133
+ for each row execute function kontrolia.log_device_revoked();
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@kontrolia/db",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
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
+ "keywords": [
7
+ "kontrolia",
8
+ "auth",
9
+ "postgres",
10
+ "supabase",
11
+ "migrations",
12
+ "rbac"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/rauldolores/auth.git",
17
+ "directory": "packages/db"
18
+ },
19
+ "homepage": "https://github.com/rauldolores/auth#readme",
20
+ "bugs": "https://github.com/rauldolores/auth/issues",
21
+ "author": "KontrolIA",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "bin": {
29
+ "kontrolia-db-migrate": "./dist/cli.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "migrations"
34
+ ],
35
+ "dependencies": {
36
+ "pg": "^8.13.1"
37
+ },
38
+ "devDependencies": {
39
+ "@types/pg": "^8.11.10",
40
+ "eslint": "^9.17.0",
41
+ "tsx": "^4.19.2",
42
+ "typescript": "^5.7.2",
43
+ "@kontrolia/config": "0.1.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.json",
47
+ "dev": "tsc -p tsconfig.json --watch",
48
+ "migrate": "tsx src/cli.ts",
49
+ "lint": "eslint .",
50
+ "typecheck": "tsc -p tsconfig.json --noEmit",
51
+ "clean": "rimraf dist"
52
+ }
53
+ }