Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions supabase/functions/_shared/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createClient } from "npm:@supabase/supabase-js@2";
import { corsHeaders, jsonResponse } from "./cors.ts";
import { executeJudge0 } from "./judge0.ts";
import { syncSubmissionToGitHub } from "./github-submissions.ts";
import { getSecureVariant } from "./secure-variants.ts";
import type { SecureVariantData } from "./secure-variants.ts";
import type { Language, MissionTest } from "./types.ts";

interface ExecuteBody {
Expand Down Expand Up @@ -219,16 +221,16 @@ export function createExecutionHandler(kind: "run" | "submit") {
const variant = variantData as unknown as VariantRow;
let hiddenTests: MissionTest[] = [];
if (kind === "submit") {
const { data: secure, error: secureError } = await admin
.schema("private")
.from("mission_variants_secure")
.select("hidden_tests")
.eq("variant_id", variant.id)
.single();
if (secureError || !secure) {
let secure: SecureVariantData | null = null;
try {
secure = await getSecureVariant(admin, variant.id);
} catch {
// The secure helper logs the database error without exposing it to students.
}
if (!secure) {
return jsonResponse(request, { error: "Tests privados no disponibles." }, 503);
}
hiddenTests = secure.hidden_tests as MissionTest[];
hiddenTests = secure.hiddenTests;
}

const result = await executeJudge0(
Expand Down
60 changes: 60 additions & 0 deletions supabase/functions/_shared/secure-variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { SupabaseClient } from "npm:@supabase/supabase-js@2";
import type { MissionTest } from "./types.ts";

export interface SecureVariantData {
referenceSolution: string;
hiddenTests: MissionTest[];
}

interface SecureVariantRpcRow {
reference_solution: string;
hidden_tests: MissionTest[];
}

export async function getSecureVariant(
admin: SupabaseClient,
variantId: string,
): Promise<SecureVariantData | null> {
const { data, error } = await admin.rpc("get_mission_variant_secure", {
p_variant_id: variantId,
});
if (error) {
console.error("Secure variant read failed.", {
variantId,
code: error.code,
message: error.message,
});
throw new Error("No se pudieron leer los datos privados de la misión.");
}

const rows =
(Array.isArray(data) ? data : data ? [data] : []) as SecureVariantRpcRow[];
const secure = rows[0];
return secure
? {
referenceSolution: secure.reference_solution,
hiddenTests: secure.hidden_tests,
}
: null;
}

export async function upsertSecureVariant(
admin: SupabaseClient,
variantId: string,
referenceSolution: string,
hiddenTests: MissionTest[],
): Promise<void> {
const { error } = await admin.rpc("upsert_mission_variant_secure", {
p_variant_id: variantId,
p_reference_solution: referenceSolution,
p_hidden_tests: hiddenTests,
});
if (error) {
console.error("Secure variant write failed.", {
variantId,
code: error.code,
message: error.message,
});
throw new Error("No se pudieron guardar los datos privados de la misión.");
}
}
66 changes: 24 additions & 42 deletions supabase/functions/mission-admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createClient } from "npm:@supabase/supabase-js@2";
import { corsHeaders, jsonResponse } from "../_shared/cors.ts";
import { executeJudge0 } from "../_shared/judge0.ts";
import {
getSecureVariant,
upsertSecureVariant,
} from "../_shared/secure-variants.ts";
import type {
Language,
MissionExample,
Expand Down Expand Up @@ -122,12 +126,7 @@ Deno.serve(async (request) => {
(data ?? []).map(async (version) => {
const variants = await Promise.all(
(version.mission_variants ?? []).map(async (variant) => {
const { data: secure } = await admin
.schema("private")
.from("mission_variants_secure")
.select("reference_solution, hidden_tests")
.eq("variant_id", variant.id)
.single();
const secure = await getSecureVariant(admin, variant.id);
return {
id: variant.id,
language: variant.language,
Expand All @@ -136,8 +135,8 @@ Deno.serve(async (request) => {
examples: variant.examples ?? [],
publicTests: variant.public_tests,
hiddenTestCount: variant.hidden_test_count,
referenceSolution: secure?.reference_solution ?? "",
hiddenTests: secure?.hidden_tests ?? [],
referenceSolution: secure?.referenceSolution ?? "",
hiddenTests: secure?.hiddenTests ?? [],
};
}),
);
Expand Down Expand Up @@ -179,13 +178,8 @@ Deno.serve(async (request) => {
}
const completeVariants = await Promise.all(
variants.map(async (variant) => {
const { data: secure, error } = await admin
.schema("private")
.from("mission_variants_secure")
.select("reference_solution, hidden_tests")
.eq("variant_id", variant.id)
.single();
if (error || !secure) throw new Error("Datos privados incompletos.");
const secure = await getSecureVariant(admin, variant.id);
if (!secure) throw new Error("Datos privados incompletos.");
return { ...variant, secure };
}),
);
Expand All @@ -211,15 +205,12 @@ Deno.serve(async (request) => {
.select("id")
.single();
if (error || !inserted) throw error ?? new Error("No se pudo copiar la variante.");
const { error: secureError } = await admin
.schema("private")
.from("mission_variants_secure")
.insert({
variant_id: inserted.id,
reference_solution: variant.secure.reference_solution,
hidden_tests: variant.secure.hidden_tests,
});
if (secureError) throw secureError;
await upsertSecureVariant(
admin,
inserted.id,
variant.secure.referenceSolution,
variant.secure.hiddenTests,
);
}
}

Expand Down Expand Up @@ -254,13 +245,8 @@ Deno.serve(async (request) => {
if (variantError || !variant) {
return jsonResponse(request, { error: "Solución no encontrada." }, 404);
}
const { data: secure, error: secureError } = await admin
.schema("private")
.from("mission_variants_secure")
.select("reference_solution")
.eq("variant_id", variant.id)
.single();
if (secureError || !secure) {
const secure = await getSecureVariant(admin, variant.id);
if (!secure) {
return jsonResponse(
request,
{ error: "Solución privada no disponible." },
Expand All @@ -273,7 +259,7 @@ Deno.serve(async (request) => {
missionVersion: body.missionVersion,
language: body.language,
expectedSignature: variant.expected_signature ?? "",
referenceSolution: secure.reference_solution,
referenceSolution: secure.referenceSolution,
explanation:
"Esta implementación de referencia cumple el contrato y pasa los tests públicos y privados de la versión seleccionada.",
},
Expand Down Expand Up @@ -410,16 +396,12 @@ Deno.serve(async (request) => {
.select("id")
.single();
if (error || !variant) throw error;
const { error: secureError } = await admin
.schema("private")
.from("mission_variants_secure")
.upsert({
variant_id: variant.id,
reference_solution: body.referenceSolution,
hidden_tests: body.hiddenTests,
updated_at: new Date().toISOString(),
});
if (secureError) throw secureError;
await upsertSecureVariant(
admin,
variant.id,
body.referenceSolution,
body.hiddenTests,
);
return jsonResponse(request, { drafts: await loadDrafts() });
}

Expand Down
86 changes: 86 additions & 0 deletions supabase/migrations/202607300004_secure_variant_rpc.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
begin;

create or replace function public.get_mission_variant_secure(
p_variant_id uuid
)
returns table (
reference_solution text,
hidden_tests jsonb
)
language plpgsql
security definer
set search_path = ''
as $$
begin
if auth.role() is distinct from 'service_role' then
raise exception 'Service role required.' using errcode = '42501';
end if;

return query
select
secure.reference_solution,
secure.hidden_tests
from private.mission_variants_secure secure
where secure.variant_id = p_variant_id;
end;
$$;

create or replace function public.upsert_mission_variant_secure(
p_variant_id uuid,
p_reference_solution text,
p_hidden_tests jsonb
)
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
if auth.role() is distinct from 'service_role' then
raise exception 'Service role required.' using errcode = '42501';
end if;
if p_reference_solution is null
or octet_length(p_reference_solution) > 65536
then
raise exception 'Invalid reference solution.' using errcode = '22023';
end if;
if p_hidden_tests is null or jsonb_typeof(p_hidden_tests) <> 'array' then
raise exception 'Hidden tests must be an array.' using errcode = '22023';
end if;

insert into private.mission_variants_secure (
variant_id,
reference_solution,
hidden_tests,
updated_at
)
values (
p_variant_id,
p_reference_solution,
p_hidden_tests,
now()
)
on conflict (variant_id) do update set
reference_solution = excluded.reference_solution,
hidden_tests = excluded.hidden_tests,
updated_at = excluded.updated_at;
end;
$$;

revoke all on function public.get_mission_variant_secure(uuid)
from public, anon, authenticated;
revoke all on function public.upsert_mission_variant_secure(uuid, text, jsonb)
from public, anon, authenticated;

grant usage on schema public to service_role;
grant select, insert, update, delete on all tables in schema public
to service_role;
grant usage, select on all sequences in schema public
to service_role;

grant execute on function public.get_mission_variant_secure(uuid)
to service_role;
grant execute on function public.upsert_mission_variant_secure(uuid, text, jsonb)
to service_role;

commit;
47 changes: 46 additions & 1 deletion supabase/tests/rls.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
begin;

create extension if not exists pgtap with schema extensions;
select plan(15);
select plan(18);

insert into auth.users (
id, instance_id, aud, role, email, encrypted_password,
Expand Down Expand Up @@ -213,6 +213,39 @@ select is(
'requesting changes migrates the student to the current mission version'
);

set local role service_role;
select set_config('request.jwt.claim.role', 'service_role', true);

select is(
(
select count(*)
from public.get_mission_variant_secure(
(
select variant.id
from public.mission_variants variant
join public.mission_versions version
on version.id = variant.mission_version_id
where version.mission_id = 'p1-01-la-once'
order by version.version desc
limit 1
)
)
),
1::bigint,
'service role reads one secure variant through the narrow RPC'
);

select ok(
has_function_privilege(
'service_role',
'public.upsert_mission_variant_secure(uuid,text,jsonb)',
'EXECUTE'
),
'service role can maintain secure variants through the write RPC'
);

reset role;

set local role authenticated;
select set_config(
'request.jwt.claim.sub',
Expand Down Expand Up @@ -279,6 +312,18 @@ select throws_ok(
'students cannot access private tests or solutions'
);

select throws_ok(
$$
select *
from public.get_mission_variant_secure(
'00000000-0000-0000-0000-000000000000'
)
$$,
'42501',
'permission denied for function get_mission_variant_secure',
'students cannot call the secure variant RPC'
);

select throws_ok(
$$
insert into public.attempts (
Expand Down
Loading