feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: AES-256-GCM column-level encryption for user PII - #65

Merged
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption
May 3, 2026
Merged

feat: AES-256-GCM column-level encryption for user PII#65
AndresL230 merged 22 commits into
mainfrom
feat/aes-256-gcm-encryption

Conversation

@AndresL230

@AndresL230AndresL230 commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AES-256-GCM column-level encryption for user PII, OAuth tokens, assignment notes/points, document summaries/concept notes, session summaries, and chat messages. Includes encrypt-on-write + decrypt-on-read across all affected routes, a schema migration that retypes encrypted columns to TEXT, and an idempotent backfill script for existing rows.

Rollout — three things you need to do

1. What to apply to Supabase

Run one SQL file in the Supabase SQL editor: backend/db/migration_encryption_text_columns.sql. It contains four ALTER TABLE ... ALTER COLUMN ... TYPE TEXT USING ::TEXT statements.

Apply it once, before deploying the new code. The casts preserve existing rows as TEXT (e.g., 87.5 becomes the string "87.5"); the decrypt_if_present / decrypt_numeric fallbacks keep those legacy rows readable until you backfill.

2. Values you need to set

Just one new env var. ENCRYPTION_KEY = 64 hex chars (32 bytes). Generate it with:

python -c "import secrets; print(secrets.token_hex(32))"

Set it in:

  • backend/.env for local dev (backend/.env.example already has the placeholder + the generation command).
  • Wherever your backend actually runs in deploy (Cloudflare Workers/Pages env vars, Vercel, Fly, Railway, whatever — the same place SUPABASE_SERVICE_KEY is set today). docker-compose.yml already passes it through from backend/.env.

Two non-negotiables:

  • The same key must be used everywhere your backend runs. Different keys = ciphertext written by one instance is unreadable by another.
  • Never rotate or lose this key. Losing it permanently bricks every encrypted column. If you need to rotate, write a script that decrypts with the old key and re-encrypts with the new one in one transaction.

The backend will refuse to boot if ENCRYPTION_KEY is missing, not 64 chars, or not valid hex (intentional fail-fast).

3. The backfill script

backend/db/backfill_encryption.py. For each encrypted column on each table (users.name/email/bio/..., oauth_tokens.access_token/refresh_token, assignments.notes/points_*, documents.summary/concept_notes, sessions.summary_json, messages.content, room_messages.text), it:

  1. Selects every row.
  2. For each cell, calls decrypt(value) once.
  3. If it succeeds → already encrypted → skip.
  4. If it raises → the cell is still legacy plaintext → encrypt it and write back.

Prints counts per column. Idempotent — running it a second time changes nothing. Defaults to dry-run (counts only); --apply to actually write. --table users to do it in stages.

How to actually run all of this, in order

# 1. Generate and set the key
python -c "import secrets; print(secrets.token_hex(32))"# paste output as ENCRYPTION_KEY=... in backend/.env# AND set the same value in your deploy environment# 2. Apply the schema migration in Supabase# Dashboard → SQL Editor → paste contents of:# backend/db/migration_encryption_text_columns.sql → Run# 3. Deploy the new backend code (encryption wiring is on this branch)# 4. Backfill — first dry-run to see what will change:cd backend
.\venv\Scripts\python.exe -m db.backfill_encryption
# 5. Then actually apply:
.\venv\Scripts\python.exe -m db.backfill_encryption --apply

After the backfill completes successfully, the decrypt_if_present fallback-to-raw-value warnings should stop appearing in logs. That's the signal it worked.

Test plan

  • Apply migration_encryption_text_columns.sql to a Supabase branch DB
  • Set ENCRYPTION_KEY (64-hex) and confirm backend boots; confirm it refuses to boot when the var is missing/invalid
  • Run backend tests: cd backend && python -m pytest tests/ -q (includes new test_encryption.py)
  • Sign in via Google OAuth → confirm users.name/email written as ciphertext, /me returns plaintext
  • Upload a document → confirm documents.summary/concept_notes ciphertext at rest, decrypt on library/study-guide/flashcards reads
  • Sync calendar → confirm oauth_tokens.access_token/refresh_token and assignments.notes/points_* encrypted
  • Send a room message + a learn-session message → confirm room_messages.text and messages.content encrypted
  • Run backfill in dry-run on a copy of prod data, verify counts, then --apply and re-run to confirm idempotency (zero changes)
  • Confirm decrypt_if_present fallback warnings disappear from logs after backfill

Summary by CodeRabbit

  • New Features

    • End-to-end encryption for sensitive data (profiles, documents, sessions, messages, tokens).
  • Improvements

    • Decryption in responses and encryption on save for many user-facing flows.
    • Tighter per-request authorization across endpoints and stricter ownership checks.
  • Chores

    • Added encryption key configuration and dependency updates; adjusted schema to support encrypted values.
  • Tests & Docs

    • Added encryption test coverage and an implementation/rollout plan.

AndresL230and others added 21 commits May 3, 2026 01:11
- auth_guard.get_session_user_id no longer falls back to query-param user_id
- conftest installs autouse fixture stubbing require_self/admin/role for tests
- routes/graph.py enforces require_self on every endpoint
- routes/auth.py /me reads user_id from session token, not query param
- ENCRYPTED LATER markers placed on every column slated for column-level encryption
- adds the AES-256-GCM column encryption implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required by the upcoming services/encryption.py helper module that wraps
AES-256-GCM for sensitive Supabase columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds services/encryption.py providing encrypt/decrypt plus *_if_present
and JSON/numeric helpers for sensitive Supabase columns. Key is loaded
once at import time from ENCRYPTION_KEY (64 hex chars / 32 bytes); each
encrypt() mints a fresh 12-byte nonce and stores base64(nonce || ct+tag).
Reads use *_if_present helpers that fall back to the raw value with a
warning, so legacy plaintext rows continue to load until the backfill
runs.
Tests cover round-trip (ASCII/unicode/long/empty), JSON helpers, numeric
coercion, _if_present None passthrough, legacy plaintext fallback, nonce
randomness, and tamper detection. conftest.py sets ENCRYPTION_KEY to a
deterministic 32-byte zero key so the module imports cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire AES-256-GCM column encryption into the Google OAuth callback so user
names, emails, and OAuth access/refresh tokens are encrypted before
persistence. The plaintext locals stay in process memory long enough to run
the @bu.edu domain check, split the display name, and build the redirect
URL; encryption happens at the table boundary only.
Also disables the legacy email-based account merge branch — random-nonce
GCM ciphertext cannot be matched by an equality lookup on the plaintext
email — and drops the `name` query param from the post-signin redirect so
PII no longer leaks into HTTP logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arkers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r tutor prompts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…generation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pt build
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assignments.points_possible/points_earned and documents.concept_notes /
sessions.summary_json must store base64 ciphertext, not numeric/json. Casts
existing plaintext rows in place; the legacy-plaintext fallback in
services/encryption.py keeps reads working until backfill runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…json
Closes the deferred-work items from the plan:
- learn.py save_message/get_conversation_history encrypt+decrypt content
- learn.py end_session encrypts summary_json via encrypt_json
- flashcards.py _get_session_summary decrypts summary_json
- social.py send/edit/get_room_messages encrypt+decrypt room_messages.text
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The redirect URL no longer carries the user's display name (PII leak).
The Next.js callback now fetches it from /api/auth/me after the session
cookie is set, restoring end-to-end sign-in. /me decrypts the encrypted
name column before returning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every encrypted column, detects rows still stored as plaintext
(decrypt() raises), and rewrites them through the encrypt helpers.
Idempotent — re-runs are no-ops once every cell is valid ciphertext.
Defaults to dry-run; --apply actually writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 3, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend43d9015Commit Preview URL

Branch Preview URL
May 03 2026, 07:57 PM

@coderabbitai

coderabbitaiBot commented May 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds AES-256-GCM column-level encryption: new encryption service, test coverage, env/docker wiring, a migration to TEXT for certain columns, a one-shot backfill CLI, and pervasive route changes to encrypt sensitive writes, decrypt reads, and enforce request-based authorization.

Changes

Encryption rollout & route wiring

Layer / File(s)Summary
Data Shape / Schema Prep
backend/db/migration_encryption_text_columns.sql
Retypes specific numeric/JSONB columns to TEXT using USING <col>::TEXT to allow storing ciphertext strings.
Dependencies
backend/requirements.txt
Pins cryptography (>=42,<46).
Environment
backend/.env.example, docker-compose.yml
Adds ENCRYPTION_KEY placeholder and passes ${ENCRYPTION_KEY} into the backend service.
Encryption Core
backend/services/encryption.py
New AES-256-GCM helpers: key loader/validation, encrypt/decrypt, encrypt_if_present/decrypt_if_present, decrypt_numeric, encrypt_json/decrypt_json.
Backfill Tool
backend/db/backfill_encryption.py
One-shot CLI that scans configured tables/columns, detects already-encrypted vs legacy plaintext/JSON/numeric values, and optionally writes encrypted values with --apply.
Auth Guard Tightening
backend/services/auth_guard.py
get_session_user_id now requires a valid session token (no query-param fallback) and raises 401 on missing/invalid payload.
Route Integration (reads/writes & auth)
backend/routes/*.py (auth, onboarding, profile, admin, social, quiz, learn, calendar, gradebook, documents, flashcards, study_guide, graph)
Handlers now accept Request where applicable, enforce require_self/session ownership, encrypt sensitive fields on write and decrypt on read, and narrow SELECT projections. OAuth tokens are decrypted for use and encrypted on refresh/storage. Several patch/allowlist behaviors adjusted to exclude sensitive fields.
Frontend adjustments
frontend/src/app/auth/callback/page.tsx, frontend/src/app/api/auth/session/route.ts, frontend/src/middleware.ts
Auth callback no longer requires name param and fetches /api/auth/me from session; session route now requires/verifies authToken; middleware forwards sapling_session cookie to backend auth check.
Tests & Test Setup
backend/tests/conftest.py, backend/tests/test_encryption.py, backend/tests/*
Deterministic test key in env, autouse fixture bypasses session auth for route tests, new encryption unit tests (string/JSON/numeric/nonce/tamper), and multiple route test updates to accommodate request-based auth and encrypted fields.
Docs / Plan
docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
Comprehensive rollout plan, inventory of # ENCRYPTED LATER markers, migration/backfill instructions, and follow-up tasks.

Sequence Diagram

sequenceDiagram
participant Client
participant RouteHandler
participant AuthGuard
participant EncryptionSvc
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Write path (encrypt before persist)
Client->>RouteHandler: POST /api/... (user_id, sensitive)
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>EncryptionSvc: encrypt(sensitive)
EncryptionSvc-->>RouteHandler: ciphertext
RouteHandler->>Database: INSERT/UPDATE (ciphertext)
Database-->>RouteHandler: OK
RouteHandler-->>Client: 200 (response with decrypted fields)
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Database: Read path (decrypt on load)
Client->>RouteHandler: GET /api/.../{user_id}
RouteHandler->>AuthGuard: require_self(user_id, request)
AuthGuard-->>RouteHandler: authorized
RouteHandler->>Database: SELECT (ciphertext_field)
Database-->>RouteHandler: row with ciphertext
RouteHandler->>EncryptionSvc: decrypt_if_present(ciphertext)
EncryptionSvc-->>RouteHandler: plaintext or legacy value
RouteHandler-->>Client: 200 (plaintext)
end
rect rgba(200, 100, 100, 0.5)
Note over RouteHandler,EncryptionSvc: Legacy plaintext fallback
RouteHandler->>EncryptionSvc: decrypt_if_present(legacy_plaintext)
EncryptionSvc-->>RouteHandler: returns original plaintext (logs warning)
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
I hopped through code at dawn's first light,
Wrapped secrets snug in AES so tight,
Nonces fresh, each byte takes flight,
Now data sleeps secure each night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 16.56% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title 'feat: AES-256-GCM column-level encryption for user PII' directly and clearly summarizes the main feature being added—column-level encryption for sensitive user data.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description check✅ PassedThe PR description comprehensively covers what the PR does, why, rollout steps, environment configuration, backfill instructions, and test plan—all aligned with the description template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/aes-256-gcm-encryption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

if user_id:
require_self(user_id, request)
else:
user_id = get_session_user_id(request)
def get_students():
def get_students(request: Request):
"""Return a lightweight profile for every user in the DB."""
user_id = get_session_user_id(request)
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from google.auth.transport.requests import Request as GoogleAuthRequest

The encryption key is set in conftest.py so the module imports cleanly.
"""
import json
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/documents.py Fixed
Comment threadbackend/routes/flashcards.py Fixed
Comment threadbackend/routes/learn.py Fixed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
backend/routes/quiz.py (1)

190-203: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard student_name against nulls before template replacement.

decrypt_if_present() can return None. If users.name is null, the .replace("{student_name}", student_name) call raises TypeError after the quiz attempt and mastery updates have already been persisted.

Suggested fix
- student_name = decrypt_if_present(user_rows[0]["name"]) if user_rows else "Student"+ student_name = (+ decrypt_if_present(user_rows[0].get("name")) or "Student"+ if user_rows+ else "Student"+ )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/quiz.py` around lines 190 - 203, The student_name value used
in the prompt can be None because decrypt_if_present(user_rows[0]["name"]) may
return None; before calling .replace("{student_name}", student_name) ensure
student_name is a non-null string (e.g., coerce to a fallback like "Student" or
"" if None) so template replacement never receives None; update the code around
where student_name is assigned (the decrypt_if_present call and subsequent use
in ctx_prompt/_load_prompt) to coerce or default the value and use that
sanitized variable in the .replace call.
backend/routes/gradebook.py (1)

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return decrypted fields from create_assignment().

This route now writes encrypted points_possible, points_earned, and notes, then returns inserted[0] verbatim. Any client that uses the POST response to update local state will receive ciphertext instead of the values it just submitted, while the read endpoints return plaintext.

Suggested fix
 inserted = table("assignments").insert({
"id": new_id,
"user_id": body.user_id,
"course_id": body.course_id,
"title": body.title,
"category_id": body.category_id,
"points_possible": encrypt_if_present(body.points_possible),
"points_earned": encrypt_if_present(body.points_earned),
"due_date": body.due_date,
"assignment_type": body.assignment_type,
"notes": encrypt_if_present(body.notes),
"source": "manual",
})
- return {"assignment": inserted[0] if inserted else None}+ assignment = inserted[0] if inserted else None+ if assignment:+ assignment["points_possible"] = decrypt_numeric(assignment.get("points_possible"))+ assignment["points_earned"] = decrypt_numeric(assignment.get("points_earned"))+ assignment["notes"] = decrypt_if_present(assignment.get("notes"))+ return {"assignment": assignment}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/gradebook.py` around lines 242 - 255, The POST handler
currently inserts encrypted values (using encrypt_if_present) and returns
inserted[0] as-is, causing ciphertext to be returned; after the insert in
create_assignment (the block that builds inserted =
table("assignments").insert(...)), decrypt the stored fields before returning by
running points_possible, points_earned, and notes through the corresponding
decrypt helper (e.g., decrypt_if_present) and then return the modified
assignment object (rather than the raw inserted[0]); update the return to return
{"assignment": decrypted_assignment} so clients receive plaintext fields
consistent with read endpoints.
backend/routes/learn.py (1)

525-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Messages returned by resume_session are not decrypted.

When resuming a persisted session, the messages fetched from the database have encrypted content but are returned directly without decryption. This is inconsistent with get_conversation_history which decrypts message content.

🐛 Proposed fix
 msgs = table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
+ for m in msgs:+ m["content"] = decrypt_if_present(m.get("content"))
return {
"session": session_rows[0],
"messages": msgs,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 525 - 532, The messages returned by
resume_session are fetched into msgs via table("messages").select but not
decrypted; update resume_session to reuse the decryption logic from
get_conversation_history (or call get_conversation_history(session_id) instead
of returning raw msgs) so each message's content is decrypted before returning.
Locate the msgs variable and the return that currently returns session_rows[0]
and msgs, then either map over msgs and replace each message's content with the
decrypted value using the same helper used by get_conversation_history, or
delegate to get_conversation_history to produce the decrypted messages, and
return that result.
backend/routes/social.py (1)

343-371: ⚠️ Potential issue | 🟠 Major

user_name is stored unencrypted in room_messages.

The message text is encrypted but body.user_name is stored in plaintext. This is inconsistent with the PR's goal of encrypting PII. User names are PII and should be encrypted at rest.

This also requires updating get_room_messages to decrypt user_name in the message rows (line 290) and reply snippets (line 320).

🔒 Proposed fix to encrypt user_name
 row = table("room_messages").insert({
"room_id": room_id,
"user_id": body.user_id,
- "user_name": body.user_name,+ "user_name": encrypt_if_present(body.user_name),
"text": encrypt_if_present(body.text or None),
"image_url": body.image_url or None,
"reply_to_id": body.reply_to_id or None,
})
if row:
row[0]["text"] = decrypt_if_present(row[0].get("text"))
+ row[0]["user_name"] = decrypt_if_present(row[0].get("user_name"))

In get_room_messages, decrypt user_name when retrieving message rows and reply snippets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/social.py` around lines 343 - 371, send_room_message currently
stores body.user_name in plaintext; update the insert in send_room_message to
pass user_name through encrypt_if_present (same pattern as text) so user_name is
encrypted at rest. Then update get_room_messages to call decrypt_if_present on
the message row's user_name and also on any reply snippet user_name (the
reply_to handling at/around where reply snippets are built) to return decrypted
names to clients; preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
🧹 Nitpick comments (8)
backend/services/auth_guard.py (1)

60-61: 💤 Low value

Add exception chaining for better error tracing.

While catching a broad Exception is acceptable here (any decode failure should yield 401), using raise ... from would preserve the original exception context for debugging.

♻️ Suggested improvement
- except Exception:- raise HTTPException(status_code=401, detail="Not authenticated")+ except Exception as exc:+ raise HTTPException(status_code=401, detail="Not authenticated") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/services/auth_guard.py` around lines 60 - 61, The except block
currently swallows the original exception; update the handler in auth_guard.py
so the broad except Exception captures the original exception (e.g., except
Exception as e:) and re-raise the HTTPException with exception chaining (raise
HTTPException(status_code=401, detail="Not authenticated") from e) to preserve
the original traceback for debugging while still returning 401 from the
authentication function/handler.
backend/routes/onboarding.py (1)

14-14: 💤 Low value

Unused request parameter in search_courses.

The request: Request parameter is accepted but never used in this function. If this is intentional for consistency or future auth guards, consider adding a comment. Otherwise, remove it to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/onboarding.py` at line 14, The function search_courses
currently declares an unused parameter request: Request which causes confusion;
either remove the request parameter from the function signature of
search_courses (and any corresponding route decorator/handler registration) or,
if it's intentionally reserved for future use (e.g., auth or middleware), add an
inline comment above the signature explaining why request is unused (e.g., "#
request kept for future auth guard") and prefix the variable with an underscore
(request -> _request) to signal intentional non-use. Update any callers or route
mappings that expect the old signature accordingly and ensure imports (Request)
are cleaned up if removed.
backend/db/backfill_encryption.py (2)

91-91: 🏗️ Heavy lift

No pagination — large tables may cause memory/timeout issues.

table(table_name).select(...) on line 91 (and similar calls) loads all rows into memory. For tables with millions of rows (e.g., messages, room_messages), this could exhaust memory or timeout.

Consider adding batching with limit and offset, or using cursor-based pagination:

Example batch approach
BATCH_SIZE=1000offset=0whileTrue:
rows=table(table_name).select(
",".join([pk, *columns]),
limit=BATCH_SIZE,
offset=offset,
order=f"{pk}.asc"
) or []
ifnotrows:
break# process rows...offset+=BATCH_SIZE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` at line 91, The current call rows =
table(table_name).select(",".join([pk, *columns])) loads the entire table into
memory; change the logic around table(table_name).select to iterate in batches
(use limit/offset or a cursor) while selecting the same columns (referencing
table_name, pk, columns and the table(...) call) so you fetch e.g. BATCH_SIZE
rows at a time, process them, then advance the offset (or cursor) and repeat
until no rows remain; ensure you preserve ordering (order by pk) to make
pagination deterministic and avoid loading all rows into memory.

162-172: 💤 Low value

Corrupt JSON encrypted as raw string may cause decrypt mismatch.

When JSON parsing fails (line 164), the raw string is encrypted via encrypt(v) (line 169). However, reads use decrypt_json() which expects json.loads(decrypt(value)). Decrypting will succeed, but json.loads will fail on the corrupt JSON, triggering the fallback path.

This is acceptable since the fallback handles it, but documenting this behavior would help future maintainers understand why some cells fail JSON parsing after decryption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/db/backfill_encryption.py` around lines 162 - 172, When JSON parsing
fails in backfill_encryption.py (the _json.loads(v) exception path), we
currently encrypt the raw string with encrypt(v) so the value can round-trip
through decrypt_if_present/decrypt_json(); add a clear inline comment near the
except block explaining that decrypt_json() will call json.loads(decrypt(value))
and therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at
read time, and that this is intentional because the runtime fallback will handle
it; reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
frontend/src/app/auth/callback/page.tsx (1)

70-77: 💤 Low value

Silent failure fallback masks /api/auth/me errors.

When the /api/auth/me fetch fails (line 75), the code defaults onboardingCompleted = true, which could incorrectly skip onboarding for users who haven't completed it if the API is temporarily unavailable. Consider logging this case or retrying.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/auth/callback/page.tsx` around lines 70 - 77, The catch
block for the fetch('/api/auth/me') call in page.tsx silently sets
onboardingCompleted = true which can incorrectly skip onboarding on transient
API failures; update the catch to capture the error (e.g., catch (err)), log it
(console.error or your app logger) and set onboardingCompleted conservatively
(false or leave undefined) or implement a simple retry before deciding,
referencing the fetch('/api/auth/me') call and the onboardingCompleted/local
state update to locate the fix.
backend/routes/flashcards.py (1)

128-137: 💤 Low value

Silent pass on decrypt failure is acceptable but consider logging.

The try-except-pass on lines 133-136 silently ignores decryption failures for concept_notes. While this is intentional for legacy plaintext compatibility, the encryption module's docstring mentions "fallback logs a warning." Consider adding a logging statement here for consistency with the stated rollout observability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/flashcards.py` around lines 128 - 137, The code silently
swallows exceptions when calling decrypt_json on d.get("concept_notes"); instead
add a warning log inside the except to surface the failure (include context like
the doc id/index and use exc_info=True or log the exception message) while
preserving the fallback behavior of leaving concept_notes unchanged; reference
the decrypt_json call and d["concept_notes"], and ensure you use the module
logger (e.g., obtain logging.getLogger(__name__) or reuse an existing logger
variable) before logging.
backend/routes/learn.py (1)

231-239: ⚡ Quick win

Use explicit None union type annotation.

PEP 484 prohibits implicit Optional. The type hint should use | None for clarity.

♻️ Proposed fix
-def save_message(session_id: str, role: str, content: str, graph_update: dict = None):+def save_message(session_id: str, role: str, content: str, graph_update: dict | None = None):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/learn.py` around lines 231 - 239, Update the function
signature for save_message to use an explicit None union type for the
graph_update parameter: replace the implicit Optional style "graph_update: dict
= None" with the explicit union form "graph_update: dict | None = None"
(referencing the save_message function and the graph_update parameter) so the
type hint conforms to PEP 484; ensure no other code changes are required.
backend/tests/test_encryption.py (1)

99-106: Use specific exception type for tamper detection test.

The test currently catches Exception, which is too broad. AES-GCM tamper detection raises cryptography.exceptions.InvalidTag, and using the specific exception type makes the test more precise and prevents accidentally passing if a different exception is raised.

♻️ Proposed fix
 def test_tampered_ciphertext_raises():
import base64
+ from cryptography.exceptions import InvalidTag
ct = encryption.encrypt("secret")
raw = bytearray(base64.b64decode(ct))
raw[-1] ^= 0x01 # flip one bit in the tag
tampered = base64.b64encode(bytes(raw)).decode()
- with pytest.raises(Exception):+ with pytest.raises(InvalidTag):
encryption.decrypt(tampered)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/tests/test_encryption.py` around lines 99 - 106, The test
test_tampered_ciphertext_raises should assert the specific AES-GCM tamper
exception instead of catching all Exceptions: import InvalidTag from
cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/db/migration_encryption_text_columns.sql`:
- Around line 13-27: The migration casts documents.concept_notes and
sessions.summary_json to TEXT but services/encryption.py's decrypt_json() lacks
the legacy-plaintext fallback, so pre-backfill rows will break; update
decrypt_json() (in backend/services/encryption.py) to detect and return
plaintext JSON when input isn't valid ciphertext (similar to the scalar
fallbacks used by decrypt_text()/decrypt_number()), or alternatively ensure the
DB backfill and enabling of readers are performed atomically so decrypt_json()
never sees legacy plaintext—modify either decrypt_json() to include a JSON
fallback helper or adjust the deployment/migration process to run the backfill
before enabling readers for documents.concept_notes and sessions.summary_json.
In `@backend/routes/auth.py`:
- Around line 228-229: The code is encrypting an empty string when
creds.refresh_token is None (calling encrypt("")), causing inconsistent handling
vs backfilled rows; change the assignment that sets "refresh_token" to either
call encrypt_if_present(creds.refresh_token) or keep a None value when
creds.refresh_token is falsy so empty strings are not encrypted—update the
object construction where "refresh_token" is set (referencing encrypt and
creds.refresh_token) to use encrypt_if_present or conditional preservation of
None to match the backfill behavior.
In `@backend/services/encryption.py`:
- Around line 116-117: decrypt_json currently assumes input is encrypted and
will raise if passed legacy plaintext JSON; update decrypt_json to mirror
decrypt_if_present by attempting to decrypt then json.loads, but on decryption
failure (or if decrypted result is invalid) fall back to json.loads(value) so
pre-encryption JSON still parses; locate the decrypt_json function and wrap its
call to decrypt(value) in a try/except (or check the decrypt result) and use
json.loads(value) as the fallback.
In `@backend/tests/conftest.py`:
- Around line 32-69: The current fixture only monkeypatches already-imported
route modules, leaving services.auth_guard itself untouched and causing
import-order dependent behavior; update the fixture to also monkeypatch the
functions on the services.auth_guard module (replace auth_guard._decode_session
with _decode_session_stub and auth_guard.get_session_user_id,
auth_guard.require_self, auth_guard.require_admin, and auth_guard.require_role
with the corresponding stubs/_require_role_stub) so any later-imported routes
that reference auth_guard will get the bypassed implementations; keep the
existing stubs (_decode_session_stub, _get_session_user_id_stub,
_require_self_stub, _require_admin_stub, _require_role_stub) and use
monkeypatch.setattr(auth_guard, "<name>", <stub>) for each symbol.
---
Outside diff comments:
In `@backend/routes/gradebook.py`:
- Around line 242-255: The POST handler currently inserts encrypted values
(using encrypt_if_present) and returns inserted[0] as-is, causing ciphertext to
be returned; after the insert in create_assignment (the block that builds
inserted = table("assignments").insert(...)), decrypt the stored fields before
returning by running points_possible, points_earned, and notes through the
corresponding decrypt helper (e.g., decrypt_if_present) and then return the
modified assignment object (rather than the raw inserted[0]); update the return
to return {"assignment": decrypted_assignment} so clients receive plaintext
fields consistent with read endpoints.
In `@backend/routes/learn.py`:
- Around line 525-532: The messages returned by resume_session are fetched into
msgs via table("messages").select but not decrypted; update resume_session to
reuse the decryption logic from get_conversation_history (or call
get_conversation_history(session_id) instead of returning raw msgs) so each
message's content is decrypted before returning. Locate the msgs variable and
the return that currently returns session_rows[0] and msgs, then either map over
msgs and replace each message's content with the decrypted value using the same
helper used by get_conversation_history, or delegate to get_conversation_history
to produce the decrypted messages, and return that result.
In `@backend/routes/quiz.py`:
- Around line 190-203: The student_name value used in the prompt can be None
because decrypt_if_present(user_rows[0]["name"]) may return None; before calling
.replace("{student_name}", student_name) ensure student_name is a non-null
string (e.g., coerce to a fallback like "Student" or "" if None) so template
replacement never receives None; update the code around where student_name is
assigned (the decrypt_if_present call and subsequent use in
ctx_prompt/_load_prompt) to coerce or default the value and use that sanitized
variable in the .replace call.
In `@backend/routes/social.py`:
- Around line 343-371: send_room_message currently stores body.user_name in
plaintext; update the insert in send_room_message to pass user_name through
encrypt_if_present (same pattern as text) so user_name is encrypted at rest.
Then update get_room_messages to call decrypt_if_present on the message row's
user_name and also on any reply snippet user_name (the reply_to handling
at/around where reply snippets are built) to return decrypted names to clients;
preserve null/None handling consistent with
encrypt_if_present/decrypt_if_present usage.
---
Nitpick comments:
In `@backend/db/backfill_encryption.py`:
- Line 91: The current call rows = table(table_name).select(",".join([pk,
*columns])) loads the entire table into memory; change the logic around
table(table_name).select to iterate in batches (use limit/offset or a cursor)
while selecting the same columns (referencing table_name, pk, columns and the
table(...) call) so you fetch e.g. BATCH_SIZE rows at a time, process them, then
advance the offset (or cursor) and repeat until no rows remain; ensure you
preserve ordering (order by pk) to make pagination deterministic and avoid
loading all rows into memory.
- Around line 162-172: When JSON parsing fails in backfill_encryption.py (the
_json.loads(v) exception path), we currently encrypt the raw string with
encrypt(v) so the value can round-trip through
decrypt_if_present/decrypt_json(); add a clear inline comment near the except
block explaining that decrypt_json() will call json.loads(decrypt(value)) and
therefore decrypted corrupt JSON will re-trigger the JSON parse fallback at read
time, and that this is intentional because the runtime fallback will handle it;
reference the functions _json.loads, encrypt, decrypt_json, and
decrypt_if_present in the comment so future maintainers understand the
round-trip behavior and why some cells may still fail JSON parsing after
decryption.
In `@backend/routes/flashcards.py`:
- Around line 128-137: The code silently swallows exceptions when calling
decrypt_json on d.get("concept_notes"); instead add a warning log inside the
except to surface the failure (include context like the doc id/index and use
exc_info=True or log the exception message) while preserving the fallback
behavior of leaving concept_notes unchanged; reference the decrypt_json call and
d["concept_notes"], and ensure you use the module logger (e.g., obtain
logging.getLogger(__name__) or reuse an existing logger variable) before
logging.
In `@backend/routes/learn.py`:
- Around line 231-239: Update the function signature for save_message to use an
explicit None union type for the graph_update parameter: replace the implicit
Optional style "graph_update: dict = None" with the explicit union form
"graph_update: dict | None = None" (referencing the save_message function and
the graph_update parameter) so the type hint conforms to PEP 484; ensure no
other code changes are required.
In `@backend/routes/onboarding.py`:
- Line 14: The function search_courses currently declares an unused parameter
request: Request which causes confusion; either remove the request parameter
from the function signature of search_courses (and any corresponding route
decorator/handler registration) or, if it's intentionally reserved for future
use (e.g., auth or middleware), add an inline comment above the signature
explaining why request is unused (e.g., "# request kept for future auth guard")
and prefix the variable with an underscore (request -> _request) to signal
intentional non-use. Update any callers or route mappings that expect the old
signature accordingly and ensure imports (Request) are cleaned up if removed.
In `@backend/services/auth_guard.py`:
- Around line 60-61: The except block currently swallows the original exception;
update the handler in auth_guard.py so the broad except Exception captures the
original exception (e.g., except Exception as e:) and re-raise the HTTPException
with exception chaining (raise HTTPException(status_code=401, detail="Not
authenticated") from e) to preserve the original traceback for debugging while
still returning 401 from the authentication function/handler.
In `@backend/tests/test_encryption.py`:
- Around line 99-106: The test test_tampered_ciphertext_raises should assert the
specific AES-GCM tamper exception instead of catching all Exceptions: import
InvalidTag from cryptography.exceptions and change the context manager to with
pytest.raises(InvalidTag): calling encryption.decrypt(tampered). Keep the rest
of the test (encryption.encrypt and bit-flip tampering) unchanged so it still
exercises tamper detection.
In `@frontend/src/app/auth/callback/page.tsx`:
- Around line 70-77: The catch block for the fetch('/api/auth/me') call in
page.tsx silently sets onboardingCompleted = true which can incorrectly skip
onboarding on transient API failures; update the catch to capture the error
(e.g., catch (err)), log it (console.error or your app logger) and set
onboardingCompleted conservatively (false or leave undefined) or implement a
simple retry before deciding, referencing the fetch('/api/auth/me') call and the
onboardingCompleted/local state update to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c1bef18-e8e8-4608-9891-9e17ab2b8476

📥 Commits

Reviewing files that changed from the base of the PR and between 1d184ef and ba27384.

📒 Files selected for processing (29)
  • backend/.env.example
  • backend/db/backfill_encryption.py
  • backend/db/migration_encryption_text_columns.sql
  • backend/requirements.txt
  • backend/routes/admin.py
  • backend/routes/auth.py
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/onboarding.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/routes/social.py
  • backend/routes/study_guide.py
  • backend/services/auth_guard.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_encryption.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_onboarding_routes.py
  • backend/tests/test_shared_course_context.py
  • backend/tests/test_social_messages.py
  • docker-compose.yml
  • docs/superpowers/plans/2026-05-03-aes-256-gcm-column-encryption.md
  • frontend/src/app/auth/callback/page.tsx

Comment threadbackend/db/migration_encryption_text_columns.sql
Comment threadbackend/routes/auth.py Outdated
Comment threadbackend/services/encryption.py Outdated
Comment threadbackend/tests/conftest.py
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 2 issues:

  1. resume_session returns encrypted messages.content to the client without decrypting. save_message now writes encrypt_if_present(content) and get_conversation_history correctly applies decrypt_if_present, but resume_session selects id,role,content,created_at from the messages table and returns the rows as-is — users resuming a session will see base64 ciphertext instead of message text.

msgs=table("messages").select(
"id,role,content,created_at",
filters={"session_id": f"eq.{session_id}"},
order="created_at.asc",
)
return {
"session": session_rows[0],
"messages": msgs,
}

  1. The /auth/me endpoint signature changed to require a verified session (get_session_user_id(request) — accepts only an auth_token query param or sapling_session cookie), but frontend/src/middleware.ts still calls ${API_URL}/api/auth/me?user_id=... from edge middleware without forwarding either credential. Edge fetch() does not auto-forward browser cookies, so every protected-route navigation will receive 401 Not authenticated and fall into the redirectToSignin(request, 'session_expired') branch — full sign-in regression for approved users. The same ?user_id=... pattern is also used by frontend/src/app/api/auth/session/route.ts (fallback path) and frontend/src/context/UserContext.tsx.

if(!API_URL)returnredirectToSignin(request,'google_not_configured')
try{
constcontroller=newAbortController()
consttimeout=setTimeout(()=>controller.abort(),3000)
letres: Response
try{
res=awaitfetch(
`${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`,
{signal: controller.signal},
)
}finally{
clearTimeout(timeout)
}
if(!res.ok)returnredirectToSignin(request,'session_expired')
constdata=awaitres.json()
if(data.is_approved!==true)returnNextResponse.redirect(newURL('/pending',request.url))
}catch{
returnredirectToSignin(request,'signin_failed')
}

def_decode_session(request: Request) ->dict:
"""Extract and verify the session token from query params or cookies."""
token=request.query_params.get("auth_token") orrequest.cookies.get("sapling_session")
ifnottokenornotSESSION_SECRET:
raiseHTTPException(status_code=401, detail="Not authenticated")
parts=token.split(".")
iflen(parts) !=2:
raiseHTTPException(status_code=401, detail="Invalid session token")
payload_b64, sig_b64=parts
# Verify signature
expected_sig=_hmac.new(
SESSION_SECRET.encode(), payload_b64.encode(), hashlib.sha256
).digest()
expected_b64=base64.urlsafe_b64encode(expected_sig).decode().rstrip("=")
ifnot_hmac.compare_digest(sig_b64, expected_b64):
raiseHTTPException(status_code=401, detail="Invalid session token")
# Decode payload
padding=4-len(payload_b64) %4
ifpadding!=4:
payload_b64+="="*padding
try:
payload=json.loads(base64.urlsafe_b64decode(payload_b64).decode())
exceptException:
raiseHTTPException(status_code=401, detail="Invalid session token")
# Check expiry
ifpayload.get("exp", 0) <int(_time.time()):
raiseHTTPException(status_code=401, detail="Session expired")
returnpayload

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- learn.py resume_session: decrypt messages.content before returning
- middleware.ts: forward sapling_session cookie to backend /me
- session/route.ts: require authToken; remove dead unauthenticated /me fallback
- encryption.py decrypt_json: fall back to plaintext JSON for legacy rows
- documents.py / learn.py / flashcards.py: drop now-unnecessary try/except wrappers around decrypt_json
- auth.py: use encrypt_if_present for refresh_token to preserve None
- conftest.py: monkeypatch services.auth_guard so lazy route imports inherit the bypass; expose _real_* for tests that need the originals
- test_flashcard_import_routes.py: import _real_require_self instead of the bypassed alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/app/api/auth/session/route.ts (1)

57-70: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Align the sapling_session payload with backend auth_guard.

This route still mints the browser session through signSession(verifiedUserId), but frontend/src/lib/sessionToken.ts:32-41 signs { userId, exp } while backend/services/auth_guard.py:54-64 looks for payload["user_id"]. After this PR, /api/auth/me and the newly request-authenticated backend routes rely on that cookie, so the frontend will accept the session locally but the backend will 401 it and bounce approved users back to sign-in. Please make both sides read/write the same claim name, ideally with backward-compatible decoding during rollout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/api/auth/session/route.ts` around lines 57 - 70, The
frontend is signing session tokens with a payload using the claim name userId
while the backend auth_guard expects user_id; update the signer used by
signSession (and the payload created in frontend's sessionToken.ts) to emit {
user_id, exp } instead of { userId, exp } or add backward-compatible decoding in
signSession so it also sets user_id when only userId exists, and ensure
verifyAuthToken/signSession continue to produce the cookie name sapling_session
with the same maxAge settings so backend(routes using auth_guard.py) will accept
the cookie during rollout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/routes/auth.py`:
- Around line 225-230: The calendar token reader must decrypt persisted
ciphertext before building the Credentials object: update the code that
constructs Credentials(...) in backend/routes/calendar.py (where it currently
passes token_row["access_token"] and token_row.get("refresh_token")) to call
decrypt on access_token and decrypt_if_present on refresh_token (or handle
None/empty), and convert the stored expires_at ISO string back to a datetime for
expiry; ensure the same decrypt helper used for encrypt_if_present is referenced
so refresh/decode works after the Google callback writes encrypted tokens.
---
Outside diff comments:
In `@frontend/src/app/api/auth/session/route.ts`:
- Around line 57-70: The frontend is signing session tokens with a payload using
the claim name userId while the backend auth_guard expects user_id; update the
signer used by signSession (and the payload created in frontend's
sessionToken.ts) to emit { user_id, exp } instead of { userId, exp } or add
backward-compatible decoding in signSession so it also sets user_id when only
userId exists, and ensure verifyAuthToken/signSession continue to produce the
cookie name sapling_session with the same maxAge settings so backend(routes
using auth_guard.py) will accept the cookie during rollout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b8fe9a-7972-4263-9df1-4a7b944fcea4

📥 Commits

Reviewing files that changed from the base of the PR and between ba27384 and 43d9015.

📒 Files selected for processing (9)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/flashcards.py
  • backend/routes/learn.py
  • backend/services/encryption.py
  • backend/tests/conftest.py
  • backend/tests/test_flashcard_import_routes.py
  • frontend/src/app/api/auth/session/route.ts
  • frontend/src/middleware.ts
✅ Files skipped from review due to trivial changes (2)
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/conftest.py

Comment on lines +208 to +222
# Email-based account merge is disabled because emails are now encrypted
# with random nonces; equality lookups by plaintext email cannot match.
# New sign-ins for users without a google_id always create a fresh row.
user_id = f"user_{google_id}"
is_approved = False
table("users").insert({
"id": user_id,
"name": encrypt_if_present(name),
"first_name": encrypt_if_present(first_name),
"last_name": encrypt_if_present(last_name),
"email": encrypt_if_present(email),
"google_id": google_id,
"avatar_url": avatar_url,
"auth_provider": "google",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This will orphan pre-existing accounts that don't already have google_id.

When no google_id match exists, the new path always inserts user_{google_id} with is_approved = False. Any existing approved user created before google_id was populated will end up with a second account and lose access to their original courses, documents, graph, and approval state on next sign-in. This needs a transition strategy before merge, such as a one-time backfill of google_id or a temporary legacy-link path that can still find the old row.

Comment on lines 225 to 230
table("oauth_tokens").upsert(
{
"user_id": user_id,
"access_token": creds.token,
"refresh_token": creds.refresh_token or "",
"access_token": encrypt(creds.token),
"refresh_token": encrypt_if_present(creds.refresh_token),
"expires_at": creds.expiry.isoformat() if creds.expiry else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update the calendar token reader in the same rollout.

These writes now persist ciphertext, but backend/routes/calendar.py:35-71 still passes token_row["access_token"] and token_row.get("refresh_token") directly into Credentials(...). After the first successful Google callback, calendar refresh/sync will start using encrypted strings and fail. Please land the matching decrypt-on-read change before shipping this write path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 225 - 230, The calendar token reader
must decrypt persisted ciphertext before building the Credentials object: update
the code that constructs Credentials(...) in backend/routes/calendar.py (where
it currently passes token_row["access_token"] and
token_row.get("refresh_token")) to call decrypt on access_token and
decrypt_if_present on refresh_token (or handle None/empty), and convert the
stored expires_at ISO string back to a datetime for expiry; ensure the same
decrypt helper used for encrypt_if_present is referenced so refresh/decode works
after the Google callback writes encrypted tokens.

@AndresL230
AndresL230 merged commit 8593633 into mainMay 3, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230