System Consolidation · SGA Dental Partners

The One → SGA Intranet

Folding Scott's 44-project Cloudflare dashboard portal into the Railway-hosted intranet — one login, one access model, one database, one hub.
📅
Drafted 2026-08-25
👥
Scott (The One) & Dakota (Intranet)
🛠️
Migration engine: Railway CLI
🎯
~33 dashboards · 3 CF accounts → 1 system
Summary Why Merge The Two Systems Security 0 · Align 1 · Identity 2 · Data 3 · Dashboards 4 · Cutover Access Map Railway CLI Timeline Decisions

Executive Summary

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.

44 → 1
CF projects → hub
~33
Dashboards to fold
3 → 1
CF accounts → system
1
Identity · RBAC · DB
4
Phases + align

🎯 The one-line goal

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.

💡 Why merge — 1 + 1 = 3

🔗 The union thesis

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 unlocksWhy it's only possible together
One login, one audit trailEntra backs The One today; bring it to the intranet (Security section) and kill 44 middleware copies + a second admin surface.
Dashboards stop being islandsA 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 consolidatesD1 SQLite islands → one Postgres + ClickHouse + Power BI. The intranet's ingest already mirrors the Gen4 ELT the dashboards depend on.
Ops gets cheaperOne Railway deploy, one health check, one migration ledger, one CI — instead of wrangler deploy × 44 and a hand-kept registry.

🧭 The two systems today

The One · Scott

Cloudflare Pages · D1 · CF Access
  • Portal sga-one-corp + admin /admin/
  • 44 CF projects, ~33 live dashboards
  • Auth: CF Access → Entra JWT, sgadental.com only
  • Registry D1 sga-the-one: people / dashboards / grants / audit
  • Per-app _middleware.js gate, fail-closed 403
  • Static bundles; numbers baked from PBI/Databricks pulls
  • 3 CF accounts (corp / gmail / gen4)
consolidate

SGA Intranet · Dakota

Railway · Fastify · Drizzle · Postgres
  • npm-workspace monorepo: apps / services / packages
  • API: ~60 Fastify routes, Dockerfile deploy, /health
  • Migrations: NNN_*.sql, exactly-once on boot (ledger)
  • RBAC: title / role / permission / scope, catalog-as-code
  • Admin board /platform/access + /me/permissions
  • Reporting hub /reporting/* (funnel, scorecard, bridge)
  • Services: api · intranet · marketing-ingest · Postgres

✅ The join key is identical

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.

🛡️ Security & compliance — the merge must be a net upgrade

⚠️ Read before folding anything in

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.

⚖️ The honest comparison — strong on opposite axes

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.

AxisThe One (Cloudflare)Intranet (Railway)Safer today
AuthenticationEntra/M365 SSO — MFA, conditional access, no password storeSelf-managed email/password + sga_session cookieThe One
Network perimeterCF Access edge gate — no unauth request hits origin; WAF/DDoSPublic origin; login/reset/forms/health reachable pre-authThe One
AuthorizationFlat per-dashboard grants(role, mode)4-concept RBAC, deny-by-default, per-practice scopeIntranet
Auditabilityaudit table on grantsRicher grant audit + impersonation trailIntranet
Identity lifecycleOne Entra offboard kills all accessApp-managed users; offboarding done in-appThe One

💥 The blast-radius problem & the fix

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).

🔧 The fix — bring The One's perimeter to the intranet, then keep its RBAC

  • Front the intranet with an edge gate + SSO first — Cloudflare Access (CF Tunnel / CF-for-SaaS over Railway) and/or Entra OIDC in the app, retiring self-managed passwords.
  • Block the raw *.up.railway.app origin so traffic only enters through the gated custom domain.
  • Then the intranet's superior RBAC governs authorization behind that gate.
  • Verify session cookie flags (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.

📋 Compliance notes

  • PHI stays Phase A on both sides; no patient PHI moves until Phase B (post-BAA). Consolidation raises the business-data blast radius, so the perimeter work is a compliance control, not just hygiene.
  • Hosting BAA posture (Railway vs Cloudflare) only bites at Phase B — note it now, don't solve it yet.
  • Free-text D1 tables (notes/comments) are the top PHI risk — sample real rows before any load.
0

Align & Inventory

Week 1 · foundation

📋 Inventory & disposition

Before anything moves, map every dashboard and decide its fate. Scott's Claude drives this from The One source; Dakota confirms intranet overlaps.

TaskOwnerPriorityTarget
Dump registry schema + row counts (people/dashboards/grants/audit)ScottHighWk 1
Per-app audit: own DB? live vs static? which roles?ScottHighWk 1
Emit disposition table (rehost / iframe / rebuild)BothHighWk 1
Confirm intranet overlaps (pnl, pacing, case-acceptance…)DakotaMedWk 1
Stand up a Railway staging environment for the migrationDakotaHighWk 1

📎 Deliverable

A shared disposition table — one row per dashboard — is the gate out of Phase 0. Nothing moves to production until it's reviewed.

Phase 0 Exit Criteria

  • Every dashboard has a disposition (rehost / iframe / rebuild) + one-line rationale
  • Registry row counts recorded as the reconciliation baseline
  • Railway staging environment live and linked
1

Identity & Access Unification

Weeks 2–3 · harden the perimeter, then unify access

🛡️ Perimeter hardening DO FIRST — GATES EVERYTHING

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.

TaskOwnerPriorityTarget
Front app.sga3p.com with an edge gate — Cloudflare Access (CF Tunnel / CF-for-SaaS over Railway)DakotaHighWk 2
Adopt Entra OIDC SSO; retire self-managed passwords + reset flowsDakotaHighWk 2
Block the raw *.up.railway.app origin — only the gated custom domain accepts trafficEngHighWk 2
Verify cookie flags (HttpOnly/Secure/SameSite); remove DEMO_PASSWORD from prod; keep rate-limit + TurnstileEngMedWk 2

🚧 Gate criterion

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.

🔐 One identity, one RBAC

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.

TaskOwnerPriorityTarget
Map Entra identities → users (join on @sgadental.com email)DakotaHighWk 2
Add a permission key per dashboard to the catalog (V1_PERMISSIONS)DakotaHighWk 2
Write import-the-one-registry.ts: people→users, grants→permission grants (idempotent)EngHighWk 2
Run importer against staging via railway run, reconcile countsBothHighWk 3
Verify on /platform/access: grant on → 200, off → 403, non-granted → 403DakotaMedWk 3

Phase 1 Exit Criteria

  • SGA users authenticate to the intranet via Entra, no CF Access needed
  • Every dashboard has a permission key; The One's grants are reflected as intranet grants
  • Test matrix passes on staging (allow/deny/non-granted all correct, server-enforced)
2

Data Consolidation — D1 → Postgres

Weeks 3–5 · move the numbers

🗄️ Registry + per-app datasets into Postgres

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.

TaskOwnerPriorityTarget
Export D1s: wrangler d1 export for sga-the-one + labor/araging/ucrScottHighWk 3
Type-cast pass: 0/1→bool, TEXT ts→timestamptz, JSON-in-TEXT→jsonbEngHighWk 3
Write generated/migrations/NNN_<app>.sql (schema shared) per datasetEngHighWk 4
railway redeploy → confirm ledger applies in railway logsBothMedWk 4
Loaders via railway run tsx load-app-dataset.ts <app>, reconcile countsEngHighWk 5
PHI/PII sweep on free-text D1 tables before load (sample rows)BothHighWk 3

⚠️ PHI guardrail

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.

Phase 2 Exit Criteria

  • Registry + per-app datasets live in Postgres, counts reconciled source-vs-target
  • Every new table under shared schema, importer re-runnable (ON CONFLICT)
  • No patient PHI crossed the line without review
3

Dashboard Onboarding

Weeks 4–8 · fold the tiles, batched

🧩 Disposition decision tree

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.

PathWhenExample dashboards
RehostReference/lookup content, no live querydax-dictionary, enterprise-docs, data-trust, integrations-roster, prod-terminology, ai-governance
RebuildNumber already in Postgres / overlaps /reporting/*pnl, pacing, case-acceptance, scurve, cancel, weekly-output
IframeComplex app; want it gated now, rebuild laterthe long tail — barometer, catchment, payor-intel, neurality…

Batch onboarding tasks

TaskOwnerPriorityTarget
Rehost wave: bundle → SPA route (or static dashboards service), gate w/ permissionBothHighWk 4–5
Rebuild wave: reporting-hub overlaps as native pages on live PostgresDakotaMedWk 5–8
Iframe wave: static Railway service embedded in gated intranet shellEngLowWk 6–8
Seed nav entries from imported dashboards rowsEngMedWk 5

Phase 3 Exit Criteria

  • Every dashboard reachable inside the intranet behind its permission
  • Rehost + rebuild waves done; iframe tail scheduled with rebuild owners
  • Nav reflects entitlements — a tile appears only when the user is granted
4

Cutover & Decommission

Weeks 7–9 · retire Cloudflare

🚦 Verify, redirect, retire

Per-dashboard cutover, never a big bang. Cloudflare stays live as rollback until each intranet copy is signed off.

TaskOwnerPriorityTarget
Per-dashboard sign-off: side-by-side numbers match live CFBothHighWk 7–9
Replace each CF project with a redirect stub → intranet routeScottMedWk 8
Rotate + reissue secrets in Railway (railway variables --set), revoke oldDakotaHighWk 8
Decommission CF Access apps + projects (only after sign-off)ScottLowWk 9
Archive The One D1 audit history; retire the JSON project registryBothLowWk 9

Phase 4 Exit Criteria

  • All ~33 dashboards served from the intranet; CF projects redirect-stubbed then retired
  • Secrets reissued in Railway, old keys revoked
  • One hub, one login, one access board, one database — The One decommissioned

🔀 Concept mapping — The One → intranet

The One (Cloudflare)Intranet (Railway)Migration action
CF Access + Entra JWTIntranet auth — upgrade to Entra SSO + edge gate firstSelf-managed passwords today; harden before folding (see Security). Join key stays the sgadental.com email
dashboards registryNav + a permission key eachImport rows → seed nav + catalog
peopleusersMatch on email — identical join key
grants(role, mode)permission + scope grantsTranslate dashboard_id + role → grant
auditAccess-board auditFold 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 schemaNumbered migration per dataset
<app>-corp static bundleSPA route / static svc / native rebuildDisposition (rehost/iframe/rebuild)
_middleware.jspermission-authz preHandlerDelete middleware; intranet gate replaces it

🚂 Railway CLI — the migration engine

⌨️ The commands that do the work

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

Why railway run is the key

It 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 ….

📅 Timeline

WorkstreamW1W2W3W4W5W6W7W8W9
0 · Align & inventory
align
1 · Identity & access
unify RBAC
2 · Data → Postgres
D1 → Postgres
3 · Dashboard onboarding
rehost · rebuild · iframe
4 · Cutover & decommission
verify · redirect · retire

Sequencing logic

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.

🧠 Open decisions

⚖️ Serve dashboards inside the SPA, or as a separate static service?

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

⚖️ Fold The One's audit history, or archive it?

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

⚖️ Which rebuilds are worth doing vs. iframing indefinitely?

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

🔒 Standing guardrails (not decisions — rules)

  • Non-destructive: nothing on Cloudflare deleted until the intranet copy is signed off.
  • PHI Phase A only: patient PHI excluded until Phase B; free-text tables sampled, not trusted.
  • Secrets never travel: reissue fresh in Railway, revoke the old.
  • Fail closed: every route enforces its permission server-side; hiding a tile is never the control.
  • Verify every number: reconcile counts source-vs-target after each load.