Problem
Two free-text user-input columns are stored in plaintext:
feedback.commentTEXT-- plaintextfeedback.topicTEXT-- plaintextissue_reports.topicTEXTNOT NULL-- plaintextissue_reports.descriptionTEXTNOT NULL-- plaintext
Neither routes/feedback.py nor the issue-report write path imports services.encryption — verified by grepping every file that references the module.
Why it matters
These are unbounded free-text boxes shown to users at the moment something has gone wrong. In practice people paste whatever is on screen: error messages containing IDs, chat excerpts, assignment text, occasionally their own contact details. messages.content and notes.body — the other two places students type prose — are both encrypted. These two are the same class of input with none of the protection.
issue_reports.screenshot_urls points into the issues-media-files bucket, which was made private in #231, so the images are already protected. The description sitting beside them is not.
Proposed fix
encrypt_if_present at write, decrypt_if_present at read, matching notes_service.py.
Check first — this is the thing that makes it non-trivial:
- The admin surfaces that list feedback and issue reports must decrypt before render, or they will show ciphertext. Check
routes/admin.py for every read of these tables. - Confirm nothing filters or searches on
comment / description in SQL. If admin search over feedback text exists, encryption removes it and that trade needs a decision. feedback.selected_options (JSONB) is a fixed enum set, not free text — leave it queryable.
Backfill existing rows via db/backfill_encryption.py.
Acceptance
Parent: see the encryption-coverage epic. Verified against origin/main on 2026-08-02.
Problem
Two free-text user-input columns are stored in plaintext:
Neither
routes/feedback.pynor the issue-report write path importsservices.encryption— verified by grepping every file that references the module.Why it matters
These are unbounded free-text boxes shown to users at the moment something has gone wrong. In practice people paste whatever is on screen: error messages containing IDs, chat excerpts, assignment text, occasionally their own contact details.
messages.contentandnotes.body— the other two places students type prose — are both encrypted. These two are the same class of input with none of the protection.issue_reports.screenshot_urlspoints into theissues-media-filesbucket, which was made private in #231, so the images are already protected. The description sitting beside them is not.Proposed fix
encrypt_if_presentat write,decrypt_if_presentat read, matchingnotes_service.py.Check first — this is the thing that makes it non-trivial:
routes/admin.pyfor every read of these tables.comment/descriptionin SQL. If admin search over feedback text exists, encryption removes it and that trade needs a decision.feedback.selected_options(JSONB) is a fixed enum set, not free text — leave it queryable.Backfill existing rows via
db/backfill_encryption.py.Acceptance
feedback.comment,feedback.topic,issue_reports.topic,issue_reports.descriptionencrypted at writeCLAUDE.md+ Canopysapling-infrastructureencrypted-columns table updatedParent: see the encryption-coverage epic. Verified against
origin/mainon 2026-08-02.