Skip to content

EDFI-2776: Windows/IIS installation scripts - #234

Closed
analiliagutierrez wants to merge 61 commits into
mainfrom
EDFI-2776-windows-install-restructure
Closed

EDFI-2776: Windows/IIS installation scripts#234
analiliagutierrez wants to merge 61 commits into
mainfrom
EDFI-2776-windows-install-restructure

Conversation

@analiliagutierrez

@analiliagutierrez analiliagutierrez commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Draft. Opening early for visibility and feedback on structure and approach. See "What is still open" before a full review pass.

Relates to:

Summary

Adds a set of PowerShell scripts under /windows-install that automate the Windows/IIS installation of Admin App v4. The scripts are numbered to match the order of the official installation guide, and can be run end to end via install-all.ps1 or one at a time. The flow is provider agnostic: it works with any OIDC provider, with an optional local Keycloak example kept separate from the generic path.

What is included

  • Numbered scripts 00 to 06 covering prerequisite checks, IIS/SQL/Node prerequisites, build and deployment of the API and frontend.
  • install-all.ps1 to run the full sequence in one pass.
  • Two standalone HTTP IIS sites (API on 3333, frontend on 4200) as described in the official docs.
  • uninstall.ps1 aligned with the standalone topology.
  • Optional local IdP example consolidated into idp-keycloak-setup.ps1 and idp-keycloak-start.ps1, with teardown in uninstall-keycloak.ps1, kept separate from the generic install path.
  • PostgreSQL support via -DbEngine.

What is still open

  • QA on a clean Windows Server target is pending.
  • Packaging and distribution of the scripts (repo vs Azure Artifacts vs hybrid) is a separate team decision.
  • Pairs with the docs reorganization in EDFI-2779 (separate PR on the docs repo).

Testing / QA performed

  • Full run via install-all.ps1 on a clean Windows Server/IIS target
  • Step by step run of 00 through 06 in guide order
  • SQL Server path validated
  • PostgreSQL path validated (-DbEngine)
  • Frontend reachable on 4200, API reachable on 3333
  • Uninstall returns the machine to a clean state
  • Optional Keycloak example validated independently of the generic path

analiliagutierrez and others added 19 commits June 4, 2026 12:05
Automates the official Windows install docs to produce a working AdminApp at https://localhost/adminapp.
Scripts live in windows-install folder.

Pipeline:
- setup-vm-prereqs.ps1: scan-then-install for IIS / SQL Server / Git (optional on already-prepared machines)
- install-all.ps1: master orchestrator with phased re-run flags (-OnlyPhase1, -SkipPhase1, -SkipPhase2, -SkipPreflightCheck, etc.)
Adds -DbEngine (mssql default, pgsql alternative) so the same installer targets either engine, plus -UsePostgresDocker for a dockerized Postgres in local installs.

- install-all.ps1 / 04-deploy-api.ps1 / 00-check-prereqs.ps1 / uninstall.ps1: engine-aware preflight, production.js patching, [user] probe, admin-user upsert, summary, and teardown
- New windows-install/docker/ assets: docker-compose.yml (postgres:16-alpine + cert-init + SSL + healthcheck), .env.example, init/01-create-adminapp-user.sh
- Idempotent re-run: re-applies the app user's password + grants via psql on every bring-up (init scripts only run on fresh volumes)
- Drive-by: 03a-prereqs-runtime.ps1 routes `java -version` through `cmd /c ... 2>&1` to dodge PS 5.1 NativeCommandError wrapping
Three modes selected by install-all.ps1: disabled (default, USE_YOPASS=false), -YopassUrl (existing Yopass), or -SetupYopassDocker (stand up locally via docker-compose, pinned to jhaals/yopass:12.5.0 on container port 80). Preflight checks that Docker is running and the publish port is free, allowing our own container for idempotent re-runs. Also fixes a pre-existing wiring gap where -YopassUrl was declared on install-all.ps1 but never forwarded to 04-deploy-api.ps1.
Rename the per-section install scripts so their numbering follows the
official Windows IIS installation guide, and pull the Keycloak and
Yopass helpers out of the numbered sequence. Only file names and the
references to them change; no script logic or behavior is altered.

  01-prereqs-sql.ps1     -> 02-prereqs-sql.ps1
  02-prereqs-iis.ps1     -> 01-prereqs-iis.ps1
  03b-keycloak-start.ps1 -> idp-keycloak-start.ps1
  03c-build-project.ps1  -> 04-build.ps1
  03d-yopass-docker.ps1  -> yopass-docker.ps1
  04-deploy-api.ps1      -> 05-deploy-api.ps1
  05-deploy-fe.ps1       -> 06-deploy-fe.ps1
Split the old runtime prereq script and fold the Node helper and Keycloak
bootstrap into two focused scripts, matching the generic vs IdP-example
separation:

  03a-prereqs-runtime.ps1 + 00a-fix-node.ps1 -> 03-prereqs-node.ps1
    (Node install, nvm remediation, npm cache; no Java, no Keycloak)
  03a (JDK + Keycloak download) + 06-keycloak-bootstrap.ps1
    -> idp-keycloak-setup.ps1
    (JDK, download, start via idp-keycloak-start.ps1, then provision
     realm/client/user inline; one run leaves Keycloak ready)

idp-keycloak-start.ps1 now points users to idp-keycloak-setup.ps1 when
kc.bat is missing. install-all.ps1 is rewired: Node runs once up front
(idempotent), and all Keycloak work happens in Phase 3 through
idp-keycloak-setup.ps1.

00-check-prereqs.ps1 and README.md still reference the old script names in
advisory text; those are reconciled in later phases.
Move the local Keycloak IdP teardown out of the generic uninstaller so the
two concerns are independent:

  uninstall-keycloak.ps1 (new)
    - stop the Keycloak process (kc.bat/keycloak/quarkus java + :8080)
    - delete the Keycloak install dir (honors -KeepKeycloakDownload)
    - unset Machine JAVA_HOME

  uninstall.ps1 (generic only)
    - drop the Keycloak stop step, the install-dir delete, and JAVA_HOME
      from the env-var unset; remove the -KeepKeycloakDownload param
    - renumber the remaining teardown sections
    - add an informational final step that detects Keycloak leftovers
      (install dir, JAVA_HOME, a running Keycloak process) and suggests
      running uninstall-keycloak.ps1, without acting on them

-KeycloakInstallPath stays on uninstall.ps1 for the leftover check only.
Replace the single nested "Ed-Fi" IIS site (API + FE under one site, which
broke base-path routing) with two independent standalone HTTP sites:

  EdFi-AdminApp-API  http://localhost:3333
  EdFi-AdminApp-FE   http://localhost:4200

No self-signed cert and no 443 binding; local dev is HTTP only.

  01-prereqs-iis.ps1  drop cert generation, Trusted Root add, 443 binding,
                      and the Ed-Fi parent-site creation; keep URL Rewrite,
                      iisnode, the handlers unlock, and the
                      HTTP_X_ORIGINAL_URL allow
  04-build.ps1        VITE_API_URL=http://localhost:3333, VITE_BASE_PATH="/"
  05-deploy-api.ps1   standalone site is the only mode; MY_URL/FE_URL default
                      to the :3333 / :4200 sites; drop the nested branch and
                      the unused ParentSiteName/AppAlias/ApiPort/FePort params
  06-deploy-fe.ps1    standalone site is the only mode; drop the nested branch
  idp-keycloak-setup.ps1  client redirect/origin URIs default to the new
                      standalone HTTP URLs
  install-all.ps1     smoke test hits http://localhost:3333 (no cert trust
                      needed); summary URLs and notes updated for HTTP

uninstall.ps1 is aligned with the new topology in a follow-up commit.
The install now creates two standalone HTTP sites instead of a nested
Ed-Fi site with a self-signed cert, so the teardown is simplified to match:

  - Remove the standalone sites EdFi-AdminApp-API and EdFi-AdminApp-FE
    (the API was previously a sub-application under the Ed-Fi site).
  - Drop the now-dead parent-site removal, the 0.0.0.0:443 SSL binding
    removal, and the self-signed cert removal section.
  - Remove the -RemoveParentEdFiSite, -KeepCert, -CertFriendlyName, and
    -ParentSiteName params.
  - Always delete the two deployed subdirs (and the parent dir if empty);
    no more surgical-vs-full-tree branch.
  - Renumber the remaining teardown sections.

The global iisnode-all handler scrub, database/Yopass teardown, npm cache,
NPM_CONFIG_CACHE unset, and the informational Keycloak-leftovers check are
unchanged.
The Admin App's auth engine is provider-agnostic (generic OIDC discovery);
the Keycloak coupling was only in the install scripts' parameter names. Make
the API deploy provider-neutral so any OIDC IdP (Entra, Google, Auth0, ...)
works by overriding the defaults.

  05-deploy-api.ps1
    - rename -Keycloak* params to -Oidc* (Issuer, ClientId, ClientSecret,
      ManagementDomain, MachineSecret); Keycloak values kept as example
      defaults
    - add -OidcScope (default 'openid email profile') and patch production.js
      scope: '' -> scope: 'openid email profile'
  install-all.ps1  pass the client secret as -OidcClientSecret
  04-build.ps1     frame VITE_IDP_ACCOUNT_URL as the IdP account URL (the
                   Keycloak value is just the example default)

install-all.ps1 keeps its Keycloak-named params: it is the local-Keycloak
example orchestrator (it runs idp-keycloak-setup).
Update 00-check-prereqs.ps1 (deferred from the Phase 1 restructure) so its
advisory text and collision checks match the new script names and the
standalone HTTP topology:

  - point every "<script> will ..." hint at the renamed scripts
    (03-prereqs-node, idp-keycloak-setup, idp-keycloak-start, 02/04/05/06)
  - a too-old Node is now INFO, not FAIL: 03-prereqs-node remediates it via
    nvm-windows
  - attribute the JDK install to idp-keycloak-setup (the optional IdP path)
  - replace the obsolete Ed-Fi-site / HTTPS:443 collision checks with a
    standalone-site view: check the EdFi-AdminApp-API site, and flag ports
    3333 (API) and 4200 (FE) already in use by a foreign process

install-all.ps1: update the -AcceptRisks help and the risk prompt to mention
ports 3333/4200 instead of the removed :443 binding.
Scripts relied on implicit machine-state handoffs; add fail-early guards with
actionable messages so a missing prerequisite is obvious instead of surfacing
as a cryptic error later.

  01-prereqs-iis.ps1   require the IIS role (W3SVC) before adding URL Rewrite +
                       iisnode
  02-prereqs-sql.ps1   require sqlcmd on PATH (used throughout)
  05-deploy-api.ps1    guard the WebAdministration import with an actionable
                       message; require NPM_CONFIG_CACHE (set by
                       03-prereqs-node) so npm under iisnode has a writable cache
  06-deploy-fe.ps1     guard the WebAdministration import
  idp-keycloak-start.ps1  require a JDK (java on PATH or a valid JAVA_HOME)
                       before starting Keycloak; point to idp-keycloak-setup

04-build.ps1 already guards Node on PATH and package.json; 03-prereqs-node and
idp-keycloak-setup are installers with no upstream precondition.
The npm cache override was set as a Machine env var, which changed npm's cache
location for every user and process on the box (env vars outrank user/project
.npmrc). Scope it to the API App Pool instead so the user's other npm usage is
untouched, and remove the implicit 03 -> 05 handoff.

  03-prereqs-node.ps1  drop the npm cache folder + Machine NPM_CONFIG_CACHE and
                       the -NpmCachePath param; it is now Node-only
  05-deploy-api.ps1    own the cache: create the cache folder, grant the App
                       Pool Modify, and set NPM_CONFIG_CACHE in the App Pool's
                       environmentVariables; add -NpmCachePath; drop the
                       NPM_CONFIG_CACHE precondition (no longer a handoff)
  00-check-prereqs.ps1 check the cache folder (pool-scoped now) and drop the
                       obsolete machine NPM_CONFIG_CACHE collision risk
  uninstall.ps1        the pool env var goes away with the App Pool; stop
                       unsetting a Machine NPM_CONFIG_CACHE we no longer set

The App Pool environmentVariables collection requires IIS 10+ and takes effect
on the next worker start; to be confirmed in the Phase 6 end-to-end run.
Wrap the failure-prone operations in try/catch that re-throw with context
(likely cause + fix + the original error) instead of surfacing a raw
exception. Every catch ends in throw -- nothing is swallowed.

  01-prereqs-iis.ps1  network errors on the URL Rewrite / iisnode downloads
  02-prereqs-sql.ps1  SQL registry writes (Mixed Mode + TCP/IP) and the
                      service restart
  03-prereqs-node.ps1 the nodejs.org AV-fallback download
  04-build.ps1        seeding packages\fe\.env
  05-deploy-api.ps1   robocopy copies (clearer messages), App Pool create/
                      config, IIS site create/update, web.config and
                      production.js writes, and the App Pool NPM_CONFIG_CACHE
                      env var (notes the IIS 10+ requirement)
Rewrite the README for the restructured scripts and the standalone HTTP
topology:

  - end state is HTTP (FE http://localhost:4200, API http://localhost:3333);
    no self-signed cert or 443 binding
  - group the scripts into generic path / local IdP example / transversal,
    with new names and an official-guide section mapping
  - add an "Other identity providers" section (generic OIDC via -Oidc* on
    05-deploy-api; Keycloak is just the local example)
  - split the uninstall docs (uninstall.ps1 + uninstall-keycloak.ps1)
  - refresh the install-all phase walk-through, flags, and known issues
    (npm cache scoped to the App Pool, Node via 03-prereqs-node, HTTP-only
    TLS note)
The App Pool environmentVariables collection that 05-deploy-api uses to scope
NPM_CONFIG_CACHE was added in IIS 10.0, so fail early when IIS is older instead
of erroring during deploy.

  01-prereqs-iis.ps1   throw if HKLM:\...\InetStp MajorVersion < 10
  00-check-prereqs.ps1 report IIS version (FAIL on < 10) so the pre-flight
                       aborts before install-all starts
The standalone API/FE sites deployed into C:\inetpub\Ed-Fi\adminapp-api and
\adminapp -- subfolders of an existing Ed-Fi site's root on machines that
already host one (e.g. alongside AdminApi). IIS then nested them visually under
Ed-Fi and that site re-exposed the content over :443, undermining the
"independent sites" goal. Deploy to dedicated top-level directories instead.

  05-deploy-api.ps1   DestPath default -> C:\inetpub\EdFi-AdminApp-API
  06-deploy-fe.ps1    DestPath default -> C:\inetpub\EdFi-AdminApp-FE
  uninstall.ps1       delete those dedicated dirs (ApiDestPath/FeDestPath
                      replace InetpubPath; drop the empty-parent cleanup)
  install-all.ps1     iisnode log path in the failure hint
  README.md           rate-limit production.js path

Found during the local end-to-end test on a machine sharing the Ed-Fi site
with AdminApi.
00-check-prereqs.ps1 is the pre-flight for the generic Admin App install;
Keycloak is the optional local-IdP example, so its checks don't belong here
(and shouldn't fire when 00-check is run on its own or with a non-Keycloak
provider). Drop them: the Java (JDK) diagnostic + RISK, the Keycloak download
and running/realm checks, the -KeycloakInstallPath param, and the
$MinJavaMajor constant. 00-check is now provider-agnostic.

Nothing is lost: idp-keycloak-setup.ps1 already installs/detects the JDK and
idp-keycloak-start.ps1 already requires one. The "your Java/JAVA_HOME will be
modified" heads-up moves into idp-keycloak-setup.ps1, where the mutation
actually happens.
install-all is now provider-agnostic via a mandatory -IdpProvider
(keycloak | microsoft | google | other), instead of being hardwired to the
local Keycloak example.

  - keycloak: unchanged -- runs idp-keycloak-setup, requires
    -KeycloakAdminPassword/-TestUserPassword.
  - microsoft | google | other: skip the local IdP setup; require
    -OidcIssuer/-OidcClientId/-OidcClientSecret (issuer/account-url defaulted
    per provider); validate the issuer discovery endpoint; print the redirect/
    origin URIs to register; seed the [user] row for -AdminUsername (the
    identity itself lives in the external IdP).
  - rename -KeycloakClientSecret -> -OidcClientSecret (the OIDC client secret
    in every mode); pass -Oidc*/-ViteIdpAccountUrl through to 05-deploy-api and
    04-build; mode-aware install summary.

README: document -IdpProvider, the external-provider flow, and where to find
the OIDC issuer.
-OidcManagementDomain / -OidcMachineSecret patched production.js's
MANAGEMENT_DOMAIN / MANAGEMENT_CLIENT_SECRET for an IdP management (M2M)
client that the install scripts never provision and the deployed AdminApp
doesn't need for the validated flows (login, environments; Ed-Fi API client
credentials live in the Admin DB, not the IdP). Remove the params and their
production.js patches; the template keeps its inert MANAGEMENT_* defaults.
Re-add deliberately (provider-specific) if that feature is ever wired up.
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Test Results

  5 files  ± 0  121 suites  +2   3m 22s ⏱️ -10s
604 tests + 8  604 ✅ + 8  0 💤 ±0  0 ❌ ±0 
676 runs  +10  676 ✅ +10  0 💤 ±0  0 ❌ ±0 

Results for commit 256b193. ± Comparison against base commit 98b356c.

♻️ This comment has been updated with latest results.

Correct doc-comment inaccuracies in the windows-install scripts so they
match the actual behavior (and the installation guide):
- install-all.ps1: 01-prereqs-iis sets up URL Rewrite + iisnode and
  unlocks the handlers section (HTTP only); it does not create an HTTPS
  cert or binding.
- install-all.ps1: the npm cache is configured by 05-deploy-api
  (App-Pool-scoped), not by 03-prereqs-node.
- idp-keycloak-setup.ps1: FeBaseUrl/ApiBaseUrl default to the standalone
  HTTP sites (FE 4200, API 3333), not the sub-app deployment.
- yopass-docker.ps1: the published host port maps to container port 80,
  not 1337.
- docker-compose.yopass.yml and 05-deploy-api.ps1: drop references to the
  old script numbering (03d, 03a) that no longer exists after the renumber.

No functional changes.
@analiliagutierrez
analiliagutierrez force-pushed the EDFI-2776-windows-install-restructure branch from b39a637 to a61aa92 Compare June 24, 2026 17:52
analiliagutierrez and others added 3 commits June 24, 2026 14:43
idp-keycloak-start.ps1 redirected Keycloak's output to pipes it never read;
a full pipe buffer can block startup so it never becomes ready. Launch via
Start-Process with output redirected to log files instead. No change to the
readiness check or detached-process behavior.
@stephenfuqua

Copy link
Copy Markdown
Contributor

Architecture Review: Windows Installation Scripts vs. PRD FT3

Overall Assessment

These scripts are a competent, well-engineered automation of a Windows/IIS Admin App install. The headline architectural point is that the Node hosting approach both diverges from FT3 and rests on an abandoned dependency — and, helpfully, a single change resolves both at once.

FT3 prescribes three things (PRD-Deployment-Guide-v4.0.md:171-173):

  1. Frontend static site in IIS — delivered (06-deploy-fe.ps1).
  2. Node.js backend on port 8135 as a managed service — not delivered; the API runs under iisnode, in-process, on port 3333.
  3. IIS as a reverse proxy to that Node backend — not delivered; there is no proxy hop.

So the design-of-record (PRD) and the as-built scripts disagree, and the as-built path also depends on iisnode, which is effectively EOL. The recommendation below (a httpPlatform handler — HttpBridge or HttpPlatformHandler) is attractive precisely because it delivers FT3's IIS-reverse-proxy model and retires the dead dependency in the same move.

The engineering quality of what was built is high: idempotency is taken seriously, error messages are actionable, the pre-flight check is genuinely useful, and field edge cases (AV quarantine of Node, Keycloak first-run bootstrap, PG reserved-word quoting) are handled with evident experience.

The Node Hosting Question: iisnode vs. httpPlatform vs. service + ARR

Recommendation: replace iisnode with a httpPlatform handler — preferably HttpBridge, with Microsoft's HttpPlatformHandler v1.2 as a fallback. This satisfies FT3's reverse-proxy intent and removes the abandoned iisnode dependency. Do not build the originally-envisioned standalone Windows Service + ARR reverse proxy.

How the current scripts wire it (05-deploy-api.ps1:189-219): the API is a standalone IIS site; a URL Rewrite rule routes non-file requests to main.js, which the iisnode handler executes in-process. 01-prereqs-iis.ps1 installs URL Rewrite + iisnode and unlocks the handlers section. There is no ARR, no service wrapper, and no proxy hop.

Why a httpPlatform handler is the right target — on both axes:

Satisfies FT3. IIS launches node.exe main.js, hands it a loopback port via %HTTP_PLATFORM_PORT%, and reverse-proxies inbound requests to it. That is the "IIS as reverse proxy to the Node backend" FT3 specifies. IIS owns the process lifecycle (start/stop/recycle, auto-start on boot, crash restart, stdout logging), which serves FT3's "managed Node backend" intent. The only literal mismatch left is FT3's "standalone Windows service on port 8135" — and with httpPlatform, IIS is the supervisor, which is a better fit for the Windows/IIS persona than registering a separate service. (PRD to be realigned separately.)

Architecturally better than iisnode. iisnode is abandoned (last release v0.2.26, 2020; no security fixes) and hardcodes the node.exe path in web.config (05-deploy-api.ps1:207), which is brittle against the nvm-windows install path in 03-prereqs-node.ps1. A httpPlatform handler launches Node as an ordinary child process — simpler to reason about, easier to debug (it's just "node listening on a port"), and not Node-specific (the same pattern could front Keycloak/Java later).

Two concrete options, in preference order:

  1. HttpBridge (https://github.com/lextudio/httpbridge) — community fork (LeXtudio, MIT) built on the ASP.NET Core Module codebase. Drop-in compatible with the original httpPlatform config schema and actively maintained (latest v10.0.0 RC1, Nov 2025). Recommended: supported codebase, no config rewrite. Caveat: v10 is currently an RC.
  2. Microsoft HttpPlatformHandler v1.2 — the original; still ships and runs on current IIS, smaller attack surface than iisnode, but frozen since ~2016. Reasonable fallback if depending on a community fork or an RC is undesirable.

Migration cost is small: production.js is already process.env.PORT-aware (05-deploy-api.ps1:288, promoteServerVars="PORT" at line 207), so the app maps to HTTP_PLATFORM_PORT with a one-line change.

Both use the same web.config shape, e.g.:

<handlers>
  <add name="httpPlatformHandler" path="*" verb="*"
       modules="httpPlatformHandler" resourceType="Unspecified" />
</handlers>
<httpPlatform processPath="C:\Program Files\nodejs\node.exe"
              arguments="main.js"
              stdoutLogEnabled="true" stdoutLogFile=".\logs\node-stdout.log"
              startupTimeLimit="60" startupRetryCount="3">
  <environmentVariables>
    <environmentVariable name="NODE_ENV" value="production" />
  </environmentVariables>
</httpPlatform>

Why not the standalone Windows Service + ARR reverse proxy (the literal FT3 design): it is the heaviest operational burden for the least Node-experienced persona — ARR is fiddly to configure as a reverse proxy, and the service wrapper is its own rabbit hole (sc.exe shim / unsigned nssm / node-windows, which reintroduces the Node knowledge the persona lacks). Two moving parts and a proxy hop to debug, versus one IIS-owned process with httpPlatform.

Key Architectural Strengths

  1. Idempotency is pervasive and deliberate. Mutating steps read current state and skip/no-op when already correct: SQL registry writes guarded with restart only on change (02-prereqs-sql.ps1:58-103), web.config/production.js rewritten only on diff (05-deploy-api.ps1:223-238,307-317), Keycloak realm/client/user PUT-on-drift (idp-keycloak-setup.ps1:374-390,443-461,505-521), build skipped when artifacts are current (04-build.ps1:81-95). Re-running install-all.ps1 is genuinely safe.
  2. Clean modular decomposition with a coherent orchestrator. Numbered scripts map 1:1 to the official guide's section order; each runs standalone with sensible defaults; install-all.ps1 sequences them in three explicit phases (prereqs / build / deploy) with skip flags.
  3. Excellent pre-flight and risk model. 00-check-prereqs.ps1 is read-only, distinguishes manual-vs-auto-installed-vs-reconfigure, and adds a collision-risk tier (shared SQL instance, ports in use, iisnode version drift) with a tri-state exit code the orchestrator respects (install-all.ps1:317-340).
  4. Good rollback story. uninstall.ps1 / uninstall-keycloak.ps1 are best-effort, ledger-reporting teardowns that leave shared engines (SQL, IIS, Node, JDK) intact and only remove AdminApp-owned state; the scrub of a stray global iisnode-all handler (uninstall.ps1:215-239) shows awareness of how iisnode breaks sibling sites.
  5. Field-hardened error handling. Actionable throw messages; PS 5.1 native-stderr quirks handled; AV/EDR Node-quarantine fallback to direct download (03-prereqs-node.ps1:192-260); Keycloak blocked-pipe avoided via file redirection.

Concerns, Ranked by Impact

1. (High) Node hosting: iisnode diverges from FT3 and depends on an EOL module. Covered above. Resolution: adopt a httpPlatform handler (HttpBridge preferred, HttpPlatformHandler fallback) to deliver FT3's IIS-reverse-proxy model and retire iisnode; realign the PRD to describe this approach. Until migrated, shipping iisnode is acceptable — the README already records its EOL status.

2. (High) Docker is mixed into a "Windows-native / IIS" install, against the persona. The PRD persona has little Docker experience (PRD:34), yet the scripts pull Docker in for optional PostgreSQL and Yopass, and use Keycloak (a non-IIS Java process) as the default IdP. The Docker pieces are opt-in and well-gated, preserving coherence for the SQL-Server default path — but the README leads with Docker Desktop in "Before you start," which risks signaling Docker is part of the baseline. Acceptable as long as Docker stays strictly optional and is framed as an advanced/alternative path.

3. (High) HTTP-only with no TLS path is the only supported end state. No HTTPS binding, cert, or 443 (01-prereqs-iis.ps1:17-18; README.md:222-224). Defensible as behind-the-firewall local dev, but the PRD persona is explicitly TLS-comfortable and FT2 assumes a firewalled deployment, so the gap is unbridged. The API URL is baked into the FE bundle at build time (04-build.ps1:97-114), so an HTTPS cutover requires a rebuild, not a config flip. Action: document the TLS cutover and parameterize the build URLs (partly possible via -ViteApiUrl).

4. (Medium) Config applied by brittle literal string replacement. 05-deploy-api.ps1:270-305 patches production.js via exact .Replace("KEY: 'old',", ...) calls keyed to the template's exact formatting; any whitespace/quote change upstream silently no-ops the patch, shipping defaults. Prefer structured config (env vars / .env / JSON the template reads).

5. (Medium) Keycloak runs via Start-Process, not a service — won't survive reboot. Acknowledged in README.md:226, but the example IdP dies on reboot and the admin must re-run idp-keycloak-start.ps1 — a likely support ticket for a "get in and get out" guide. (Note the irony: with the httpPlatform change, the API gets IIS-managed lifecycle while the IdP still doesn't.)

6. (Low) Environment assumptions. Default SQL instance only (MSSQLSERVER); fixed install paths; IIS 10+ hard requirement for App-Pool env vars; sa with CHECK_POLICY=OFF (fine for local dev, flagged as a RISK on shared instances). Mostly parameterized or flagged; the default-instance assumption is the most likely real-world stumble.

7. (Low) Secrets written in plaintext to install-summary.txt (install-all.ps1:730-761); the file self-warns. Acceptable for local dev, worth a louder callout for non-dev use.

Summary

High-quality automation with strong idempotency, modularity, pre-flight, and teardown. The headline finding: the Node hosting approach both diverges from FT3's IIS-reverse-proxy design and depends on the abandoned iisnode module — and a httpPlatform handler (HttpBridge preferred, HttpPlatformHandler as fallback) fixes both, because it is the IIS-managed-Node-behind-a-reverse-proxy model FT3 calls for. production.js is already process.env.PORT-aware, so the lift is small. Avoid the standalone service + ARR design; it's the heaviest burden for the least Node-experienced persona. Secondary priorities: reconcile the HTTP-only end state with the PRD's TLS assumption (and the build-time-baked API URL), make Keycloak survive reboots, and replace literal-string production.js patching with structured config. The PRD's FT3 hosting guidance should be updated to match the chosen httpPlatform approach.


This is an AI-assisted review based on reading (not executing) the scripts. Please verify before acting, particularly the HttpBridge maintenance/RC status and the iisnode/nvm node-path coupling.

🤖 Generated with Claude Code

Comment thread windows-install/docker/init/01-create-adminapp-user.sh
Comment thread windows-install/docker/README.md Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about the necessity of these Compose files, when we already have a nice set of files in the root compose/ directory. We can refactor those files if the code here offers improvements.

I am not 100% opposed to these additional files, but I do want to make sure we have thought through the implications and that we're ready to maintain some files that are near duplicates.

Comment thread windows-install/00-check-prereqs.ps1
Comment thread windows-install/docker/README.md Outdated
Comment thread windows-install/01-prereqs-iis.ps1 Outdated
Comment thread windows-install/01-prereqs-iis.ps1 Outdated
if (Test-Path $iisnodeDll) {
Write-Host "iisnode already installed at $iisnodeDll"
} else {
$url = "https://github.com/Azure/iisnode/releases/download/$IisNodeVersion/iisnode-full-$IisNodeVersion-x64.msi"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: also put the file hash process here.

iisnode has not had a release in many years, so it would be fine to hard-code the specific version here.

@stephenfuqua

Copy link
Copy Markdown
Contributor

Functionality Review: FT3 Windows Server Installation

Summary of the implemented architecture

The scripts deploy a different topology than FT3 prescribes. Instead of the Node backend as a Windows service on port 8135 fronted by IIS as a reverse-proxy, the scripts host the Node API inside IIS via iisnode as a standalone HTTP site on port 3333 (05-deploy-api.ps1:32,181), and serve the React frontend as a second standalone IIS site on port 4200 (06-deploy-fe.ps1:24,61). There is no Windows service, no reverse-proxy, and no port 8135 anywhere. The IdP path is Keycloak (idp-keycloak-setup.ps1), with external providers (microsoft/google/other) supported by skipping the local IdP and patching config.

Note: the recommended remediation for the hosting topology (a httpPlatform handler — HttpBridge or HttpPlatformHandler — which delivers FT3's IIS-reverse-proxy model while retiring the EOL iisnode dependency) is detailed in the separate Architecture review comment.

1. Requirement-by-requirement coverage

FT3 requirement Status Evidence
Create Admin App system user in Entra ID; record issuer/clientID/secret/scope Partial / N-A No Entra walkthrough in scripts (correct — FT1 owns Entra). install-all.ps1 -IdpProvider microsoft consumes those four values as params and prints redirect/origin URIs to register (install-all.ps1:490-494, README:124-126). It does not walk through creating the system user/app registration.
Install AdminApp database tables Met DB created in 02-prereqs-sql.ps1:166-172; TypeORM migrations create tables on first API request (install-all.ps1:549-554, seeding migration 1697203599392-Seeding.ts).
Recommend AdminApp DB in its own instance, independent of EdFi_Admin/EdFi_Security Partial A dedicated sbaa database is created standalone (02-prereqs-sql.ps1:36,168) and is independent in practice, but no script or README text states the recommendation to keep it separate or explains the multi-deployment rationale.
Insert Entra settings into the Admin App oidc table Met (indirectly) App reads OIDC providers from the oidc DB table at login (oidc.strategy.ts:18). Scripts patch SAMPLE_OIDC_CONFIG.{issuer,clientId,clientSecret,scope} in production.js (05-deploy-api.ps1:291-297), and the seed migration inserts those into oidc when the table is empty. Functionally equivalent, but see Gap A.
Configure & load website files into IIS as a new site (optional PS script) Met 06-deploy-fe.ps1 creates IIS site, copies build output, writes SPA web.config.
Run Node backend as a Windows service on port 8135 Missing / Divergent Backend runs under iisnode inside IIS on port 3333, not as a Windows service, not on 8135 (05-deploy-api.ps1:32,181,207).
Configure IIS as reverse-proxy to the Node server Missing / Divergent No reverse-proxy. IIS hosts Node directly via the iisnode handler and a rewrite-to-main.js rule (05-deploy-api.ps1:195-217).
Keycloak vs Entra consistency with FT3 Consistent FT3's recipe text is Entra-centric, but FT1 covers Entra as the alternative; the README/scripts correctly treat Keycloak as the local example and support -IdpProvider microsoft for Entra.

2. Functional gaps / bugs that could block end-to-end install

  • Gap A — OIDC table only seeds once; re-runs silently don't update it. The seed migration inserts into oidc only when the table is empty (1697203599392-Seeding.ts:67-68). 05-deploy-api.ps1 patches production.js but never UPDATEs the oidc row. So changing the issuer/clientId/secret on a re-deploy (e.g., correcting an Entra value) updates production.js but leaves the stale row in the oidc table, and login keeps using the old values. This contradicts the README's claim that the client secret is "idempotently updatable on every re-run" (README:198) — true for Keycloak's client, but not for the oidc DB row the API authenticates against.

  • Gap B — Redirect URI mismatch risk for external IdPs. The login strategy builds the callback as /auth/callback/${oidcConfig.id} (oidc.strategy.ts:37), i.e. the DB row's auto-generated primary key. install-all.ps1:491 instructs the user to register .../api/auth/callback/1, assuming the seeded row gets id=1. On a clean install this holds, but if any prior oidc row existed the id won't be 1 and the registered Entra redirect URI won't match — login fails. The guide hard-codes /1 without explaining the dependency.

  • Gap C — Entra "email claim" caveat under-documented. The strategy requires a non-empty userinfo.email (oidc.strategy.ts:44-48). README:126 acknowledges "Entra's email-claim handling is a known gotcha" but provides no concrete remediation. With a default Entra app registration this is a common cause of a login that authenticates but then errors.

  • No bug in the SQL, IIS, build, or FE scripts that would block the happy-path local Keycloak install; they are idempotent and have a smoke test that also force-upserts the admin user with roleId=2 (install-all.ps1:599-640), compensating for the seed migration occasionally not firing.

3. Is the condition of satisfaction plausibly met?

Yes for the local Keycloak path; qualified yes for the Entra/external path. A reader running install-all.ps1 -IdpProvider keycloak ... ends with a working sign-in and an admin user able to create environments — the smoke test verifies the API responds and the admin user has the global-admin role. For the Entra path the condition is plausibly met but more fragile: the user must correctly create the Entra app registration themselves (not scripted — appropriate, since FT1 owns it), register the exact /auth/callback/1 redirect URI (Gap B), and navigate the email-claim issue (Gap C).


This is an AI-assisted review based on reading (not executing) the scripts. Please verify before acting — particularly the seed-migration/oidc-row behavior (Gap A) against the app source.

🤖 Generated with Claude Code

@stephenfuqua

Copy link
Copy Markdown
Contributor

Security Review: Windows Install Scripts

Scope: all scripts in windows-install/ plus docker/ and the patched config template packages/api/config/production.js-edfi. Assessed against PRD FT3, which prescribes that the app sit inside the network firewall. That framing matters: many findings are acceptable for the documented "inside-the-firewall, HTTP-only local dev" posture but become serious if the deployment is exposed. The scripts repeatedly and honestly disclaim production-hardening — genuine strength. Findings rank what an operator must still know and what could bite even an inside-firewall install.

Critical

C1. Hardcoded application data-encryption key shipped in the deployed config and never rotated

  • Location: packages/api/config/production.js-edfi:37-41; deploy logic 05-deploy-api.ps1:262-320.
  • Risk: DB_ENCRYPTION_SECRET_VALUE.KEY is a fixed 32-byte hex value baked into the repo template. 05-deploy-api.ps1 copies the template to production.js and patches DB/OIDC/Yopass values, but never replaces the encryption KEY. This key encrypts the application's sensitive data at rest (its whole purpose is credential management — Ed-Fi ODS/API client secrets). Every install ends up with the same publicly-known encryption key from the source tree. Anyone with read access to the database (or the repo) can decrypt stored credentials. Invisible to the operator (no warning printed).
  • Remediation: Generate a per-install random key and patch DB_ENCRYPTION_SECRET_VALUE.KEY the same way the OIDC secret is patched. Record it in install-summary.txt with a "back this up; losing it makes encrypted data unrecoverable" note. At minimum, fail loudly if the known-default key is detected.

High

H1. Stale management client secret left at its hardcoded default

  • Location: production.js-edfi:53-61; not in the patch list of 05-deploy-api.ps1:288-305.
  • Risk: AUTH0_CONFIG_SECRET_VALUE.MANAGEMENT_CLIENT_SECRET remains 'edfi-machine-secret-456' after deployment. If any code path uses the management client (machine-to-machine / Admin API EdOrg refresh), it ships with a known credential. Even if currently unused, a latent default-credential.
  • Remediation: Patch it from a parameter, strip the AUTH0_CONFIG_SECRET_VALUE block when unused, or document explicitly that it is inert. Don't leave a hardcoded secret string in a deployed file.

H2. Secrets passed on the command line and persisted in plaintext install summary

  • Location: README.md:41; install-all.ps1:730-763 writes install-summary.txt; 02-prereqs-sql.ps1:178 echoes the sa password to console.
  • Risk: -SaPassword, -KeycloakAdminPassword, -OidcClientSecret, -TestUserPassword, -PostgresSuperuserPassword, -PostgresAppPassword are plain [string] params on the command line — they land in PowerShell history and the parent process command line is visible to any local user. install-all.ps1 then writes every password in cleartext to install-summary.txt in the parent of the repo dir with no ACL restriction. A self-aware footer warns about this — good — but it's still written by default with inherited ACLs.
  • Remediation: Switch sensitive params to [SecureString] / Read-Host -AsSecureString, or read from env/secret store. Omit secrets from the summary (reference where set) or restrict the file ACL to Administrators/SYSTEM. Stop echoing the sa password.

H3. PostgreSQL superuser password passed via -e PGPASSWORD to docker exec

  • Location: install-all.ps1:411,571,624; init grants in docker/init/01-create-adminapp-user.sh:7.
  • Risk: Passing the superuser password through docker exec -e exposes it in the container's process environment and the host's process command line during execution. Transient (lower severity than C1/H1), but the highest-privilege DB credential.
  • Remediation: Prefer PGPASSFILE or stdin-based auth, or run the privilege sync inside the init script (already runs as superuser on first boot) rather than re-running from the host (install-all.ps1:404-415).

H4. App database user has full privileges; MSSQL path uses sa as the application login

  • Location: MSSQL: 02-prereqs-sql.ps1:140-153, 05-deploy-api.ps1:272-274 (production.js gets MSSQL_DB_USERNAME: 'sa', never changed); PG: docker/init/01-create-adminapp-user.sh:7-9, install-all.ps1:405-409 (GRANT ALL PRIVILEGES).
  • Risk: In MSSQL mode the application connects as sa — the web-facing Node app runs every query as the server-wide sysadmin. A SQL-injection or RCE in the app becomes full SQL Server compromise. The PG path is better (dedicated user) but grants ALL PRIVILEGES.
  • Remediation: Create a dedicated least-privilege SQL login for the app (db_datareader/db_datawriter + EXECUTE, or ownership of just its own schema) instead of sa. For PG, scope grants to owned objects rather than ALL PRIVILEGES. The single most valuable database-security improvement.

H5. CHECK_POLICY = OFF on the sa login disables password-strength enforcement

  • Location: 02-prereqs-sql.ps1:148; documented at README.md:48.
  • Risk: Script explicitly turns off Windows password-policy checking for sa, then accepts any string. Invites weak sa passwords on a Mixed-Mode, TCP-1433-listening instance.
  • Remediation: Drop CHECK_POLICY = OFF; require a strong password (validate in the param). Tie to H4 — if the app stops using sa, blast radius shrinks.

Medium

M1. Keycloak runs in start-dev mode (HTTP, dev profile) — not production-safe

  • Location: idp-keycloak-start.ps1:105-108 (kc.bat start-dev); README acknowledges it.
  • Risk: start-dev disables HTTPS requirement, uses the dev H2 database, disables hostname strictness. The IdP for a credential-management app is a high-value target; tokens and admin console traverse plain HTTP on :8080.
  • Remediation: For anything beyond local dev, run kc.bat start with --hostname, a real database, and TLS. Document prominently.

M2. No HTTPS/TLS anywhere; IIS sites and Keycloak are HTTP-only

  • Location: 01-prereqs-iis.ps1 (no cert/binding), 05-deploy-api.ps1:181, 06-deploy-fe.ps1:61, README:5/224.
  • Risk: OIDC authorization codes, session cookies, and credential payloads cross the wire in cleartext on 3333/4200/8080. The web.config also sets devErrorsEnabled="true", debuggingEnabled="true", errorMode="Detailed" (05-deploy-api.ps1:207,214), leaking stack traces. Acceptable for loopback dev per the PRD's firewall stance; a clear gap over any network.
  • Remediation: Default devErrorsEnabled/debuggingEnabled/detailed errors to off for non-dev; ship/optionally invoke a TLS-binding step or document the reverse-proxy front-end the PRD assumes.

M3. Reverse-proxy / rewrite hardening minimal; no security headers; permissive Keycloak redirect URIs

  • Location: rewrite + HTTP_X_ORIGINAL_URL (01-prereqs-iis.ps1:78, 05-deploy-api.ps1:200-203); Keycloak client idp-keycloak-setup.ps1:417-423.
  • Risk: (a) No HSTS/X-Content-Type-Options/X-Frame-Options/CSP on either IIS site. (b) Rewrite forwards HTTP_X_ORIGINAL_URL to Node — if a client can spoof it and the app trusts it, a spoofing vector worth verifying. (c) Keycloak client registers wildcard redirect/post-logout "$fe/*" — broader than necessary, an open-redirect amplifier.
  • Remediation: Add a baseline security-header block to both web.configs. Verify the app doesn't trust client-supplied X-Original-URL. Tighten Keycloak redirect/post-logout URIs to explicit paths.

M4. Remote MSI/zip downloads executed with no checksum or signature verification

  • Location: URL Rewrite MSI 01-prereqs-iis.ps1:53-62; iisnode MSI :93-102; Node zip fallback 03-prereqs-node.ps1:222-235; Keycloak zip idp-keycloak-setup.ps1:222-235; arbitrary -JdkDownloadUrl zip :240-256.
  • Risk: Each download + execute/extract runs as administrator with no hash pinning or Authenticode check. A compromised mirror, MITM, or malicious -JdkDownloadUrl yields admin-level RCE. Inconsistent: the codebase does know how (production.js-edfi:79 pins CERT_BRUNO_SRC_CHECKSUM).
  • Remediation: Pin SHA-256 hashes and verify; verify Authenticode signatures on MSIs. Constrain/warn on -JdkDownloadUrl.

M5. Execution-policy bypass and blanket Unblock-File of all scripts

  • Location: README.md:33; setup-vm-prereqs.ps1:49-63.
  • Risk: Unblock-File *.ps1 strips mark-of-the-web from every script in the directory indiscriminately. Combined with RemoteSigned, any .ps1 later dropped into windows-install/ runs without the downloaded-file guard.
  • Remediation: Unblock only the specific known scripts, or verify a signature. Prefer -Scope Process Bypass over a persisted CurrentUser policy change.

Low

L1. Yopass loopback-bound (good), but dockerized Postgres published on 0.0.0.0

  • Location: docker-compose.yopass.yml:52-53 (loopback, HTTP only); docker-compose.yml:36-37 ("${POSTGRES_PORT_EXPOSED:-5432}:5432" — all interfaces).
  • Risk: Postgres container publishes 5432 on 0.0.0.0, so on a multi-homed VM the DB is reachable from the network, gated only by the password (see H5). PRD calls for Yopass to be reachable by vendors through the firewall — its HTTP-only posture becomes relevant then.
  • Remediation: Bind Postgres to 127.0.0.1:...:5432 unless remote access intended. Document that exposing Yopass requires fronting it with TLS.

L2. Self-signed Postgres cert with DB_SSL: false / DB_TRUST_CERTIFICATE: true, and app-level SSL_VERIFICATION: false

  • Location: docker-compose.yml:4-27, docker/README.md:74-81; 05-deploy-api.ps1:271,280; production.js-edfi:76.
  • Risk: SSL_VERIFICATION: false disables outbound TLS verification from the app (e.g. to IdP/ODS). Low for loopback, a foot-gun if the app makes outbound calls over a network.
  • Remediation: Ensure SSL_VERIFICATION is true whenever the app talks off-box; the template comment already admits this — enforce it in the patch step.

L3. EnableDirectAccessGrants enables the OAuth password grant

  • Location: idp-keycloak-setup.ps1:117,408-429,540-551; README:171.
  • Risk: When set, the test user's password is sent in a grant_type=password body over HTTP :8080. Clearly labeled testing-only, so low.
  • Remediation: Keep test-only/gated; consider warning when combined with a non-localhost issuer.

L4. Broad icacls Modify/Full grants to the App Pool identity

  • Location: 05-deploy-api.ps1:325,328,337.
  • Risk: App Pool virtual account gets Modify recursively over packages\ (which contains production.js with all secrets). A worker-process compromise can rewrite its own config. Standard for iisnode and scoped to the dedicated pool identity (good), but notable given the secrets now in that tree.
  • Remediation: Grant Modify only where the runtime genuinely writes (logs, npm cache); make production.js read-only to the pool identity if the app doesn't rewrite it at runtime.

Good Practices Observed

  • Strong, honest disclaiming: README "What these scripts don't do" and inline comments repeatedly flag HTTP-only, no-TLS, dev-mode, not-production-hardened.
  • Yopass container correctly bound to 127.0.0.1; memcached not published.
  • Yopass image pinned by digest (jhaals/yopass:12.5.0@sha256:...) — exactly the integrity control missing from the MSI/zip downloads (M4).
  • App Pool runs under the per-app virtual account IIS APPPOOL\EdFi-AdminApp-API (not LocalSystem/NetworkService); npm cache override scoped to the pool.
  • 00-check-prereqs.ps1 is read-only and surfaces collision/[RISK] items before mutating the box; install-all.ps1 prompts on risk unless -AcceptRisks.
  • SQL config idempotent, restarts service only on actual registry change; teardown uses SINGLE_USER WITH ROLLBACK IMMEDIATE correctly.
  • idp-keycloak-start.ps1:112 removes the bootstrap admin password from the parent process environment after spawning Keycloak — thoughtful secret-lifetime handling.
  • Uninstall scripts are careful/least-destructive: best-effort, per-step ledger, leave shared engines intact, only kill java.exe on :8080, don't auto-delete Keycloak from the generic uninstaller.
  • Destructive actions (docker compose down -v, DB drop, dir deletes) gated behind -Force/y-N confirmation and enumerated before running.

Recommendation Priority Order

  1. C1 — generate a unique DB_ENCRYPTION_SECRET_VALUE.KEY per install (defeats the app's core purpose otherwise).
  2. H4 — stop running the app as sa; provision a least-privilege DB login.
  3. H1 — patch or remove the hardcoded MANAGEMENT_CLIENT_SECRET.
  4. H2/H3 — move secrets off the command line; restrict/scrub install-summary.txt.
  5. H5 — restore CHECK_POLICY and require a strong sa password.
  6. M4 — add checksum/signature verification to remote downloads.
  7. M1/M2/M3 — TLS + Keycloak start, security headers, tightened redirect URIs before the app is reachable beyond loopback.

This is an AI-assisted review based on reading (not executing) the scripts. Please have a human verify before acting — particularly confirm in the app source whether MANAGEMENT_CLIENT_SECRET, the DB_ENCRYPTION_SECRET_VALUE.KEY, and the X-Original-URL header are actually consumed, since that determines whether C1/H1/M3(b) are active or latent.

🤖 Generated with Claude Code

The app builds each OIDC callback as /api/auth/callback/<id> from the oidc
row's auto-generated id (oidc.strategy.ts), but the scripts hardcoded /1,
assuming the seeded row always gets id=1. That only holds on a clean install;
if a prior oidc row existed, the new id != 1 and the registered redirect URI no
longer matches what the app sends, so login fails (PR #234 Functionality
review, Gap B).

After the app boots, read back the real id of the row the installer manages
(for its clientId) and use it for the redirect URI. The id is a DB-row
property, so this runs for every provider:
  - Keycloak (script-provisioned client): idp-keycloak-setup gains a
    -RedirectCallbackId param (default 1); install-all re-invokes it with the
    real id only when it differs, correcting the client's redirectUris
    idempotently against the already-running Keycloak.
  - Entra/Google/other (user-provisioned): no script can touch the provider's
    portal, so install-all prints the exact redirect URI to register.

No literal /callback/1 remains in install-all.ps1 or idp-keycloak-setup.ps1.
The README example is deferred to the Tier 4 docs pass (tracked under T4.4).
Node, Keycloak, and JDK downloads ran without integrity checks (PR #234
Security review M4). Node zips are now verified against nodejs.org's
official SHASUMS256.txt for the resolved version; the Keycloak zip against
a pinned SHA-256 (default) or -KeycloakSha256; and a -JdkDownloadUrl zip
against a required -JdkSha256 over HTTPS only. Already-downloaded files
are reused when their hash matches.
Add baseline security headers to the API and FE web.config: HSTS,
Referrer-Policy, and a Content-Security-Policy in Report-Only mode
(enforcing lands with the always-on TLS work). Remove the X-Powered-By
header from both sites. Node already emits X-Content-Type-Options and
X-Frame-Options, so those are added only on the FE to avoid duplicate
headers on the API's proxied responses.

Give 06-deploy-fe.ps1 an -ApiUrl parameter so the CSP connect-src names
the exact API origin the FE bundle calls. Default the API's IIS error
mode to DetailedLocalOnly with a -DevErrors opt-in, so stack detail is
not exposed to remote clients by default.
06-deploy-fe.ps1 created the FE site without an application pool, so it bound
to DefaultAppPool. When that pool is Stopped (common after a reboot or recycle)
the SPA returned 503. Create a dedicated EdFi-AdminApp-FE pool, assign the site
to it, and start it explicitly (the pool auto-starts on reboot). uninstall.ps1
tears the pool down, guarded so it never removes DefaultAppPool.
The client registered a wildcard redirect and post-logout URI (/*), far
broader than the app actually uses. The app's only OIDC redirect_uri is the API
callback (auth.controller.ts / oidc.strategy.ts) and its only post_logout_redirect_uri
is MY_URL/api/auth/post-logout, from which it forwards to the FE itself. Drop the
wildcard from both redirectUris and post.logout.redirect.uris, keeping the explicit
paths. Force a client rewrite when a wildcard from an earlier install survives, so
a re-run strips it. Login and logout verified end to end.
Add an HTTPS binding to the API (3443) and FE (4443) IIS sites, always on.
The certificate is resolved by precedence: -CertificateThumbprint, an imported
-CertificatePfxPath, or a self-signed cert auto-generated for localhost (keeping
the local quick-start working). 05-deploy-api.ps1 resolves the cert and
06-deploy-fe.ps1 reuses it (matched by FriendlyName), so both sites share one
certificate. install-all.ps1 forwards the HTTPS ports and cert parameters to both.
The HTTP bindings stay in place; the HTTP->HTTPS redirect and https URL
propagation follow in later Tier 3 steps.
Add a 301 redirect rule to the API and FE web.config so plain-HTTP requests are
permanently redirected to the HTTPS port (3443/4443). The rule captures the host
without its port and targets the explicit HTTPS port. On the API it is a new
<rewrite> block that runs before the httpPlatform handler, so the redirect fires
without proxying to node; on the FE it precedes the SPA fallback rule. 301
(Permanent) is used because IIS URL Rewrite has no 308; it pairs with the HSTS
header and the POST->GET downgrade is moot once app URLs are all https.
Derive the API and FE URLs as https on the mirror ports in install-all.ps1 and
pass -ViteApiUrl to 04-build.ps1 (previously not forwarded, so the bundle baked
the http default). Since Vite bakes VITE_API_URL at build time, this is what lets
the FE target HTTPS. The API/FE production config and Keycloak client still carry
http here; T3.4 flips those so the full flow is https.
Flip the app URLs to https so the whole flow runs over TLS: install-all passes
https ApiUrl/FeUrl to 05 (production.js MY_URL/FE_URL/WHITELISTED_REDIRECTS via
NODE_CONFIG), the https API origin to 06 (CSP connect-src), and https
ApiBaseUrl/FeBaseUrl to the Keycloak client so its redirect/origin URIs match.
The standalone defaults in 05, 06 and idp-keycloak-setup move to https on the
mirror ports, the printed redirect/summary URLs use the resolved https URLs, and
the smoke test hits https (accepting the self-signed cert for the local check).
Keycloak's own issuer stays http (TLS for Keycloak is a later item).
… ports

uninstall.ps1 now removes the HTTP.sys SSL certificate bindings for the API and
FE HTTPS ports (which persist after Remove-Website) and deletes the auto-generated
self-signed certificate, matched by FriendlyName so a user-supplied cert is left
untouched. 00-check-prereqs.ps1 adds pre-flight port checks for the HTTPS ports
(3443/4443) alongside the existing HTTP ports.
The smoke test now shells out to curl.exe instead of Invoke-WebRequest: PS 5.1's
Invoke-WebRequest cannot complete the TLS handshake to the self-signed HTTPS
binding ("unexpected error on send"), even with a cert-validation bypass and
TLS 1.2 forced, whereas curl.exe (bundled since Win10 1803 / Server 2019) does.
The retry window is widened to ~3 minutes because a fresh cold start runs
migrations and the catalog sync before the first request is served, and the
post-boot steps are gated on the smoke test. The external-provider OIDC discovery
check now enables TLS 1.2 on ServicePointManager so the handshake to Entra/Google
(which require TLS 1.2+) does not fail under PS 5.1's default protocol set.
Flip the CSP header on the API and FE from Content-Security-Policy-Report-Only to
enforcing Content-Security-Policy now that TLS is always-on. Validated in a browser
against the deployed sites with no violations: the FE policy (default-src 'self',
MUI's inline styles allowed, connect-src limited to the API origin) and the API
policy (default-src 'none' -- safe because the API serves only JSON in production;
Swagger UI is disabled when NODE_ENV=production). The deploy success messages in
05 and 06 now point at the HTTPS URLs (the HTTP ports only redirect).
…efault

When the deploy generates a self-signed cert (no real cert supplied), add its
public part to LocalMachine\Root so browsers on the host trust it and do not show
a "Not Secure" warning -- the common local/quick-start case. Only the self-signed
path does this; a supplied real cert is already CA-trusted and is never added to
Root. A -SkipSelfSignedTrust switch (threaded through install-all to 05/06) opts
out where policy forbids adding trusted roots. The trusted copy carries the same
FriendlyName, and uninstall now removes the cert from both My and Root, matching
only our FriendlyName so unrelated localhost roots are never touched.
Keycloak omits false boolean and empty array client properties from the
client JSON, so the direct property reads in the client-exists-update
comparison throw under Set-StrictMode -Version Latest. Read optional
properties through a Get-KcClientProp guard so the diff stays
StrictMode-safe; normal-path behavior is unchanged.
The API disabled SSL verification for its outbound HTTPS calls (ODS/API,
AdminApi, Yopass), so it accepted any certificate and was exposed to
man-in-the-middle attacks off-box. Ship SSL_VERIFICATION on by default in
the production config template and via NODE_CONFIG, with a
-DisableSslVerification opt-out (and a loud warning) for local/dev
upstreams that use self-signed certificates Node's CA store won't trust.
Direct Access Grants (the OAuth password/ROPC grant) sends user
credentials straight to the token endpoint and is a local-testing
convenience only. Warn when it is enabled against a non-loopback
Keycloak, and note the same in the parameter documentation.
The compose file published the Postgres port on 0.0.0.0, so on a
multi-homed host the database was reachable over the network, gated
only by the password. Bind to 127.0.0.1 by default via a new
POSTGRES_BIND_HOST variable; set it to 0.0.0.0 to opt into remote
exposure (documented, and only over TLS).
The example Keycloak IdP was launched with Start-Process, so it died on
reboot and had to be restarted by hand. Add an opt-in
-RegisterKeycloakStartupTask (install-all) / -RegisterStartupTask
(idp-keycloak scripts) that registers a startup Scheduled Task to relaunch
it as SYSTEM at boot; tear the task down in uninstall-keycloak.ps1 via
schtasks (robust against the Get-ScheduledTask CIM enumeration error a
single corrupt task on the machine can cause). Also warn on every launch
that start-dev is local-dev-only and document kc.bat start with a
hostname, real database, and TLS for anything beyond local dev.
SSL_VERIFICATION is on by default, so adding an Environment against a
self-signed or dev ODS/API or Admin API fails with a certificate error and
the fix was only discoverable in a parameter comment. Add a README note
covering the -DisableSslVerification opt-out and the NODE_EXTRA_CA_CERTS /
--use-system-ca alternatives, and print the same heads-up at the end of
install-all when verification is left on.
…user

The Admin App creates the citext extension and the pg-boss job-queue schema
at boot, both of which require CREATE on the database. init/01 and the
existing-volume sync granted only CONNECT plus public-schema ownership, so the
API failed to start with "permission denied for database sbaa". Grant CONNECT
and CREATE (citext is a trusted extension, so no superuser is needed), keeping
the user a non-superuser with no database-wide GRANT ALL.
…g PID

The uninstall killed the API's node child by extracting its PID from the
stdout-log filename, assuming Microsoft HttpPlatformHandler's
node-stdout.log_<pid>_<timestamp>.log naming. LeXtudio HttpBridge names those
logs differently, so the first digit group is a 14-digit timestamp; casting it
to [int] overflowed Int32 and threw. Match the node process by the deployment
directory in its command line instead -- handler-agnostic and never throws.
…talled one

Both HttpBridge and Microsoft HttpPlatformHandler register the same
'httpPlatformHandler' module, so the re-run-safe skip could not tell them apart
and silently kept whichever was installed -- a -HttpHandler choice was ignored
without notice. Detect the installed handler MSI and warn, with the manual
uninstall steps, when it differs from the requested -HttpHandler.
…timestamp

The build-current heuristic checked only that artifacts exist and are newer than
package.json, so a dist built for a different VITE_API_URL was reused and
deployed. Under the enforcing CSP that stale bundle calls a disallowed origin and
the app fails after login. Skip the build only when the last-built .env matches
the requested VITE_API_URL, and clear the nx cache (which ignores .env) when the
FE config changed so the bundle is genuinely rebuilt.
…install

The best-effort [oidc] reconciliation already fell back to a warning on a
non-zero sqlcmd exit, but the try had no catch: under $ErrorActionPreference =
'Stop' a -b native error is a terminating NativeCommandError, so a transient SQL
login timeout aborted the whole install before the exit code was checked. On a
first install this step is a guaranteed no-op (the [oidc] table does not exist
yet). Catch the error and fall through to the warning, and raise the sqlcmd login
timeout to 30s for headroom under load.
@analiliagutierrez

analiliagutierrez commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Closing this PR — the windows-install suite has moved to its own repository

The entire windows-install/ suite has been imported into Ed-Fi-Exchange-OSS/Admin-App-Installation-Scripts.

New PR: Ed-Fi-Exchange-OSS/Admin-App-Installation-Scripts#2 (branch EDFI-2776_Windows_Install_Scripts).

All the work reviewed here is carried forward: the final commit in the new repo already incorporates every fix from this PR's review cycle.

Why the full review history stays here: this PR retains the complete discussion and the commit-level history of how each finding was diagnosed and fixed. The new repo starts from a clean import commit, so please refer back to this PR for that context.

E2E validation (against Admin App v4.0.1): the full matrix passed (documented in the new PR's description) — SQL Server and PostgreSQL/Docker; Keycloak, Entra ID, and Google as OIDC providers, including live browser logins. Entra ID OIDC seeding was validated on both engines (closes EDFI-2774).

Review continues in the new repository.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants