Two teams built the same thing on different rails. Scott's "The One" is a Cloudflare-Access-gated portal that folds ~33 executive dashboards behind a single SGA login and a D1 entitlement registry. Dakota's SGA intranet is a Railway-hosted application platform — Fastify + Drizzle + Postgres — with a real relational access-control model, a live task/project engine, and a reporting hub. Each is strong exactly where the other is weak.
This plan merges them into one system. The One's dashboards, registry, and audit trail move into the intranet; the intranet's identity, RBAC, and live database absorb them. The result retires 44 Cloudflare projects and a second admin surface, and turns static dashboards into live pages that share the same Postgres as tasks, reporting, and access control. The Railway CLI is the execution engine for every data and service move.
Every dashboard a user opens in The One today, they open inside the intranet tomorrow — same Entra login, governed by one permission + scope grant, reading one Postgres. Cloudflare stays live as rollback until each is verified.
The One is a great distribution layer (gated hub + registry + audit) sitting on static, siloed dashboards. The intranet is a great application platform (relational RBAC, live API, task/project/reporting engine) that today carries fewer executive dashboards. Merged, each covers the other's gap:
| What the merge unlocks | Why it's only possible together |
|---|---|
| One login, one audit trail | Entra backs The One today; bring it to the intranet (Security section) and kill 44 middleware copies + a second admin surface. |
| Dashboards stop being islands | A number on a dashboard can spawn a task, feed an insight, or drive a scope grant — impossible while it's a static CF page. |
| Access control levels up | "Which dashboard can you see" collapses into permission + scope grants that also govern tasks, projects, and reporting — one model, one board. |
| Data consolidates | D1 SQLite islands → one Postgres + ClickHouse + Power BI. The intranet's ingest already mirrors the Gen4 ELT the dashboards depend on. |
| Ops gets cheaper | One Railway deploy, one health check, one migration ledger, one CI — instead of wrangler deploy × 44 and a hand-kept registry. |
sga-one-corp + admin /admin/sga-the-one: people / dashboards / grants / audit_middleware.js gate, fail-closed 403/healthNNN_*.sql, exactly-once on boot (ledger)/platform/access + /me/permissions/reporting/* (funnel, scorecard, bridge)Both systems key identity on the @sgadental.com email. The One's people → the intranet's users, and grants → permission grants, is a deterministic translation, not a fuzzy match. That is what makes this a consolidation instead of a rebuild.
Done naively, this migration is a perimeter regression: it moves ~33 dashboards from behind The One's hard edge gate onto a single public-origin app with app-layer password auth. The intranet must be hardened to match The One's perimeter first — that's why Phase 1 opens with a Perimeter Hardening workstream.
Neither system is strictly safer. The One wins on authentication + perimeter; the intranet wins on authorization + audit. The consolidation should combine the strong half of each.
| Axis | The One (Cloudflare) | Intranet (Railway) | Safer today |
|---|---|---|---|
| Authentication | Entra/M365 SSO — MFA, conditional access, no password store | Self-managed email/password + sga_session cookie | The One |
| Network perimeter | CF Access edge gate — no unauth request hits origin; WAF/DDoS | Public origin; login/reset/forms/health reachable pre-auth | The One |
| Authorization | Flat per-dashboard grants(role, mode) | 4-concept RBAC, deny-by-default, per-practice scope | Intranet |
| Auditability | audit table on grants | Richer grant audit + impersonation trail | Intranet |
| Identity lifecycle | One Entra offboard kills all access | App-managed users; offboarding done in-app | The One |
Today The One is 44 compartmentalized apps behind a hard edge gate with no public origin. Collapsing them into one public-origin app means a single auth bug exposes everything — and concentrates far more sensitive business data (labor, comp, P&L) in one place. The intranet's own history proves this class of bug is real (the X-SGA-Persona header once authenticated as hq-admin in prod; fixed).
*.up.railway.app origin so traffic only enters through the gated custom domain.HttpOnly/Secure/SameSite), keep rate-limit + Turnstile, remove DEMO_PASSWORD from prod.Net result: Entra SSO + edge perimeter (The One's strengths) plus 4-concept RBAC + audit (the intranet's strengths) — strictly safer than either system alone.
Before anything moves, map every dashboard and decide its fate. Scott's Claude drives this from The One source; Dakota confirms intranet overlaps.
| Task | Owner | Priority | Target |
|---|---|---|---|
| Dump registry schema + row counts (people/dashboards/grants/audit) | Scott | High | Wk 1 |
| Per-app audit: own DB? live vs static? which roles? | Scott | High | Wk 1 |
| Emit disposition table (rehost / iframe / rebuild) | Both | High | Wk 1 |
| Confirm intranet overlaps (pnl, pacing, case-acceptance…) | Dakota | Med | Wk 1 |
| Stand up a Railway staging environment for the migration | Dakota | High | Wk 1 |
A shared disposition table — one row per dashboard — is the gate out of Phase 0. Nothing moves to production until it's reviewed.
Before a single dashboard folds in, bring the intranet's perimeter up to The One's standard (see Security section). The intranet uses self-managed passwords on a public origin today; folding gated dashboards onto that as-is is a regression. This workstream is the gate on the whole migration.
| Task | Owner | Priority | Target |
|---|---|---|---|
Front app.sga3p.com with an edge gate — Cloudflare Access (CF Tunnel / CF-for-SaaS over Railway) | Dakota | High | Wk 2 |
| Adopt Entra OIDC SSO; retire self-managed passwords + reset flows | Dakota | High | Wk 2 |
Block the raw *.up.railway.app origin — only the gated custom domain accepts traffic | Eng | High | Wk 2 |
Verify cookie flags (HttpOnly/Secure/SameSite); remove DEMO_PASSWORD from prod; keep rate-limit + Turnstile | Eng | Med | Wk 2 |
No dashboard onboarding (Phase 3) or production data load (Phase 2) begins until this workstream is signed off. Cloudflare stays the perimeter until the intranet's is proven equivalent.
With the perimeter hardened, translate the D1 registry into the intranet's permission model. Once access is unified behind the gate, dashboards can arrive one at a time.
| Task | Owner | Priority | Target |
|---|---|---|---|
Map Entra identities → users (join on @sgadental.com email) | Dakota | High | Wk 2 |
Add a permission key per dashboard to the catalog (V1_PERMISSIONS) | Dakota | High | Wk 2 |
Write import-the-one-registry.ts: people→users, grants→permission grants (idempotent) | Eng | High | Wk 2 |
Run importer against staging via railway run, reconcile counts | Both | High | Wk 3 |
Verify on /platform/access: grant on → 200, off → 403, non-granted → 403 | Dakota | Med | Wk 3 |
D1 is SQLite; Postgres is stricter. Each dataset becomes a numbered migration (applied exactly-once on boot) plus an idempotent loader run through the Railway CLI.
| Task | Owner | Priority | Target |
|---|---|---|---|
Export D1s: wrangler d1 export for sga-the-one + labor/araging/ucr | Scott | High | Wk 3 |
| Type-cast pass: 0/1→bool, TEXT ts→timestamptz, JSON-in-TEXT→jsonb | Eng | High | Wk 3 |
Write generated/migrations/NNN_<app>.sql (schema shared) per dataset | Eng | High | Wk 4 |
railway redeploy → confirm ledger applies in railway logs | Both | Med | Wk 4 |
Loaders via railway run tsx load-app-dataset.ts <app>, reconcile counts | Eng | High | Wk 5 |
| PHI/PII sweep on free-text D1 tables before load (sample rows) | Both | High | Wk 3 |
Free-text D1 tables (notes/comments) are the top risk. Sample real rows, don't trust the schema. Patient PHI present → stop and hand to a PHI reviewer. Staff/business PII is Phase-A OK behind the gate.
shared schema, importer re-runnable (ON CONFLICT)Every dashboard takes one of three paths. Do fast rehosts first to prove the gate, then the high-value native rebuilds, leaving iframe as the pragmatic tail.
| Path | When | Example dashboards |
|---|---|---|
| Rehost | Reference/lookup content, no live query | dax-dictionary, enterprise-docs, data-trust, integrations-roster, prod-terminology, ai-governance |
| Rebuild | Number already in Postgres / overlaps /reporting/* | pnl, pacing, case-acceptance, scurve, cancel, weekly-output |
| Iframe | Complex app; want it gated now, rebuild later | the long tail — barometer, catchment, payor-intel, neurality… |
| Task | Owner | Priority | Target |
|---|---|---|---|
Rehost wave: bundle → SPA route (or static dashboards service), gate w/ permission | Both | High | Wk 4–5 |
| Rebuild wave: reporting-hub overlaps as native pages on live Postgres | Dakota | Med | Wk 5–8 |
| Iframe wave: static Railway service embedded in gated intranet shell | Eng | Low | Wk 6–8 |
Seed nav entries from imported dashboards rows | Eng | Med | Wk 5 |
Per-dashboard cutover, never a big bang. Cloudflare stays live as rollback until each intranet copy is signed off.
| Task | Owner | Priority | Target |
|---|---|---|---|
| Per-dashboard sign-off: side-by-side numbers match live CF | Both | High | Wk 7–9 |
| Replace each CF project with a redirect stub → intranet route | Scott | Med | Wk 8 |
Rotate + reissue secrets in Railway (railway variables --set), revoke old | Dakota | High | Wk 8 |
| Decommission CF Access apps + projects (only after sign-off) | Scott | Low | Wk 9 |
| Archive The One D1 audit history; retire the JSON project registry | Both | Low | Wk 9 |
| The One (Cloudflare) | → | Intranet (Railway) | Migration action |
|---|---|---|---|
| CF Access + Entra JWT | → | Intranet auth — upgrade to Entra SSO + edge gate first | Self-managed passwords today; harden before folding (see Security). Join key stays the sgadental.com email |
dashboards registry | → | Nav + a permission key each | Import rows → seed nav + catalog |
people | → | users | Match on email — identical join key |
grants(role, mode) | → | permission + scope grants | Translate dashboard_id + role → grant |
audit | → | Access-board audit | Fold in or archive D1 history |
ONE_DB (D1/SQLite) | → | Postgres (DATABASE_URL) | Export → type-cast → load |
per-app DB (labor/araging/ucr) | → | New tables in shared schema | Numbered migration per dataset |
<app>-corp static bundle | → | SPA route / static svc / native rebuild | Disposition (rehost/iframe/rebuild) |
_middleware.js | → | permission-authz preHandler | Delete middleware; intranet gate replaces it |
Everything The One did with wrangler + the D1 console + a JSON registry, the intranet does with the Railway CLI against one project. This is how Scott's Claude moves data and stands up services without a browser.
# Connect npm i -g @railway/cli railway login # or: railway login --browserless railway link # pick the SGA intranet project + environment railway status # Services present: api · intranet · marketing-ingest · Postgres railway service # Env vars — where reissued secrets land (never echo values) railway variables --service api railway variables --service api --set "KEY=value" # The migration workhorse — runs locally with prod DATABASE_URL injected railway run --service api tsx scripts/import-the-one-registry.ts railway run --service api tsx scripts/load-app-dataset.ts labor # Talk to Postgres directly railway connect Postgres # interactive psql on prod # Apply a new migration (added as generated/migrations/NNN_*.sql) railway redeploy --service api # ledger applies it once on boot railway logs --service api # watch it apply # Deploy a dashboard bundle / static service, attach a domain railway up --service intranet railway domain --service intranet
railway run is the keyIt executes a local script with the linked service's environment — crucially DATABASE_URL — injected. That's the safe way to run import/backfill scripts against the real Postgres. The One's "ROTATE the secrets" step becomes railway variables --set, one key at a time; the D1→Postgres load becomes railway run tsx ….
| Workstream | W1 | W2 | W3 | W4 | W5 | W6 | W7 | W8 | W9 |
|---|---|---|---|---|---|---|---|---|---|
| 0 · Align & inventory | |||||||||
| 1 · Identity & access | |||||||||
| 2 · Data → Postgres | |||||||||
| 3 · Dashboard onboarding | |||||||||
| 4 · Cutover & decommission | |||||||||
Identity unifies first (it gates everything). Data and dashboards overlap — a dashboard can rehost as soon as its data lands. Cutover trails each verification, so Cloudflare is always available as rollback until the last dashboard is signed off.
Rehosted bundles could be routes in apps/intranet or a dedicated dashboards Railway service the SPA iframes. SPA-native is cleaner UX; a separate service is faster to land the tail. Decision needed by end of Phase 0. Both
Import the D1 audit rows into the access board's trail, or keep them as a read-only archive and start the intranet audit fresh at cutover. Decision by Phase 2. Dakota
The reporting-hub overlaps (pnl, pacing, case-acceptance) are high-value native rebuilds. The long tail may live as iframes for a while. Rank by usage from The One's audit. Decision during Phase 3. Both