@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 +21 -0
- package/README.md +18 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +48 -0
- package/dist/register-application.d.ts +28 -0
- package/dist/register-application.d.ts.map +1 -0
- package/dist/register-application.js +34 -0
- package/migrations/0001_schema_and_extensions.sql +10 -0
- package/migrations/0002_organizations.sql +12 -0
- package/migrations/0003_applications_and_permissions.sql +41 -0
- package/migrations/0004_roles.sql +30 -0
- package/migrations/0005_memberships.sql +42 -0
- package/migrations/0006_invitations_devices_audit.sql +41 -0
- package/migrations/0007_custom_access_token_hook.sql +93 -0
- package/migrations/0008_grants_and_hook_permissions.sql +33 -0
- package/migrations/0009_helper_functions.sql +39 -0
- package/migrations/0010_rls_policies.sql +103 -0
- package/migrations/0011_org_owner_bootstrap_trigger.sql +32 -0
- package/migrations/0012_devices_and_session_revoke.sql +37 -0
- package/migrations/0013_audit_log_triggers.sql +133 -0
- package/package.json +53 -0
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 @@
|
|
|
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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,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"}
|
package/dist/migrate.js
ADDED
|
@@ -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
|
+
}
|