Files
MineGeo/supabase/migrations/20261012124943_developer_api_keys.sql

129 lines
8.6 KiB
PL/PgSQL

-- Developer credentials are opaque application tokens, never Supabase sessions.
-- Only the trusted server can manage/consume them; the raw token is not retained.
create table public.developer_api_keys (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles(id) on delete cascade,
name text not null check (length(btrim(name)) between 1 and 60),
prefix text not null check (prefix ~ '^[A-Za-z0-9_-]{8,24}$'),
token_hash text not null unique check (token_hash ~ '^[a-f0-9]{64}$'),
scopes text[] not null check (
cardinality(scopes) between 1 and 4 and array_ndims(scopes)=1 and
array_position(scopes,null) is null and scopes <@ array[
'maps:read','account:read','account:maps:read','account:games:read']::text[]),
created_at timestamptz not null default clock_timestamp(),
expires_at timestamptz not null,
revoked_at timestamptz,
last_used_at timestamptz,
rate_window timestamptz,
rate_count integer not null default 0 check (rate_count between 0 and 60)
);
create index developer_api_keys_owner_created on public.developer_api_keys(owner_id,created_at desc);
create table public.developer_api_owner_limits (
owner_id uuid primary key references public.profiles(id) on delete cascade,
rate_window timestamptz not null,
rate_count integer not null default 0 check (rate_count between 0 and 180)
);
alter table public.developer_api_keys enable row level security;
alter table public.developer_api_owner_limits enable row level security;
revoke all on public.developer_api_keys, public.developer_api_owner_limits from public, anon, authenticated;
grant select, insert, update, delete on public.developer_api_keys, public.developer_api_owner_limits to service_role;
create policy "Server manages developer keys" on public.developer_api_keys for all to service_role using (true) with check (true);
create policy "Server manages developer quotas" on public.developer_api_owner_limits for all to service_role using (true) with check (true);
create function public.developer_keys_list(p_owner uuid) returns jsonb
language plpgsql security invoker set search_path = '' as $$
begin
-- profiles.id references auth.users ON DELETE CASCADE, so this also checks
-- account existence without granting service_role access to auth internals.
if not exists(select 1 from public.profiles p where p.id=p_owner) then
raise exception using errcode='42501', message='Account required';
end if;
-- Bounded metadata history, with every active key retained before old records.
-- Explicit field selection deliberately excludes token_hash and quota state.
return (select coalesce(jsonb_agg(jsonb_build_object(
'id',k.id,'name',k.name,'prefix',k.prefix,'scopes',k.scopes,
'createdAt',k.created_at,'expiresAt',k.expires_at,'revokedAt',k.revoked_at,'lastUsedAt',k.last_used_at
) order by k.active desc,k.created_at desc,k.id desc),'[]'::jsonb) from (
select d.*, (d.revoked_at is null and d.expires_at>clock_timestamp()) as active
from public.developer_api_keys d where d.owner_id=p_owner
order by active desc,d.created_at desc,d.id desc limit 100
) k);
end; $$;
create function public.developer_key_create(p_owner uuid,p_name text,p_scopes text[],p_token_hash text,p_prefix text,p_expires_days integer) returns jsonb
language plpgsql security invoker set search_path = '' as $$
declare k public.developer_api_keys; normalized_scopes text[];
begin
if p_name is null or length(btrim(p_name)) not between 1 and 60
or p_token_hash is null or p_token_hash !~ '^[a-f0-9]{64}$'
or p_prefix is null or p_prefix !~ '^[A-Za-z0-9_-]{8,24}$'
or p_expires_days is null or p_expires_days not between 1 and 365 then
raise exception using errcode='22023', message='Invalid API key settings';
end if;
if p_scopes is null or cardinality(p_scopes) not between 1 and 4 or array_ndims(p_scopes)<>1
or array_position(p_scopes,null) is not null or not p_scopes <@ array[
'maps:read','account:read','account:maps:read','account:games:read']::text[] then
raise exception using errcode='22023', message='Invalid API key scopes';
end if;
select array_agg(distinct s order by s) into normalized_scopes from unnest(p_scopes) s;
-- A single owner lock serializes both key creation and quota consumption.
perform p.id from public.profiles p where p.id=p_owner for update;
if not found then raise exception using errcode='42501', message='Account required'; end if;
if (select count(*) from public.developer_api_keys where owner_id=p_owner and revoked_at is null and expires_at>clock_timestamp())>=5 then
raise exception using errcode='22023', message='Maximum 5 active API keys';
end if;
insert into public.developer_api_keys(owner_id,name,prefix,token_hash,scopes,expires_at)
values(p_owner,btrim(p_name),p_prefix,p_token_hash,normalized_scopes,clock_timestamp()+make_interval(days=>p_expires_days)) returning * into k;
return jsonb_build_object('id',k.id,'name',k.name,'prefix',k.prefix,'scopes',k.scopes,
'createdAt',k.created_at,'expiresAt',k.expires_at,'revokedAt',k.revoked_at,'lastUsedAt',k.last_used_at);
end; $$;
create function public.developer_key_revoke(p_owner uuid,p_key uuid) returns boolean
language plpgsql security invoker set search_path = '' as $$
begin
perform p.id from public.profiles p where p.id=p_owner for update;
if not found then raise exception using errcode='42501', message='Account required'; end if;
update public.developer_api_keys set revoked_at=coalesce(revoked_at,clock_timestamp()) where owner_id=p_owner and id=p_key;
return found;
end; $$;
create function public.developer_key_consume(p_token_hash text) returns jsonb
language plpgsql security invoker set search_path = '' set statement_timeout = '10s' as $$
declare k public.developer_api_keys; limits public.developer_api_owner_limits;
owner uuid; checked_at timestamptz; window_at timestamptz; reset_at bigint; key_count integer; owner_count integer;
begin
if p_token_hash is null or p_token_hash !~ '^[a-f0-9]{64}$' then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
select owner_id into owner from public.developer_api_keys where token_hash=p_token_hash;
if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
-- Every consume/create/revoke takes the same owner lock first, then the key.
-- This ensures concurrent keys cannot overspend a shared account allowance.
perform p.id from public.profiles p where p.id=owner for update;
if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
checked_at:=clock_timestamp(); window_at:=date_trunc('minute',checked_at);
reset_at:=extract(epoch from window_at+interval '1 minute')::bigint;
select * into k from public.developer_api_keys where token_hash=p_token_hash and owner_id=owner
and revoked_at is null and expires_at>checked_at for update;
if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
insert into public.developer_api_owner_limits(owner_id,rate_window) values(owner,window_at) on conflict(owner_id) do nothing;
select * into limits from public.developer_api_owner_limits where owner_id=owner for update;
key_count:=case when k.rate_window=window_at then k.rate_count else 0 end;
owner_count:=case when limits.rate_window=window_at then limits.rate_count else 0 end;
if key_count>=60 or owner_count>=180 then
return jsonb_build_object('ok',false,'error','rate_limited','limit',60,'remaining',0,
'ownerLimit',180,'ownerRemaining',greatest(180-owner_count,0),'resetAt',reset_at);
end if;
update public.developer_api_keys set rate_window=window_at,rate_count=key_count+1,last_used_at=checked_at where id=k.id;
update public.developer_api_owner_limits set rate_window=window_at,rate_count=owner_count+1 where owner_id=owner;
return jsonb_build_object('ok',true,'keyId',k.id,'ownerId',owner,'scopes',k.scopes,
'limit',60,'remaining',59-key_count,'ownerLimit',180,'ownerRemaining',179-owner_count,'resetAt',reset_at);
end; $$;
revoke all on function public.developer_keys_list(uuid) from public, anon, authenticated;
revoke all on function public.developer_key_create(uuid,text,text[],text,text,integer) from public, anon, authenticated;
revoke all on function public.developer_key_revoke(uuid,uuid) from public, anon, authenticated;
revoke all on function public.developer_key_consume(text) from public, anon, authenticated;
grant execute on function public.developer_keys_list(uuid) to service_role;
grant execute on function public.developer_key_create(uuid,text,text[],text,text,integer) to service_role;
grant execute on function public.developer_key_revoke(uuid,uuid) to service_role;
grant execute on function public.developer_key_consume(text) to service_role;