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
71 changes: 18 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ the constructorfabric/* namespace flip (2026-06-09). -->
| Language | TypeScript 6 (strict) |
| Styling | Tailwind CSS 4 + shadcn/ui (`base-vega` style, CSS variables) |
| Charts | Recharts 3 |
| Auth | OIDC via `oidc-client-ts` (Authorization Code + PKCE) |
| Auth | Server-side session (cookie/BFF through the gateway) |
| i18n | `i18next` + `react-i18next` (English only today) |
| Mocks | MSW (Mock Service Worker) |
| Linting | ESLint (flat config) |
Expand Down Expand Up @@ -53,7 +53,6 @@ To enable synthetic data for an offline / demo session, copy `.env.example` to `

```
VITE_ENABLE_MOCKS=true
VITE_DEV_USER_EMAIL=bob.park@example.com
```

A yellow warning strip renders at the top of the page whenever mocks are active so synthetic values cannot be mistaken for real ones. Set `VITE_HIDE_MOCK_BANNER=true` to hide the strip during screenshots — mocks remain active. Prod builds (`pnpm build`) drop the mock subtree entirely.
Expand All @@ -75,15 +74,15 @@ Seeded mock people: `bob.park@example.com`, `carol.chen@example.com`, `alice.kim

```
src/
auth/ # OIDC manager singleton, useAuth hook, start-url capture
api/ # Fetch clients (analytics, identity, accounts) + fetchWithAuth wrapper
queries/ # React Query hooks per screen (ic-dashboard, team-view, executive-view)
auth/ # Session probe + refresh driver, useAuth / useViewer hooks
api/ # Fetch clients (analytics, identity) + fetchWithAuth wrapper
queries/ # React Query hooks (metric-results, member-grid, metric-definitions)
routes/ # TanStack Router file-based routes (auto-discovered)
routeTree.gen.ts # ← auto-generated, do not edit
screens/ # Page components composed by routes
components/
ui/ # shadcn/ui primitives (button, card, dialog, alert, …)
widgets/ # Feature widgets (metric-card, bullet-chart, drill-modal, …)
widgets/ # Feature widgets (dashboard/, metric-views/, …)
app-sidebar.tsx # Org-tree sidebar (recursive nav)
theme-provider.tsx # Light/dark/system theme (localStorage-backed)
mock-banner.tsx # Warning strip when mocks are on
Expand All @@ -96,35 +95,19 @@ src/
i18n/ # i18next setup
types/ # Shared TypeScript types
index.css # Tailwind v4 inline config + theme tokens (light + dark)
main.tsx # Entry: storeStartUrl → enableMocking → OidcManager.init → render
main.tsx # Entry: consumeOverrideParam → enableMocking → loadSession → render
router.ts # createRouter(routeTree)
```

## Authentication (OIDC)
## Authentication

Authorization Code + PKCE via [`oidc-client-ts`](https://github.com/authts/oidc-client-ts). The OIDC issuer/client are not baked into the build — they're injected at container start.
Server-side cookie/BFF flow — the SPA holds no tokens.

### Flow

1. [src/main.tsx](src/main.tsx) calls `storeStartUrl()` (captures the full URL with any `?code=…&state=…` before the router strips it), then `OidcManager.init()` reads `window.__OIDC_CONFIG__` and restores a session from `sessionStorage` if one exists.
2. Root route's `beforeLoad` ([src/routes/__root.tsx](src/routes/__root.tsx)) inspects `authStore`. `authenticated` → render; `idle` / `expired` → `OidcManager.signIn()` (redirects to the IdP). `/callback` is whitelisted.
3. [src/routes/callback.tsx](src/routes/callback.tsx) calls `OidcManager.handleCallback(startUrl)` to exchange the code for tokens, then `window.location.replace(state.returnUrl)`.
4. [src/api/fetch-with-auth.ts](src/api/fetch-with-auth.ts) injects `Authorization: Bearer <token>` on every request. `X-Tenant-ID` is reserved for future use (current backend doesn't require it); when `authStore.tenantId` is populated by something downstream, the header fires automatically. On 401, it calls `OidcManager.refresh()` once and retries — concurrent in-flight refreshes are deduplicated inside `OidcManager.refresh()`.
5. Viewer identity is sourced directly from JWT claims via `oidc-client-ts` (`user.profile.email` / `user.profile.sub`) — no extra `/api/accounts/user/current` round-trip. The auth module's `useViewer()` hook is the single source of truth: it prefers the OIDC-authenticated user's email and falls back to `VITE_DEV_USER_EMAIL` in dev.

### Dev bypass

When `window.__OIDC_CONFIG__` is absent (typical for local dev) **and** `import.meta.env.DEV` is true, `OidcManager.init()` sets status to `authenticated` and resolves with no user. Combine with `VITE_DEV_USER_EMAIL` to impersonate a person from the identity service.

### Runtime config (Docker)

The container's `docker-entrypoint.sh` writes `/oidc-config.js` from env vars and injects a `<script src="/oidc-config.js">` tag into `index.html`. The script sets `window.__OIDC_CONFIG__ = { issuer_url, client_id, scopes }`.

| Variable | Description | Example |
|---|---|---|
| `OIDC_ISSUER` | OIDC issuer URL | `https://auth.example.com/application/o/insight/` |
| `OIDC_CLIENT_ID` | OAuth2 public client ID | `C6YjC67CCDBUMygEeoBIlSX3mhRkNpCPxQxa2zaT` |
| `OIDC_SCOPES` | Space-separated scopes | `openid profile email api://insight/Access.Default` |
1. The browser hits `/auth/login`; the gateway and authenticator run the provider handshake and set a `__Host-sid` session cookie.
2. [src/main.tsx](src/main.tsx) probes `/auth/me` via `loadSession()` before the router mounts, so the root route reads a resolved auth store.
3. [src/api/fetch-with-auth.ts](src/api/fetch-with-auth.ts) sends `credentials: "include"` on every request — no `Authorization` header, no tenant header. The gateway injects the downstream JWT.
4. A 401 bounces the whole page into `/auth/login?return_to=…` ([src/auth/use-auth.ts](src/auth/use-auth.ts)); there is no client-side token to refresh.
5. The session is non-sliding: [src/auth/refresh.ts](src/auth/refresh.ts) drives `POST /auth/refresh` on the server-supplied `refresh_at`.

## Environment Variables

Expand All @@ -134,24 +117,20 @@ Build-time (Vite, `.env.local`):
|---|---|
| `VITE_ENABLE_MOCKS` | `"true"` to enable MSW (dev only; stripped from prod). |
| `VITE_HIDE_MOCK_BANNER` | `"true"` to hide the warning strip while mocks are on (for screenshots). |
| `VITE_DEV_USER_EMAIL` | Impersonate a person by email when no OIDC session is present. |
| `VITE_API_PROXY_TARGET` | Dev-only `/api` proxy target (e.g. `http://localhost:8080`). |
| `VITE_API_BASE` | Override analytics API base URL (default `/api/analytics/v1`). |
| `VITE_IDENTITY_BASE` | Override identity API base URL (default `/api/identity/v1`). |
| `VITE_ACCOUNTS_BASE` | Override accounts API base URL (default `/api/accounts`). |

Runtime (container only, **no** `VITE_` prefix): `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_SCOPES`.

## Routes

| Path | Screen | Notes |
|---|---|---|
| `/` | IC dashboard (or impersonation prompt) | Resolves viewer via OIDC user or `VITE_DEV_USER_EMAIL`. |
| `/` | Dashboard for the signed-in viewer | Resolves the viewer from the session. |
| `/ic/$person` | (redirects to `/ic/$person/personal`) | |
| `/ic/$person/personal` | IC dashboard | Branches on viewer department (engineering vs sales). |
| `/ic/$person/team` | Team view | Members table, bullet sections, drill modals. |
| `/ic/$person/exec` | Executive view | Org KPIs, health radar, teams table. |
| `/callback` | OIDC callback handler | Exchanges code for tokens, redirects to original URL. |
| `/ic/$person/personal` | Dashboard | KPI row, attention list, metric group cards + drilldowns. |
| `/ic/$person/team` | Team view | Members heatmap, attention list, metric group drilldowns. |
| `/metrics` | Metric catalog | Metric definitions browser. |
| `/whats-new` | Release notes | |

## Theming

Expand All @@ -169,16 +148,6 @@ Runtime (container only, **no** `VITE_` prefix): `OIDC_ISSUER`, `OIDC_CLIENT_ID`
docker build -t insight-frontend:local .
```

### Run with OIDC

```bash
docker run -d -p 8080:80 \
-e OIDC_ISSUER=https://auth.example.com/application/o/insight/ \
-e OIDC_CLIENT_ID=your-client-id \
-e OIDC_SCOPES="openid profile email" \
insight-frontend:local
```

### Run without a backend (mock mode)

```bash
Expand All @@ -192,8 +161,6 @@ All screens render synthetic data and the warning strip stays visible.

```bash
cp docker-compose.yml docker-compose.override.yml
# Edit OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_SCOPES in the override

docker compose up -d --build
```

Expand All @@ -207,8 +174,6 @@ From the [insight monorepo](https://github.com/constructorfabric/insight):
./up.sh # full stack (ingestion + backend + frontend)
```

Helm chart supports OIDC config via `--set oidc.issuer=… --set oidc.clientId=… --set oidc.scopes=…`.

## License

See [LICENSE](LICENSE) and [NOTICE](NOTICE).
71 changes: 32 additions & 39 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,26 @@ checklist for exposing Insight to the public internet (without VPN).

## Threat model

The frontend is a public-facing SPA that authenticates users via OIDC
(Authorization Code + PKCE) against a customer-provided issuer and calls a
single backend through `/api/*`. Trust boundary: the FE itself is untrusted —
all authorization decisions live on the backend; the FE only attaches the
bearer token and the tenant id.

## Token storage

Access tokens are stored in `sessionStorage` via `oidc-client-ts`'s
`WebStorageStateStore`. This is the standard SPA tradeoff:

- `sessionStorage` is per-tab and cleared when the tab closes — better than
`localStorage` for blast radius.
- Tokens are accessible from JavaScript, so any XSS = token exfiltration. The
CSP below is the primary mitigation; keep it tight.
- HttpOnly cookies would be stronger, but require a backend session bridge —
out of scope for the current architecture.
The frontend is a public-facing SPA. Authentication is a server-side
cookie/BFF flow: the gateway and authenticator own the provider handshake and
set a `__Host-sid` session cookie; the SPA calls `/api/*` and `/auth/*`
same-origin. Trust boundary: the FE itself is untrusted — all authorization
decisions live on the backend, and the FE supplies no credentials of its own
beyond the cookie the browser sends automatically.

## Session handling

The SPA stores no tokens. The session is an HttpOnly, `Secure`, `__Host-`
prefixed cookie set by the authenticator; the gateway exchanges it for a
downstream JWT server-side.

- Not readable from JavaScript, so XSS cannot exfiltrate a long-lived
credential. The CSP below remains the primary XSS mitigation.
- State-changing `/auth/*` calls carry the session's CSRF token, which arrives
with `/auth/me` at boot.
- The session is non-sliding: `src/auth/refresh.ts` drives `POST /auth/refresh`
on the server-supplied `refresh_at`, so an idle tab's session dies on the
server's schedule rather than the client's.

## Headers shipped by nginx

Expand All @@ -42,45 +45,35 @@ Defined in `Dockerfile`:

- `style-src 'unsafe-inline'` is required for React inline styles and recharts.
Tightening this requires a nonce-based pipeline — tracked as a follow-up.
- `connect-src` and `frame-src` are templated at container start by
`docker-entrypoint.sh`: when `OIDC_ISSUER` is set, the issuer's origin is
substituted in (tight). When not set, falls back to broad `https:` (still
better than `*`). The substitution covers silent-renew iframe and token
endpoint requests.
- After deployment, verify silent renew works (token auto-refreshes after 5 min)
— if the OIDC issuer sets `X-Frame-Options: DENY` on its authorize endpoint,
silent renew will fail and you'll need a different refresh strategy.
- `connect-src` and `frame-src` stay at `'self'`: the SPA is same-origin only,
reaching `/api/*` and `/auth/*` through the gateway that fronts it. The nginx
config ships with no runtime placeholders.

## Build hygiene

- `build.sourcemap: false` — production bundles never ship source maps.
- `esbuild.drop: ['debugger']` — `debugger` statements are stripped.
- `console.*` calls are gated behind `import.meta.env.DEV` and tree-shaken in
production. After `npm run build`, verify with:
production. After `pnpm build`, verify with:
```sh
grep -c "AuthPlugin\|Auto-discovered\|OIDC skipped" dist/assets/*.js
# Expect: 0
ls dist/assets/*.map 2>/dev/null
# Expect: nothing
```

## Pre-deployment checklist (no-VPN exposure)

- [ ] `.env` on the build host does NOT contain `VITE_DEV_USER_EMAIL` or
`VITE_ENABLE_MOCKS=true`. These are dev-only and tree-shaken in prod, but
double-check there's no DEV build going to production.
- [ ] `.env` on the build host does NOT contain `VITE_ENABLE_MOCKS=true`. It is
dev-only and tree-shaken in prod, but double-check there's no DEV build
going to production.
- [ ] Container is served behind HTTPS-terminating reverse proxy. HSTS only
makes sense over TLS.
- [ ] `window.__OIDC_CONFIG__` is injected at container startup with the real
issuer URL, client id, and redirect URI. No fallback to mock auth in prod.
- [ ] Backend (`api-gateway`, `analytics-api`, `identity-resolution`) validates
the `X-Tenant-ID` header against the JWT's tenant claim. When the FE sends
this header (currently reserved — wired through `authStore.tenantId` in
`src/api/fetch-with-auth.ts`), without server-side validation a
logged-in user can read other tenants by editing the header in DevTools.
- [ ] Backend (`api-gateway`, `analytics-api`, `identity-resolution`) derives
tenant scoping from the session/gateway JWT only, never from a
client-supplied header. The FE sends no tenant header.
- [ ] Backend rate-limits unauthenticated and authenticated endpoints
separately. The FE has no rate-limiting and shouldn't.
- [ ] Verify silent renew works in staging for ≥10 minutes of idle session.
- [ ] Verify session refresh works in staging for ≥10 minutes of idle session
(`POST /auth/refresh` fires before the server-supplied `refresh_at`).
- [ ] Confirm CSP doesn't break recharts / shadcn / @base-ui styling on every screen.
- [ ] Run `npm audit --omit=dev` — no high-severity findings.

Expand Down
101 changes: 0 additions & 101 deletions cypilot/config/artifacts.toml
Original file line number Diff line number Diff line change
@@ -1,102 +1 @@
# Cypilot artifacts registry

[[systems]]
name = "Auth"
slug = "auth"
kit = "sdlc"
codebase = ["src/app/auth", "src/app/plugins"]

[[systems.artifacts]]
kind = "PRD"
path = "docs/components/auth/specs/PRD.md"
name = "Auth PRD"

[[systems.artifacts]]
kind = "DESIGN"
path = "docs/components/auth/specs/DESIGN.md"
name = "Auth Design"

[[systems.artifacts]]
kind = "DECOMPOSITION"
path = "docs/components/auth/specs/DECOMPOSITION.md"
name = "Auth Decomposition"

[[systems]]
name = "Layout"
slug = "layout"
kit = "sdlc"
codebase = ["src/app/layout"]

[[systems.artifacts]]
kind = "PRD"
path = "docs/components/layout/specs/PRD.md"
name = "Layout PRD"

[[systems.artifacts]]
kind = "DESIGN"
path = "docs/components/layout/specs/DESIGN.md"
name = "Layout Design"

[[systems]]
name = "UIKit"
slug = "uikit"
kit = "sdlc"
codebase = ["src/screensets/insight/uikit", "src/screensets/insight/screens/uikit"]

[[systems.artifacts]]
kind = "PRD"
path = "docs/components/uikit/specs/PRD.md"
name = "UIKit PRD"

[[systems.artifacts]]
kind = "DESIGN"
path = "docs/components/uikit/specs/DESIGN.md"
name = "UIKit Design"

[[systems]]
name = "Executive View"
slug = "executive-view"
kit = "sdlc"
codebase = ["src/screensets/insight/screens/executive-view"]

[[systems.artifacts]]
kind = "PRD"
path = "docs/domain/executive-view/specs/PRD.md"
name = "Executive View PRD"

[[systems.artifacts]]
kind = "DESIGN"
path = "docs/domain/executive-view/specs/DESIGN.md"
name = "Executive View Design"

[[systems]]
name = "Team View"
slug = "team-view"
kit = "sdlc"
codebase = ["src/screensets/insight/screens/team-view"]

[[systems.artifacts]]
kind = "PRD"
path = "docs/domain/team-view/specs/PRD.md"
name = "Team View PRD"

[[systems.artifacts]]
kind = "DESIGN"
path = "docs/domain/team-view/specs/DESIGN.md"
name = "Team View Design"

[[systems]]
name = "IC Dashboard"
slug = "ic-dashboard"
kit = "sdlc"
codebase = ["src/screensets/insight/screens/ic-dashboard"]

[[systems.artifacts]]
kind = "PRD"
path = "docs/domain/ic-dashboard/specs/PRD.md"
name = "IC Dashboard PRD"

[[systems.artifacts]]
kind = "DESIGN"
path = "docs/domain/ic-dashboard/specs/DESIGN.md"
name = "IC Dashboard Design"
3 changes: 1 addition & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
services:
insight-front:
# Serves the static SPA only. Auth (/auth/*) and the API (/api/*) are
# fronted by the nginx `gateway` in front of this container, so no OIDC
# env is needed here anymore.
# fronted by the nginx `gateway` in front of this container.
build: .
ports:
- "8080:80"
Expand Down
9 changes: 3 additions & 6 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@ set -e
# fronts `/api/*`, `/auth/*`, and `/` (this container). Authentication is a
# server-side cookie/BFF flow: the browser hits `/auth/login` (gateway ->
# authenticator -> IdP), gets a `__Host-sid` session cookie, and the SPA calls
# `/api/*` and `/auth/me` same-origin with `credentials: 'include'`. There is
# no client-side OIDC and no runtime config injection anymore, so this
# entrypoint only renders the nginx config template and execs the CMD.
# `/api/*` and `/auth/me` same-origin with `credentials: 'include'`.

# Place the nginx config. It has no runtime placeholders anymore (the SPA is
# same-origin only, so the CSP needs no injected OIDC issuer origin), but the
# build ships it under /etc/nginx/templates, so copy it into conf.d here.
# Place the nginx config. The build ships it under /etc/nginx/templates, so
# copy it into conf.d here.
cp /etc/nginx/templates/default.conf.template /etc/nginx/conf.d/default.conf

exec "$@"
21 changes: 0 additions & 21 deletions docs/components/auth/specs/DESIGN.md

This file was deleted.

Loading