Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading
, '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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading
, '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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading
, '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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading
, '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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading
, '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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading
, '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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading
, '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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/db/_staging_ops.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
"""Shared helpers for the one-off staging ops scripts in this directory:
copy_courses_to_staging, grant_staging_admin, approve_staging_users.

Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a
Comment on lines +4 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

safe-by-default write guard, the staging-key setup, and the decrypt-email→user index.

Credentials are read from gitignored dotenv files (never argv/process env), so the
DSN/keys don't leak into shell history or logs.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import urlparse


def dotenv_value(path: str | Path, key: str) -> str:
p = Path(path)
for line in p.read_text().splitlines():
line = line.strip()
if line.startswith(key + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"ERROR: {key} not found in {p}")


def set_encryption_key(staging_env: str | Path = ".env.staging") -> None:
"""Put the staging ENCRYPTION_KEY in the process env. MUST be called before
importing services.encryption, which loads the key at import time."""
os.environ["ENCRYPTION_KEY"] = dotenv_value(staging_env, "ENCRYPTION_KEY")


def target_host(db_url: str) -> str:
return urlparse(db_url).hostname or "<unknown>"


def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply
Comment on lines +39 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.



def user_email_index(cur) -> dict[str, tuple[str, bool]]:
"""Map decrypted plaintext email -> (user_id, is_approved) for all users.

users.email is AES-GCM encrypted with a random nonce (non-deterministic), so
matching requires decrypting each row. Imports decrypt lazily so callers can
call set_encryption_key() first.
"""
from services.encryption import decrypt_if_present

cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index
Comment on lines +61 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

69 changes: 69 additions & 0 deletions backend/db/approve_staging_users.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"""One-off ops script: approve staging user accounts by email (is_approved=true).

This is the access gate the sign-in flow checks: a brand-new sign-in always lands
`is_approved=false` and there's no auto-approve path, so accounts must be approved
out of band. Mirrors the admin portal's `user.approve`, but works directly against
the DB (handy for bootstrapping before anyone is an admin).

Matches by decrypted email (see db/_staging_ops.user_email_index). Emails with no
user row yet (haven't signed into staging) are reported PENDING and skipped — they
can't be pre-approved because sign-in matches on google_id, not email. Idempotent.
Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu # preview
venv/bin/python -m db.approve_staging_users a@bu.edu b@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("emails", nargs="+")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply approvals (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

targets = {e.strip().lower() for e in args.emails}

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
index = user_email_index(cur)
to_approve = [] # (email, uid, was_approved)
pending = [] # email with no row yet
for email in sorted(targets):
if email in index:
uid, was = index[email]
to_approve.append((email, uid, was))
else:
pending.append(email)

applying = confirm_write(db_url, args.yes, f"approve {len(to_approve)} account(s)")
if applying:
for email, uid, was in to_approve:
if not was:
cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
conn.commit()

verb = "approved" if args.yes else "would approve"
for email, uid, was in to_approve:
state = "already approved" if was else verb
print(f" ✓ {email} ({uid}) -> {state}")
for email in pending:
print(f" … {email} -> PENDING: no staging account yet (must sign in once)")
print(f"\n{len(to_approve)} matched, {len(pending)} pending.")
return 0


if __name__ == "__main__":
sys.exit(main())
136 changes: 136 additions & 0 deletions backend/db/copy_courses_to_staging.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
"""One-off ops script: copy the canonical `courses` catalog from prod to staging.

`courses` is the shared course catalog (no `user_id`, no encrypted columns), so it
can be copied row-for-row across projects safely — unlike `users`, whose encrypted
columns are keyed to a per-environment ENCRYPTION_KEY.

This spans two Supabase projects, so it deliberately bypasses
db/connection.py::table() (single-environment). It reads PROD via PostgREST (prod has
no direct DB URL) and writes STAGING via psycopg, mirroring db/migrate.py's direct
connection. Writes target STAGING only; prod is read-only.

Sync semantics: additive upsert keyed on `id` (INSERT … ON CONFLICT DO UPDATE).
Courses deleted in prod are NOT removed from staging — this seeds/refreshes, it does
not mirror. Safe to rerun.

Usage (from backend/):
venv/bin/python -m db.copy_courses_to_staging # preview (read-only)
venv/bin/python -m db.copy_courses_to_staging --yes # apply the upsert
"""

from __future__ import annotations

import argparse
import sys

import httpx
import psycopg

from db._staging_ops import confirm_write, dotenv_value

# Columns present on prod's `courses` table (a subset of staging's schema). Staging
# defaults fill the rest (credits, meeting_times, location, syllabus_url).
COLUMNS = [
"id",
"course_code",
"course_name",
"department",
"description",
"instructor_name",
"school",
"semester",
"created_at",
]

PAGE = 1000 # rows requested per PostgREST Range page


def fetch_prod_courses(base_url: str, service_key: str) -> list[dict]:
# Page via the PostgREST Range header with Prefer: count=exact, and loop until
# we've fetched the total reported in Content-Range. Inferring completion from
# batch length vs PAGE is unreliable: if prod's db-max-rows is below PAGE the
# first response is short and the loop would stop early, under-fetching silently.
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Prefer": "count=exact",
}
select = ",".join(COLUMNS)
rows: list[dict] = []
offset = 0
total: int | None = None
with httpx.Client(timeout=30) as client:
while True:
range_headers = {**headers, "Range-Unit": "items", "Range": f"{offset}-{offset + PAGE - 1}"}
r = client.get(
f"{base_url}/rest/v1/courses",
params={"select": select, "order": "id"},
headers=range_headers,
)
r.raise_for_status()
batch = r.json()
rows.extend(batch)

# Content-Range looks like "0-999/1234" (or "*/1234" when empty).
content_range = r.headers.get("Content-Range", "")
if total is None and "/" in content_range:
tail = content_range.split("/", 1)[1].strip()
if tail.isdigit():
total = int(tail)

if not batch:
break
offset += len(batch)
if total is not None and offset >= total:
break
return rows


def upsert_staging(db_url: str, rows: list[dict]) -> int:
cols = ", ".join(COLUMNS)
placeholders = ", ".join(["%s"] * len(COLUMNS))
# Keep `created_at` in the INSERT (set on first seed) but exclude it from the
# UPDATE: it is effectively immutable, so re-runs must not overwrite staging's
# existing value with prod's.
immutable = {"id", "created_at"}
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLUMNS if c not in immutable)
sql = (
f"INSERT INTO courses ({cols}) VALUES ({placeholders}) "
f"ON CONFLICT (id) DO UPDATE SET {updates}"
)
values = [tuple(row.get(c) for c in COLUMNS) for row in rows]
with psycopg.connect(db_url, connect_timeout=15) as conn:
with conn.cursor() as cur:
cur.executemany(sql, values)
cur.execute("SELECT count(*) FROM courses")
total = cur.fetchone()[0]
conn.commit()
return total


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prod-env", default=".env")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the upsert (default: preview)")
args = ap.parse_args()

prod_url = dotenv_value(args.prod_env, "SUPABASE_URL")
prod_key = dotenv_value(args.prod_env, "SUPABASE_SERVICE_KEY")
staging_db = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

print(f"Reading courses from prod ({prod_url}) ...")
rows = fetch_prod_courses(prod_url, prod_key)
print(f" fetched {len(rows)} course rows")

if not confirm_write(staging_db, args.yes, f"upsert {len(rows)} courses"):
return 0

print("Upserting into staging ...")
total = upsert_staging(staging_db, rows)
print(f" done. staging courses table now has {total} rows")
return 0


if __name__ == "__main__":
sys.exit(main())
82 changes: 82 additions & 0 deletions backend/db/grant_staging_admin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
"""One-off ops script: approve a user and grant them the `admin` role in staging.

Bootstraps the first staging admin, which the in-app `assign_role` endpoint can't do
(it requires an *existing* admin). The user must have signed into staging at least
once (so their row exists).

Matches by decrypted email (see db/_staging_ops.user_email_index). The `admin`
role_id is a per-project UUID, so it's looked up by slug='admin', not hardcoded.
Idempotent. Targets STAGING only.

Usage (from backend/):
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu # preview
venv/bin/python -m db.grant_staging_admin aflopez@bu.edu --yes # apply
"""

from __future__ import annotations

import argparse
import sys

from db._staging_ops import confirm_write, dotenv_value, set_encryption_key, user_email_index


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("email")
ap.add_argument("--staging-env", default=".env.staging")
ap.add_argument("--yes", action="store_true", help="apply the grant (default: preview)")
args = ap.parse_args()

set_encryption_key(args.staging_env) # before importing services.encryption
db_url = dotenv_value(args.staging_env, "SUPABASE_DB_URL")

import psycopg

target = args.email.strip().lower()

with psycopg.connect(db_url, connect_timeout=15) as conn, conn.cursor() as cur:
match = user_email_index(cur).get(target)
if match is None:
print(
f"ERROR: no staging user with email {target!r}. "
f"Have them sign into staging once, then re-run."
)
return 1
uid, approved = match

cur.execute("SELECT id FROM roles WHERE slug = 'admin'")
role_row = cur.fetchone()
if not role_row:
print("ERROR: no role with slug='admin' in staging.")
return 1
admin_role_id = role_row[0]

print(f"{target} -> id={uid} (currently approved={approved})")
if not confirm_write(db_url, args.yes, "approve + grant admin"):
return 0

cur.execute("UPDATE users SET is_approved = true WHERE id = %s", (uid,))
cur.execute(
"INSERT INTO user_roles (user_id, role_id, granted_by) "
"VALUES (%s, %s, %s) ON CONFLICT (user_id, role_id) DO NOTHING",
(uid, admin_role_id, "bootstrap:grant_staging_admin"),
)
conn.commit()

cur.execute(
"SELECT u.is_approved, "
"coalesce(string_agg(r.slug, ',' ORDER BY r.slug), '') "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE u.id = %s GROUP BY u.is_approved",
(uid,),
)
is_approved, roles = cur.fetchone()
print(f"OK: {target} (id={uid}) -> approved={is_approved}, roles=[{roles}]")
return 0


if __name__ == "__main__":
sys.exit(main())
16 changes: 16 additions & 0 deletions backend/db/migrations/0019_newsletter_approved_at.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
-- newsletter_emails.approved_at: timestamp an admin approved an email for the
-- newsletter allowlist (NULL = pending). The admin allowlist endpoints
-- (POST /api/admin/allowlist/approve and /revoke in routes/admin.py) read and
-- write this column.
--
-- Drift fix: prod had this column added out-of-band (it exists there), but it was
-- never captured as a migration, so environments built purely from migrations
-- (staging, CI, fresh DBs) were missing it — which 500s the allowlist endpoints.
-- IF NOT EXISTS makes this a safe no-op where the column already exists (prod).
ALTER TABLE newsletter_emails
ADD COLUMN IF NOT EXISTS approved_at timestamptz;

-- Manual run notes:
-- 1. Apply on staging first (python -m db.migrate with the staging SUPABASE_DB_URL).
-- 2. On prod the column already exists, so this only records the migration.
-- 3. NULL approved_at = not yet approved; /allowlist/approve stamps it, /revoke nulls it.
Loading