geocrop-platform./apps/nextgen/sql/20260729_superadmin_rpc_fun...

144 lines
4.7 KiB
PL/PgSQL

-- 20260729_superadmin_rpc_functions.sql
--
-- Postgres SECURITY DEFINER RPC functions callable from the anon role.
-- The SuperAdmin no longer holds the service-role key — all SuperAdmin
-- writes (push tenant row, push signed license) go through these
-- functions, which run with the function owner's privileges and bypass
-- RLS. The trust boundary moves from "whoever holds the service-role
-- key" to "whoever can present a valid signed license token to
-- upsert_license_cloud".
--
-- Apply in psql against the Supabase project:
-- psql "$SUPABASE_DB_URL" -f sql/20260729_superadmin_rpc_functions.sql
--
-- Idempotent — uses CREATE OR REPLACE FUNCTION.
CREATE OR REPLACE FUNCTION public.upsert_tenant_cloud(p_tenant jsonb)
RETURNS public.tenants
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row public.tenants;
BEGIN
INSERT INTO public.tenants AS t (
id, name, code, contact_email, phone, address, status, updated_at
)
VALUES (
(p_tenant->>'id')::uuid,
p_tenant->>'name',
p_tenant->>'code',
NULLIF(p_tenant->>'contact_email', ''),
NULLIF(p_tenant->>'phone', ''),
NULLIF(p_tenant->>'address', ''),
COALESCE(NULLIF(p_tenant->>'status', ''), 'active'),
NOW()
)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
code = EXCLUDED.code,
contact_email = EXCLUDED.contact_email,
phone = EXCLUDED.phone,
address = EXCLUDED.address,
status = EXCLUDED.status,
updated_at = NOW()
RETURNING * INTO v_row;
RETURN v_row;
END;
$$;
CREATE OR REPLACE FUNCTION public.upsert_license_cloud(p_license jsonb)
RETURNS public.tenant_licenses
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row public.tenant_licenses;
v_token text;
v_payload text;
v_tid text;
BEGIN
-- Trust boundary: a license row is only accepted if its
-- `signed_license_token` field is a well-formed JWT whose payload
-- `tid` (or `tenant_id`) claim matches the row's `tenant_id`. This
-- prevents an anon caller from forging a license for an arbitrary
-- tenant.
v_token := p_license->>'signed_license_token';
IF v_token IS NULL OR v_token = '' THEN
RAISE EXCEPTION 'signed_license_token is required';
END IF;
IF (p_license->>'tenant_id') IS NULL THEN
RAISE EXCEPTION 'tenant_id is required';
END IF;
IF split_part(v_token, '.', 2) = '' THEN
RAISE EXCEPTION 'malformed license token';
END IF;
v_payload := convert_from(
decode(
replace(replace(split_part(v_token, '.', 2), '-', '+'), '_', '/'),
'base64'
),
'UTF8'
);
v_tid := COALESCE(
(v_payload::jsonb->>'tid'),
(v_payload::jsonb->>'tenant_id')
);
IF v_tid IS DISTINCT FROM (p_license->>'tenant_id') THEN
RAISE EXCEPTION 'license token tenant_id mismatch (got %, expected %)',
v_tid, p_license->>'tenant_id';
END IF;
INSERT INTO public.tenant_licenses AS tl (
id, tenant_id, license_key, signed_license_token, algorithm,
term_name, academic_year, start_date, end_date,
grace_period_days, max_students, max_staff,
status, override_reason, issued_by, updated_at
)
VALUES (
(p_license->>'id')::uuid,
(p_license->>'tenant_id')::uuid,
p_license->>'license_key',
p_license->>'signed_license_token',
COALESCE(NULLIF(p_license->>'algorithm', ''), 'EdDSA'),
p_license->>'term_name',
NULLIF(p_license->>'academic_year', ''),
(p_license->>'start_date')::date,
(p_license->>'end_date')::date,
COALESCE((p_license->>'grace_period_days')::int, 7),
COALESCE((p_license->>'max_students')::int, 1000),
COALESCE((p_license->>'max_staff')::int, 200),
COALESCE(NULLIF(p_license->>'status', ''), 'active'),
NULLIF(p_license->>'override_reason', ''),
COALESCE(NULLIF(p_license->>'issued_by', ''), 'SuperAdmin'),
NOW()
)
ON CONFLICT (id) DO UPDATE SET
license_key = EXCLUDED.license_key,
signed_license_token = EXCLUDED.signed_license_token,
algorithm = EXCLUDED.algorithm,
term_name = EXCLUDED.term_name,
academic_year = EXCLUDED.academic_year,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
grace_period_days = EXCLUDED.grace_period_days,
max_students = EXCLUDED.max_students,
max_staff = EXCLUDED.max_staff,
status = EXCLUDED.status,
override_reason = EXCLUDED.override_reason,
issued_by = EXCLUDED.issued_by,
updated_at = NOW()
RETURNING * INTO v_row;
RETURN v_row;
END;
$$;
-- The anon role needs EXECUTE on the new functions; the function
-- bodies are SECURITY DEFINER so they run as the function owner and
-- bypass RLS for the writes.
GRANT EXECUTE ON FUNCTION public.upsert_tenant_cloud(jsonb) TO anon, authenticated;
GRANT EXECUTE ON FUNCTION public.upsert_license_cloud(jsonb) TO anon, authenticated;