144 lines
4.7 KiB
PL/PgSQL
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;
|