From 0fcb9b96961cd5be477e5f4a239e612b5c7ac93e Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:49:36 -0700 Subject: [PATCH 1/5] feat(flashcards): encrypt front/back at rest, decrypt-aware dedupe (#518) Both flashcards insert row-builders (AI-generated /generate, imported /import/commit) now encrypt front/back before the row reaches Supabase. The list read (GET /user/{user_id}) decrypts front/back for every returned row, tolerating legacy plaintext rows via decrypt_if_present's raw-value fallback. dedup_against_existing decrypts each existing row's front before normalizing, so dedupe still matches against ciphertext (and legacy plaintext) rows. /generate's response is built from a decrypted copy of the inserted rows (mirroring notes_service's write/read split) so the frontend still sees plaintext front/back for cards it just generated, instead of the ciphertext written to the row-builder. Co-Authored-By: Claude Opus 5 --- backend/routes/flashcards.py | 23 ++- backend/services/flashcard_import_service.py | 5 +- .../tests/test_flashcard_import_service.py | 22 +++ backend/tests/test_flashcards_routes.py | 136 ++++++++++++++++++ 4 files changed, 179 insertions(+), 7 deletions(-) diff --git a/backend/routes/flashcards.py b/backend/routes/flashcards.py index 0cfcdbd5..a017882f 100644 --- a/backend/routes/flashcards.py +++ b/backend/routes/flashcards.py @@ -14,7 +14,7 @@ from services.academics import resolve_offering, term_id_for_label from services.auth_guard import require_self, get_session_user_id from services.achievement_service import check_achievements -from services.encryption import decrypt_if_present, decrypt_json +from services.encryption import decrypt_if_present, decrypt_json, encrypt_if_present from services.flashcard_import_service import ( dedup_against_existing, check_rate_limit, @@ -236,8 +236,8 @@ def generate(body: GenerateFlashcardsBody, request: Request): "id": str(uuid.uuid4()), "user_id": body.user_id, "topic": body.topic, - "front": c["front"], - "back": c["back"], + "front": encrypt_if_present(c["front"]), + "back": encrypt_if_present(c["back"]), "times_reviewed": 0, "last_reviewed_at": None, "created_at": now, @@ -261,8 +261,16 @@ def generate(body: GenerateFlashcardsBody, request: Request): except Exception: pass + # rows_to_insert holds the ciphertext just written; the response the + # frontend renders (freshly generated cards, before any list re-fetch) + # must carry plaintext front/back, not the encrypted insert payload. + response_cards = [ + {**row, "front": decrypt_if_present(row["front"]), "back": decrypt_if_present(row["back"])} + for row in rows_to_insert + ] + return { - "flashcards": rows_to_insert, + "flashcards": response_cards, "context_used": { "documents_found": len(documents), "weak_concepts_found": len(weak_concepts), @@ -291,6 +299,9 @@ def get_flashcards( "id,user_id,topic,offering_id,front,back,times_reviewed,last_rating,last_reviewed_at,created_at", filters=filters, order="created_at.desc" ) or [] + for r in rows: + r["front"] = decrypt_if_present(r.get("front")) + r["back"] = decrypt_if_present(r.get("back")) if semester: # Term scoping (#141). This route is user-wide (no course id), so # the filter works on the cards' offering: cards from the selected @@ -397,8 +408,8 @@ def import_commit(body: ImportCommitBody, request: Request): "user_id": body.user_id, "topic": body.topic, "offering_id": offering_id, - "front": c["front"], - "back": c["back"], + "front": encrypt_if_present(c["front"]), + "back": encrypt_if_present(c["back"]), "times_reviewed": 0, "last_reviewed_at": None, "created_at": now, diff --git a/backend/services/flashcard_import_service.py b/backend/services/flashcard_import_service.py index dda8b10a..d40b422d 100644 --- a/backend/services/flashcard_import_service.py +++ b/backend/services/flashcard_import_service.py @@ -33,6 +33,7 @@ from agents.flashcard import flashcard_agent from agents.usage import record_agent_usage from services import extraction_service +from services.encryption import decrypt_if_present logger = logging.getLogger(__name__) @@ -76,7 +77,9 @@ def dedup_against_existing( filters["topic"] = f"eq.{topic}" existing = table("flashcards").select("front", filters=filters) or [] - existing_norm = [_normalize(r.get("front", "")) for r in existing] + existing_norm = [ + _normalize(decrypt_if_present(r.get("front", "")) or "") for r in existing + ] keep: list[Card] = [] skipped: list[Card] = [] diff --git a/backend/tests/test_flashcard_import_service.py b/backend/tests/test_flashcard_import_service.py index 9a661671..de959579 100644 --- a/backend/tests/test_flashcard_import_service.py +++ b/backend/tests/test_flashcard_import_service.py @@ -59,6 +59,28 @@ def test_filters_by_topic_when_offering_id_is_none(self): assert "offering_id" not in str(call_kwargs) assert "course_id" not in str(call_kwargs) + def test_dedup_matches_against_encrypted_existing_cards(self): + # #518: dedupe must be decrypt-aware — existing rows are ciphertext. + from services.encryption import encrypt + with patch("services.flashcard_import_service.table") as t: + t.return_value.select.return_value = [ + {"front": encrypt("What is a variable?")} + ] + new = [{"front": "What is a variable?", "back": "A named storage location."}] + keep, skipped = svc.dedup_against_existing("u1", "c1", new) + assert keep == [] + assert skipped == new + + def test_dedup_still_matches_legacy_plaintext_rows(self): + # Pre-backfill rows are still plaintext — decrypt_if_present falls + # back to the raw value, so dedupe keeps working unchanged. + with patch("services.flashcard_import_service.table") as t: + t.return_value.select.return_value = [{"front": "What is a variable?"}] + new = [{"front": "What is a variable?", "back": "A named storage location."}] + keep, skipped = svc.dedup_against_existing("u1", "c1", new) + assert keep == [] + assert skipped == new + # ── check_rate_limit ───────────────────────────────────────────────────────── diff --git a/backend/tests/test_flashcards_routes.py b/backend/tests/test_flashcards_routes.py index f2174091..67d187be 100644 --- a/backend/tests/test_flashcards_routes.py +++ b/backend/tests/test_flashcards_routes.py @@ -195,3 +195,139 @@ def test_route_passes_the_semester_into_the_docs_context(self): }) assert r.status_code == 200 gd.assert_called_once_with(USER_ID, "Intro CS", semester="Fall 2025") + + +# ── Encryption at rest (#518) ──────────────────────────────────────────────── + +def _table_recording_flashcard_inserts(captured_inserts): + """table() stand-in that records every row passed to flashcards.insert() + into `captured_inserts`, and returns [] for every other select (so + _get_course_documents/_get_weak_concepts helpers, when not mocked out, + degrade harmlessly).""" + def side_effect(name): + m = MagicMock() + m.select.return_value = [] + if name == "flashcards": + m.insert.side_effect = lambda row: captured_inserts.append(row) + return m + return side_effect + + +class TestFlashcardEncryption: + def test_generated_cards_encrypted_at_write(self): + from services.encryption import decrypt + + plaintext_front = "What is a variable?" + plaintext_back = "A named storage location for a value." + plaintext_topic = "CS Basics" + captured_inserts: list[dict] = [] + + with patch("routes.flashcards._get_course_documents", return_value=[]), \ + patch("routes.flashcards._get_weak_concepts", return_value=[]), \ + patch("routes.flashcards._generate", return_value=[ + {"front": plaintext_front, "back": plaintext_back}, + ]), \ + patch("routes.flashcards.table", + side_effect=_table_recording_flashcard_inserts(captured_inserts)), \ + patch("services.achievement_service.check_achievements"): + r = client.post("/api/flashcards/generate", json={ + "user_id": USER_ID, "topic": plaintext_topic, "count": 1, + }) + + assert r.status_code == 200 + assert len(captured_inserts) == 1 + for row in captured_inserts: + assert row["front"] != plaintext_front + assert row["back"] != plaintext_back + assert decrypt(row["front"]) == plaintext_front + assert decrypt(row["back"]) == plaintext_back + # topic stays plaintext (it's a filter column): + assert row["topic"] == plaintext_topic + + # The API response itself still carries plaintext front/back — the + # frontend must not see ciphertext for cards it just generated. + returned = r.json()["flashcards"] + assert len(returned) == 1 + assert returned[0]["front"] == plaintext_front + assert returned[0]["back"] == plaintext_back + + def test_import_commit_cards_encrypted_at_write(self): + from services.encryption import decrypt + + plaintext_front = "Mitosis" + plaintext_back = "Cell division" + captured_inserts: list[dict] = [] + + def side_effect(name): + m = MagicMock() + m.select.return_value = [] + if name == "flashcards": + m.insert.side_effect = lambda rows: captured_inserts.extend(rows) + return m + + with patch("routes.flashcards.require_self", return_value=None), \ + patch("routes.flashcards.resolve_offering", return_value=None), \ + patch("routes.flashcards.table", side_effect=side_effect), \ + patch("routes.flashcards.check_achievements"): + r = client.post("/api/flashcards/import/commit", json={ + "user_id": USER_ID, + "topic": "Bio", + "cards": [{"front": plaintext_front, "back": plaintext_back}], + "dedup": False, + }) + + assert r.status_code == 200 + assert len(captured_inserts) == 1 + row = captured_inserts[0] + assert row["front"] != plaintext_front + assert row["back"] != plaintext_back + assert decrypt(row["front"]) == plaintext_front + assert decrypt(row["back"]) == plaintext_back + + def test_list_decrypts_front_and_back(self): + from services.encryption import encrypt + + def side_effect(name): + m = MagicMock() + if name == "flashcards": + m.select.return_value = [{ + "id": "f1", "user_id": USER_ID, "topic": "Bio", + "offering_id": None, + "front": encrypt("Q"), "back": encrypt("A"), + "times_reviewed": 0, "last_rating": None, + "last_reviewed_at": None, "created_at": "2026-01-01T00:00:00Z", + }] + else: + m.select.return_value = [] + return m + + with patch("routes.flashcards.table", side_effect=side_effect): + r = client.get(f"/api/flashcards/user/{USER_ID}") + + assert r.status_code == 200 + card = r.json()["flashcards"][0] + assert card["front"] == "Q" + assert card["back"] == "A" + + def test_list_tolerates_legacy_plaintext_rows(self): + def side_effect(name): + m = MagicMock() + if name == "flashcards": + m.select.return_value = [{ + "id": "f1", "user_id": USER_ID, "topic": "Bio", + "offering_id": None, + "front": "Q", "back": "A", + "times_reviewed": 0, "last_rating": None, + "last_reviewed_at": None, "created_at": "2026-01-01T00:00:00Z", + }] + else: + m.select.return_value = [] + return m + + with patch("routes.flashcards.table", side_effect=side_effect): + r = client.get(f"/api/flashcards/user/{USER_ID}") + + assert r.status_code == 200 + card = r.json()["flashcards"][0] + assert card["front"] == "Q" + assert card["back"] == "A" From ec3c1940473d19095f442811eca424a5e5347bd4 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:57:17 -0700 Subject: [PATCH 2/5] feat(study-guide): encrypt content JSON at rest (#518) Task 10 of the encryption-coverage epic: study_guides.content is now ciphertext at write (encrypt_json) and decrypted at both readers (get_cached_guides' list loop and the /guide cached-row return) via decrypt_json_column, which also tolerates legacy plaintext dict rows. The ETag on /cached still derives from id+generated_at only, computed before decrypt. Co-Authored-By: Claude Opus 5 --- backend/routes/study_guide.py | 13 +- backend/tests/test_study_guide_encryption.py | 218 +++++++++++++++++++ 2 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_study_guide_encryption.py diff --git a/backend/routes/study_guide.py b/backend/routes/study_guide.py index 567b1ba4..8792d58d 100644 --- a/backend/routes/study_guide.py +++ b/backend/routes/study_guide.py @@ -24,7 +24,12 @@ ) from services.graph_service import get_courses as graph_get_courses from services.auth_guard import require_self -from services.encryption import decrypt_if_present, decrypt_json +from services.encryption import ( + decrypt_if_present, + decrypt_json, + decrypt_json_column, + encrypt_json, +) from services.http_cache import cached_json, conditional, make_etag from services.request_context import current_request_id @@ -198,7 +203,7 @@ def _generate_and_insert(user_id: str, offering_id: str, exam_id: str) -> dict: "offering_id": offering_id, "exam_id": exam_id, "generated_at": now, - "content": content, + "content": encrypt_json(content), } table("study_guides").insert(row) @@ -245,7 +250,7 @@ def get_cached_guides(user_id: str, request: Request): result = [] for g in guides: - content = g.get("content") or {} + content = decrypt_json_column(g.get("content")) or {} course_id = offering_to_course.get(g.get("offering_id")) result.append({ "id": g["id"], @@ -330,7 +335,7 @@ def get_guide( ) if cached: row = cached[0] - return {"guide": row["content"], "generated_at": row["generated_at"], "cached": True} + return {"guide": decrypt_json_column(row["content"]), "generated_at": row["generated_at"], "cached": True} result = _generate_and_insert(user_id, offering_id, exam_id) return {"guide": result["content"], "generated_at": result["generated_at"], "cached": False} diff --git a/backend/tests/test_study_guide_encryption.py b/backend/tests/test_study_guide_encryption.py new file mode 100644 index 00000000..fdde97b8 --- /dev/null +++ b/backend/tests/test_study_guide_encryption.py @@ -0,0 +1,218 @@ +""" +Unit tests for the study_guides.content encryption boundary (#518 Task 10). + +Pins: + - _generate_and_insert writes ciphertext to study_guides.content, but the + RESPONSE (GET /guide, uncached path) still carries the plaintext dict — + the response is built from the local plaintext variable, not a re-read + of the encrypted row, so this must be asserted explicitly (the flashcards + task hit exactly this trap: fixing the row dict alone can still leave a + stale plaintext reference feeding the response). + - GET /guide (cached path) and GET /cached both decrypt content before + building their response. + - Both readers tolerate legacy plaintext dict rows (pre-encryption data) + identically to encrypted rows. +""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.testclient import TestClient + +from main import app +from services.encryption import decrypt_json_column, encrypt_json + +client = TestClient(app) + +USER_ID = "user_test" +COURSE_ID = "course_1" +EXAM_ID = "exam_1" + + +def _agent_run_returning(content): + """AsyncMock standing in for study_guide_agent.run; its .output.model_dump() + yields the given legacy-dict content.""" + return AsyncMock( + return_value=SimpleNamespace( + output=SimpleNamespace(model_dump=lambda: content) + ) + ) + + +class TestGenerateInsertsEncryptedContent: + def test_generate_inserts_encrypted_content(self): + fresh_content = {"exam": "Final", "topics": [{"name": "Topic 1"}]} + captured = {} + + def table_side_effect(name): + m = MagicMock() + if name == "study_guides": + m.select.return_value = [] # nothing cached → generate + + def _insert(row): + captured["row"] = row + return [{}] + m.insert.side_effect = _insert + elif name == "assignments": + m.select.return_value = [{"title": "Final", "due_date": "2026-05-01"}] + elif name == "enrollments": + m.select.return_value = [{"id": "enr1", "offering_id": "off1"}] + elif name == "documents": + m.select.return_value = [] + else: + m.select.return_value = [] + return m + + agent_run = _agent_run_returning(fresh_content) + with patch("routes.study_guide.table", side_effect=table_side_effect), \ + patch("routes.study_guide.user_enrollment_ids", return_value=[{"id": "enr1", "offering_id": "off1"}]), \ + patch("routes.study_guide.resolve_offering", return_value="off1"), \ + patch("routes.study_guide.study_guide_agent.run", new=agent_run): + r = client.get(f"/api/study-guide/{USER_ID}/guide?course_id={COURSE_ID}&exam_id={EXAM_ID}") + + assert r.status_code == 200 + # The persisted row must be ciphertext, not the raw dict. + row = captured["row"] + assert isinstance(row["content"], str) + assert row["content"] != fresh_content + assert decrypt_json_column(row["content"])["exam"] == "Final" + + # The trap: the response is built from the local plaintext `content` + # variable in _generate_and_insert, not by re-reading/decrypting the + # row — so it must still come back as a plaintext dict, unchanged. + body = r.json() + assert body["cached"] is False + assert body["guide"] == fresh_content + assert body["guide"]["exam"] == "Final" + + +class TestCachedReadDecryptsContent: + def test_cached_read_decrypts_content(self): + plaintext = {"exam": "Midterm", "topics": ["a", "b"]} + cached_row = { + "id": "g1", "user_id": USER_ID, + "offering_id": "off1", "exam_id": EXAM_ID, + "generated_at": "2026-04-01T00:00:00Z", + "content": encrypt_json(plaintext), + } + agent_run = _agent_run_returning({"exam": "should not be used", "topics": []}) + with patch("routes.study_guide.table") as t, \ + patch("routes.study_guide.study_guide_agent.run", new=agent_run): + t.return_value.select.return_value = [cached_row] + r = client.get(f"/api/study-guide/{USER_ID}/guide?course_id={COURSE_ID}&exam_id={EXAM_ID}") + + assert r.status_code == 200 + body = r.json() + assert body["cached"] is True + # The response "guide" must be the plaintext dict, not ciphertext. + assert body["guide"] == plaintext + agent_run.assert_not_called() + + +class TestListDecryptsContentForTitles: + def test_list_decrypts_content_for_titles(self): + guides = [ + {"id": "g1", "offering_id": "off1", "exam_id": "e1", + "generated_at": "2026-04-01T00:00:00Z", + "content": encrypt_json({"exam": "Midterm", "overview": "Covers ch1-5"})}, + {"id": "g2", "offering_id": "off-untermed", "exam_id": "e2", + "generated_at": "2026-03-01T00:00:00Z", + "content": encrypt_json({"exam": "Final", "overview": ""})}, + ] + + def table_side_effect(name): + m = MagicMock() + if name == "study_guides": + m.select.return_value = guides + elif name == "courses": + m.select.return_value = [{"id": "c1", "course_name": "Calc II"}] + else: + m.select.return_value = [] + return m + + terms = {"off1": {"id": "term-f25", "label": "Fall 2025"}} + with patch("routes.study_guide.table", side_effect=table_side_effect), \ + patch("routes.study_guide.offering_course_id", return_value="c1"), \ + patch("routes.study_guide.term_for_offering", + side_effect=lambda o: terms.get(o)): + r = client.get(f"/api/study-guide/{USER_ID}/cached") + + assert r.status_code == 200 + out = r.json()["guides"] + assert out[0]["exam_title"] == "Midterm" + assert out[0]["overview"] == "Covers ch1-5" + assert out[1]["exam_title"] == "Final" + assert out[1]["overview"] == "" + + +class TestReadsTolerateLegacyPlaintextDictRows: + def test_guide_cached_read_tolerates_legacy_plaintext_dict(self): + plaintext = {"exam": "Midterm", "topics": ["a", "b"]} + cached_row = { + "id": "g1", "user_id": USER_ID, + "offering_id": "off1", "exam_id": EXAM_ID, + "generated_at": "2026-04-01T00:00:00Z", + "content": plaintext, # legacy: raw dict, never encrypted + } + with patch("routes.study_guide.table") as t: + t.return_value.select.return_value = [cached_row] + r = client.get(f"/api/study-guide/{USER_ID}/guide?course_id={COURSE_ID}&exam_id={EXAM_ID}") + + assert r.status_code == 200 + body = r.json() + assert body["cached"] is True + assert body["guide"] == plaintext + + def test_list_tolerates_legacy_plaintext_dict_rows(self): + guides = [ + {"id": "g1", "offering_id": "off1", "exam_id": "e1", + "generated_at": "2026-04-01T00:00:00Z", + "content": {"exam": "Midterm", "overview": "Covers ch1-5"}}, # legacy dict + ] + + def table_side_effect(name): + m = MagicMock() + if name == "study_guides": + m.select.return_value = guides + else: + m.select.return_value = [] + return m + + with patch("routes.study_guide.table", side_effect=table_side_effect), \ + patch("routes.study_guide.offering_course_id", return_value="c1"), \ + patch("routes.study_guide.term_for_offering", return_value=None): + r = client.get(f"/api/study-guide/{USER_ID}/cached") + + assert r.status_code == 200 + out = r.json()["guides"][0] + assert out["exam_title"] == "Midterm" + assert out["overview"] == "Covers ch1-5" + + def test_encrypted_and_legacy_rows_produce_identical_list_responses(self): + """Same content, once ciphertext once a raw dict — the /cached response + entries must be identical either way.""" + plaintext_content = {"exam": "Final", "overview": "Chapters 6-10"} + + def _run(content_value): + guides = [{ + "id": "g1", "offering_id": "off1", "exam_id": "e1", + "generated_at": "2026-04-01T00:00:00Z", + "content": content_value, + }] + + def table_side_effect(name): + m = MagicMock() + if name == "study_guides": + m.select.return_value = guides + else: + m.select.return_value = [] + return m + + with patch("routes.study_guide.table", side_effect=table_side_effect), \ + patch("routes.study_guide.offering_course_id", return_value=None), \ + patch("routes.study_guide.term_for_offering", return_value=None): + return client.get(f"/api/study-guide/{USER_ID}/cached").json() + + encrypted_response = _run(encrypt_json(plaintext_content)) + legacy_response = _run(dict(plaintext_content)) + + assert encrypted_response == legacy_response From fdc0b78d231177f2a5dce6ba3fbca9bc38510100 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:07:36 -0700 Subject: [PATCH 3/5] feat(social): encrypt cached room summaries at rest (#518) Co-Authored-By: Claude Opus 5 --- backend/services/social_cache_service.py | 5 ++- backend/tests/test_social_cache_service.py | 51 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_social_cache_service.py diff --git a/backend/services/social_cache_service.py b/backend/services/social_cache_service.py index 4692bf59..477ec69c 100644 --- a/backend/services/social_cache_service.py +++ b/backend/services/social_cache_service.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from db.connection import table +from services.encryption import decrypt_if_present, encrypt_if_present def _compute_hash(member_summaries: list[str]) -> str: @@ -26,7 +27,7 @@ def get_cached_summary(room_id: str, member_summaries: list[str]) -> str | None: filters={"room_id": f"eq.{room_id}"}, ) if rows and rows[0]["member_hash"] == current_hash: - return rows[0]["summary"] + return decrypt_if_present(rows[0]["summary"]) return None @@ -34,7 +35,7 @@ def save_summary(room_id: str, member_summaries: list[str], summary: str) -> Non table("room_summaries").upsert( { "room_id": room_id, - "summary": summary, + "summary": encrypt_if_present(summary), "member_hash": _compute_hash(member_summaries), "updated_at": datetime.now(timezone.utc).isoformat(), }, diff --git a/backend/tests/test_social_cache_service.py b/backend/tests/test_social_cache_service.py new file mode 100644 index 00000000..6172fdba --- /dev/null +++ b/backend/tests/test_social_cache_service.py @@ -0,0 +1,51 @@ +"""#518: room_summaries.summary is ciphertext at rest; cache keys on member_hash.""" +from unittest.mock import patch + +from services.encryption import decrypt, encrypt +from services.social_cache_service import get_cached_summary, save_summary, _compute_hash + + +class FakeTable: + def __init__(self, rows=None): + self.rows = rows or [] + self.upserted = [] + + def select(self, *a, **k): + return list(self.rows) + + def upsert(self, data, on_conflict=None): + self.upserted.append(data) + return [data] + + +def test_save_summary_encrypts(): + fake = FakeTable() + with patch("services.social_cache_service.table", return_value=fake): + save_summary("room1", ["s1", "s2"], "Everyone is stuck on recursion") + row = fake.upserted[0] + assert row["summary"] != "Everyone is stuck on recursion" + assert decrypt(row["summary"]) == "Everyone is stuck on recursion" + assert row["member_hash"] == _compute_hash(["s1", "s2"]) # hash stays comparable + + +def test_get_cached_summary_decrypts_on_hash_hit(): + members = ["s1", "s2"] + fake = FakeTable(rows=[{ + "summary": encrypt("Everyone is stuck on recursion"), + "member_hash": _compute_hash(members), + }]) + with patch("services.social_cache_service.table", return_value=fake): + assert get_cached_summary("room1", members) == "Everyone is stuck on recursion" + + +def test_get_cached_summary_tolerates_legacy_plaintext(): + members = ["s1"] + fake = FakeTable(rows=[{"summary": "plain", "member_hash": _compute_hash(members)}]) + with patch("services.social_cache_service.table", return_value=fake): + assert get_cached_summary("room1", members) == "plain" + + +def test_get_cached_summary_miss_on_stale_hash(): + fake = FakeTable(rows=[{"summary": encrypt("old"), "member_hash": "stale"}]) + with patch("services.social_cache_service.table", return_value=fake): + assert get_cached_summary("room1", ["new"]) is None From a4fd8f09939fea3d89659169364de4454b2230b2 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:12:17 -0700 Subject: [PATCH 4/5] feat(ops): backfill/seed/oracle/roundtrip coverage for derived-content encryption (#518) Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- backend/db/backfill_encryption.py | 15 +++++++++++ backend/db/seed_local_rich.py | 26 ++++++++++++++++--- backend/e2e_oracles/gather.py | 4 +++ .../integration/test_encryption_roundtrip.py | 15 +++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f597c65..eecfe592 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ make explore # Chapter 2: bounded AI exploration of the ru ## Gotchas -- Column-level encryption is on for sensitive columns: `user_profiles.name`/`first_name`/`last_name`/`bio`/`location` (these moved off `users` to `user_profiles` in the 0024 identity split), Google OAuth tokens, `messages.content`, `room_messages.text`, `sessions.summary_json`, `documents.summary` + `concept_notes` + `extracted_text` (the RAG OCR text added in 0030), `notes.title`/`body`/`last_summary`, `assignments.notes`/`points_possible`/`points_earned` (the enrollment-keyed gradebook table; points columns carry numeric semantics — use `decrypt_numeric` at read), `feedback.comment`/`topic` + `issue_reports.topic`/`description` (free-text user input, #520), and `quiz_attempts.questions_json`/`answers_json` + `quiz_context.context_json` (quiz performance data, #521; scalar analytics columns — score/total/difficulty/completed_at — stay plaintext). Helpers live in `backend/services/encryption.py`; use `encrypt_if_present` at write boundaries and `decrypt_if_present` / `decrypt_numeric` at read boundaries (including before injecting into AI prompts). `ENCRYPTION_KEY` must be set (32 bytes as 64 hex chars; generate via `python -c "import secrets; print(secrets.token_hex(32))"`). Deliberate exception: `newsletter_emails.email` stays plaintext (ADR 0026) — the UNIQUE constraint, lookup index, and both subscribe/allowlist upserts key on the value, and AES-GCM's per-call nonce breaks value equality. +- Column-level encryption is on for sensitive columns: `user_profiles.name`/`first_name`/`last_name`/`bio`/`location` (these moved off `users` to `user_profiles` in the 0024 identity split), Google OAuth tokens, `messages.content`, `room_messages.text`, `sessions.summary_json`, `documents.summary` + `concept_notes` + `extracted_text` (the RAG OCR text added in 0030), `notes.title`/`body`/`last_summary`, `assignments.notes`/`points_possible`/`points_earned` (the enrollment-keyed gradebook table; points columns carry numeric semantics — use `decrypt_numeric` at read), `feedback.comment`/`topic` + `issue_reports.topic`/`description` (free-text user input, #520), `quiz_attempts.questions_json`/`answers_json` + `quiz_context.context_json` (quiz performance data, #521; scalar analytics columns — score/total/difficulty/completed_at — stay plaintext), and `flashcards.front`/`back` + `study_guides.content` + `room_summaries.summary` (derived content, #518; `study_guides.content` uses the JSON pair, `room_summaries.summary` keys its cache on the separate plaintext `member_hash` column so encryption doesn't affect cache-hit lookups). Helpers live in `backend/services/encryption.py`; use `encrypt_if_present` at write boundaries and `decrypt_if_present` / `decrypt_numeric` at read boundaries (including before injecting into AI prompts). `ENCRYPTION_KEY` must be set (32 bytes as 64 hex chars; generate via `python -c "import secrets; print(secrets.token_hex(32))"`). Deliberate exception: `newsletter_emails.email` stays plaintext (ADR 0026) — the UNIQUE constraint, lookup index, and both subscribe/allowlist upserts key on the value, and AES-GCM's per-call nonce breaks value equality. - Knowledge-graph mastery is now an append-only `node_mastery_events` table (replaced the `graph_nodes.mastery_events` JSON column in 0023); node/edge dedup is enforced by UNIQUE constraints. Don't read/write a `mastery_events` column. - Optional cross-worker cache (#97): `services/cache.py` wraps Redis and is **off by default** — with no `REDIS_URL` set it's a zero-overhead no-op and never fails a request (any Redis error → clean miss + warning). Currently backs the content-addressed OCR/extraction cache (`extraction_service.extract_text_from_file`, keyed on `sha256(file_bytes)` + engine). The `redis` dependency is only imported when `REDIS_URL` is set. - HTTP caching (#99): conditional GETs use `services/http_cache.py` (`make_etag`/`conditional`/`cached_json`). `Cache-Control` on these routes is **always `private`, never `public`** — the responses carry user-scoped, app-decrypted columns that must never be cached at a shared proxy/CDN. Derive the ETag from cheap change-keys (ids, `updated_at`, existing content hashes), not from the fully-built payload. \ No newline at end of file diff --git a/backend/db/backfill_encryption.py b/backend/db/backfill_encryption.py index 782703e0..43ee28b8 100644 --- a/backend/db/backfill_encryption.py +++ b/backend/db/backfill_encryption.py @@ -302,6 +302,18 @@ def backfill_quiz_context(apply: bool) -> dict: return _encrypt_json_column("quiz_context", "context_json", pk="id", apply=apply) +def backfill_flashcards(apply: bool) -> dict: + return _encrypt_text_column("flashcards", ["front", "back"], pk="id", apply=apply) + + +def backfill_study_guides(apply: bool) -> dict: + return _encrypt_json_column("study_guides", "content", pk="id", apply=apply) + + +def backfill_room_summaries(apply: bool) -> dict: + return _encrypt_text_column("room_summaries", ["summary"], pk="room_id", apply=apply) + + RUNNERS: dict[str, Callable[[bool], dict]] = { "users": backfill_users, "user_settings": backfill_user_settings, @@ -315,6 +327,9 @@ def backfill_quiz_context(apply: bool) -> dict: "issue_reports": backfill_issue_reports, "quiz_attempts": backfill_quiz_attempts, "quiz_context": backfill_quiz_context, + "flashcards": backfill_flashcards, + "study_guides": backfill_study_guides, + "room_summaries": backfill_room_summaries, } diff --git a/backend/db/seed_local_rich.py b/backend/db/seed_local_rich.py index 77518786..3dce2aad 100644 --- a/backend/db/seed_local_rich.py +++ b/backend/db/seed_local_rich.py @@ -553,7 +553,7 @@ def seed_notes_documents() -> None: ) -# (fc_id, user_id, offering_id, topic, front, back) — plaintext, grouped by topic. +# (fc_id, user_id, offering_id, topic, front, back) — 🔒 front/back, grouped by topic. _FLASHCARDS = [ ("rich-fc-cs-1", USER_ACTIVE, OFF_CS_F25, "CS Basics", "What is a variable?", "A named storage location for a value."), @@ -579,8 +579,9 @@ def seed_flashcards() -> None: "user_id": user_id, "offering_id": off_id, "topic": topic, - "front": front, - "back": back, + # 🔒 front / back (#518) + "front": encrypt_if_present(front), + "back": encrypt_if_present(back), }, ) @@ -625,11 +626,26 @@ def seed_study_guides() -> None: "offering_id": off_id, "exam_id": exam_id, "generated_at": generated_at, - "content": content, + # 🔒 content (#518) + "content": encrypt_json(content), }, ) +def seed_room_summaries() -> None: + # #518: room_summaries.summary is 🔒. PK is room_id (no id column), so this + # can't go through insert_if_absent. + if not table("room_summaries").select("room_id", filters={"room_id": f"eq.{ROOM_STUDY}"}): + table("room_summaries").insert({ + "room_id": ROOM_STUDY, + "summary": encrypt_if_present("The group is reviewing recursion before the midterm."), + "member_hash": "rich-member-hash-v1", + }) + h.record("room_summaries", created=True) + else: + h.record("room_summaries", created=False) + + # (qa_id, concept_node_id, difficulty, score, total, questions_json, answers_json, completed_at) _QUIZ_ATTEMPTS = [ ("rich-qa-cs-variables-1", "rich-node-cs-variables", "easy", 9, 10, @@ -766,6 +782,7 @@ def seed_sessions() -> None: "schools", "courses", "course_offerings", "users", "user_profiles", "user_roles", "enrollments", "graph_nodes", "graph_edges", "node_mastery_events", "gradebook_categories", "assignments", "rooms", "room_members", "room_messages", + "room_summaries", "notes", "documents", "flashcards", "study_guides", "quiz_attempts", "quiz_context", "sessions", "messages", "feedback", "issue_reports", ] @@ -785,6 +802,7 @@ def main() -> None: seed_notes_documents() seed_flashcards() seed_study_guides() + seed_room_summaries() seed_quiz() seed_feedback() seed_sessions() diff --git a/backend/e2e_oracles/gather.py b/backend/e2e_oracles/gather.py index 2b1e30eb..cfc007d8 100644 --- a/backend/e2e_oracles/gather.py +++ b/backend/e2e_oracles/gather.py @@ -195,6 +195,10 @@ def run_counts(args: argparse.Namespace) -> tuple[list[Finding], int]: ("quiz_attempts", "id", "questions_json"), ("quiz_attempts", "id", "answers_json"), ("quiz_context", "id", "context_json"), + ("flashcards", "id", "front"), + ("flashcards", "id", "back"), + ("study_guides", "id", "content"), + ("room_summaries", "room_id", "summary"), ) diff --git a/backend/tests/integration/test_encryption_roundtrip.py b/backend/tests/integration/test_encryption_roundtrip.py index 484c53a8..3ae09bbb 100644 --- a/backend/tests/integration/test_encryption_roundtrip.py +++ b/backend/tests/integration/test_encryption_roundtrip.py @@ -46,6 +46,13 @@ "Upload stuck"), ("issue_reports.description", "issue_reports", "id", "rich-issue-1", "description", "Syllabus upload spins forever."), + ("flashcards.front", "flashcards", "id", "rich-fc-cs-1", "front", + "What is a variable?"), + ("flashcards.back", "flashcards", "id", "rich-fc-cs-1", "back", + "A named storage location for a value."), + ("room_summaries.summary", "room_summaries", "room_id", + "rich-room-study-group", "summary", + "The group is reviewing recursion before the midterm."), ] # (label, id_value, column, expected_number) @@ -123,6 +130,14 @@ def test_quiz_context_context_json_is_ciphertext_and_decrypts(db_conn): assert decoded["asked"] == 2 +def test_study_guides_content_is_ciphertext_and_decrypts(db_conn): + """#518: study_guides.content needs the JSON pair (encrypt_json/decrypt_json).""" + raw = _raw(db_conn, "study_guides", "id", "rich-guide-cs-f25-mid", "content") + assert isinstance(raw, str), "content stored as PLAINTEXT JSONB — encryption regressed" + decoded = decrypt_json(raw) + assert decoded["exam"] == "Midterm Exam" + + def test_documents_concept_notes_uses_json_encryption(db_conn): """documents.concept_notes is stored via encrypt_json (a list of concepts).""" raw = _raw(db_conn, "documents", "id", "rich-doc-cs-syllabus", "concept_notes") From 6bf1b18b55bb6ce8ba9bb04271bb07f1f53e37a3 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:08:24 -0400 Subject: [PATCH 5/5] fix(study-guide): degrade per-row content decrypt failures in the cached list (#518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_cached_guides ran decrypt_json_column unguarded per row — one corrupt study_guides.content 500ed the WHOLE list instead of degrading just that entry. Wrap the per-row decrypt in try/except, log a warning, and fall back to content={} (exam_title/overview blank for that row only) so the rest of the list still renders. Extends tests/test_study_guide_encryption.py with a mixed-row case: one good encrypted guide + one corrupt-content guide returns 200 with the good guide's exam_title intact and the corrupt one's exam_title == "" (verified to fail via stash/run/pop when the guard is reverted). Co-Authored-By: Claude Opus 5 --- backend/routes/study_guide.py | 12 ++++++- backend/tests/test_study_guide_encryption.py | 36 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/backend/routes/study_guide.py b/backend/routes/study_guide.py index 8792d58d..4a227de4 100644 --- a/backend/routes/study_guide.py +++ b/backend/routes/study_guide.py @@ -4,6 +4,7 @@ Study guide generation and caching. """ +import logging import uuid from datetime import datetime, timezone @@ -33,6 +34,8 @@ from services.http_cache import cached_json, conditional, make_etag from services.request_context import current_request_id +logger = logging.getLogger(__name__) + router = APIRouter() @@ -250,7 +253,14 @@ def get_cached_guides(user_id: str, request: Request): result = [] for g in guides: - content = decrypt_json_column(g.get("content")) or {} + try: + content = decrypt_json_column(g.get("content")) or {} + except Exception: + logger.warning( + "get_cached_guides: content decrypt failed for guide %s; degrading", + g.get("id"), + ) + content = {} course_id = offering_to_course.get(g.get("offering_id")) result.append({ "id": g["id"], diff --git a/backend/tests/test_study_guide_encryption.py b/backend/tests/test_study_guide_encryption.py index fdde97b8..afcdce84 100644 --- a/backend/tests/test_study_guide_encryption.py +++ b/backend/tests/test_study_guide_encryption.py @@ -216,3 +216,39 @@ def table_side_effect(name): legacy_response = _run(dict(plaintext_content)) assert encrypted_response == legacy_response + + +class TestListDegradesCorruptContentPerRow: + """Finding 4: one corrupt study_guides.content row must not 500 the + whole /cached list — it degrades that row's content to {} (exam_title + "") while the other rows still decrypt normally. MUST fail if the + per-row try/except guard is reverted (verify via stash/run/pop).""" + + def test_one_corrupt_row_degrades_without_500ing_the_list(self): + guides = [ + {"id": "g_good", "offering_id": "off1", "exam_id": "e1", + "generated_at": "2026-04-01T00:00:00Z", + "content": encrypt_json({"exam": "Midterm", "overview": "Covers ch1-5"})}, + {"id": "g_corrupt", "offering_id": "off1", "exam_id": "e2", + "generated_at": "2026-03-01T00:00:00Z", + "content": "corrupt-not-json-not-b64"}, + ] + + def table_side_effect(name): + m = MagicMock() + if name == "study_guides": + m.select.return_value = guides + else: + m.select.return_value = [] + return m + + with patch("routes.study_guide.table", side_effect=table_side_effect), \ + patch("routes.study_guide.offering_course_id", return_value="c1"), \ + patch("routes.study_guide.term_for_offering", return_value=None): + r = client.get(f"/api/study-guide/{USER_ID}/cached") + + assert r.status_code == 200 + out = {g["id"]: g for g in r.json()["guides"]} + assert out["g_good"]["exam_title"] == "Midterm" + assert out["g_good"]["overview"] == "Covers ch1-5" + assert out["g_corrupt"]["exam_title"] == ""