Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
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
Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
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
Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
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
Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
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
Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
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
Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
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
Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
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
Merged
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
83 changes: 83 additions & 0 deletions docs/security/realtime-jwt-bridge-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
# Realtime option (a) — JWT-mint bridge (design, #231)

**Status: DESIGN for review. Build AFTER the RLS lockdown lands** — this is the
piece that restores `room_messages` realtime under RLS. Not built yet.

## Goal
Keep live chat working once RLS is enabled, **without** the public anon key:
the realtime client authenticates as the logged-in user via a Supabase JWT, and
an RLS policy scopes delivery to the user's room memberships.

## Why a JWT is needed
Sapling uses its **own HMAC session** (`sapling_session`), not Supabase Auth, so
`auth.uid()` is empty and RLS can't identify the user. We bridge by minting a
Supabase-format JWT for the same user and handing it to the realtime client.

## Components

### 1. Mint a Supabase JWT (backend)
At login (and on refresh), the backend mints a short-lived JWT signed with the
**Supabase JWT secret** (legacy HS256; the same secret behind the anon key):
```
claims: { sub: <user_id>, role: "authenticated", aud: "authenticated", exp: now+1h, iat: now }
```
- New endpoint, e.g. `GET /api/auth/realtime-token` (auth-gated by the existing
session): returns `{ token, expires_at }`, minted from the still-valid 30-day
session.
- New env `SUPABASE_JWT_SECRET` (from Supabase → Settings → API → JWT secret).
Add it to `validate_config()` (#174) as required outside local.
- Note: Supabase is migrating to **asymmetric signing keys**. If this project is
on the new keys, mint/verify with the project's signing key instead of an
HS256 shared secret — confirm which at build time.

### 2. RLS SELECT policy on room_messages (membership-scoped)
```sql
CREATE POLICY room_messages_member_read ON public.room_messages
FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.room_members m
WHERE m.room_id = room_messages.room_id AND m.user_id = auth.uid()
));
```
With the lockdown's RLS enabled and the JWT setting `auth.uid()`, an
`authenticated` subscriber receives changes **only for rooms they belong to** —
the client-side `room_id` filter stops being the only gate. (`authenticated`
still holds the table GRANT SELECT, which the lockdown intentionally left.)

### 3. Realtime delivery
Two options, smallest first:
- **(i) postgres_changes + the RLS policy above (recommended).** Realtime
evaluates the subscriber's RLS on each change, so the existing
`Social.tsx` subscription keeps working but is now membership-scoped. Minimal
client change: set the JWT (below). `room_messages` is already in the
`supabase_realtime` publication.
- **(ii) Private channels (Realtime Authorization).** Mark the channel
`{ config: { private: true } }` and add a policy on `realtime.messages` for
the topic. More robust/explicit but a larger client rework. Defer unless (i)
proves insufficient.

### 4. Client wiring (frontend)
- After login, fetch the realtime token and apply it:
`getSupabase().realtime.setAuth(token)` (and pass it when (re)creating the
client). The client stops relying on the anon key for authorization.
- **JWT refresh (the main complexity):** the session is **30 days** but the
Supabase JWT is **~1 hour**. Add a refresh loop — re-fetch the token shortly
before `expires_at` and call `setAuth` again — or the subscription drops when
the JWT expires. Handle: tab wake from sleep, network reconnect, and a failed
refresh (fall back to REST-only, which the #230 display fix already supports).

## Sequencing
1. RLS lockdown (separate, urgent) — breaks anon realtime (accepted).
2. This bridge — restores realtime for authenticated users, membership-scoped.

## Effort estimate
Moderate. Backend JWT-mint endpoint + env wiring (small); RLS SELECT policy
(small); client setAuth (small); **JWT refresh loop + reconnect handling
(the real work)**. Reuses the existing Supabase realtime architecture — no
backend fan-out/broker, no client rebuild (contrast option (c)).

## Optional follow-up
If live reactions are wanted back (the dead `room_reactions` subscription is
being removed), publish `room_reactions` to `supabase_realtime` and add the same
membership-scoped SELECT policy. Until then, reactions update on
load/refresh via REST.
97 changes: 97 additions & 0 deletions docs/security/rls-lockdown-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Project-wide RLS lockdown — apply & verification plan (#231)

**Status: APPLIED to production 2026-06-13.** Anon is confirmed locked out
(direct REST calls to `users`/`oauth_tokens`/`user_roles`/etc. now return
`permission denied`, SQLSTATE 42501). This doc is the record of what was applied
and how it was verified. The SQL scripts (`rls_lockdown.sql` apply,
`rls_lockdown_rollback.sql` emergency revert) live in **PR #232** as the applied
record — intentionally NOT merged to `main` (the change went straight to prod;
nothing re-runs them from the repo).

## Why this is safe for the backend
The backend authenticates to Supabase with `SUPABASE_SERVICE_KEY` → the
`service_role`, which has **`rolbypassrls = true`** (verified live: `SELECT
rolname, rolbypassrls FROM pg_roles` → `service_role=t`, `anon=f`,
`authenticated=f`). RLS does not apply to row-bypass roles, so **every backend
query keeps working unchanged**. RLS only constrains `anon`/`authenticated`,
which is exactly the public-anon-key path we're closing.

## Expected breakage (accepted)
Anon realtime on `room_messages` stops delivering once RLS is on / anon DML is
revoked. This stays broken until the **option (a)** JWT bridge lands
(`docs/security/realtime-jwt-bridge-design.md`). Per decision, the full-DB
exposure outranks live chat updates. The #230 display fix already re-fetches via
the (service-role) REST endpoint, so chat still works on load/refresh — only the
live push is paused.

## Test-first on a branch (if available)
Supabase branching wasn't reachable via the MCP for this project (`list_branches`
errored), so it may be on a plan/permission that doesn't expose it. If you have
branching:
1. Create a dev branch in the dashboard.
2. Run `rls_lockdown.sql` against the branch.
3. Run the verification below pointed at the branch.
4. Merge the branch (or apply the same SQL to prod) once green.

If branching is unavailable: apply to prod during a low-traffic window with
`rls_lockdown_rollback.sql` open and ready. The change is transactional
(`BEGIN/COMMIT`) and fast (DDL only, no table rewrites).

## Pre-apply snapshot (record for diffing)
```sql
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls_on,
count(*) FILTER (WHERE NOT relrowsecurity) AS rls_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expected before: rls_on=2, rls_off=38
```

## Apply
Run `backend/db/security/rls_lockdown.sql`.

## Post-apply verification checklist
1. **RLS now on for all public tables:**
```sql
SELECT count(*) FILTER (WHERE NOT relrowsecurity) AS still_off
FROM pg_class WHERE relnamespace='public'::regnamespace AND relkind='r';
-- expect: still_off = 0
```
2. **anon has no table DML left:**
```sql
SELECT count(*) AS anon_grants
FROM information_schema.role_table_grants
WHERE table_schema='public' AND grantee='anon'
AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE');
-- expect: anon_grants = 0
```
3. **anon is blocked at the REST endpoint** (the actual exposure): with the
public anon key,
```
curl -s -o /dev/null -w "%{http_code}\n" \
"https://jxqcmjqtjlpuxfrxmrdv.supabase.co/rest/v1/users?select=id&limit=1" \
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
```
Expect **401** (or `[]` with permission-denied), not a row. Repeat for
`user_roles`, `oauth_tokens`, `messages`.
4. **Backend still works (service_role):**
- `cd backend && python -m pytest tests/ -q` (suite is hermetic; sanity only).
- Hit live read + write endpoints against the target DB and confirm normal
behavior, e.g. `GET /api/auth/me` (read), a calendar/gradebook create
(write), a notes save. All should succeed exactly as before (service_role
bypasses RLS).
5. **Realtime is paused (expected):** open a room — messages still load and
refresh via REST; live push is down until option (a). No errors beyond the
subscription returning nothing.

## Rollback
If something critical breaks: run
`backend/db/security/rls_lockdown_rollback.sql` (re-grants anon, disables RLS on
the 38). ⚠️ This restores the insecure state — re-apply the lockdown + option
(a) as soon as the issue is understood.

## Follow-ups (not in this script)
- `authenticated` keeps its grants (RLS-with-no-policy denies it today); option
(a) adds membership-scoped policies for it on `room_messages`.
- Storage hardening is a separate track (`docs/security/storage-hardening-plan.md`).
- The 2 already-RLS tables (`achievement_cosmetics`, `achievement_triggers`)
have RLS on but **no policies** — confirm nothing legitimately reads them via
anon (the backend uses service_role, so it's unaffected).
54 changes: 54 additions & 0 deletions docs/security/storage-hardening-plan.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
# Storage hardening — PR-plan (#231)

**Status: DRAFT plan for review. Nothing applied.**

## Live findings (Sapling prod, read-only)
Buckets that actually exist (3):

| Bucket | `public` | Written by | Read by | Issue |
|---|---|---|---|---|
| `issues-media-files` (issue-report screenshots) | **true** | frontend **anon key** (`ReportIssueFlow.tsx`) | `getPublicUrl` (public) | anon upload + public read |
| `application_resumes` (résumés) | **true** | backend service key (`careers.py`) | `getPublicUrl` (public) | **résumé PII publicly readable** |
| `avatars` | true | backend service key (`storage_service.py`) | public `<img>` | intended public read |

`storage.objects` policies: `"Allow public read"` (SELECT, `{public}`, `issues-media-files`) and **`"Allow uploads"` (INSERT, `{public}`, no bucket/auth restriction)** → anyone can upload to **any** bucket, unauthenticated, unbounded (no size limit on `issues-media-files`/`application_resumes`).

Note: `chat-images` and `cosmetic-assets` referenced in code **do not exist** — those upload paths are dead (separate cleanup; not a live exposure).

## Target state
All storage writes go through the **backend (service_role)**; private buckets are read via **backend-generated signed URLs**; only `avatars` stays public-read. After this, there are **no anon/public storage policies** — the anon storage surface is gone.

| Bucket | public | upload path | read path |
|---|---|---|---|
| `issues-media-files` | **false** | new backend endpoint (multipart → service-key upload), reusing `request_limits.read_within_limit` + content-type allowlist (the #220/#229 pattern) | backend signed URL (admin view) |
| `application_resumes` | **false** | already backend (`careers.py`) | backend signed URL (admin view) |
| `avatars` | true | already backend | public (unchanged) |

## Changes

### SQL (review before applying)
```sql
BEGIN;
UPDATE storage.buckets SET public = false WHERE id IN ('issues-media-files','application_resumes');
DROP POLICY IF EXISTS "Allow uploads" ON storage.objects; -- kills the global public INSERT
DROP POLICY IF EXISTS "Allow public read" ON storage.objects; -- issues-media-files public read
COMMIT;
```
No new storage.objects policies are needed: backend uploads/reads use `service_role` (bypasses storage RLS). `avatars` stays `public=true` so its objects remain readable without a policy.

### Backend
- New `POST /api/issue-reports/screenshot` (auth-gated via `get_session_user_id`): accepts the file, validates type+size with the shared `request_limits` helpers, uploads to `issues-media-files` with the service key (mirror `careers._upload_resume`), returns the storage path (not a public URL).
- Signed-URL helper for private buckets (admin views of screenshots/résumés): backend issues a short-TTL signed URL via the storage REST API with the service key.

### Frontend
- `ReportIssueFlow.tsx`: stop using the anon `supabase.storage` client; POST the screenshot to the new backend endpoint. Removes a direct anon-key path (also shrinks the #231 surface).
- Admin résumé/screenshot views: fetch signed URLs from the backend instead of assuming public URLs.

## Verification
- `storage.buckets`: `issues-media-files` and `application_resumes` show `public=false`; `avatars` stays `true`.
- `pg_policies` (schema `storage`): the two `{public}` policies are gone.
- Anon upload attempt → denied. Public URL to a private-bucket object → 400/403; signed URL → 200.
- Issue-report flow and résumé upload still work end-to-end via the backend; avatars still render.

## Priority
`application_resumes` (résumé PII, publicly readable) is **equal priority** to the screenshots bucket — both flip to private first; the global public-INSERT policy is dropped in the same change.
Loading