@kontrolia/db 1.0.1 → 1.2.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.
@@ -0,0 +1,5 @@
1
+ /** Generates a new plaintext application sync API key. Shown once, never stored. */
2
+ export declare function generateApplicationApiKey(): string;
3
+ /** sha256 hex digest — what actually gets stored in kontrolia.applications.api_key_hash. */
4
+ export declare function hashApplicationApiKey(plaintext: string): string;
5
+ //# sourceMappingURL=api-key.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,10 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ const API_KEY_PREFIX = "kapp_";
3
+ /** Generates a new plaintext application sync API key. Shown once, never stored. */
4
+ export function generateApplicationApiKey() {
5
+ return `${API_KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
6
+ }
7
+ /** sha256 hex digest — what actually gets stored in kontrolia.applications.api_key_hash. */
8
+ export function hashApplicationApiKey(plaintext) {
9
+ return createHash("sha256").update(plaintext).digest("hex");
10
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { migrate, type MigrateOptions } from "./migrate.js";
2
2
  export { registerApplication, type RegisterApplicationOptions, type RegisteredApplication, type PermissionInput, } from "./register-application.js";
3
+ export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAC1B,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAC1B,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { migrate } from "./migrate.js";
2
2
  export { registerApplication, } from "./register-application.js";
3
+ export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
@@ -14,15 +14,23 @@ export interface RegisterApplicationOptions {
14
14
  export interface RegisteredApplication {
15
15
  applicationId: string;
16
16
  permissionKeys: string[];
17
+ /**
18
+ * The plaintext sync API key (see POST /api/applications/sync on
19
+ * auth-server) — only present the first time this slug is registered.
20
+ * Only the hash is stored; there is no way to recover it later, so the
21
+ * caller must surface it to the operator immediately. `null` means the
22
+ * application already existed and its key (if any) was left untouched.
23
+ */
24
+ apiKey: string | null;
17
25
  }
18
26
  /**
19
27
  * Inserts an application and its permission catalog directly against
20
28
  * Postgres. kontrolia.applications/permissions have no insert policy for
21
29
  * 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.
30
+ * go through a platform-admin path), so this is that path: a direct,
31
+ * service-role-equivalent write, the same way migrate() bypasses RLS to
32
+ * apply schema changes. Safe to re-run — the slug and permission key are
33
+ * both upserted, and re-running never rotates an existing api_key_hash.
26
34
  */
27
35
  export declare function registerApplication(options: RegisterApplicationOptions): Promise<RegisteredApplication>;
28
36
  //# sourceMappingURL=register-application.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"register-application.d.ts","sourceRoot":"","sources":["../src/register-application.ts"],"names":[],"mappings":"AAGA,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;IACzB;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAkC7G"}
@@ -1,21 +1,23 @@
1
1
  import { Client } from "pg";
2
+ import { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
2
3
  /**
3
4
  * Inserts an application and its permission catalog directly against
4
5
  * Postgres. kontrolia.applications/permissions have no insert policy for
5
6
  * 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.
7
+ * go through a platform-admin path), so this is that path: a direct,
8
+ * service-role-equivalent write, the same way migrate() bypasses RLS to
9
+ * apply schema changes. Safe to re-run — the slug and permission key are
10
+ * both upserted, and re-running never rotates an existing api_key_hash.
10
11
  */
11
12
  export async function registerApplication(options) {
12
13
  const client = new Client({ connectionString: options.connectionString });
13
14
  await client.connect();
14
15
  try {
15
- const { rows: [application], } = await client.query(`insert into kontrolia.applications (name, slug, environment)
16
- values ($1, $2, $3)
16
+ const candidateApiKey = generateApplicationApiKey();
17
+ const { rows: [application], } = await client.query(`insert into kontrolia.applications (name, slug, environment, api_key_hash)
18
+ values ($1, $2, $3, $4)
17
19
  on conflict (slug) do update set name = excluded.name, environment = excluded.environment
18
- returning id`, [options.name, options.slug, options.environment]);
20
+ returning id, (xmax = 0) as inserted`, [options.name, options.slug, options.environment, hashApplicationApiKey(candidateApiKey)]);
19
21
  if (!application)
20
22
  throw new Error(`Failed to upsert application "${options.slug}"`);
21
23
  const permissionKeys = [];
@@ -26,7 +28,7 @@ export async function registerApplication(options) {
26
28
  on conflict (key) do update set description = excluded.description`, [application.id, permission.resource, permission.action, key, permission.description ?? null]);
27
29
  permissionKeys.push(key);
28
30
  }
29
- return { applicationId: application.id, permissionKeys };
31
+ return { applicationId: application.id, permissionKeys, apiKey: application.inserted ? candidateApiKey : null };
30
32
  }
31
33
  finally {
32
34
  await client.end();
@@ -0,0 +1,11 @@
1
+ -- Lets an already-registered application authenticate its own catalog-sync
2
+ -- calls (POST /api/applications/sync on auth-server) without needing direct
3
+ -- database access — the same "declare your own permissions" pattern Auth0's
4
+ -- Resource Servers / Stripe's API keys use. Only a hash is ever stored; the
5
+ -- plaintext key is generated once, shown to the operator, and never
6
+ -- persisted anywhere.
7
+
8
+ alter table kontrolia.applications add column api_key_hash text;
9
+
10
+ comment on column kontrolia.applications.api_key_hash is
11
+ 'sha256 hex digest of the application''s sync API key. Null for applications registered before this column existed, or that opted out — those can only be updated by re-running the CLI/registerApplication() directly against the database.';
@@ -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
+ $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontrolia/db",
3
- "version": "1.0.1",
3
+ "version": "1.2.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": [