docs(tasks): update phase 1, 2, and 3 tasks with forensic audit-1 hardening

This commit is contained in:
Tyler Gillispie 2026-08-26 15:14:48 -07:00
parent 1ef1125b4a
commit e88512edad
3 changed files with 186 additions and 159 deletions

View File

@ -9,25 +9,25 @@
claims.
- **Dependencies:** None.
- **Additional Important Notes:** Follow zero-trust default-deny principles for
ForwardAuth. Guest sessions minting needs to correctly log to the Merkle audit
ForwardAuth. Guest session minting must correctly log to the Merkle audit
ledger for SIEM visibility.
---
### 2. Architectural Considerations & Risks
## 2. Architectural Considerations & Risks
- **Risks:**
- **ForwardAuth Security:** Modifying ForwardAuth to accept `guest` accounts
risks inadvertently allowing guests to access applications they are not
explicitly scoped for. Strict validation of `auth.customScopes` against the
requested `appRecord.name` is critical.
- **Launchpad Leakage:** The `getDashboardApps` query modification must be
robust. If the UNION query is not correctly structured, it could leak
visibility of unregistered or unauthorized apps to guests.
- **Launchpad SQL Array Guard:** If a user or guest has no `app:` custom
scopes, passing an empty array into an `IN ()` clause causes invalid SQL.
The query must use `if (appNames.length > 0)` or
`WHERE name = ANY(${appNames}::text[])`.
- **Audit Logging Integrity:** Failure to capture the audit events properly
during the `join` phase would break SIEM visibility. Using the non-blocking
`auditWrapper.auditLog` is required to avoid impacting user request latency
while ensuring compliance.
`auditWrapper.auditLog` with `getClientIp(c)` is required.
- **Alternatives:**
- We considered a bypass in `ui/db_queries.ts` where guests completely skip
@ -35,51 +35,55 @@
seamlessly supports both pure guests and regular active users who might hold
delegated scoped passes. It provides a unified data retrieval flow.
### 3. Proposed Implementation
---
#### Phase 1: ForwardAuth Guest Ingress (`server/routes/auth_forward.ts`)
## 3. Proposed Implementation
1. **Permit Guest Accounts:** Update the account status validation to allow
`user.account_status === 'guest'` in addition to `'active'`.
### Phase 1: ForwardAuth Guest Ingress (`server/routes/auth_forward.ts`)
1. **Permit Guest Accounts:** Update the account status validation (around
line 120) to allow `user.account_status === 'guest'` in addition to
`'active'`.
2. **Custom Scopes Validation:** If the user is a guest (or relying on custom
scopes), verify that `auth.customScopes` contains the explicit application
grant (e.g., `app:${appRecord.name}`). If not, return a 403 Forbidden.
grant (e.g., `app:${appRecord.name}` or wildcard `*`). If not, return a
`403 Forbidden`.
3. **Header Injection:** For guest sessions, compute the scopes to inject into
`X-Forwarded-Scopes` by taking the comma-joined list of `auth.customScopes`
(`auth.customScopes.join(",")`). This propagates the app scope (e.g.,
`app:ed-droid`) and role (e.g., `viewer`) to downstream reverse proxies.
(`auth.customScopes.filter(Boolean).join(",") || "viewer"`). This propagates
the app scope (e.g., `app:ed-droid`) and role (e.g., `viewer`) to downstream
reverse proxies.
#### Phase 2: Launchpad App Query (`ui/db_queries.ts` & `ui/mod.ts`)
### Phase 2: Launchpad App Query (`ui/db_queries.ts` & `ui/mod.ts`)
1. **Function Signature Update:** Update
`getDashboardApps(userId: string, isAdmin: boolean, customScopes?: string[])`
to accept the session's custom scopes.
2. **UNION Query:** Update the non-admin SQL query to use a `UNION`.
- The first part queries explicitly granted apps via the `grants` table.
- The second part parses the provided `customScopes` array to extract allowed
app names (e.g., stripping the `app:` prefix), querying the `apps` table
for those matching names, and assigning them a pseudo-role such as
`"Guest (Viewer)"` (or extracting the role from the scopes if possible).
3. **UI Integration:** In `ui/mod.ts`, pass `auth.customScopes` from the
resolved session into `getDashboardApps`.
2. **UNION Query with Array Guard:**
- Extract app names from `customScopes` (e.g. scopes starting with `app:`).
- If `appNames.length > 0`, execute a `UNION` with
`SELECT id, name, description, domain, 'Guest (Viewer)' as role FROM apps WHERE domain IS NOT NULL AND name = ANY(${appNames}::text[])`.
- If `appNames.length === 0`, return only the primary `grants` query result.
3. **UI Integration:** In `ui/mod.ts` under `/dashboard`, pass
`auth.customScopes` from the resolved session into `getDashboardApps`.
#### Phase 3: Join Audit Logging (`server/routes/events.ts`)
### Phase 3: Join Audit Logging (`server/routes/events.ts`)
1. **Audit `POST /api/join` (Web):** Immediately after minting the guest
session, invoke
1. **Import `getClientIp`:** Import `getClientIp` from `../middleware.ts`.
2. **Audit `POST /api/join` (Web):** Immediately after minting the guest
session, invoke:
`auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "web" }, getClientIp(c))`.
2. **Audit `GET /join/:slug` (CLI):** Similarly, wire
`auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "cli" }, getClientIp(c))`
into this endpoint before returning the token.
3. **Audit `GET /join/:slug` (CLI):** Similarly, wire:
`auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "cli" }, getClientIp(c))`.
#### Phase 4: Unit Testing
### Phase 4: Unit Testing
1. **ForwardAuth Tests (`server/tests/forward_auth.test.ts`):**
- Add tests simulating a guest session with an `app:{name}` scope to ensure a
`200 OK` response with correctly formatted `X-Forwarded-Scopes`.
- Add tests simulating a guest session without the necessary `app:{name}`
scope to ensure a `403 Forbidden` response.
- Add test:
`Tier 1 & 2: GET /api/forward-auth - Guest session with app scope allowed`.
- Add test:
`Tier 1 & 2: GET /api/forward-auth - Guest session without app scope rejected (403)`.
2. **Events Tests (`server/tests/events.test.ts`):**
- Ensure the `POST /api/join` and `GET /join/:slug` assertions capture the
`auditLog` spy calls and verify the exact payload (`event_seat_claimed`
action, `event.id` as resource, correct metadata structure).
- Ensure the `POST /api/join` and `GET /join/:slug` assertions verify the
`auditLog` call payload (`event_seat_claimed` action, `event.id` as
resource, and correct metadata structure).

View File

@ -1,84 +1,101 @@
### 1. TASK METADATA
# TASK METADATA
- **Target Files:** `server/routes/events.ts`, `ui/components/LoginPage.tsx`,
`ui/components/RegisterPage.tsx`, `ui/components/EventJoinPage.tsx`
`ui/components/RegisterPage.tsx`, `ui/components/EventJoinPage.tsx`,
`server/tests/events.test.ts`
- **Core Objective:** Implement Phase 2 Event Overhaul: Add PIN discovery links,
normalize input, apply tiered Valkey rate limiting, and implement NAT-safe
idempotent re-entry on join.
- **Dependencies:** `server/middleware.ts` (for getClientIp),
bifurcate slug/PIN input normalization, apply tiered Valkey rate limiting on
failed brute-force attempts, and implement NAT-safe idempotent cookie
re-entry.
- **Dependencies:** `server/middleware.ts` (for `getClientIp`),
`server/ratelimit.ts` (for rate limiting logic).
- **Additional Important Notes:** Frontend changes should mirror backend
normalization. Re-entry must seamlessly return an existing active guest
session linked to the correct event without incrementing `seats_claimed`.
- **Additional Important Notes:** Attendee identity must remain 100% IP-agnostic
by relying on browser session cookies (NAT-safe for shared WiFi). IP tracking
is strictly reserved for throttling failed brute-force attacks and forensic
audit logs.
---
### 2. Architectural Considerations & Risks
## 2. Architectural Considerations & Risks
- **Risks:**
- If idempotent re-entry is flawed, returning a mismatched event session could
allow access to an event a user didn't register for, or incorrectly redirect
them. We must strictly verify the existing session is a guest session
belonging to the target event.
- Rate limiting logic might accidentally block legitimate NAT'ed traffic if
thresholds are set too low; 5 fail / 3 success per minute per IP should be
monitored.
- Normalization on the backend must be implemented securely using robust
string replacements to prevent SQL injection or unexpected matching
behaviors, especially with PostgreSQL parameterized queries
(`sqlWrapper.sql`).
- **Slug Hyphen Destruction:** Event slugs legitimately contain hyphens (e.g.,
`testing-workshop-event-...`). Stripping hyphens globally breaks slug
lookups. Normalization MUST bifurcate: preserve hyphens for slugs
(`LOWER(slug)`), while stripping hyphens only for PIN matching
(`REPLACE(pin_code, '-', '')`).
- **NAT False-Positives:** Never bind attendee identity or successful seats
strictly to an IP. Shared conference/classroom WiFi shares one egress IP.
Idempotent re-entry must inspect the incoming `session_id` cookie.
- **Rate Limiter State Machine:** Pre-check gates at the route entry must
reject exceeded IPs (`429`), while post-execution increments must distinctly
separate failed code attempts (`ratelimit:join:fail:<ip>`) from successful
joins.
- **Alternatives:**
- For normalization, instead of just runtime stripping, we could normalize
upon creation and store both normalized and display values. However,
stripping at runtime with `REPLACE(pin_code, '-', '')` and `LOWER(slug)` is
an acceptable and low-friction approach for our scale.
- Rate limiting could use sliding window algorithms, but Valkey-backed fixed
windows via the existing `rateLimitWrapper` are more efficient and standard
for our stack.
- Storing pre-normalized columns was evaluated and rejected; runtime
normalization with `LOWER(slug)` and `REPLACE(pin_code, '-', '')` is fast
and zero-migration.
### 3. Proposed Implementation
---
#### Phase 1: Login & Registration Discovery Links
## 3. Proposed Implementation
- **File:** `ui/components/LoginPage.tsx`
- Add a "Join with PIN" link next to the existing "Register with Invite" in
the footer links section to ensure unauthenticated users can easily discover
the join portal.
- **File:** `ui/components/RegisterPage.tsx`
- Add a similar "Join with PIN" link to the footer for consistency across
entry points.
### Phase 1: Login & Registration Discovery Links
#### Phase 2: Input Normalization (Frontend & Backend)
1. **`ui/components/LoginPage.tsx`:** Add a "Join with PIN" link next to
"Register with Invite" in the footer links section.
2. **`ui/components/RegisterPage.tsx`:** Add a similar "Join with PIN" link to
the footer for consistency across all unauthenticated entry points.
- **File:** `ui/components/EventJoinPage.tsx`
- Enhance the UI script to tolerate input formatting, naturally handling
hyphens, spaces, and case differences during submission, providing immediate
visual feedback without rejecting valid raw input (e.g. `241881` vs
`241-881`).
- **File:** `server/routes/events.ts` (`POST /api/join` & `GET /join/:slug`)
- Strip whitespace, hyphens, and force lowercase on the incoming code on the
backend (`code.trim().replace(/[-\s]/g, '').toLowerCase()`).
- Update the `sqlWrapper` query to match normalized inputs:
`WHERE (LOWER(slug) = ${code} OR REPLACE(pin_code, '-', '') = ${code})`.
### Phase 2: Bifurcated Input Normalization
#### Phase 3: Anti-DoS Rate Limiting
1. **`ui/components/EventJoinPage.tsx`:** Enhance the client-side script to
accept raw 6 digits (`241881`) or hyphenated PINs (`241-881`) and slugs
without throwing client-side validation errors.
2. **`server/routes/events.ts` (`POST /api/join` & `GET /join/:slug`):**
- Create two normalized representations:
- `rawNormalized = code.trim().toLowerCase()` (preserves hyphens for
slugs).
- `pinNormalized = code.trim().replace(/[-\s]/g, '')` (strips
hyphens/spaces for PINs).
- Update SQL query:
`WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})`
`AND is_active = TRUE AND (expires_at IS NULL OR expires_at > NOW())`
- **File:** `server/routes/events.ts` (`POST /api/join`)
- Use `getClientIp(c)` from our middleware to determine the client IP address.
- Mount tiered Valkey rate limits using the keys `ratelimit:join:fail:<ip>`
(limit: 5 per 60s) and `ratelimit:join:success:<ip>` (limit: 3 per 60s).
- Apply the rate limiting logic prior to executing any database queries to
prevent database DoS on brute-force PIN attempts.
### Phase 3: Anti-DoS Rate Limiting on `POST /api/join`
#### Phase 4: NAT-Safe Idempotent Re-entry
1. **Pre-Check Gate (Top of Route):**
- Resolve IP via `const clientIp = getClientIp(c)`.
- Check if `ratelimit:join:fail:${clientIp}` exceeds 5 attempts per 60s. If
so, return `429 Too Many Requests`.
2. **Post-Execution Increments:**
- If lookup returns 0 rows (invalid code / expired): increment
`ratelimit:join:fail:${clientIp}` (window: 60s) before returning `404`.
- If seat is successfully claimed: optionally increment
`ratelimit:join:success:${clientIp}` (limit: 3 per 60s).
- **File:** `server/routes/events.ts` (`POST /api/join`)
- Before claiming a seat and generating a new session, check for an existing
valid user session using `getAuthenticatedUser(c)`.
- If a valid session exists, query the database to determine if the `user_id`
is linked to a guest account for the target event (e.g., username matches
`guest_<event.slug>_%`).
- If the user is already a guest of this event, bypass the `seats_claimed + 1`
update and early-return `{ success: true, sessionId, redirectUrl }`, keeping
the existing session intact.
### Phase 4: NAT-Safe Idempotent Re-entry
1. **Session Cookie Inspection (`POST /api/join`):**
- Before executing the `seats_claimed + 1` update, check
`getAuthenticatedUser(c)`.
- If a valid session exists, verify if the session is a guest session
belonging to the matched event slug
(`username.startsWith('guest_' + event.slug + '_')`).
- If matched:
- Re-issue the `session_id` cookie with fresh TTL.
- Return
`{ success: true, sessionId: auth.sessionId, redirectUrl, reused: true }`
without incrementing `seats_claimed`.
### Phase 5: Unit Testing
1. **`server/tests/events.test.ts`:**
- Add test:
`POST /api/join - normalizes PIN without hyphens (241881 -> 241-881)`.
- Add test:
`POST /api/join - normalizes slug case-insensitively while preserving hyphens`.
- Add test:
`POST /api/join - idempotent re-entry returns existing session without burning seat`.
- Add test: `POST /api/join - enforces 5 failed attempts rate limit per IP`.

View File

@ -2,84 +2,90 @@
- **Target Files:** `ui/components/SessionsPage.tsx`,
`ui/components/sessions/WorkshopDrawer.tsx`,
`ui/components/sessions/SessionsScript.tsx`
`ui/components/sessions/SessionsScript.tsx`, `ui/ui_scripts.test.ts`
- **Core Objective:** Overhaul visual hierarchy of `/dashboard/sessions` and
enforce strict 2-state mutually exclusive rendering in `WorkshopDrawer.tsx`.
- **Dependencies:** None.
- **Additional Important Notes:** Must use pure vanilla JavaScript for DOM
manipulation (no React/framework state). Ensure copy pills trigger accessible
notifications (aria-live/toast). Fix double emoji bug in JS. Ensure mobile
title wrapping. All changes must pass `ui/ui_scripts.test.ts`.
notifications (aria-live/toast) with explicit `aria-label` tags. Fix double
emoji bug in JS. Ensure mobile title wrapping. All changes must pass
`ui/ui_scripts.test.ts`.
---
## Architectural Considerations & Risks
## 2. Architectural Considerations & Risks
- **Risks:**
- **DOM Manipulation Regression:** Because this UI operates strictly on
vanilla JavaScript, changing the layout and grouping DOM elements within new
containers (like `#eventCreateState` and `#eventHandoffState`) risks
breaking existing DOM ID bindings in `SessionsScript.tsx` if IDs are changed
or misplaced. We must strictly preserve all existing DOM IDs.
- **Test Compatibility:** The client-side scripts are tested via hermetic
`new Function()` execution in `ui/ui_scripts.test.ts` (`validateJsSyntax`).
The script modifications must remain structurally valid and strictly avoid
any unsupported syntax or external framework dependencies.
- **Mobile Layout Breakage:** When fixing the long titles to wrap correctly,
we must ensure CSS properties like `word-break: break-word` and
`overflow-wrap: break-word` don't inadvertently stretch flex containers
horizontally on small screens.
containers (`#eventCreateState` and `#eventHandoffState`) risks breaking
existing DOM ID bindings in `SessionsScript.tsx`. All existing DOM IDs must
be preserved.
- **Test Compatibility:** Client scripts are validated via hermetic execution
in `ui/ui_scripts.test.ts`. Script modifications must remain structurally
valid and strictly avoid any external framework dependencies.
- **Mobile Layout Breakage:** When fixing long titles to wrap correctly, CSS
properties `overflow-wrap: break-word` and `word-break: break-word` must be
paired with bounded max-widths to prevent horizontal container stretching.
- **Alternatives:**
- The proposed container-based 2-state machine approach for
`WorkshopDrawer.tsx` is native and robust. It's the most appropriate
solution given our constraints (no client-side frameworks). Using a
class-based toggle could also work, but explicit element display
manipulation is clearer for the specific 2-state requirement.
- The container-based 2-state machine approach for `WorkshopDrawer.tsx` is
native and robust. Using a class-based toggle was considered, but explicit
element display manipulation is clearer for the specific 2-state
requirement.
## Proposed Implementation
---
### 1. Page Hierarchy Update (`ui/components/SessionsPage.tsx`)
## 3. Proposed Implementation
- Relocate the main header block (containing `<h1>Active Sessions & Passes</h1>`
and the `[ 🔑 Delegate Session ]` button) to be the uppermost visible element
below the `#status-banner`.
- Move the `EventCockpitDeck` component to render below the new header block.
- Keep the `SessionTable` and `SessionDeck` at the bottom.
### Phase 1: Page Hierarchy Update (`ui/components/SessionsPage.tsx`)
### 2. WorkshopDrawer 2-State Machine (`ui/components/sessions/WorkshopDrawer.tsx`)
1. **Header Block Relocation:** Move the main header block
(`<h1>Active Sessions & Passes</h1>`, subtitle, and `[ 🔑 Delegate Session ]`
button) to be the uppermost visible element below the `#status-banner`.
2. **Section Hierarchy:**
- Section 1: `EventCockpitDeck` (`Event Passes` cards). If 0 event passes
exist, render a clean empty state or let the deck cleanly return null
without an orphaned section heading.
- Section 2: `SessionTable` / `SessionDeck` (`Active Sessions`).
- Wrap the initial creation form (`#eventForm`), including inputs, submit
button, and cancel button, into a new container `div` with
`id="eventCreateState"`. Default this container to `display: block`.
- Rename or wrap the `#eventHandoffModal` content into a new container `div`
with `id="eventHandoffState"`. Default this container to `display: none`.
- In `#eventHandoffState`, ensure it contains the 3 cards (PIN + /join, Direct
Link, CLI 1-Liner) and update the final button to be a single "Dismiss"
button.
- Ensure `createdEventTitle` has `overflow-wrap: break-word` and
`word-break: break-word` along with a max-width to allow long titles to wrap
cleanly on mobile screens.
### Phase 2: WorkshopDrawer 2-State Machine (`ui/components/sessions/WorkshopDrawer.tsx`)
### 3. JavaScript Logic Update (`ui/components/sessions/SessionsScript.tsx`)
1. **State 1 (`#eventCreateState`):** Wrap the initial creation form
(`#eventForm`), inputs, submit button, and cancel button in
`<div id="eventCreateState" style="display: block;">`.
2. **State 2 (`#eventHandoffState`):** Wrap the credential cards in
`<div id="eventHandoffState" style="display: none;">`.
- Render the 3 distinct cards (PIN + /join, Direct Link, CLI 1-Liner).
- Add explicit `aria-label` on copy buttons (`"Copy Universal PIN"`,
`"Copy Direct Link"`, `"Copy Terminal curl command"`).
- Render a single **"Dismiss"** button at the bottom of `#eventHandoffState`.
3. **Drawer Title Transition:** Add `id="eventDrawerHeaderTitle"` to the top
header so JavaScript can transition it from `"New Event Pass"` $\rightarrow$
`"Event Pass Active"`.
4. **Mobile Title Wrapping:** Ensure `createdEventTitle` has
`overflow-wrap: break-word; word-break: break-word;` with safe max-width.
- **State Toggling:** Update `handleCreateEvent` so that upon successful
creation, it sets
`document.getElementById('eventCreateState').style.display = 'none'` and
### Phase 3: JavaScript Logic Update (`ui/components/sessions/SessionsScript.tsx`)
1. **State Toggling:** Update `handleCreateEvent` so that upon successful
creation:
- Sets `document.getElementById('eventCreateState').style.display = 'none'`.
- Sets
`document.getElementById('eventHandoffState').style.display = 'block'`.
- **Drawer Reset:** Ensure when the delegate drawer is closed (via
`closeDelegateDrawer` or the new Dismiss button), the states are reset:
`#eventCreateState` to block, `#eventHandoffState` to none.
- **Emoji Fix:** Remove the hardcoded `'🎟️ '` prefix from the event title
- Updates
`document.getElementById('eventDrawerHeaderTitle').textContent = 'Event Pass Active'`.
2. **Drawer Reset:** When the drawer is closed or dismissed, reset state back:
- `#eventCreateState.style.display = 'block'`
- `#eventHandoffState.style.display = 'none'`
- Reset form inputs and restore header to `'New Event Pass'`.
3. **Emoji Fix:** Remove the hardcoded `'🎟️ '` prefix from the event title
injection line:
`document.getElementById('createdEventTitle').textContent = ev.name + ' Live!';`
- **Accessibility / Toast:** Ensure `copyEventHandoff` function already triggers
`showNotice` (which acts as a toast). Verify ARIA roles for the banner if
necessary, or just rely on the existing `showNotice` function which is already
in place.
### 4. Quality Gates
### Phase 4: Quality Gates
- Run Deno tests: `deno test --allow-all` (specifically validating
1. Run `deno fmt` and `deno task lint`.
2. Run Deno tests: `deno test --allow-all` (specifically validating
`ui/ui_scripts.test.ts`).
- Run code formatting: `deno fmt`.