diff --git a/.env.local b/.env.local index 18e4e74..b819ddc 100644 --- a/.env.local +++ b/.env.local @@ -1,5 +1,5 @@ TRIGGER_API_KEY=tr_dev_f9kjPUxxfZO79OSjy1su TRIGGER_API_URL=https://api.trigger.dev NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY=pk_dev_0wewTNYuGo93XkoHaU9s -NEXT_PUBLIC_SUPABASE_URL=https://yexfstvjfxhursmqcqcu.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlleGZzdHZqZnhodXJzbXFjcWN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTI2ODg2OTMsImV4cCI6MjAyODI2NDY5M30.Mq1aKeO_SlwS-kEdsSn7VhHxHxNx1eeejd6jnBC7VJw +NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU diff --git a/src/jobs/examples.ts b/src/jobs/examples.ts index 7093abb..7673304 100644 --- a/src/jobs/examples.ts +++ b/src/jobs/examples.ts @@ -12,12 +12,13 @@ client.defineJob({ id: "bus-stop-sanity-check", name: "Sanity Check Potential Job Arrivals", version: "0.1.1", + enabled: false, // 1 run per insert is a bit too insane. trigger: db.onInserted({ table: 'potential_arrivals', }), run: async (payload, io, ctx) => { await io.runTask("log-payload", async function() { - console.log(payload); + io.logger.log(JSON.stringify(payload)); }); }, }); diff --git a/supabase/functions/download-bus-stops/index.ts b/supabase/functions/download-bus-stops/index.ts index 9fd8ca1..cf8ee6d 100644 --- a/supabase/functions/download-bus-stops/index.ts +++ b/supabase/functions/download-bus-stops/index.ts @@ -1,4 +1,4 @@ -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { SupabaseClient, createClient } from "https://esm.sh/@supabase/supabase-js@2"; interface StraetoVariables { [key: string]: number | string; @@ -93,35 +93,52 @@ async function getStops(): Promise> { }; const resp = await fetch(apiUrl, opts); - const body = await resp.json(); + const body = await resp.json(); return body.data.GtfsStops.results; } -Deno.serve(async (req) => { - const busStops = await getStops(); +async function verifyUserIsAdmin(userClient: SupabaseClient): Promise { + const user = await userClient.auth.getUser(); + const metadata = user.data.user?.app_metadata; + if (!metadata || (metadata && !metadata.claims_admin)) { + return false; + } + + return true; +} + +Deno.serve(async (req) => { const authHeader = req.headers.get("Authorization")!; - const supabaseClient = createClient( + + const userClient = createClient( Deno.env.get("SUPABASE_URL") ?? "", - Deno.env.get("SUPABASE_ANON_KEY") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "", { global: { headers: { Authorization: authHeader } } }, ); + const secureClient = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "", + ); + + if (!(await verifyUserIsAdmin(userClient))) { + return new Response( + JSON.stringify({ status: "denied", error: "must be an admin" }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + + const busStops = await getStops(); + console.log("inserting", busStops.length, "rows"); const toInsert = busStops.map(toDbBusStop); - const insert = await supabaseClient.from("bus_stops") + const insert = await secureClient.from("bus_stops") .upsert(toInsert, { ignoreDuplicates: true }); console.log(insert.statusText, insert.error?.code, insert.error?.message); - console.log("rows inserted", insert.count); - - // const { name } = await req.json() - // const data = { - // message: `Hello ${name}!`, - // } - return new Response( JSON.stringify({ "status": insert.statusText, @@ -139,7 +156,6 @@ Deno.serve(async (req) => { curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/download-bus-stops' \ --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \ - --header 'Content-Type: application/json' \ - --data '{"name":"Functions"}' + --header 'Content-Type: application/json' */ diff --git a/supabase/migrations/20240410213210_custom-claims.sql b/supabase/migrations/20240410213210_custom-claims.sql new file mode 100644 index 0000000..ce36efa --- /dev/null +++ b/supabase/migrations/20240410213210_custom-claims.sql @@ -0,0 +1,101 @@ +CREATE OR REPLACE FUNCTION is_claims_admin() RETURNS "bool" + LANGUAGE "plpgsql" + AS $$ + BEGIN + IF session_user = 'authenticator' THEN + -------------------------------------------- + -- To disallow any authenticated app users + -- from editing claims, delete the following + -- block of code and replace it with: + -- RETURN FALSE; + -------------------------------------------- + IF extract(epoch from now()) > coalesce((current_setting('request.jwt.claims', true)::jsonb)->>'exp', '0')::numeric THEN + return false; -- jwt expired + END IF; + If current_setting('request.jwt.claims', true)::jsonb->>'role' = 'service_role' THEN + RETURN true; -- service role users have admin rights + END IF; + IF coalesce((current_setting('request.jwt.claims', true)::jsonb)->'app_metadata'->'claims_admin', 'false')::bool THEN + return true; -- user has claims_admin set to true + ELSE + return false; -- user does NOT have claims_admin set to true + END IF; + -------------------------------------------- + -- End of block + -------------------------------------------- + ELSE -- not a user session, probably being called from a trigger or something + return true; + END IF; + END; +$$; + +CREATE OR REPLACE FUNCTION get_my_claims() RETURNS "jsonb" + LANGUAGE "sql" STABLE + AS $$ + select + coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata', '{}'::jsonb)::jsonb +$$; +CREATE OR REPLACE FUNCTION get_my_claim(claim TEXT) RETURNS "jsonb" + LANGUAGE "sql" STABLE + AS $$ + select + coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata' -> claim, null) +$$; + +CREATE OR REPLACE FUNCTION get_claims(uid uuid) RETURNS "jsonb" + LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public + AS $$ + DECLARE retval jsonb; + BEGIN + IF NOT is_claims_admin() THEN + RETURN '{"error":"access denied"}'::jsonb; + ELSE + select raw_app_meta_data from auth.users into retval where id = uid::uuid; + return retval; + END IF; + END; +$$; + +CREATE OR REPLACE FUNCTION get_claim(uid uuid, claim text) RETURNS "jsonb" + LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public + AS $$ + DECLARE retval jsonb; + BEGIN + IF NOT is_claims_admin() THEN + RETURN '{"error":"access denied"}'::jsonb; + ELSE + select coalesce(raw_app_meta_data->claim, null) from auth.users into retval where id = uid::uuid; + return retval; + END IF; + END; +$$; + +CREATE OR REPLACE FUNCTION set_claim(uid uuid, claim text, value jsonb) RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public + AS $$ + BEGIN + IF NOT is_claims_admin() THEN + RETURN 'error: access denied'; + ELSE + update auth.users set raw_app_meta_data = + raw_app_meta_data || + json_build_object(claim, value)::jsonb where id = uid; + return 'OK'; + END IF; + END; +$$; + +CREATE OR REPLACE FUNCTION delete_claim(uid uuid, claim text) RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public + AS $$ + BEGIN + IF NOT is_claims_admin() THEN + RETURN 'error: access denied'; + ELSE + update auth.users set raw_app_meta_data = + raw_app_meta_data - claim where id = uid; + return 'OK'; + END IF; + END; +$$; +NOTIFY pgrst, 'reload schema';