Compare commits

..

5 Commits

Author SHA1 Message Date
Hermes cc-worker
7ba7523d1c fix(kong): add app-dev LAN origin (192.168.0.251:13000) to dev CORS
Some checks failed
supabase-ci / validate (push) Has been cancelled
Completes the dev CORS origin set after an infra-drift reset discarded a local
kong.yml change (t_764c4b28). Tracked superset so a future kong reload can only
add origins, never drop a needed one. Live .94 kong unchanged (applies on next reload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:39:46 +00:00
Hermes cc-worker
e856de79b8 [verified] add taxonomy storage RLS policies
Some checks failed
supabase-ci / validate (push) Has been cancelled
(cherry picked from commit bebd0e6312370620dc1e9df97f7e80076a7654be)
2026-06-08 01:52:08 +00:00
Hermes cc-worker
75a0779a3a fix(files): allow GC through storage protect_delete
Some checks failed
supabase-ci / validate (push) Has been cancelled
(cherry picked from commit a6eff7ad124c94c75751c77a499433b562911b7b)
2026-06-08 01:17:01 +00:00
Hermes cc-worker
0f2aca3a73 [verified] fix cabinet memberships recursive RLS
Some checks failed
supabase-ci / validate (push) Has been cancelled
(cherry picked from commit facdfd21c9c0c17f09e1a3a5fbe2e2b253f76d82)
2026-06-08 01:15:25 +00:00
f8fcff600f [verified] add S5 exam marker layout schema
Some checks failed
supabase-ci / validate (push) Has been cancelled
2026-06-07 19:13:27 +00:00
5 changed files with 370 additions and 0 deletions

View File

@ -51,6 +51,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -95,6 +96,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -139,6 +141,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -185,6 +188,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -241,6 +245,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -297,6 +302,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -359,6 +365,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -414,6 +421,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -469,6 +477,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -516,6 +525,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods: methods:
- GET - GET
- POST - POST
@ -596,6 +606,7 @@ services:
- "http://192.168.0.74" - "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed - "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default - "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
- "http://192.168.0.94:50001" - "http://192.168.0.94:50001"
- "http://192.168.0.74" - "http://192.168.0.74"
methods: methods:

View File

@ -0,0 +1,112 @@
-- 74-exam-marker-layout.sql
-- S5 exam-marker layout layer + AI/manual seam provenance.
--
-- Adds the per-page layout projection that the docling auto-map pipeline writes to, plus
-- provenance columns needed to distinguish unconfirmed AI suggestions from teacher/manual data.
-- Safe/idempotent on top of 72-exam-marker.sql + 73-exam-marker-regions.sql.
--==========================================================================================
-- 1. exam_template_layout: stage-2 per-page layout layer
--==========================================================================================
create table if not exists public.exam_template_layout (
id uuid primary key default gen_random_uuid(),
template_id uuid not null references public.exam_templates(id) on delete cascade,
page_index int not null,
role text,
margin_left numeric,
margin_right numeric,
margin_top numeric,
margin_bottom numeric,
margins_enabled boolean not null default true,
source text not null default 'manual' check (source in ('manual','ai')),
confirmed boolean not null default true,
confidence numeric,
derivation text,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now()),
unique (template_id, page_index)
);
comment on table public.exam_template_layout is
'Per-template/per-page layout projection for exam-marker auto-map review: page role, nullable margins, provenance and future layout-profile metadata.';
comment on column public.exam_template_layout.role is
'Page role such as cover|question|continuation|blank|appendix; nullable when unknown/manual draft.';
comment on column public.exam_template_layout.meta is
'Forward-compatible layout metadata: columns, front-matter, appendix/blank markers, future profile_id linkage, etc.';
create index if not exists idx_exam_template_layout_template on public.exam_template_layout(template_id);
create index if not exists idx_exam_template_layout_source_confirmed on public.exam_template_layout(template_id, source, confirmed);
drop trigger if exists handle_exam_template_layout_updated_at on public.exam_template_layout;
create trigger handle_exam_template_layout_updated_at before update on public.exam_template_layout
for each row execute function public.handle_updated_at();
--==========================================================================================
-- 2. AI seam/provenance columns on existing physical template tables
--==========================================================================================
-- Existing/manual rows default to authoritative manual data. Auto-map rows must explicitly set
-- source='ai' and confirmed=false so reruns can refresh only unconfirmed AI suggestions.
alter table public.exam_questions add column if not exists source text not null default 'manual' check (source in ('manual','ai'));
alter table public.exam_questions add column if not exists confirmed boolean not null default true;
alter table public.exam_questions add column if not exists confidence numeric;
alter table public.exam_questions add column if not exists derivation text;
alter table public.exam_response_areas add column if not exists mark_subtype text;
alter table public.exam_response_areas add column if not exists derivation text;
alter table public.exam_response_areas drop constraint if exists exam_response_areas_mark_subtype_check;
alter table public.exam_response_areas add constraint exam_response_areas_mark_subtype_check
check (
mark_subtype is null
or (kind = 'mark_area' and mark_subtype in ('part_marks','question_total','grader_box'))
);
alter table public.exam_boundaries add column if not exists confidence numeric;
alter table public.exam_boundaries add column if not exists derivation text;
-- Confidence values are normalized model/layout confidence scores when present.
alter table public.exam_template_layout drop constraint if exists exam_template_layout_confidence_check;
alter table public.exam_template_layout add constraint exam_template_layout_confidence_check
check (confidence is null or (confidence >= 0 and confidence <= 1));
alter table public.exam_questions drop constraint if exists exam_questions_confidence_check;
alter table public.exam_questions add constraint exam_questions_confidence_check
check (confidence is null or (confidence >= 0 and confidence <= 1));
alter table public.exam_response_areas drop constraint if exists exam_response_areas_confidence_check;
alter table public.exam_response_areas add constraint exam_response_areas_confidence_check
check (confidence is null or (confidence >= 0 and confidence <= 1));
alter table public.exam_boundaries drop constraint if exists exam_boundaries_confidence_check;
alter table public.exam_boundaries add constraint exam_boundaries_confidence_check
check (confidence is null or (confidence >= 0 and confidence <= 1));
comment on column public.exam_questions.derivation is
'Lightweight provenance such as ai|manual|boundary_derived|margin_derived|layout_derived.';
comment on column public.exam_response_areas.mark_subtype is
'Only meaningful for kind=mark_area: part_marks|question_total|grader_box. Grader boxes are persisted only when explicitly detected/provided.';
comment on column public.exam_response_areas.derivation is
'Lightweight provenance such as ai|manual|detected|layout_derived.';
comment on column public.exam_boundaries.derivation is
'Lightweight provenance such as ai|manual|band_derived.';
create index if not exists idx_exam_questions_source_confirmed on public.exam_questions(template_id, source, confirmed);
create index if not exists idx_exam_regions_source_confirmed on public.exam_response_areas(template_id, source, confirmed);
create index if not exists idx_exam_boundaries_source_confirmed on public.exam_boundaries(template_id, source, confirmed);
--==========================================================================================
-- 3. RLS — mirror exam_templates ownership/institute scope via the owning template
--==========================================================================================
alter table public.exam_template_layout enable row level security;
drop policy if exists exam_template_layout_service on public.exam_template_layout;
create policy exam_template_layout_service on public.exam_template_layout
using (auth.role() = 'service_role');
drop policy if exists exam_template_layout_all on public.exam_template_layout;
create policy exam_template_layout_all on public.exam_template_layout for all to authenticated
using (exists (select 1 from public.exam_templates t
where t.id = exam_template_layout.template_id
and t.institute_id in (select public.user_institute_ids())))
with check (exists (select 1 from public.exam_templates t
where t.id = exam_template_layout.template_id
and t.teacher_id = auth.uid()
and t.institute_id in (select public.user_institute_ids())));

View File

@ -0,0 +1,51 @@
-- Allow our file-GC helpers to satisfy Supabase storage.protect_delete
-- without weakening the managed storage trigger for arbitrary raw deletes.
--
-- Supabase storage.protect_delete permits direct storage.objects deletes only
-- when the transaction-local GUC storage.allow_delete_query is 'true'. The
-- Storage API sets that GUC for its own deletes. public.files GC is our
-- trigger-side cleanup path, so scope the same GUC to these SECURITY DEFINER
-- helpers immediately before their storage.objects DELETE statements.
create or replace function public._delete_storage_objects(p_bucket text, p_path text)
returns void
language plpgsql security definer
set search_path to 'public', 'storage'
as $$
begin
if p_bucket is null or p_path is null then
return;
end if;
perform set_config('storage.allow_delete_query', 'true', true);
delete from storage.objects where bucket_id = p_bucket and name = p_path;
delete from storage.objects where bucket_id = p_bucket and name like p_path || '/%';
end
$$;
create or replace function public._delete_storage_prefix(p_bucket text, p_prefix text)
returns void
language plpgsql security definer
set search_path to 'public', 'storage'
as $$
begin
if p_bucket is null or p_prefix is null then
return;
end if;
perform set_config('storage.allow_delete_query', 'true', true);
-- Delete any objects whose name starts with the prefix + '/'
delete from storage.objects where bucket_id = p_bucket and name like p_prefix || '/%';
-- In case an object exists exactly at the prefix (rare but safe)
delete from storage.objects where bucket_id = p_bucket and name = p_prefix;
end
$$;
-- These helpers are intended for our SECURITY DEFINER GC triggers/helpers, not
-- as a general SQL delete API for application roles.
revoke all on function public._delete_storage_objects(text, text) from public;
revoke all on function public._delete_storage_prefix(text, text) from public;
revoke all on function public._delete_storage_objects(text, text) from anon, authenticated;
revoke all on function public._delete_storage_prefix(text, text) from anon, authenticated;

View File

@ -0,0 +1,88 @@
-- 76-cabinet-memberships-rls-definer.sql
-- Fix cabinet_memberships/file_cabinets/files recursive RLS.
--
-- The original cabinet membership policies joined file_cabinets directly, while
-- the file_cabinets/files membership policies joined cabinet_memberships
-- directly. Under an authenticated as-user SELECT this creates an RLS cycle and
-- PostgreSQL raises 42P17 (infinite recursion detected in policy for relation
-- "cabinet_memberships").
--
-- SECURITY DEFINER helpers evaluate the ownership/membership checks as the
-- function owner (bypassing the inner RLS checks) while still keying the result
-- to auth.uid(), matching the class-management helper pattern in 71.
--
-- The existing public tables are owned by supabase_admin; Supabase migrations
-- run as that table owner on dev/prod so the SECURITY DEFINER helpers are owned
-- by a role that bypasses the inner RLS checks.
create or replace function public.is_cabinet_owner(p_cabinet uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.file_cabinets c
where c.id = p_cabinet
and c.user_id = auth.uid()
)
$$;
create or replace function public.is_cabinet_member(p_cabinet uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.cabinet_memberships m
where m.cabinet_id = p_cabinet
and m.profile_id = auth.uid()
)
$$;
-- Keep function execution available to the roles used by RLS policies. The
-- functions disclose only a boolean about the caller's own auth.uid() state.
revoke all on function public.is_cabinet_owner(uuid) from public, anon;
revoke all on function public.is_cabinet_member(uuid) from public, anon;
grant execute on function public.is_cabinet_owner(uuid) to authenticated, service_role;
grant execute on function public.is_cabinet_member(uuid) to authenticated, service_role;
-- Re-declare cabinet_memberships policies without direct file_cabinets subqueries.
drop policy if exists cm_read_self_or_owner on public.cabinet_memberships;
create policy cm_read_self_or_owner on public.cabinet_memberships
for select to authenticated
using (profile_id = auth.uid() or public.is_cabinet_owner(cabinet_id));
drop policy if exists cm_insert_by_owner on public.cabinet_memberships;
create policy cm_insert_by_owner on public.cabinet_memberships
for insert to authenticated
with check (public.is_cabinet_owner(cabinet_id));
drop policy if exists cm_update_by_owner on public.cabinet_memberships;
create policy cm_update_by_owner on public.cabinet_memberships
for update to authenticated
using (public.is_cabinet_owner(cabinet_id))
with check (public.is_cabinet_owner(cabinet_id));
drop policy if exists cm_delete_by_owner on public.cabinet_memberships;
create policy cm_delete_by_owner on public.cabinet_memberships
for delete to authenticated
using (public.is_cabinet_owner(cabinet_id));
-- Re-declare membership-based cabinet/file read policies without direct
-- cabinet_memberships subqueries, so selecting cabinets/files does not recurse
-- back through cabinet_memberships RLS.
drop policy if exists "User can access cabinets via membership" on public.file_cabinets;
create policy "User can access cabinets via membership" on public.file_cabinets
for select to authenticated
using (public.is_cabinet_member(id));
drop policy if exists "User can access files via membership" on public.files;
create policy "User can access files via membership" on public.files
for select to authenticated
using (public.is_cabinet_member(cabinet_id));

View File

@ -0,0 +1,108 @@
-- 77-storage-taxonomy-rls.sql
-- RLS for taxonomy storage buckets. cc.examboards intentionally gets no
-- direct object-read policy: exam-board PDF bytes are exposed only through
-- API-issued, time-limited signed URLs.
create or replace function public.is_platform_admin()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.admin_profiles ap
where ap.id = auth.uid()
and coalesce(ap.is_super_admin, false)
)
$$;
grant execute on function public.is_platform_admin() to authenticated;
-- Use the first path segment of cc.institutes objects as the institute id.
-- Expected layout: cc.institutes/<institute_uuid>/...
create or replace function public.storage_object_first_path_segment_uuid(p_name text)
returns uuid
language plpgsql
immutable
as $$
declare
v_segment text;
begin
v_segment := split_part(coalesce(p_name, ''), '/', 1);
if v_segment = '' then
return null;
end if;
begin
return v_segment::uuid;
exception when invalid_text_representation then
return null;
end;
end;
$$;
grant execute on function public.storage_object_first_path_segment_uuid(text) to authenticated;
-- Exam-board catalogue rows are authenticated metadata. PDF bytes remain
-- protected separately in cc.examboards storage and require signed URLs.
alter table public.eb_specifications enable row level security;
alter table public.eb_exams enable row level security;
drop policy if exists "eb_specifications read by authenticated users" on public.eb_specifications;
drop policy if exists "eb_exams read by authenticated users" on public.eb_exams;
create policy "eb_specifications read by authenticated users"
on public.eb_specifications
for select
to authenticated
using (true);
create policy "eb_exams read by authenticated users"
on public.eb_exams
for select
to authenticated
using (true);
alter table storage.objects enable row level security;
-- Replace only the policies owned by this migration. The absence of any
-- cc.examboards SELECT policy is the deny-by-default posture for raw reads.
drop policy if exists "cc.admin objects read by platform admins" on storage.objects;
drop policy if exists "cc.institutes objects read by institute members" on storage.objects;
drop policy if exists "cc.public objects read by authenticated users" on storage.objects;
create policy "cc.admin objects read by platform admins"
on storage.objects
for select
to authenticated
using (
bucket_id = 'cc.admin'
and public.is_platform_admin()
);
create policy "cc.institutes objects read by institute members"
on storage.objects
for select
to authenticated
using (
bucket_id = 'cc.institutes'
and public.storage_object_first_path_segment_uuid(name) in (
select public.user_institute_ids()
)
);
create policy "cc.public objects read by authenticated users"
on storage.objects
for select
to authenticated
using (bucket_id = 'cc.public');
comment on policy "cc.admin objects read by platform admins" on storage.objects
is 'cc.admin raw reads are limited to platform admins.';
comment on policy "cc.institutes objects read by institute members" on storage.objects
is 'cc.institutes raw reads are scoped by leading path segment institute uuid.';
comment on policy "cc.public objects read by authenticated users" on storage.objects
is 'cc.public raw reads are available to any authenticated user.';
comment on table storage.objects
is 'Classroom Copilot taxonomy buckets use RLS; cc.examboards raw reads stay denied and are served through API signed URLs.';