@kontrolia/db 2.1.2 → 2.1.4
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/dist/api-key.d.ts +3 -1
- package/dist/api-key.d.ts.map +1 -1
- package/dist/api-key.js +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/migrations/0037_grant_oauth_client_id_select.sql +19 -0
- package/migrations/0038_role_and_role_permissions_audit_triggers.sql +84 -0
- package/migrations/0039_instance_settings.sql +54 -0
- package/migrations/0040_application_api_keys.sql +171 -0
- package/migrations/0041_mcp_oauth_client.sql +14 -0
- package/migrations/0042_supabase_oauth_connection.sql +32 -0
- package/package.json +1 -1
package/dist/api-key.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Generates a new plaintext application sync API key. Shown once, never stored. */
|
|
2
2
|
export declare function generateApplicationApiKey(): string;
|
|
3
|
-
/** sha256 hex digest — what actually gets stored
|
|
3
|
+
/** sha256 hex digest — what actually gets stored as application_api_keys.key_hash. */
|
|
4
4
|
export declare function hashApplicationApiKey(plaintext: string): string;
|
|
5
|
+
/** First few characters after the prefix, stored alongside the hash purely so a key list UI can show "kapp_a1b2c3…" without ever persisting (or being able to redisplay) the full secret. */
|
|
6
|
+
export declare function applicationApiKeyDisplayPrefix(plaintext: string): string;
|
|
5
7
|
//# sourceMappingURL=api-key.d.ts.map
|
package/dist/api-key.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-key.d.ts","sourceRoot":"","sources":["../src/api-key.ts"],"names":[],"mappings":"AAIA,oFAAoF;AACpF,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,
|
|
1
|
+
{"version":3,"file":"api-key.d.ts","sourceRoot":"","sources":["../src/api-key.ts"],"names":[],"mappings":"AAIA,oFAAoF;AACpF,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,sFAAsF;AACtF,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,6LAA6L;AAC7L,wBAAgB,8BAA8B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExE"}
|
package/dist/api-key.js
CHANGED
|
@@ -4,7 +4,11 @@ const API_KEY_PREFIX = "kapp_";
|
|
|
4
4
|
export function generateApplicationApiKey() {
|
|
5
5
|
return `${API_KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
|
|
6
6
|
}
|
|
7
|
-
/** sha256 hex digest — what actually gets stored
|
|
7
|
+
/** sha256 hex digest — what actually gets stored as application_api_keys.key_hash. */
|
|
8
8
|
export function hashApplicationApiKey(plaintext) {
|
|
9
9
|
return createHash("sha256").update(plaintext).digest("hex");
|
|
10
10
|
}
|
|
11
|
+
/** First few characters after the prefix, stored alongside the hash purely so a key list UI can show "kapp_a1b2c3…" without ever persisting (or being able to redisplay) the full secret. */
|
|
12
|
+
export function applicationApiKeyDisplayPrefix(plaintext) {
|
|
13
|
+
return plaintext.slice(0, API_KEY_PREFIX.length + 6);
|
|
14
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { migrate, type MigrateOptions } from "./migrate.js";
|
|
2
2
|
export { grantPlatformAdmin, type GrantPlatformAdminOptions, type GrantPlatformAdminResult } from "./grant-platform-admin.js";
|
|
3
3
|
export { registerApplication, type RegisterApplicationOptions, type RegisteredApplication, type PermissionInput, } from "./register-application.js";
|
|
4
|
-
export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
|
|
4
|
+
export { generateApplicationApiKey, hashApplicationApiKey, applicationApiKeyDisplayPrefix } from "./api-key.js";
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,EAAE,kBAAkB,EAAE,KAAK,yBAAyB,EAAE,KAAK,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC9H,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"}
|
|
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,EAAE,kBAAkB,EAAE,KAAK,yBAAyB,EAAE,KAAK,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC9H,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAC1B,KAAK,eAAe,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,8BAA8B,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { migrate } from "./migrate.js";
|
|
2
2
|
export { grantPlatformAdmin } from "./grant-platform-admin.js";
|
|
3
3
|
export { registerApplication, } from "./register-application.js";
|
|
4
|
-
export { generateApplicationApiKey, hashApplicationApiKey } from "./api-key.js";
|
|
4
|
+
export { generateApplicationApiKey, hashApplicationApiKey, applicationApiKeyDisplayPrefix } from "./api-key.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Migration 0036 added applications.oauth_client_id but forgot the
|
|
2
|
+
-- column-level grant migration 0032 made mandatory for any new column on
|
|
3
|
+
-- this table: 0032 revoked the old blanket `grant select on all tables to
|
|
4
|
+
-- authenticated` and replaced it with an explicit per-column list, so a
|
|
5
|
+
-- column added later without its own grant is invisible to `authenticated`
|
|
6
|
+
-- — worse than invisible, actually: PostgREST's single SELECT statement
|
|
7
|
+
-- fails outright with "permission denied for table applications" the
|
|
8
|
+
-- moment oauth_client_id is included alongside the columns that ARE
|
|
9
|
+
-- granted, taking the whole query down with it. Live-reproduced against
|
|
10
|
+
-- production: apps/admin-panel's applications page select list includes
|
|
11
|
+
-- oauth_client_id unconditionally, so every authenticated user's query
|
|
12
|
+
-- failed and the entire "Aplicaciones" list rendered empty since 0036
|
|
13
|
+
-- shipped — not a caching or migration-timing issue, a missing grant.
|
|
14
|
+
--
|
|
15
|
+
-- Not sensitive like api_key_hash (a GoTrue client_id, not a secret), so
|
|
16
|
+
-- it belongs in the same broadly-readable column set as
|
|
17
|
+
-- owner_organization_id/homepage_url/etc., not restricted to service_role.
|
|
18
|
+
|
|
19
|
+
grant select (oauth_client_id) on kontrolia_auth.applications to authenticated;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
-- 0013/0024 established "the database logs it, not application code" for
|
|
2
|
+
-- every other mutation surface, but roles and role_permissions were never
|
|
3
|
+
-- covered — creating or deleting a custom role, or granting/revoking a
|
|
4
|
+
-- permission on one, has produced zero audit trail since day one. Found
|
|
5
|
+
-- while designing API + MCP routes for role management: those routes must
|
|
6
|
+
-- not become the first and only place these actions get logged, since the
|
|
7
|
+
-- whole point of trigger-based auditing is that it keeps recording
|
|
8
|
+
-- regardless of which caller (browser, API, MCP, Studio/psql) made the
|
|
9
|
+
-- change.
|
|
10
|
+
--
|
|
11
|
+
-- These fire for system-role rows too (the auto-created "Administrador de
|
|
12
|
+
-- <app>" role, and its auto-synced permission set) — same as every other
|
|
13
|
+
-- trigger here, the log reflects what actually happened in the database,
|
|
14
|
+
-- not just human-initiated changes. actor_user_id is auth.uid(), which is
|
|
15
|
+
-- null for those system-triggered inserts; that's expected and matches how
|
|
16
|
+
-- 0013's own device.revoked/membership.created triggers already behave.
|
|
17
|
+
|
|
18
|
+
create or replace function kontrolia_auth.log_role_change()
|
|
19
|
+
returns trigger
|
|
20
|
+
language plpgsql
|
|
21
|
+
security definer
|
|
22
|
+
set search_path = ''
|
|
23
|
+
as $$
|
|
24
|
+
begin
|
|
25
|
+
if tg_op = 'INSERT' then
|
|
26
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
27
|
+
values (
|
|
28
|
+
new.organization_id, auth.uid(), 'role.created', 'role', new.id::text,
|
|
29
|
+
jsonb_build_object('name', new.name, 'slug', new.slug, 'application_id', new.application_id, 'is_system_role', new.is_system_role)
|
|
30
|
+
);
|
|
31
|
+
return new;
|
|
32
|
+
else
|
|
33
|
+
if old.organization_id is null or exists (select 1 from kontrolia_auth.organizations where id = old.organization_id) then
|
|
34
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
35
|
+
values (
|
|
36
|
+
old.organization_id, auth.uid(), 'role.deleted', 'role', old.id::text,
|
|
37
|
+
jsonb_build_object('name', old.name, 'slug', old.slug, 'application_id', old.application_id)
|
|
38
|
+
);
|
|
39
|
+
end if;
|
|
40
|
+
return old;
|
|
41
|
+
end if;
|
|
42
|
+
end;
|
|
43
|
+
$$;
|
|
44
|
+
|
|
45
|
+
create trigger audit_role_change
|
|
46
|
+
after insert or delete on kontrolia_auth.roles
|
|
47
|
+
for each row execute function kontrolia_auth.log_role_change();
|
|
48
|
+
|
|
49
|
+
create or replace function kontrolia_auth.log_role_permission_change()
|
|
50
|
+
returns trigger
|
|
51
|
+
language plpgsql
|
|
52
|
+
security definer
|
|
53
|
+
set search_path = ''
|
|
54
|
+
as $$
|
|
55
|
+
declare
|
|
56
|
+
target_role record;
|
|
57
|
+
begin
|
|
58
|
+
select organization_id, name, slug into target_role
|
|
59
|
+
from kontrolia_auth.roles
|
|
60
|
+
where id = coalesce(new.role_id, old.role_id);
|
|
61
|
+
|
|
62
|
+
if tg_op = 'INSERT' then
|
|
63
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
64
|
+
values (
|
|
65
|
+
target_role.organization_id, auth.uid(), 'role_permission.granted', 'role', new.role_id::text,
|
|
66
|
+
jsonb_build_object('role_slug', target_role.slug, 'permission_id', new.permission_id)
|
|
67
|
+
);
|
|
68
|
+
return new;
|
|
69
|
+
else
|
|
70
|
+
if target_role.organization_id is null or exists (select 1 from kontrolia_auth.organizations where id = target_role.organization_id) then
|
|
71
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
72
|
+
values (
|
|
73
|
+
target_role.organization_id, auth.uid(), 'role_permission.revoked', 'role', old.role_id::text,
|
|
74
|
+
jsonb_build_object('role_slug', target_role.slug, 'permission_id', old.permission_id)
|
|
75
|
+
);
|
|
76
|
+
end if;
|
|
77
|
+
return old;
|
|
78
|
+
end if;
|
|
79
|
+
end;
|
|
80
|
+
$$;
|
|
81
|
+
|
|
82
|
+
create trigger audit_role_permission_change
|
|
83
|
+
after insert or delete on kontrolia_auth.role_permissions
|
|
84
|
+
for each row execute function kontrolia_auth.log_role_permission_change();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- Instance-wide branding/behavior settings for auth-server's hosted auth
|
|
2
|
+
-- screens (login, register, forgot-password, reset-password, verify-email,
|
|
3
|
+
-- MFA challenge, invitation accept, OAuth consent — everywhere AuthShell
|
|
4
|
+
-- renders). A single row, not per-organization: this installation's
|
|
5
|
+
-- self-hosted auth UI has one look, controlled by a platform admin from
|
|
6
|
+
-- admin-panel, same way there's one GoTrue project per installation.
|
|
7
|
+
--
|
|
8
|
+
-- registration_enabled: when false, auth-server hides "Crear cuenta" and
|
|
9
|
+
-- /register redirects back to /login — the only way to add a new person is
|
|
10
|
+
-- an Owner/Admin inviting them from admin-panel. theme forces light/dark
|
|
11
|
+
-- regardless of the browser's prefers-color-scheme, or 'system' (default)
|
|
12
|
+
-- to keep following it. button_color overrides --k-primary. logo_url
|
|
13
|
+
-- replaces the "K" mark + wordmark when set.
|
|
14
|
+
create table kontrolia_auth.instance_settings (
|
|
15
|
+
id boolean primary key default true,
|
|
16
|
+
registration_enabled boolean not null default true,
|
|
17
|
+
theme text not null default 'system' check (theme in ('light', 'dark', 'system')),
|
|
18
|
+
button_color text check (button_color is null or button_color ~ '^#[0-9a-fA-F]{6}$'),
|
|
19
|
+
logo_url text,
|
|
20
|
+
updated_at timestamptz not null default now(),
|
|
21
|
+
updated_by uuid references auth.users (id) on delete set null,
|
|
22
|
+
constraint instance_settings_singleton check (id)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
comment on table kontrolia_auth.instance_settings is 'Singleton row (id is always true) — instance-wide branding/behavior for auth-server''s hosted auth screens.';
|
|
26
|
+
|
|
27
|
+
insert into kontrolia_auth.instance_settings (id) values (true);
|
|
28
|
+
|
|
29
|
+
alter table kontrolia_auth.instance_settings enable row level security;
|
|
30
|
+
|
|
31
|
+
-- Public read: auth-server's login/register/etc. pages render this before
|
|
32
|
+
-- anyone is authenticated, and none of these fields are sensitive.
|
|
33
|
+
create policy "anyone can read instance settings" on kontrolia_auth.instance_settings
|
|
34
|
+
for select using (true);
|
|
35
|
+
|
|
36
|
+
-- Writes still go through auth-server's API routes with a service-role
|
|
37
|
+
-- client (same Model B pattern as platform-admins/oauth-clients), which
|
|
38
|
+
-- bypasses RLS — this policy is defense-in-depth for any direct client use.
|
|
39
|
+
create policy "platform admins can update instance settings" on kontrolia_auth.instance_settings
|
|
40
|
+
for update using (exists (select 1 from kontrolia_auth.platform_admins pa where pa.user_id = auth.uid()));
|
|
41
|
+
|
|
42
|
+
grant select on kontrolia_auth.instance_settings to anon, authenticated;
|
|
43
|
+
grant update on kontrolia_auth.instance_settings to authenticated;
|
|
44
|
+
|
|
45
|
+
-- Logo uploads. Public bucket: served via Supabase's unauthenticated
|
|
46
|
+
-- /storage/v1/object/public/branding/... path, so auth-server's own
|
|
47
|
+
-- unauthenticated login screen can just <img src> it directly — no signed
|
|
48
|
+
-- URLs, no extra round-trip. Uploads/deletes only ever happen server-side
|
|
49
|
+
-- through auth-server's service-role client (same as every other
|
|
50
|
+
-- platform-admin-gated mutation in this codebase), so no storage.objects
|
|
51
|
+
-- RLS policies are needed for writes.
|
|
52
|
+
insert into storage.buckets (id, name, public)
|
|
53
|
+
values ('branding', 'branding', true)
|
|
54
|
+
on conflict (id) do nothing;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
-- Replaces the single kapp_ key on kontrolia_auth.applications with a real
|
|
2
|
+
-- multi-key model: an application can have any number of named, revocable,
|
|
3
|
+
-- optionally-expiring keys, each independently scoped to whichever
|
|
4
|
+
-- organization it was generated for — not necessarily the application's
|
|
5
|
+
-- owner_organization_id. A tenant organization that merely enabled an
|
|
6
|
+
-- application (application_organizations) can mint its own key to call
|
|
7
|
+
-- that application's members API on its own behalf, without needing the
|
|
8
|
+
-- owning organization's involvement at all. The application's own global
|
|
9
|
+
-- permission catalog (POST /api/applications/sync) still accepts any valid,
|
|
10
|
+
-- non-revoked, non-expired key for the application regardless of which org
|
|
11
|
+
-- it's scoped to — syncing permissions isn't an org-scoped operation.
|
|
12
|
+
create table kontrolia_auth.application_api_keys (
|
|
13
|
+
id uuid primary key default gen_random_uuid(),
|
|
14
|
+
application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
|
|
15
|
+
organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
|
|
16
|
+
name text not null,
|
|
17
|
+
key_hash text not null,
|
|
18
|
+
-- First several characters of the plaintext, purely so the list UI can
|
|
19
|
+
-- show "kapp_a1b2c3…" to help an operator tell keys apart without ever
|
|
20
|
+
-- storing (or being able to show again) the full secret. Null for rows
|
|
21
|
+
-- backfilled from the old single-key column below, where no prefix was
|
|
22
|
+
-- ever recorded.
|
|
23
|
+
key_prefix text,
|
|
24
|
+
last_used_at timestamptz,
|
|
25
|
+
expires_at timestamptz,
|
|
26
|
+
revoked_at timestamptz,
|
|
27
|
+
revoked_by uuid references auth.users (id) on delete set null,
|
|
28
|
+
created_by uuid references auth.users (id) on delete set null,
|
|
29
|
+
created_at timestamptz not null default now()
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
comment on table kontrolia_auth.application_api_keys is 'Named, independently-scoped, revocable/expiring kapp_ keys — replaces the single applications.api_key_hash. organization_id is whichever org the key was generated for (must have application_id enabled), not necessarily the application''s owner.';
|
|
33
|
+
|
|
34
|
+
-- 0016's own "platform admins can view the platform admin list" SELECT
|
|
35
|
+
-- policy on platform_admins queries platform_admins from inside its own
|
|
36
|
+
-- policy — harmless as long as the only caller is service_role (which
|
|
37
|
+
-- bypasses RLS entirely, the only way this table has ever actually been
|
|
38
|
+
-- read in this codebase before now). The moment ANY other RLS policy
|
|
39
|
+
-- (this migration's, below) checks platform-admin status via a plain
|
|
40
|
+
-- `exists (select 1 from platform_admins ...)` under a real (non-service-role)
|
|
41
|
+
-- caller, Postgres has to evaluate platform_admins' own SELECT policy for
|
|
42
|
+
-- that subquery — which itself queries platform_admins — genuine infinite
|
|
43
|
+
-- recursion ("infinite recursion detected in policy for relation
|
|
44
|
+
-- platform_admins"), live-reproduced while building this migration's own
|
|
45
|
+
-- policies below. A SECURITY DEFINER function (same escape hatch
|
|
46
|
+
-- is_org_admin/is_org_owner already use for memberships/roles) runs with
|
|
47
|
+
-- the function owner's privileges, bypassing the caller's own RLS on the
|
|
48
|
+
-- table it queries — no recursion.
|
|
49
|
+
create or replace function kontrolia_auth.is_platform_admin()
|
|
50
|
+
returns boolean
|
|
51
|
+
language sql
|
|
52
|
+
stable
|
|
53
|
+
security definer
|
|
54
|
+
set search_path = ''
|
|
55
|
+
as $$
|
|
56
|
+
select exists (select 1 from kontrolia_auth.platform_admins pa where pa.user_id = auth.uid());
|
|
57
|
+
$$;
|
|
58
|
+
|
|
59
|
+
-- Retrofit 0016's policy onto the same function, closing the recursion risk
|
|
60
|
+
-- there too — not just in the new policies below that happened to expose it.
|
|
61
|
+
drop policy if exists "platform admins can view the platform admin list" on kontrolia_auth.platform_admins;
|
|
62
|
+
create policy "platform admins can view the platform admin list" on kontrolia_auth.platform_admins
|
|
63
|
+
for select using (kontrolia_auth.is_platform_admin());
|
|
64
|
+
|
|
65
|
+
alter table kontrolia_auth.application_api_keys enable row level security;
|
|
66
|
+
|
|
67
|
+
create policy "org admins can view their org's application keys" on kontrolia_auth.application_api_keys
|
|
68
|
+
for select using (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin());
|
|
69
|
+
|
|
70
|
+
create policy "org admins can create application keys for their org" on kontrolia_auth.application_api_keys
|
|
71
|
+
for insert
|
|
72
|
+
with check (
|
|
73
|
+
(kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin())
|
|
74
|
+
and exists (
|
|
75
|
+
select 1 from kontrolia_auth.application_organizations ao
|
|
76
|
+
where ao.application_id = application_api_keys.application_id
|
|
77
|
+
and ao.organization_id = application_api_keys.organization_id
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
-- Keys are only ever revoked, never edited or hard-deleted — this policy
|
|
82
|
+
-- only needs to authorize the "set revoked_at/revoked_by" update, which is
|
|
83
|
+
-- everything the API route ever writes here.
|
|
84
|
+
create policy "org admins can revoke their org's application keys" on kontrolia_auth.application_api_keys
|
|
85
|
+
for update using (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin())
|
|
86
|
+
with check (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin());
|
|
87
|
+
|
|
88
|
+
-- Column-level ACL, same reasoning as 0032 for the column this replaces:
|
|
89
|
+
-- key_hash must never be readable by any authenticated user for any
|
|
90
|
+
-- organization. The auth-server route that matches a caller's Bearer key
|
|
91
|
+
-- against these rows runs as service_role, which needs its own explicit
|
|
92
|
+
-- grant — column privileges aren't inherited from a table-level grant.
|
|
93
|
+
grant select (
|
|
94
|
+
id, application_id, organization_id, name, key_prefix, last_used_at, expires_at, revoked_at, revoked_by, created_by, created_at
|
|
95
|
+
) on kontrolia_auth.application_api_keys to authenticated;
|
|
96
|
+
grant insert (application_id, organization_id, name, key_hash, key_prefix, expires_at, created_by) on kontrolia_auth.application_api_keys to authenticated;
|
|
97
|
+
grant update (revoked_at, revoked_by) on kontrolia_auth.application_api_keys to authenticated;
|
|
98
|
+
grant select (id, application_id, organization_id, key_hash) on kontrolia_auth.application_api_keys to service_role;
|
|
99
|
+
|
|
100
|
+
-- Same "the database logs it" pattern as every other mutation surface
|
|
101
|
+
-- (0013, 0024, 0032, 0038).
|
|
102
|
+
create or replace function kontrolia_auth.log_application_api_key_lifecycle()
|
|
103
|
+
returns trigger
|
|
104
|
+
language plpgsql
|
|
105
|
+
security definer
|
|
106
|
+
set search_path = ''
|
|
107
|
+
as $$
|
|
108
|
+
begin
|
|
109
|
+
if tg_op = 'INSERT' then
|
|
110
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
111
|
+
values (
|
|
112
|
+
new.organization_id, auth.uid(), 'application_api_key.created', 'application', new.application_id::text,
|
|
113
|
+
jsonb_build_object('keyId', new.id, 'name', new.name, 'expiresAt', new.expires_at)
|
|
114
|
+
);
|
|
115
|
+
return new;
|
|
116
|
+
end if;
|
|
117
|
+
|
|
118
|
+
if old.revoked_at is null and new.revoked_at is not null then
|
|
119
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
120
|
+
values (
|
|
121
|
+
new.organization_id, auth.uid(), 'application_api_key.revoked', 'application', new.application_id::text,
|
|
122
|
+
jsonb_build_object('keyId', new.id, 'name', new.name)
|
|
123
|
+
);
|
|
124
|
+
end if;
|
|
125
|
+
return new;
|
|
126
|
+
end;
|
|
127
|
+
$$;
|
|
128
|
+
|
|
129
|
+
create trigger audit_application_api_key_lifecycle
|
|
130
|
+
after insert or update on kontrolia_auth.application_api_keys
|
|
131
|
+
for each row execute function kontrolia_auth.log_application_api_key_lifecycle();
|
|
132
|
+
|
|
133
|
+
-- Carry forward every application's existing single key as its first named
|
|
134
|
+
-- key, scoped to the same organization that already administered it
|
|
135
|
+
-- (owner_organization_id — the only organization the old RLS policy ever
|
|
136
|
+
-- let touch it).
|
|
137
|
+
insert into kontrolia_auth.application_api_keys (application_id, organization_id, name, key_hash, last_used_at, created_at)
|
|
138
|
+
select id, owner_organization_id, 'Clave original', api_key_hash, api_key_last_used_at, created_at
|
|
139
|
+
from kontrolia_auth.applications
|
|
140
|
+
where api_key_hash is not null and owner_organization_id is not null;
|
|
141
|
+
|
|
142
|
+
-- log_application_api_key_change() (0032, extended by 0034) did double duty:
|
|
143
|
+
-- logging api_key_hash changes AND ownership changes on kontrolia_auth.applications.
|
|
144
|
+
-- The key-lifecycle half moves to the new table's own trigger above; redefine
|
|
145
|
+
-- this one (same name, same trigger — a transparent swap, no drop/recreate
|
|
146
|
+
-- needed) to keep only what it's now solely responsible for.
|
|
147
|
+
create or replace function kontrolia_auth.log_application_api_key_change()
|
|
148
|
+
returns trigger
|
|
149
|
+
language plpgsql
|
|
150
|
+
security definer
|
|
151
|
+
set search_path = ''
|
|
152
|
+
as $$
|
|
153
|
+
begin
|
|
154
|
+
if old.owner_organization_id is distinct from new.owner_organization_id then
|
|
155
|
+
insert into kontrolia_auth.audit_logs (organization_id, actor_user_id, action, target_type, target_id, metadata)
|
|
156
|
+
values (
|
|
157
|
+
new.owner_organization_id,
|
|
158
|
+
auth.uid(),
|
|
159
|
+
case when old.owner_organization_id is null then 'application.ownership_claimed' else 'application.ownership_transferred' end,
|
|
160
|
+
'application',
|
|
161
|
+
new.id::text,
|
|
162
|
+
jsonb_build_object('slug', new.slug, 'previous_owner_organization_id', old.owner_organization_id)
|
|
163
|
+
);
|
|
164
|
+
end if;
|
|
165
|
+
|
|
166
|
+
return new;
|
|
167
|
+
end;
|
|
168
|
+
$$;
|
|
169
|
+
|
|
170
|
+
alter table kontrolia_auth.applications drop column api_key_hash;
|
|
171
|
+
alter table kontrolia_auth.applications drop column api_key_last_used_at;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- Tracks the single, auto-provisioned, non-deletable OAuth client reserved
|
|
2
|
+
-- for MCP agent connections (Claude Code, Claude Desktop, ChatGPT
|
|
3
|
+
-- connectors, ...). Registering a client OAuth was previously only
|
|
4
|
+
-- reachable through an application's own "Cliente OAuth" dialog — correct
|
|
5
|
+
-- for genuine app SSO, but a modeling mismatch for a generic agent that
|
|
6
|
+
-- isn't "logging in as" any one catalogued application. NULL until the
|
|
7
|
+
-- first visit to admin-panel's "Clientes OAuth" screen bootstraps it (see
|
|
8
|
+
-- POST /api/oauth-clients/mcp-bootstrap) — there's no way to create it from
|
|
9
|
+
-- a plain SQL migration, since GoTrue's OAuth clients live behind its own
|
|
10
|
+
-- admin HTTP API, not a table this schema can INSERT into directly.
|
|
11
|
+
alter table kontrolia_auth.instance_settings add column mcp_oauth_client_id text;
|
|
12
|
+
|
|
13
|
+
comment on column kontrolia_auth.instance_settings.mcp_oauth_client_id is
|
|
14
|
+
'GoTrue client_id of the reserved MCP OAuth client, once bootstrapped. Not a secret (client_id is public in the OAuth flow) — safe to expose via the same public GET /api/instance-settings as everything else on this row.';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Stores the OAuth 2.1 tokens for KontrolIA Auth's own connection to
|
|
2
|
+
-- Supabase's Management API (a "Supabase OAuth App" a platform admin
|
|
3
|
+
-- authorizes once, from admin-panel's Social login screen) — the
|
|
4
|
+
-- self-renewing alternative to pasting a Personal Access Token that
|
|
5
|
+
-- silently expires. access_token is short-lived; refresh_token lets
|
|
6
|
+
-- auth-server renew it on its own, forever, until the connection is
|
|
7
|
+
-- revoked (from here, or from the user's own Supabase account).
|
|
8
|
+
--
|
|
9
|
+
-- Deliberately NOT modeled like instance_settings (public-read singleton):
|
|
10
|
+
-- RLS is enabled with zero policies, and there is no grant to anon/
|
|
11
|
+
-- authenticated at all beyond the schema's blanket default privileges from
|
|
12
|
+
-- migration 0008 — with RLS enabled and no permissive policy, that default
|
|
13
|
+
-- grant still resolves to zero rows/denied for every command, for every
|
|
14
|
+
-- role except service_role (which bypasses RLS entirely). This table's
|
|
15
|
+
-- contents are account-wide-privileged Supabase credentials, meant to be
|
|
16
|
+
-- reachable ONLY from auth-server's own service-role client — never via
|
|
17
|
+
-- PostgREST under any authenticated session, no matter how the caller got
|
|
18
|
+
-- there.
|
|
19
|
+
create table kontrolia_auth.supabase_oauth_connection (
|
|
20
|
+
id boolean primary key default true,
|
|
21
|
+
access_token text not null,
|
|
22
|
+
refresh_token text not null,
|
|
23
|
+
expires_at timestamptz not null,
|
|
24
|
+
connected_by uuid references auth.users (id) on delete set null,
|
|
25
|
+
connected_at timestamptz not null default now(),
|
|
26
|
+
updated_at timestamptz not null default now(),
|
|
27
|
+
constraint supabase_oauth_connection_singleton check (id)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
comment on table kontrolia_auth.supabase_oauth_connection is 'Singleton row (id is always true) — OAuth tokens for this installation''s connection to Supabase''s Management API. service_role only, never exposed via PostgREST.';
|
|
31
|
+
|
|
32
|
+
alter table kontrolia_auth.supabase_oauth_connection enable row level security;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontrolia/db",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
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": [
|