@kontrolia/db 2.1.5 → 2.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,15 @@
|
|
|
1
|
+
-- Tracks the last time an application's own deploy pipeline included this
|
|
2
|
+
-- permission in a POST /api/applications/sync call. Lets admin-panel flag
|
|
3
|
+
-- permissions an app has stopped declaring (its sibling permissions synced
|
|
4
|
+
-- more recently than this one did) so a platform admin can review and
|
|
5
|
+
-- prune them explicitly — sync itself never deletes, by design, since a
|
|
6
|
+
-- partial/broken deploy shouldn't silently revoke a permission a role
|
|
7
|
+
-- already grants.
|
|
8
|
+
--
|
|
9
|
+
-- Backfilled to now() rather than left null: every existing permission
|
|
10
|
+
-- predates this column, and leaving them null would make the entire
|
|
11
|
+
-- pre-existing catalog look "never synced" the moment this migration
|
|
12
|
+
-- lands, even though most of it is actively in use. now() means nothing
|
|
13
|
+
-- looks stale until an app's sync pattern actually diverges after today.
|
|
14
|
+
alter table kontrolia_auth.permissions
|
|
15
|
+
add column last_synced_at timestamptz not null default now();
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
-- Plans, subscriptions and usage limits — the "entitlements" layer.
|
|
2
|
+
--
|
|
3
|
+
-- A plan belongs to an application and describes a commercial tier: what it
|
|
4
|
+
-- unlocks (plan_permissions — a subset of the app's own permission catalog)
|
|
5
|
+
-- and how much of it can be consumed (plan_limits — numeric quotas per
|
|
6
|
+
-- period). A subscription ties ONE organization to ONE plan of ONE
|
|
7
|
+
-- application. Organizations, not users, subscribe: the tenant is what pays,
|
|
8
|
+
-- a person can belong to several tenants with different roles, and per-seat
|
|
9
|
+
-- pricing is just a limit ("members" = 5) rather than per-user subscriptions.
|
|
10
|
+
--
|
|
11
|
+
-- Effective access = role permissions ∩ plan permissions. The access-token
|
|
12
|
+
-- hook below applies that intersection, but only for applications that opt
|
|
13
|
+
-- in with plans_required = true — every existing application keeps working
|
|
14
|
+
-- exactly as before until an operator flips that flag.
|
|
15
|
+
--
|
|
16
|
+
-- Payments themselves never live here: KontrolIA Auth brokers Stripe
|
|
17
|
+
-- (Checkout + Customer Portal + webhooks) and only stores the resulting
|
|
18
|
+
-- state and the provider's ids. Subscriptions can also be assigned
|
|
19
|
+
-- manually (free tiers, trials, enterprise customers billed by invoice).
|
|
20
|
+
|
|
21
|
+
alter table kontrolia_auth.applications
|
|
22
|
+
add column plans_required boolean not null default false,
|
|
23
|
+
add column past_due_grace_days integer not null default 7 check (past_due_grace_days >= 0);
|
|
24
|
+
|
|
25
|
+
comment on column kontrolia_auth.applications.plans_required is
|
|
26
|
+
'When true, a member only keeps the permissions of this application that the organization''s live subscription plan includes. False = plans are informational only, access is governed by roles alone.';
|
|
27
|
+
comment on column kontrolia_auth.applications.past_due_grace_days is
|
|
28
|
+
'Days after a failed renewal (status past_due) during which the organization keeps access before the subscription stops counting as live.';
|
|
29
|
+
|
|
30
|
+
-- ---------------------------------------------------------------------------
|
|
31
|
+
-- Plans
|
|
32
|
+
-- ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
create table kontrolia_auth.plans (
|
|
35
|
+
id uuid primary key default gen_random_uuid(),
|
|
36
|
+
application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
|
|
37
|
+
slug text not null check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
|
|
38
|
+
name text not null,
|
|
39
|
+
description text,
|
|
40
|
+
-- Minor units (centavos/cents), never floats.
|
|
41
|
+
price_amount integer not null default 0 check (price_amount >= 0),
|
|
42
|
+
currency text not null default 'MXN' check (currency ~ '^[A-Z]{3}$'),
|
|
43
|
+
billing_interval text not null default 'month' check (billing_interval in ('month', 'year', 'one_time')),
|
|
44
|
+
trial_days integer not null default 0 check (trial_days >= 0),
|
|
45
|
+
-- Free-form marketing bullets ("Hasta 5 usuarios", "Soporte prioritario").
|
|
46
|
+
features jsonb not null default '[]'::jsonb check (jsonb_typeof(features) = 'array'),
|
|
47
|
+
is_active boolean not null default true,
|
|
48
|
+
-- Assigned automatically (as a manual subscription) when an organization
|
|
49
|
+
-- enables the application — the "free tier". At most one per application.
|
|
50
|
+
is_default boolean not null default false,
|
|
51
|
+
sort_order integer not null default 0,
|
|
52
|
+
stripe_product_id text,
|
|
53
|
+
stripe_price_id text,
|
|
54
|
+
created_at timestamptz not null default now(),
|
|
55
|
+
updated_at timestamptz not null default now(),
|
|
56
|
+
unique (application_id, slug)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
create unique index plans_one_default_per_application on kontrolia_auth.plans (application_id) where is_default;
|
|
60
|
+
create index plans_application_id_idx on kontrolia_auth.plans (application_id);
|
|
61
|
+
|
|
62
|
+
comment on table kontrolia_auth.plans is 'Commercial tiers of an application (Startup / Pro / ...). What each unlocks lives in plan_permissions; quotas in plan_limits.';
|
|
63
|
+
|
|
64
|
+
create table kontrolia_auth.plan_permissions (
|
|
65
|
+
plan_id uuid not null references kontrolia_auth.plans (id) on delete cascade,
|
|
66
|
+
permission_id uuid not null references kontrolia_auth.permissions (id) on delete cascade,
|
|
67
|
+
primary key (plan_id, permission_id)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
comment on table kontrolia_auth.plan_permissions is 'Which of the application''s permissions a plan unlocks. Effective access for a member = role permissions ∩ this set (when the app has plans_required).';
|
|
71
|
+
|
|
72
|
+
-- A plan can only reference permissions of its own application.
|
|
73
|
+
create or replace function kontrolia_auth.enforce_plan_permission_same_application()
|
|
74
|
+
returns trigger
|
|
75
|
+
language plpgsql
|
|
76
|
+
security definer
|
|
77
|
+
set search_path = ''
|
|
78
|
+
as $$
|
|
79
|
+
declare
|
|
80
|
+
plan_app uuid;
|
|
81
|
+
permission_app uuid;
|
|
82
|
+
begin
|
|
83
|
+
select application_id into plan_app from kontrolia_auth.plans where id = new.plan_id;
|
|
84
|
+
select application_id into permission_app from kontrolia_auth.permissions where id = new.permission_id;
|
|
85
|
+
if plan_app is distinct from permission_app then
|
|
86
|
+
raise exception 'El permiso % no pertenece a la aplicación del plan %', new.permission_id, new.plan_id
|
|
87
|
+
using errcode = 'check_violation';
|
|
88
|
+
end if;
|
|
89
|
+
return new;
|
|
90
|
+
end;
|
|
91
|
+
$$;
|
|
92
|
+
|
|
93
|
+
create trigger plan_permissions_same_application
|
|
94
|
+
before insert or update on kontrolia_auth.plan_permissions
|
|
95
|
+
for each row execute function kontrolia_auth.enforce_plan_permission_same_application();
|
|
96
|
+
|
|
97
|
+
create table kontrolia_auth.plan_limits (
|
|
98
|
+
plan_id uuid not null references kontrolia_auth.plans (id) on delete cascade,
|
|
99
|
+
-- Namespaced by the application itself, e.g. "facturas.emitidas", "miembros".
|
|
100
|
+
limit_key text not null check (limit_key ~ '^[a-z0-9_.-]+$'),
|
|
101
|
+
-- NULL = unlimited for this plan (still counted, never exceeded).
|
|
102
|
+
limit_value integer check (limit_value is null or limit_value >= 0),
|
|
103
|
+
period text not null default 'month' check (period in ('day', 'month', 'year', 'lifetime')),
|
|
104
|
+
description text,
|
|
105
|
+
primary key (plan_id, limit_key)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
comment on table kontrolia_auth.plan_limits is 'Numeric quotas of a plan ("up to 100 invoices per month"). NULL limit_value = unlimited.';
|
|
109
|
+
|
|
110
|
+
-- ---------------------------------------------------------------------------
|
|
111
|
+
-- Subscriptions
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
create table kontrolia_auth.subscriptions (
|
|
115
|
+
id uuid primary key default gen_random_uuid(),
|
|
116
|
+
organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
|
|
117
|
+
application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
|
|
118
|
+
-- restrict: a plan with subscriptions can't be deleted, only deactivated.
|
|
119
|
+
plan_id uuid not null references kontrolia_auth.plans (id) on delete restrict,
|
|
120
|
+
status text not null check (status in ('trialing', 'active', 'past_due', 'canceled', 'expired')),
|
|
121
|
+
current_period_start timestamptz,
|
|
122
|
+
-- NULL = open-ended (manual subscriptions, one-time purchases).
|
|
123
|
+
current_period_end timestamptz,
|
|
124
|
+
cancel_at_period_end boolean not null default false,
|
|
125
|
+
provider text not null default 'manual' check (provider in ('manual', 'stripe')),
|
|
126
|
+
provider_customer_id text,
|
|
127
|
+
provider_subscription_id text,
|
|
128
|
+
created_by uuid,
|
|
129
|
+
created_at timestamptz not null default now(),
|
|
130
|
+
updated_at timestamptz not null default now()
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
-- One live subscription per (organization, application). Canceled/expired
|
|
134
|
+
-- rows stay as history.
|
|
135
|
+
create unique index subscriptions_one_live_per_org_app on kontrolia_auth.subscriptions (organization_id, application_id)
|
|
136
|
+
where status in ('trialing', 'active', 'past_due');
|
|
137
|
+
create unique index subscriptions_provider_subscription_id on kontrolia_auth.subscriptions (provider, provider_subscription_id)
|
|
138
|
+
where provider_subscription_id is not null;
|
|
139
|
+
create index subscriptions_org_app_idx on kontrolia_auth.subscriptions (organization_id, application_id);
|
|
140
|
+
|
|
141
|
+
comment on table kontrolia_auth.subscriptions is 'An organization''s subscription to one plan of one application. Provider-managed (Stripe) or manual.';
|
|
142
|
+
|
|
143
|
+
-- Single definition of "does this subscription currently grant access",
|
|
144
|
+
-- shared by the token hook, the entitlements API and RLS. past_due keeps
|
|
145
|
+
-- access for the application's grace window, measured from the end of the
|
|
146
|
+
-- period whose renewal failed.
|
|
147
|
+
create or replace function kontrolia_auth.subscription_is_live(s kontrolia_auth.subscriptions, grace_days integer)
|
|
148
|
+
returns boolean
|
|
149
|
+
language sql
|
|
150
|
+
stable
|
|
151
|
+
set search_path = ''
|
|
152
|
+
as $$
|
|
153
|
+
select case
|
|
154
|
+
when s.status in ('trialing', 'active') then (s.current_period_end is null or s.current_period_end > now())
|
|
155
|
+
when s.status = 'past_due' then now() < coalesce(s.current_period_end, s.updated_at) + make_interval(days => coalesce(grace_days, 0))
|
|
156
|
+
else false
|
|
157
|
+
end
|
|
158
|
+
$$;
|
|
159
|
+
|
|
160
|
+
-- The live subscription of an organization for an application, if any.
|
|
161
|
+
create or replace function kontrolia_auth.live_subscription(org_id uuid, app_id uuid)
|
|
162
|
+
returns kontrolia_auth.subscriptions
|
|
163
|
+
language sql
|
|
164
|
+
stable
|
|
165
|
+
security definer
|
|
166
|
+
set search_path = ''
|
|
167
|
+
as $$
|
|
168
|
+
select s.*
|
|
169
|
+
from kontrolia_auth.subscriptions s
|
|
170
|
+
join kontrolia_auth.applications a on a.id = s.application_id
|
|
171
|
+
where s.organization_id = org_id
|
|
172
|
+
and s.application_id = app_id
|
|
173
|
+
and s.status in ('trialing', 'active', 'past_due')
|
|
174
|
+
and kontrolia_auth.subscription_is_live(s, a.past_due_grace_days)
|
|
175
|
+
limit 1
|
|
176
|
+
$$;
|
|
177
|
+
|
|
178
|
+
-- Enabling an application for an organization automatically subscribes it
|
|
179
|
+
-- to the application's default plan (the free tier), so plans_required apps
|
|
180
|
+
-- work from the first login instead of starting locked out. A trigger, not
|
|
181
|
+
-- route code, because admin-panel enables apps with a direct client insert.
|
|
182
|
+
create or replace function kontrolia_auth.subscribe_default_plan_on_enable()
|
|
183
|
+
returns trigger
|
|
184
|
+
language plpgsql
|
|
185
|
+
security definer
|
|
186
|
+
set search_path = ''
|
|
187
|
+
as $$
|
|
188
|
+
declare
|
|
189
|
+
default_plan uuid;
|
|
190
|
+
begin
|
|
191
|
+
select id into default_plan
|
|
192
|
+
from kontrolia_auth.plans
|
|
193
|
+
where application_id = new.application_id and is_default and is_active;
|
|
194
|
+
|
|
195
|
+
if default_plan is not null and not exists (
|
|
196
|
+
select 1 from kontrolia_auth.subscriptions
|
|
197
|
+
where organization_id = new.organization_id
|
|
198
|
+
and application_id = new.application_id
|
|
199
|
+
and status in ('trialing', 'active', 'past_due')
|
|
200
|
+
) then
|
|
201
|
+
insert into kontrolia_auth.subscriptions (organization_id, application_id, plan_id, status, provider)
|
|
202
|
+
values (new.organization_id, new.application_id, default_plan, 'active', 'manual');
|
|
203
|
+
end if;
|
|
204
|
+
return new;
|
|
205
|
+
end;
|
|
206
|
+
$$;
|
|
207
|
+
|
|
208
|
+
create trigger application_organizations_subscribe_default_plan
|
|
209
|
+
after insert on kontrolia_auth.application_organizations
|
|
210
|
+
for each row execute function kontrolia_auth.subscribe_default_plan_on_enable();
|
|
211
|
+
|
|
212
|
+
create table kontrolia_auth.subscription_events (
|
|
213
|
+
id uuid primary key default gen_random_uuid(),
|
|
214
|
+
subscription_id uuid references kontrolia_auth.subscriptions (id) on delete set null,
|
|
215
|
+
organization_id uuid references kontrolia_auth.organizations (id) on delete set null,
|
|
216
|
+
application_id uuid references kontrolia_auth.applications (id) on delete set null,
|
|
217
|
+
type text not null,
|
|
218
|
+
provider text not null default 'manual',
|
|
219
|
+
-- Stripe event id; the unique index is what makes webhook delivery
|
|
220
|
+
-- idempotent (a redelivered event inserts nothing and is skipped).
|
|
221
|
+
provider_event_id text,
|
|
222
|
+
payload jsonb,
|
|
223
|
+
created_at timestamptz not null default now()
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
-- Plain (not partial) unique index: NULLs are distinct, so manual events
|
|
227
|
+
-- without a provider id never collide, and PostgREST upserts with
|
|
228
|
+
-- ignoreDuplicates can target it (partial indexes can't be an ON CONFLICT
|
|
229
|
+
-- target without their predicate).
|
|
230
|
+
create unique index subscription_events_provider_event_id on kontrolia_auth.subscription_events (provider, provider_event_id);
|
|
231
|
+
create index subscription_events_org_app_idx on kontrolia_auth.subscription_events (organization_id, application_id, created_at desc);
|
|
232
|
+
|
|
233
|
+
-- ---------------------------------------------------------------------------
|
|
234
|
+
-- Usage
|
|
235
|
+
-- ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
create table kontrolia_auth.usage_counters (
|
|
238
|
+
organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
|
|
239
|
+
application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
|
|
240
|
+
limit_key text not null,
|
|
241
|
+
-- First day of the counting window; '1970-01-01' for lifetime limits.
|
|
242
|
+
period_start date not null,
|
|
243
|
+
used integer not null default 0 check (used >= 0),
|
|
244
|
+
updated_at timestamptz not null default now(),
|
|
245
|
+
primary key (organization_id, application_id, limit_key, period_start)
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
create table kontrolia_auth.usage_events (
|
|
249
|
+
id uuid primary key default gen_random_uuid(),
|
|
250
|
+
organization_id uuid not null references kontrolia_auth.organizations (id) on delete cascade,
|
|
251
|
+
application_id uuid not null references kontrolia_auth.applications (id) on delete cascade,
|
|
252
|
+
limit_key text not null,
|
|
253
|
+
amount integer not null,
|
|
254
|
+
idempotency_key text,
|
|
255
|
+
created_at timestamptz not null default now()
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
create unique index usage_events_idempotency on kontrolia_auth.usage_events (application_id, idempotency_key);
|
|
259
|
+
|
|
260
|
+
create or replace function kontrolia_auth.usage_period_start(period text, moment timestamptz default now())
|
|
261
|
+
returns date
|
|
262
|
+
language sql
|
|
263
|
+
stable
|
|
264
|
+
set search_path = ''
|
|
265
|
+
as $$
|
|
266
|
+
select case period
|
|
267
|
+
when 'day' then (moment at time zone 'UTC')::date
|
|
268
|
+
when 'month' then date_trunc('month', moment at time zone 'UTC')::date
|
|
269
|
+
when 'year' then date_trunc('year', moment at time zone 'UTC')::date
|
|
270
|
+
else date '1970-01-01'
|
|
271
|
+
end
|
|
272
|
+
$$;
|
|
273
|
+
|
|
274
|
+
-- Atomic usage report. Resolves the organization's live plan for the app,
|
|
275
|
+
-- finds the limit (NULL limit = unlimited, still counted under a monthly
|
|
276
|
+
-- window so it can be reported), increments the counter in one statement,
|
|
277
|
+
-- and returns the resulting state. Idempotent per (application,
|
|
278
|
+
-- idempotency_key): a retried report returns the current state without
|
|
279
|
+
-- counting twice. Never blocks on its own — `exceeded` is information for
|
|
280
|
+
-- the application to act on.
|
|
281
|
+
create or replace function kontrolia_auth.record_usage(
|
|
282
|
+
org_id uuid,
|
|
283
|
+
app_id uuid,
|
|
284
|
+
key text,
|
|
285
|
+
amount integer default 1,
|
|
286
|
+
idempotency text default null
|
|
287
|
+
)
|
|
288
|
+
returns table (used integer, limit_value integer, period text, period_start date, exceeded boolean, plan_slug text)
|
|
289
|
+
language plpgsql
|
|
290
|
+
security definer
|
|
291
|
+
set search_path = ''
|
|
292
|
+
as $$
|
|
293
|
+
-- The OUT columns share names with usage_counters columns; inside SQL
|
|
294
|
+
-- statements (notably the ON CONFLICT target) the column must win.
|
|
295
|
+
#variable_conflict use_column
|
|
296
|
+
declare
|
|
297
|
+
live kontrolia_auth.subscriptions;
|
|
298
|
+
resolved_period text := 'month';
|
|
299
|
+
resolved_limit integer := null;
|
|
300
|
+
resolved_plan_slug text := null;
|
|
301
|
+
window_start date;
|
|
302
|
+
already_recorded boolean := false;
|
|
303
|
+
new_used integer;
|
|
304
|
+
begin
|
|
305
|
+
if amount < 0 then
|
|
306
|
+
raise exception 'amount debe ser >= 0' using errcode = 'check_violation';
|
|
307
|
+
end if;
|
|
308
|
+
|
|
309
|
+
live := kontrolia_auth.live_subscription(org_id, app_id);
|
|
310
|
+
if live.id is not null then
|
|
311
|
+
select pl.period, pl.limit_value, p.slug
|
|
312
|
+
into resolved_period, resolved_limit, resolved_plan_slug
|
|
313
|
+
from kontrolia_auth.plans p
|
|
314
|
+
left join kontrolia_auth.plan_limits pl on pl.plan_id = p.id and pl.limit_key = key
|
|
315
|
+
where p.id = live.plan_id;
|
|
316
|
+
resolved_period := coalesce(resolved_period, 'month');
|
|
317
|
+
end if;
|
|
318
|
+
|
|
319
|
+
window_start := kontrolia_auth.usage_period_start(resolved_period);
|
|
320
|
+
|
|
321
|
+
if idempotency is not null then
|
|
322
|
+
insert into kontrolia_auth.usage_events (organization_id, application_id, limit_key, amount, idempotency_key)
|
|
323
|
+
values (org_id, app_id, key, amount, idempotency)
|
|
324
|
+
on conflict (application_id, idempotency_key) do nothing;
|
|
325
|
+
already_recorded := not found;
|
|
326
|
+
else
|
|
327
|
+
insert into kontrolia_auth.usage_events (organization_id, application_id, limit_key, amount)
|
|
328
|
+
values (org_id, app_id, key, amount);
|
|
329
|
+
end if;
|
|
330
|
+
|
|
331
|
+
if already_recorded or amount = 0 then
|
|
332
|
+
select c.used into new_used
|
|
333
|
+
from kontrolia_auth.usage_counters c
|
|
334
|
+
where c.organization_id = org_id and c.application_id = app_id and c.limit_key = key and c.period_start = window_start;
|
|
335
|
+
new_used := coalesce(new_used, 0);
|
|
336
|
+
else
|
|
337
|
+
insert into kontrolia_auth.usage_counters as c (organization_id, application_id, limit_key, period_start, used)
|
|
338
|
+
values (org_id, app_id, key, window_start, amount)
|
|
339
|
+
on conflict (organization_id, application_id, limit_key, period_start)
|
|
340
|
+
do update set used = c.used + excluded.used, updated_at = now()
|
|
341
|
+
returning c.used into new_used;
|
|
342
|
+
end if;
|
|
343
|
+
|
|
344
|
+
return query select
|
|
345
|
+
new_used,
|
|
346
|
+
resolved_limit,
|
|
347
|
+
resolved_period,
|
|
348
|
+
window_start,
|
|
349
|
+
(resolved_limit is not null and new_used > resolved_limit),
|
|
350
|
+
resolved_plan_slug;
|
|
351
|
+
end;
|
|
352
|
+
$$;
|
|
353
|
+
|
|
354
|
+
-- ---------------------------------------------------------------------------
|
|
355
|
+
-- RLS
|
|
356
|
+
-- ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
alter table kontrolia_auth.plans enable row level security;
|
|
359
|
+
alter table kontrolia_auth.plan_permissions enable row level security;
|
|
360
|
+
alter table kontrolia_auth.plan_limits enable row level security;
|
|
361
|
+
alter table kontrolia_auth.subscriptions enable row level security;
|
|
362
|
+
alter table kontrolia_auth.subscription_events enable row level security;
|
|
363
|
+
alter table kontrolia_auth.usage_counters enable row level security;
|
|
364
|
+
alter table kontrolia_auth.usage_events enable row level security;
|
|
365
|
+
|
|
366
|
+
-- Pricing is public by nature (an app shows it to prospects) — same breadth
|
|
367
|
+
-- as the application catalog itself. All writes go through auth-server's
|
|
368
|
+
-- service-role routes (platform admin), never from a client session.
|
|
369
|
+
create policy "authenticated users can browse plans" on kontrolia_auth.plans
|
|
370
|
+
for select to authenticated using (true);
|
|
371
|
+
create policy "authenticated users can browse plan permissions" on kontrolia_auth.plan_permissions
|
|
372
|
+
for select to authenticated using (true);
|
|
373
|
+
create policy "authenticated users can browse plan limits" on kontrolia_auth.plan_limits
|
|
374
|
+
for select to authenticated using (true);
|
|
375
|
+
|
|
376
|
+
create policy "org members can view their subscriptions" on kontrolia_auth.subscriptions
|
|
377
|
+
for select to authenticated
|
|
378
|
+
using (kontrolia_auth.is_org_member(organization_id) or kontrolia_auth.is_platform_admin());
|
|
379
|
+
|
|
380
|
+
create policy "org admins can view their subscription events" on kontrolia_auth.subscription_events
|
|
381
|
+
for select to authenticated
|
|
382
|
+
using ((organization_id is not null and kontrolia_auth.is_org_admin(organization_id)) or kontrolia_auth.is_platform_admin());
|
|
383
|
+
|
|
384
|
+
create policy "org members can view their usage" on kontrolia_auth.usage_counters
|
|
385
|
+
for select to authenticated
|
|
386
|
+
using (kontrolia_auth.is_org_member(organization_id) or kontrolia_auth.is_platform_admin());
|
|
387
|
+
|
|
388
|
+
create policy "org admins can view their usage events" on kontrolia_auth.usage_events
|
|
389
|
+
for select to authenticated
|
|
390
|
+
using (kontrolia_auth.is_org_admin(organization_id) or kontrolia_auth.is_platform_admin());
|
|
391
|
+
|
|
392
|
+
-- Migration 0008's default privileges already grant authenticated/service_role
|
|
393
|
+
-- table access (RLS above is what actually scopes it); the token hook runs
|
|
394
|
+
-- as supabase_auth_admin and needs its own grants on everything it reads.
|
|
395
|
+
grant select on kontrolia_auth.applications to supabase_auth_admin;
|
|
396
|
+
grant select on kontrolia_auth.plans to supabase_auth_admin;
|
|
397
|
+
grant select on kontrolia_auth.plan_permissions to supabase_auth_admin;
|
|
398
|
+
grant select on kontrolia_auth.subscriptions to supabase_auth_admin;
|
|
399
|
+
|
|
400
|
+
-- ---------------------------------------------------------------------------
|
|
401
|
+
-- Token hook: intersect with the live plan, expose the plans claim
|
|
402
|
+
-- ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
create or replace function kontrolia_auth.custom_access_token_hook(event jsonb)
|
|
405
|
+
returns jsonb
|
|
406
|
+
language plpgsql
|
|
407
|
+
stable
|
|
408
|
+
security definer
|
|
409
|
+
set search_path = ''
|
|
410
|
+
as $$
|
|
411
|
+
declare
|
|
412
|
+
claims jsonb;
|
|
413
|
+
target_user_id uuid;
|
|
414
|
+
active_org_id uuid;
|
|
415
|
+
active_membership_id uuid;
|
|
416
|
+
role_names text[];
|
|
417
|
+
permission_keys text[];
|
|
418
|
+
platform_admin boolean;
|
|
419
|
+
plan_claims jsonb;
|
|
420
|
+
begin
|
|
421
|
+
claims := coalesce(event->'claims', '{}'::jsonb);
|
|
422
|
+
target_user_id := (event->>'user_id')::uuid;
|
|
423
|
+
|
|
424
|
+
select active_organization_id into active_org_id
|
|
425
|
+
from kontrolia_auth.sessions_context
|
|
426
|
+
where user_id = target_user_id;
|
|
427
|
+
|
|
428
|
+
if active_org_id is null then
|
|
429
|
+
select organization_id into active_org_id
|
|
430
|
+
from kontrolia_auth.memberships
|
|
431
|
+
where user_id = target_user_id and status = 'active'
|
|
432
|
+
order by created_at asc
|
|
433
|
+
limit 1;
|
|
434
|
+
end if;
|
|
435
|
+
|
|
436
|
+
if active_org_id is not null then
|
|
437
|
+
select id into active_membership_id
|
|
438
|
+
from kontrolia_auth.memberships
|
|
439
|
+
where user_id = target_user_id
|
|
440
|
+
and organization_id = active_org_id
|
|
441
|
+
and status = 'active';
|
|
442
|
+
end if;
|
|
443
|
+
|
|
444
|
+
if active_membership_id is not null then
|
|
445
|
+
select coalesce(array_agg(distinct r.slug), '{}')
|
|
446
|
+
into role_names
|
|
447
|
+
from kontrolia_auth.membership_roles mr
|
|
448
|
+
join kontrolia_auth.roles r on r.id = mr.role_id
|
|
449
|
+
where mr.membership_id = active_membership_id and r.is_system_role;
|
|
450
|
+
|
|
451
|
+
select coalesce(array_agg(distinct p.key), '{}')
|
|
452
|
+
into permission_keys
|
|
453
|
+
from (
|
|
454
|
+
select p.id, p.key
|
|
455
|
+
from kontrolia_auth.membership_roles mr
|
|
456
|
+
join kontrolia_auth.role_permissions rp on rp.role_id = mr.role_id
|
|
457
|
+
join kontrolia_auth.permissions p on p.id = rp.permission_id
|
|
458
|
+
where mr.membership_id = active_membership_id
|
|
459
|
+
union
|
|
460
|
+
select p.id, p.key
|
|
461
|
+
from kontrolia_auth.user_permissions up
|
|
462
|
+
join kontrolia_auth.permissions p on p.id = up.permission_id
|
|
463
|
+
where up.membership_id = active_membership_id and up.effect = 'allow'
|
|
464
|
+
) p
|
|
465
|
+
where not exists (
|
|
466
|
+
select 1 from kontrolia_auth.user_permissions up_deny
|
|
467
|
+
where up_deny.membership_id = active_membership_id
|
|
468
|
+
and up_deny.permission_id = p.id
|
|
469
|
+
and up_deny.effect = 'deny'
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
-- Plan gate: for applications that require a plan, keep only the
|
|
473
|
+
-- permissions the organization's live subscription plan includes.
|
|
474
|
+
-- Applications without plans_required pass through untouched.
|
|
475
|
+
select coalesce(array_agg(pk), '{}')
|
|
476
|
+
into permission_keys
|
|
477
|
+
from unnest(permission_keys) pk
|
|
478
|
+
join kontrolia_auth.permissions p on p.key = pk
|
|
479
|
+
join kontrolia_auth.applications a on a.id = p.application_id
|
|
480
|
+
where not a.plans_required
|
|
481
|
+
or exists (
|
|
482
|
+
select 1
|
|
483
|
+
from kontrolia_auth.subscriptions s
|
|
484
|
+
join kontrolia_auth.plan_permissions pp on pp.plan_id = s.plan_id and pp.permission_id = p.id
|
|
485
|
+
where s.organization_id = active_org_id
|
|
486
|
+
and s.application_id = a.id
|
|
487
|
+
and s.status in ('trialing', 'active', 'past_due')
|
|
488
|
+
and kontrolia_auth.subscription_is_live(s, a.past_due_grace_days)
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
-- { "<app slug>": "<plan slug>" } for every live subscription of the
|
|
492
|
+
-- active organization — enough for an app to show "Plan Pro" without a
|
|
493
|
+
-- round trip; limits and usage are deliberately NOT here (they change
|
|
494
|
+
-- with every use — see GET /api/entitlements).
|
|
495
|
+
select coalesce(jsonb_object_agg(a.slug, p.slug), '{}'::jsonb)
|
|
496
|
+
into plan_claims
|
|
497
|
+
from kontrolia_auth.subscriptions s
|
|
498
|
+
join kontrolia_auth.applications a on a.id = s.application_id
|
|
499
|
+
join kontrolia_auth.plans p on p.id = s.plan_id
|
|
500
|
+
where s.organization_id = active_org_id
|
|
501
|
+
and s.status in ('trialing', 'active', 'past_due')
|
|
502
|
+
and kontrolia_auth.subscription_is_live(s, a.past_due_grace_days);
|
|
503
|
+
else
|
|
504
|
+
role_names := '{}';
|
|
505
|
+
permission_keys := '{}';
|
|
506
|
+
plan_claims := '{}'::jsonb;
|
|
507
|
+
end if;
|
|
508
|
+
|
|
509
|
+
select exists(select 1 from kontrolia_auth.platform_admins where user_id = target_user_id) into platform_admin;
|
|
510
|
+
|
|
511
|
+
claims := jsonb_set(claims, '{organization_id}', coalesce(to_jsonb(active_org_id), 'null'::jsonb));
|
|
512
|
+
claims := jsonb_set(claims, '{roles}', to_jsonb(coalesce(role_names, '{}')));
|
|
513
|
+
claims := jsonb_set(claims, '{permissions}', to_jsonb(coalesce(permission_keys, '{}')));
|
|
514
|
+
claims := jsonb_set(claims, '{plans}', coalesce(plan_claims, '{}'::jsonb));
|
|
515
|
+
claims := jsonb_set(claims, '{is_platform_admin}', to_jsonb(coalesce(platform_admin, false)));
|
|
516
|
+
|
|
517
|
+
event := jsonb_set(event, '{claims}', claims);
|
|
518
|
+
return event;
|
|
519
|
+
end;
|
|
520
|
+
$$;
|
|
521
|
+
|
|
522
|
+
grant execute on function kontrolia_auth.subscription_is_live(kontrolia_auth.subscriptions, integer) to supabase_auth_admin, authenticated, service_role;
|
|
523
|
+
grant execute on function kontrolia_auth.live_subscription(uuid, uuid) to authenticated, service_role;
|
|
524
|
+
grant execute on function kontrolia_auth.record_usage(uuid, uuid, text, integer, text) to service_role;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- 0032 replaced authenticated's table-level SELECT on applications with an
|
|
2
|
+
-- explicit column list (to hide api_key_hash), and 0037 had to add
|
|
3
|
+
-- oauth_client_id to it for the same reason: any column added afterwards
|
|
4
|
+
-- is invisible to authenticated until it's granted explicitly, and
|
|
5
|
+
-- Postgres reports that as "permission denied for table applications"
|
|
6
|
+
-- on any query that names it — which is what PATCH /api/applications/[id]
|
|
7
|
+
-- and GET /api/applications hit after 0044 added these two.
|
|
8
|
+
grant select (plans_required, past_due_grace_days) on kontrolia_auth.applications to authenticated;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontrolia/db",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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": [
|