diff --git a/compose/readme.md b/compose/readme.md index 73f9d2f7..a7e15a5f 100644 --- a/compose/readme.md +++ b/compose/readme.md @@ -367,9 +367,6 @@ authentication flow: CLIENT_ID: 'edfiadminapp', CLIENT_SECRET: 'big-secret-123', MACHINE_AUDIENCE: 'edfiadminapp-api', - MANAGEMENT_DOMAIN: 'localhost', - MANAGEMENT_CLIENT_ID: 'edfiadminapp-machine', - MANAGEMENT_CLIENT_SECRET: 'edfi-machine-secret-456', } ``` diff --git a/docs/PRD-Deployment-Guide-v4.0.md b/docs/PRD-Deployment-Guide-v4.0.md new file mode 100644 index 00000000..16f82f9a --- /dev/null +++ b/docs/PRD-Deployment-Guide-v4.0.md @@ -0,0 +1,252 @@ +# PRD: Deployment Guide for Admin App v4 + +> **Last updated:** 26 June 2026 +> **Owner:** Stephen Fuqua, Ed-Fi Alliance \ +> **Jira Project:** AC \ +> **Repository:** `Ed-Fi-Alliance-OSS/Ed-Fi-AdminApp` + +## Overview + +This Product Requirements Document (PRD) outlines improvements to the initial deployment and setup of the Ed-Fi Admin App v4, intended to reduce the time to value for new users of the application. + +## Purpose and Strategic Alignment + +The Ed-Fi Alliance's strategic goals for 2026 include *"Growth, maintenance, and technical support for the Ed-Fi Data Standard and Ed-Fi Technology Suite"*. Under this heading, we have a sub-goal of: + +> Tool enhancements for cost-effective administration of large-scale deployments of the Ed-Fi API applications: adoption of new tools in 10 strategic deployments + +In this sense, the applications in question are the Ed-Fi Admin App v4 and the Ed-Fi ODS Admin API v2 application that supports it. In 2026, we have identified database instance management and certification automation as key themes for new feature development, which may help drive the adoption goal. However, it will take time to develop these features, and there is frequently a lag time between release of Ed-Fi software and its adoption. + +The goal for strategic deployments does not require these new features; deployment of the December 2025 releases (Admin App v4.0 and Admin API v2.3) also qualify. For the most part, this is a job for Ed-Fi staff, who will perform outreach and education. + +This document outlines additional engineering efforts intended to lower barriers to entry once an agency decides to embark on deployment of these applications. + +## Target Market + +While the true goal for Admin App is to support "large scale" installations — with many school years and many school districts to support — most of our current community members have simple setups. The "large scale" goal is more about the future than the present. + +Therefore, this PRD targets any relatively simple deployment scenarios in settings including pilot data hubs, state education agencies, and commercial deployments. For simplicity's sake, the document will be framed from the perspective of a small to medium sized state; however, the same factors largely apply in all other scenarios. + +## Personas + +### SEA System Administrator + +Consider a system administrator at a state education agency (SEA) whose primary mission is to collect local education agency (LEA) data for mandatory state reporting. This system administrator is in a hybrid IT role, serving both as a programmer and an IT administrator. They are responsible for deployment and maintenance of the Ed-Fi Technology Suite running on either Windows Server on-premises or have recently moved to a cloud provider. They may be interested in Docker but likely have little practical experience with it at this time. + +**Primary motivations** + +- Create and manage Ed-Fi ODS/API credentials for all applications that need to submit data on behalf of an LEA. +- Get into the application quickly and get out again, back to other pressing concerns. + +**Technical depth** + +- Has broad, but not deep, responsibilities covering programming, data engineering, deployment, and technical support. +- Technical skills are rooted in support and deployment of .NET applications in Windows, using Microsoft SQL Server, and hosting web applications in Microsoft IIS. + +**Key challenges** + +- Lack of time for professional development, learning new skills such as development and support of Node.js applications. + +## Jobs to Be Done + +This document assumes several other jobs to be done that have already been delivered in the Admin App version 4.0 release, such as: + +- Create API client credentials +- Manage claimsets +- Upload new profiles +- Etc. + +### JTBD 1: Install Admin App in Windows Server + +**Primary personas:** SEA System Administrator + +**When** I am deploying the Ed-Fi Admin App, +**I want** to quickly understand the recommended system architecture and automate as many steps as possible, +**so I can** begin issuing Ed-Fi ODS/API credentials for application integrations. + +**How the Deployment Guide Helps** + +- Lays out a roadmap for successful deployment and use of the Admin App. +- Streamlines decision-making. +- Provides tools and guidance to automate portions of the setup process. + +**Signals of Success** + +- Clear understanding of the technical requirements for deployment. +- Fewer mouse clicks through the user interface before getting to credential management. +- Low latency when accessing the application. + +## Scope + +Work on this PRD will deliver improved documentation and scripts to aide in deployment of the new Ed-Fi Admin App. Outputs from this work include: + +- New and/or improved documentation articles for [docs.ed-fi.org](https://docs.ed-fi.org) +- SQL scripts for rapid initial data setup +- PowerShell scripts for deployment of the Ed-Fi Admin App application in a Windows Server environment + +New articles will be placed in the context of the SEA Playbook in the Getting Started section of the documentation site. Additional modifications may also be necessary in the Admin App and/or ODS Admin API pages in the Reference section. + +New automation scripts will be placed in the main branch of the Admin App or other source code repositories as appropriate. + +These materials will focus on installation in Windows with Microsoft SQL Server, assuming installation on a virtual machine that could be on-premises or in the cloud. Time permitting, they may be extended to provide high-level guidance on using Docker and/or managed cloud services, and to include translation of SQL scripts to PostgreSQL. + +## Must Have Features + +The features below describe known points of friction and frustration for those new to the Ed-Fi Admin App, along with a brief overview of an approach to easing that frustration. + +> ⚠️ **Note:** The contents below include recipes and conditions of satisfaction. The recipes are intended as a starting point for understanding the challenge. The engineers implementing this PRD should review and adjust as needed. + +### FT1: Entra ID as Alternative to Keycloak + +**Goal:** Help the reader configure Microsoft Entra ID as an Open ID Connect provider for Admin App. + +For additional context and initial work on this, see [Setup a Microsoft Entra ID instance to test OpenID Connect with Admin Console](https://edfi.atlassian.net/wiki/spaces/OTD/pages/954531848/Setup+an+Microsoft+Entra+ID+instance+to+test+OpenID+Connect+with+Admin+Console) and [Jira work item AC-247](https://edfi.atlassian.net/browse/AC-247). + +**Recipe** + +- Details about how to configure Entra ID are not yet known, and will need to be determined by the engineers working on this PRD. +- Provide instructions on both Entra ID, and on how to configure the `oidc` table so that Admin App will use the correct provider. + +**Conditions of Satisfaction** + +- A system administrator can sign-in to Admin App using a Microsoft account instead of using Keycloak. +- **NOTE:** These instructions should work whether Admin App is running in Linux (including Docker containers) or in Windows. + +### FT2: Roadmap to Success + +**Goal:** Help the user understand the deployment architecture and make critical decisions before beginning the installation process. This "Roadmap to Success" page would become part of the [Getting Started](https://docs.ed-fi.org/reference/admin-app/getting-started) documentation. + +**Recipe** + +- Introduce the primary purpose of the application: management of vendor credentials for integrating with one or more deployments of the Ed-Fi ODS/API, each containing one or more ODS database instances (e.g. one instance per school year[^1]). +- Provide a simple C4 diagram showing relationships between the following components: + - Ed-Fi Admin App + - Ed-Fi ODS Admin API + - Ed-Fi ODS/API + - `EdFi_Admin` database + - `EdFi_Security` database + - `AdminApp` database + - Identity provider +- Mention that user authentication requires use of an Open ID Connect (OIDC) compatible Identity Provider (IdP) such as Keycloak or Microsoft Entra ID. +- Prescribe that the application initially be placed inside the network security firewall, for access only by staff and contractors, in order to limit the attack surface. +- Include a note about the optional Yopass component, mentioning that it provides higher security for sharing credentials, at the cost of additional components to install and configure. Note that Yopass would need to be accessible to vendors through the firewall. Then link out to the reference documentation for more information on installation. +- Introduce basic concepts in the Ed-Fi Admin App: + - Teams + - Environments + - Instances + - Education Organizations + - Vendors + - Profiles + - Claimsets + - Applications + - Credentials +- The [User's Guide to Admin App v4](https://docs.ed-fi.org/reference/admin-app/user-guide/) already documents many of these concepts. We want to make sure there is a single glossary that defines these concepts, and perhaps some diagrams that show how the concepts relate to each other (e.g. one team can manage one or many environments; one environment can have multiple instances with different education organizations; one environment can have multiple vendors, profiles, and claimsets; each vendor can have multiple applications; an application has a claimset and a profile, and it can have many credentials). +- Clarify that this deployment guide will help them quickly move through setup of a single user with full administrative rights, with a single team, single environment, and two school year ODS instances, and two local education agencies. + +**Conditions of Satisfaction** + +- A reader on docs.ed-fi.org will have a strong understanding of how Admin App relates to the Ed-Fi Technology Suite and what components they can manage within the Technology Suite. + +### FT3: Windows Server Installation + +**Goal:** Walk through installation steps for the necessary components. + +**Assumptions** + +- The Ed-Fi ODS/API is already installed and configured. +- User is familiar with basic Windows administrative tasks. +- User is comfortable configuring IIS, including TLS certificate setup. + +**Recipe** — provides instructions for: + +- Creating an Admin App system user in Entra ID, then recording the Entra ID settings for later use: + - Page for accessing personal account information, if available + - Issuer name + - Admin App system credentials: + - Client ID + - Client Secret + - Scope +- Installing the `AdminApp` database tables, with a recommendation to install into its own database instance independent from the `EdFi_Admin` and `EdFi_Security` databases. This allows the application to support multiple deployments of the ODS/API in the future, for example when a prior year is on a different ODS/API version than the current year. +- Inserting Entra ID settings into the Admin App `oidc` table. +- Configuring and loading the web site files into IIS as a new web site, with optional PowerShell script. +- Running Admin App's Node.js backend so that it starts automatically and recovers from failures, and configuring IIS as a reverse-proxy to it. The specific hosting mechanism — for example an IIS-managed process or a standalone background service — is an implementation decision left to the engineers. + +**Conditions of Satisfaction** + +- A reader on docs.ed-fi.org, following these steps, will be able to sign-in to Admin App with administrative credentials and perform basic tasks in the application (e.g. creating an environment). + +### FT4: Global Admin Quick Start + +**Goal:** Simplify the global admin configuration process so that the user can more quickly get to their end goal of credential management. + +**Recipe** + +- Provide a SQL script to pre-configure the global admin with default settings: + - Administrative user, with username set as a variable at the top of the script + - This should be the same user configured in the initial Admin App bootstrap script + - Create a single team + - Place the user in that team with global admin role + - Create a single environment, with the environment name as a variable at the top of the script, defaulting to `"Ed-Fi ODS/API v7.3"` + - Set the new team to own the environment + - Anything else required so that the user can enter the application and go straight to instance management +- Describe variables and note the username must have already been created in the OAuth provider (Entra ID, Keycloak, etc.). +- Instruct the reader to run the script in the Admin App database. +- Additionally, instruct on setup of the `dbo.odsInstances` table in the `EdFi_Admin` database, if not already configured. If already configured, note the exact instance names. +- Direct the user to try logging into the Admin App application using their administrative user account, and navigate to the environment management page to enter the ODS instance names recorded above. +- Provide links to detailed Reference pages wherever applicable. + +**Conditions of Satisfaction** + +- A system administrator will be able to install Admin App and then run this script. After running the script, the system administrator can sign-in to Admin App, can enter the new environment, and can begin managing instances. + +### FT5: Claimsets + +**Background:** Admin App does not allow users to create an application with one of the default claimsets. The end user must first copy, rename, and upload one of these claimsets. + +**Goal:** Automate the copy-and-rename process for all built-in claimsets. + +**Recipe** + +- Provide a SQL script that copies built-in claimsets and recreates them under a new name prefixed with `"AA"` for Admin App / Admin API. For example, it would copy the `"SIS Vendor"` claimset as `"AA SIS Vendor"`. Prefixing with `"AA"` will make it easy to select the new claimset in a dropdown menu. +- This script will need to insert data into the following tables: + - `ClaimSets` + - `ClaimSetResourceClaimActions` + - `ClaimSetResourceClaimActionAuthorizationStrategyOverrides` +- Instruct the reader to run the script in the `EdFi_Security` database. + +**Acceptance Criteria** + +- After running this script, a system administrator can sign-in to Admin App and create a new set of client credentials using the just-created claimset copies. + +### FT6: Changes to Existing Documentation + +**Goal:** Minor reorganization of documentation. + +**Conditions of Satisfaction** + +- Move or remove some content from the existing Reference pages: + - Move to the new deployment guide: *Security Considerations* + - Replace "Windows IIS" guidance with a link to the new pages: *"For Windows installation, see the SEA Deployment Guide"* +- Reorder the page links in the left-side navigation pane, moving "Installing Ed-Fi Admin App" to the top, followed by "Configuring an Identity Provider", and leaving all other pages in the same order. +- The ODS Instance script in the Global Administration Tasks page fails to mention which database this is in (`EdFi_Admin`). + +### FT7: Education Organization Synchronization + +**Background:** Today, Admin App users must manually create Education Organizations in the Admin App database. The next release of Admin App (4.1) will provide a synchronization utility. + +**Goal:** Create a one-time process for copying education organizations into the Admin App database. + +**Assumption:** Admin App has already been configured with relevant environments and instances. + +**Recipe** + +- Determine the requirements for loading education organizations into the Admin App database. +- Provide a SQL and/or PowerShell script to export education organizations from an `EdFi_ODS` database into a CSV file. +- Provide a SQL and/or PowerShell script to transform and load those education organizations into the Admin App database. +- These scripts should take the *type* of education organization into account. + +**Conditions of Satisfaction** + +- After running the script, a system administrator can sign into Admin App and can see relevant education organizations while creating a new Application. + +[^1]: In addition to the current school year, there may be regulatory reasons to keep one or more prior years' databases active, for example to accept corrections to prior years' data. diff --git a/packages/api/config/local.js-edfi b/packages/api/config/local.js-edfi index 03b6f9ee..0f843cb7 100644 --- a/packages/api/config/local.js-edfi +++ b/packages/api/config/local.js-edfi @@ -58,10 +58,7 @@ module.exports = { ISSUER: 'https://localhost/auth/realms/edfi', CLIENT_ID: 'edfiadminapp', CLIENT_SECRET: 'big-secret-123', - MACHINE_AUDIENCE: 'edfiadminapp-api', - MANAGEMENT_DOMAIN: 'localhost', - MANAGEMENT_CLIENT_ID: 'edfiadminapp-machine', - MANAGEMENT_CLIENT_SECRET: 'edfi-machine-secret-456' + MACHINE_AUDIENCE: 'edfiadminapp-api' }, WHITELISTED_REDIRECTS: [FE_URL], diff --git a/packages/api/config/production.js-edfi b/packages/api/config/production.js-edfi index c656277e..29451715 100644 --- a/packages/api/config/production.js-edfi +++ b/packages/api/config/production.js-edfi @@ -54,10 +54,7 @@ module.exports = { ISSUER: 'https://localhost/auth/realms/edfi', CLIENT_ID: 'edfiadminapp', CLIENT_SECRET: 'big-secret-123', - MACHINE_AUDIENCE: 'edfiadminapp-api', - MANAGEMENT_DOMAIN: 'localhost', - MANAGEMENT_CLIENT_ID: 'edfiadminapp-machine', - MANAGEMENT_CLIENT_SECRET: 'edfi-machine-secret-456' + MACHINE_AUDIENCE: 'edfiadminapp-api' }, WHITELISTED_REDIRECTS: [FE_URL], @@ -72,8 +69,10 @@ module.exports = { USE_PKCE: true, - // Deliverating set in false since we are using self-signed certificates but in production should be set in true - SSL_VERIFICATION: false, + // SSL verification for the API's outbound HTTPS calls (ODS/API, AdminApi, Yopass). + // Secure by default; the installer sets this via NODE_CONFIG and can disable it + // with -DisableSslVerification for upstreams that use self-signed certificates. + SSL_VERIFICATION: true, // Certification artifact configuration CERT_BRUNO_SRC_REF: 'v2.1.0', CERT_BRUNO_SRC_CHECKSUM: '71840f51f464c60d7b90c7bbf08d9be039df291d51dd69085ffc4703b98f11e6', diff --git a/windows-install/00-check-prereqs.ps1 b/windows-install/00-check-prereqs.ps1 new file mode 100644 index 00000000..f0a24a6a --- /dev/null +++ b/windows-install/00-check-prereqs.ps1 @@ -0,0 +1,437 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS +Read-only pre-flight check. Reports the state of every prerequisite the install +scripts touch, without changing anything. + +.DESCRIPTION +Groups checks into three categories: + + Manual prereqs — must be installed before any install script can run + Auto-installed — scripts install these if missing (informational) + Already configured — scripts will (re)apply these even if present + +Output uses [PASS] / [FAIL] / [INFO] markers. Run this once before kicking off +install-all.ps1 to see what's already in place and what needs attention. + +.PARAMETER SourcePath +Path to the cloned Ed-Fi-AdminApp repo. Defaults to the parent of the script +directory (this script lives in \windows-install\). + +.PARAMETER DatabaseName +Default: sbaa. + +.EXAMPLE +.\00-check-prereqs.ps1 +.\00-check-prereqs.ps1 -SourcePath D:\projects\Ed-Fi-AdminApp +#> + +param( + [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), + [string]$DatabaseName = "sbaa", + # Which DB engine the install will target. 'mssql' enables the SQL Server + # checks below; 'pgsql' replaces them with a docker-availability check. + [ValidateSet('mssql','pgsql')] + [string]$DbEngine = 'mssql', + + # Yopass docker mode. When the install will run with -SetupYopassDocker, + # also verify Docker is RUNNING (not just installed) and the publish port + # is free, since the Yopass + memcached containers can't otherwise start. + [switch]$SetupYopassDocker, + [int]$YopassPort = 8082 +) + +$ErrorActionPreference = 'Continue' + +# Minimum versions enforced by the install scripts. The Node floor is +# auto-detected from $SourcePath\package.json (engines.node) below when the +# repo is cloned; the constant here is the fallback when it isn't. +$MinNodeMajor = 22 # fallback if package.json detection fails + +# Auto-detect the Node floor from the repo's engines.node when available. Keeps +# the check in sync if the AdminApp bumps its requirement (e.g., 22 -> 24). +$pkgJsonPath = Join-Path $SourcePath 'package.json' +if (Test-Path $pkgJsonPath) { + try { + $engineSpec = (Get-Content $pkgJsonPath -Raw | ConvertFrom-Json).engines.node + if ($engineSpec -and $engineSpec -match '(\d+)') { + $detected = [int]$Matches[1] + if ($detected -ne $MinNodeMajor) { + Write-Host "(Node floor set from package.json engines.node='$engineSpec': $MinNodeMajor -> $detected)" -ForegroundColor DarkGray + } + $MinNodeMajor = $detected + } + } catch { + # Parsing failed; keep the hardcoded fallback + } +} + +$failures = 0 +$warnings = 0 +$risks = 0 + +function Write-Check { + param( + [string]$Level, # PASS | FAIL | INFO | RISK + [string]$Name, + [string]$Detail + ) + $color = switch ($Level) { + 'PASS' { 'Green' } + 'FAIL' { 'Red' } + 'INFO' { 'Yellow' } + 'RISK' { 'Magenta' } + default { 'White' } + } + $marker = "[$Level]".PadRight(7) + Write-Host $marker -ForegroundColor $color -NoNewline + Write-Host " $Name" -NoNewline + if ($Detail) { Write-Host " -- $Detail" -ForegroundColor DarkGray } else { Write-Host "" } + if ($Level -eq 'FAIL') { $script:failures++ } + if ($Level -eq 'INFO') { $script:warnings++ } + if ($Level -eq 'RISK') { $script:risks++ } +} + +function Write-Section { + param([string]$Title) + Write-Host "" + Write-Host $Title -ForegroundColor Cyan + Write-Host ("-" * $Title.Length) -ForegroundColor Cyan +} + +# ============================================================ +Write-Section "MANUAL PREREQUISITES (must be in place before scripts run)" +# ============================================================ + +# Admin elevation +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if ($isAdmin) { + Write-Check PASS "Running as Administrator" +} else { + Write-Check FAIL "Running as Administrator" "Open PowerShell as administrator before running install scripts" +} + +# Windows version (informational; scripts target any modern Windows + IIS) +$os = (Get-CimInstance Win32_OperatingSystem).Caption +Write-Check PASS "OS: $os" + +# IIS installed (W3SVC service exists) +$w3svc = Get-Service W3SVC -ErrorAction SilentlyContinue +if ($w3svc) { + Write-Check PASS "IIS installed" "W3SVC status: $($w3svc.Status)" +} else { + Write-Check FAIL "IIS not installed" "Run: Enable-WindowsOptionalFeature -Online -FeatureName IIS-WebServerRole, IIS-WebServer, IIS-WebServerManagementTools -All" +} + +# IIS version -- 10+ required (05-deploy-api sets App Pool environmentVariables, +# an IIS 10 feature, to scope the npm cache). +if ($w3svc) { + $iisMajor = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\InetStp" -Name MajorVersion -ErrorAction SilentlyContinue).MajorVersion + if ($iisMajor -ge 10) { + Write-Check PASS "IIS version $iisMajor" + } elseif ($iisMajor) { + Write-Check FAIL "IIS $iisMajor is too old" "Requires IIS 10+ (App Pool environment variables for the npm cache). Use Windows 10/11 or Windows Server 2016+." + } else { + Write-Check INFO "IIS version unknown" "Could not read HKLM:\SOFTWARE\Microsoft\InetStp\MajorVersion" + } +} + +# SQL Server engine installed -- only required when -DbEngine is 'mssql'. +# When the target is 'pgsql' instead, check for Docker so the docker-compose +# postgres can come up. +$sqlService = Get-Service MSSQLSERVER -ErrorAction SilentlyContinue +if ($DbEngine -eq 'mssql') { + if ($sqlService) { + $verKey = Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server" -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -like "MSSQL*.MSSQLSERVER" } | Select-Object -First 1 + Write-Check PASS "SQL Server installed" "$($verKey.PSChildName), service status: $($sqlService.Status)" + } else { + Write-Check FAIL "SQL Server not installed" "Run: winget install Microsoft.SQLServer.2022.Developer (or pass -DbEngine pgsql to use Postgres instead)" + } +} else { + if ($sqlService) { + Write-Check INFO "SQL Server present but unused (-DbEngine pgsql)" "Service status: $($sqlService.Status). No SQL Server config will be touched." + } else { + Write-Check PASS "SQL Server skipped (-DbEngine pgsql)" + } + if (Get-Command docker -ErrorAction SilentlyContinue) { + Write-Check PASS "Docker on PATH" "Required for -UsePostgresDocker" + } else { + Write-Check INFO "Docker not on PATH" "Required only if running install-all with -UsePostgresDocker; for an external Postgres, ignore this" + } +} + +# Yopass docker mode -- only checked when the install will run with +# -SetupYopassDocker. Standing up Yopass needs a RUNNING Linux Docker engine and +# a free host port to publish on; both are blocking for this mode (FAIL), but +# only fire when the mode is requested so default installs are unaffected. +if ($SetupYopassDocker) { + $dockerCmd = Get-Command docker -ErrorAction SilentlyContinue + if (-not $dockerCmd) { + Write-Check FAIL "Docker not on PATH (needed for -SetupYopassDocker)" "Install Docker Desktop, or drop -SetupYopassDocker (use -YopassUrl for an existing Yopass, or leave Yopass disabled)" + } else { + # `docker info` exits non-zero when the engine isn't running. OSType + # confirms the Linux engine is active (yopass/memcached are Linux images). + $osType = & docker info --format '{{.OSType}}' 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Check FAIL "Docker engine not running (needed for -SetupYopassDocker)" "Start Docker Desktop, wait until it reports 'running', then re-run" + } else { + Write-Check PASS "Docker engine running" "OSType: $osType" + if ($osType -and $osType -ne 'linux') { + Write-Check FAIL "Docker is in '$osType' container mode" "Yopass + memcached are Linux images -- switch Docker Desktop to Linux containers" + } + # Publish port free? Allow the case where OUR yopass container is + # already publishing it (idempotent re-run). Note: on Docker Desktop + # a published port shows as owned by com.docker.backend, so we can't + # tell ours apart by process name -- ask docker which ports our + # container actually publishes. + $listener = Get-NetTCPConnection -LocalPort $YopassPort -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $listener) { + Write-Check PASS "Yopass port $YopassPort is free" + } else { + $ourPorts = & docker ps --filter "name=^edfiadminapp-yopass$" --format "{{.Ports}}" 2>$null + if ($ourPorts -match ":$YopassPort->") { + Write-Check PASS "Yopass port $YopassPort in use by existing edfiadminapp-yopass" "Idempotent re-run -- compose up will reuse it" + } else { + $procName = (Get-Process -Id $listener.OwningProcess -ErrorAction SilentlyContinue).ProcessName + Write-Check FAIL "Yopass port $YopassPort already in use ($procName)" "Pass a free port: install-all ... -SetupYopassDocker -YopassPort " + } + } + } + } +} + +# Git installed +$git = Get-Command git -ErrorAction SilentlyContinue +if ($git) { + Write-Check PASS "Git on PATH" "$($git.Source)" +} else { + Write-Check INFO "Git not on PATH" "Optional. Used to clone the repo. winget install Git.Git" +} + +# Source repo present +if (Test-Path "$SourcePath\package.json") { + Write-Check PASS "Source repo cloned" "$SourcePath" +} else { + Write-Check FAIL "Source repo not found at $SourcePath" "Clone before running install scripts" +} + +# ============================================================ +Write-Section "AUTO-INSTALLED COMPONENTS (scripts will install if missing)" +# ============================================================ + +# URL Rewrite Module +$rewrite = Test-Path "$env:SystemRoot\System32\inetsrv\rewrite.dll" +if ($rewrite) { + Write-Check PASS "URL Rewrite Module" +} else { + Write-Check INFO "URL Rewrite Module not installed" "01-prereqs-iis.ps1 will install" +} + +# httpPlatform handler (HttpBridge or Microsoft HttpPlatformHandler). Registered +# as the global module 'httpPlatformHandler'. 01-prereqs-iis.ps1 installs it. +$httpPlatform = $false +try { + Import-Module WebAdministration -ErrorAction Stop + if (Get-WebGlobalModule -Name 'httpPlatformHandler' -ErrorAction SilentlyContinue) { $httpPlatform = $true } +} catch { } +if ($httpPlatform) { + Write-Check PASS "httpPlatform handler" +} else { + Write-Check INFO "httpPlatform handler not installed" "01-prereqs-iis.ps1 will install" +} + +# Node.js -- presence AND version (>= $MinNodeMajor). Both missing and too-old +# are INFO: 03-prereqs-node.ps1 installs LTS when missing and remediates a stale +# version via nvm-windows. +$node = Get-Command node -ErrorAction SilentlyContinue +if (-not $node) { + # Refresh PATH from registry in case it was just installed in another shell + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") + $node = Get-Command node -ErrorAction SilentlyContinue +} +if ($node) { + $nodeVer = & node --version 2>$null + if ($nodeVer -match '^v(\d+)\.') { + $nodeMajor = [int]$Matches[1] + if ($nodeMajor -ge $MinNodeMajor) { + Write-Check PASS "Node.js" "$nodeVer at $($node.Source)" + } else { + Write-Check INFO "Node.js $nodeVer is too old" "03-prereqs-node.ps1 will remediate via nvm-windows (keeps the old version installable)" + } + } else { + Write-Check INFO "Node.js version unparsable" "Output was: $nodeVer" + } +} else { + Write-Check INFO "Node.js not on PATH" "03-prereqs-node.ps1 will install LTS via winget" +} + +# ============================================================ +Write-Section "CONFIGURED STATE (scripts will (re)apply these)" +# ============================================================ + +if ($DbEngine -eq 'mssql' -and $sqlService -and $sqlService.Status -eq 'Running') { + # SQL Mixed Mode + $verKey = Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server" -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -like "MSSQL*.MSSQLSERVER" } | Select-Object -First 1 + if ($verKey) { + $loginMode = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$($verKey.PSChildName)\MSSQLServer" -Name LoginMode -ErrorAction SilentlyContinue).LoginMode + if ($loginMode -eq 2) { + Write-Check PASS "SQL Server Mixed Mode auth enabled" + } elseif ($loginMode -eq 1) { + Write-Check INFO "SQL Server is Windows-auth only" "02-prereqs-sql.ps1 will switch to Mixed Mode" + } else { + Write-Check INFO "SQL Server LoginMode = $loginMode (unknown)" + } + } + + # SQL TCP listener on 1433 + $tcpListener = Get-NetTCPConnection -LocalPort 1433 -State Listen -ErrorAction SilentlyContinue + if ($tcpListener) { + Write-Check PASS "TCP listener on port 1433" + } else { + Write-Check INFO "Nothing listening on TCP 1433" "02-prereqs-sql.ps1 will enable TCP/IP" + } + + # sbaa database + $dbCheck = & sqlcmd -S "(local)" -E -Q "SET NOCOUNT ON; SELECT name FROM sys.databases WHERE name = N'$DatabaseName'" -h-1 2>&1 + if ($LASTEXITCODE -eq 0 -and $dbCheck -match $DatabaseName) { + Write-Check PASS "Database '$DatabaseName' exists" + } else { + Write-Check INFO "Database '$DatabaseName' not found" "02-prereqs-sql.ps1 will create" + } +} else { + Write-Check INFO "SQL Server checks skipped" "Service not running" +} + +# npm cache folder (05-deploy-api sets NPM_CONFIG_CACHE on the App Pool, not machine-wide) +$npmCache = "C:\npm-cache" +if (Test-Path $npmCache) { + Write-Check PASS "npm cache folder present" "$npmCache" +} else { + Write-Check INFO "npm cache folder not present" "05-deploy-api.ps1 will create it and set NPM_CONFIG_CACHE on the App Pool" +} + +# Build artifacts present? Nx outputs to dist\packages\\, not the repo root. +$apiMainJs = "$SourcePath\dist\packages\api\main.js" +if (Test-Path $apiMainJs) { + Write-Check PASS "API build artifact present" $apiMainJs +} else { + Write-Check INFO "API not built yet" "04-build.ps1 will run npm ci + build:api" +} +$feIndex = "$SourcePath\dist\packages\fe\index.html" +if (Test-Path $feIndex) { + Write-Check PASS "FE build artifact present" "$SourcePath\dist\packages\fe\" +} else { + Write-Check INFO "FE not built yet" "04-build.ps1 will run build:fe" +} + +# IIS state +try { + Import-Module WebAdministration -ErrorAction Stop + $apiSite = Get-Website -Name "EdFi-AdminApp-API" -ErrorAction SilentlyContinue + if ($apiSite) { + Write-Check PASS "IIS site 'EdFi-AdminApp-API' present" "State: $($apiSite.State)" + } else { + Write-Check INFO "IIS site 'EdFi-AdminApp-API' not present" "05-deploy-api.ps1 will create (HTTP :3333 -> HTTPS :3443)" + } + $feSite = Get-Website -Name "EdFi-AdminApp-FE" -ErrorAction SilentlyContinue + if ($feSite) { + Write-Check PASS "IIS site 'EdFi-AdminApp-FE' present" + } else { + Write-Check INFO "IIS site 'EdFi-AdminApp-FE' not present" "06-deploy-fe.ps1 will create (HTTP :4200 -> HTTPS :4443)" + } + $apiPool = Get-Item "IIS:\AppPools\EdFi-AdminApp-API" -ErrorAction SilentlyContinue + if ($apiPool) { + $loadProfile = (Get-ItemProperty "IIS:\AppPools\EdFi-AdminApp-API" -Name "processModel.loadUserProfile").Value + Write-Check PASS "App Pool 'EdFi-AdminApp-API' present" "LoadUserProfile: $loadProfile" + } else { + Write-Check INFO "App Pool 'EdFi-AdminApp-API' not present" "05-deploy-api.ps1 will create" + } +} catch { + Write-Check INFO "IIS checks skipped" "WebAdministration module unavailable (is IIS installed?)" +} + +# ============================================================ +Write-Section "EXISTING STATE THAT WILL BE MODIFIED (collision risk check)" +# ============================================================ +# These checks flag things that already exist on this machine that the install +# scripts WILL change. On a clean dev VM, nothing here should fire. On a +# workstation that already runs other software, each RISK is a heads-up that +# another app on the box may be affected. + +# SQL Server instance is shared with other databases? +# 02-prereqs-sql.ps1 flips Mixed Mode, enables sa, forces TCP/IP on 1433, and +# restarts the MSSQLSERVER service. If the instance is hosting other apps, +# they'll feel all three. Skip the entire RISK probe when -DbEngine pgsql -- +# the SQL Server install won't be touched at all in that mode. +if ($DbEngine -eq 'mssql' -and $sqlService) { + $userDbs = & sqlcmd -S "(local)" -E -h-1 -W -Q "SET NOCOUNT ON; SELECT name FROM sys.databases WHERE database_id > 4 AND name <> N'$DatabaseName'" 2>$null | + Where-Object { $_ -and $_.Trim() -ne '' -and $_ -notmatch '^\(' } + if ($userDbs -and $userDbs.Count -gt 0) { + $preview = ($userDbs | Select-Object -First 3) -join ', ' + if ($userDbs.Count -gt 3) { $preview += ", +$($userDbs.Count - 3) more" } + Write-Check RISK "SQL instance hosts other databases" "02 will flip Mixed Mode, reset sa, force TCP:1433, restart service. Other DBs: $preview" + } + + # sa already enabled and password unknown to us? We can't know the password + # without trying it, but if sa is enabled at all on a shared instance the + # 01 script will overwrite it. + $saState = & sqlcmd -S "(local)" -E -h-1 -W -Q "SET NOCOUNT ON; SELECT CASE WHEN is_disabled = 0 THEN 'enabled' ELSE 'disabled' END FROM sys.sql_logins WHERE name = 'sa'" 2>$null | + Where-Object { $_ -and $_.Trim() -ne '' -and $_ -notmatch '^\(' } | Select-Object -First 1 + if ($saState -and $saState.Trim() -eq 'enabled' -and $userDbs -and $userDbs.Count -gt 0) { + Write-Check RISK "sa login is already enabled on a shared instance" "02 will reset sa's password to -SaPassword if the current password doesn't match" + } +} + +# Ports 3333/4200 (HTTP) and 3443/4443 (HTTPS) free? The two standalone sites bind +# these (HTTP redirects to HTTPS). If another process already owns one, New-Website +# fails. Our own AdminApp sites owning the port is fine (idempotent re-run) -- flag +# only a foreign owner. +$portChecks = @( + @{ Port = 3333; Site = 'EdFi-AdminApp-API'; Role = 'API (HTTP)' }, + @{ Port = 4200; Site = 'EdFi-AdminApp-FE'; Role = 'FE (HTTP)' }, + @{ Port = 3443; Site = 'EdFi-AdminApp-API'; Role = 'API (HTTPS)' }, + @{ Port = 4443; Site = 'EdFi-AdminApp-FE'; Role = 'FE (HTTPS)' } +) +foreach ($pc in $portChecks) { + $listener = Get-NetTCPConnection -LocalPort $pc.Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $listener) { continue } + $ours = $false + try { + Import-Module WebAdministration -ErrorAction Stop + if (Get-Website -Name $pc.Site -ErrorAction SilentlyContinue) { $ours = $true } + } catch { } + if (-not $ours) { + $procName = (Get-Process -Id $listener.OwningProcess -ErrorAction SilentlyContinue).ProcessName + Write-Check RISK "Port $($pc.Port) ($($pc.Role)) already in use ($procName)" "05/06-deploy will fail to bind the '$($pc.Site)' site -- free the port first" + } +} + +if ($risks -eq 0) { + Write-Host "[PASS] No collision risks detected." -ForegroundColor Green +} + +# ============================================================ +Write-Host "" +Write-Host ("=" * 60) -ForegroundColor Cyan +if ($failures -gt 0) { + Write-Host "NOT READY -- $failures blocking issue(s)" -ForegroundColor Red + Write-Host "Fix the FAIL items above before running install-all.ps1." -ForegroundColor Red + Write-Host ("=" * 60) -ForegroundColor Cyan + exit 1 +} elseif ($risks -gt 0) { + Write-Host "READY TO INSTALL -- with $risks collision risk(s)" -ForegroundColor Magenta + Write-Host "Review the [RISK] items above. install-all.ps1 will prompt before proceeding" -ForegroundColor Magenta + Write-Host "unless -AcceptRisks is passed." -ForegroundColor Magenta + if ($warnings -gt 0) { + Write-Host "$warnings item(s) flagged INFO -- those are things the scripts will install or configure." -ForegroundColor Yellow + } + Write-Host ("=" * 60) -ForegroundColor Cyan + exit 2 +} else { + Write-Host "READY TO INSTALL" -ForegroundColor Green + Write-Host "All manual prerequisites are in place." -ForegroundColor Green + if ($warnings -gt 0) { + Write-Host "$warnings item(s) flagged INFO -- those are things the scripts will install or configure." -ForegroundColor Yellow + } + exit 0 +} diff --git a/windows-install/01-prereqs-iis.ps1 b/windows-install/01-prereqs-iis.ps1 new file mode 100644 index 00000000..dc22201c --- /dev/null +++ b/windows-install/01-prereqs-iis.ps1 @@ -0,0 +1,171 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS +Installs the IIS URL Rewrite Module and an httpPlatform handler (HttpBridge or +Microsoft HttpPlatformHandler), and unlocks the IIS config the Admin App's +web.config files need. + +.DESCRIPTION +- Downloads + installs the IIS URL Rewrite Module MSI (verifying a pinned SHA-256) +- Downloads + installs the chosen httpPlatform handler MSI (verifying a pinned SHA-256) +- Unlocks system.webServer/handlers so app-level web.config can register the handler + +The API is hosted by IIS via the httpPlatform handler: IIS launches node.exe as a +child process, hands it a loopback port through HTTP_PLATFORM_PORT, and reverse- +proxies requests to it. + +Idempotent -- safe to re-run. + +This sets up the IIS engine prerequisites only. The API and FE are deployed as +two standalone sites by 05-deploy-api.ps1 and 06-deploy-fe.ps1. + +.PARAMETER HttpHandler +Which httpPlatform handler to install: + HttpBridge (default) -- LeXtudio fork (MIT), actively maintained, + drop-in compatible with the httpPlatform schema. Currently + shipped as a release candidate. + HttpPlatformHandler -- Microsoft's original v1.2 (signed, stable, frozen ~2016). +Both register the same 'httpPlatformHandler' global module, so the API web.config +is identical either way. + +.EXAMPLE +.\01-prereqs-iis.ps1 +.\01-prereqs-iis.ps1 -HttpHandler HttpPlatformHandler +#> + +param( + [ValidateSet('HttpBridge','HttpPlatformHandler')] + [string]$HttpHandler = 'HttpBridge' +) + +$ErrorActionPreference = 'Stop' + +# Pinned downloads. Each MSI is verified against a SHA-256 captured from the +# published artifact before install (defends against corrupt/partial downloads and +# a tampered mirror). Mirrors the CERT_BRUNO_SRC_CHECKSUM pattern in the codebase. +$UrlRewrite = @{ + Url = 'https://download.microsoft.com/download/D/D/E/DDE57C26-C62C-4C59-A1BB-31D58B36ADA2/rewrite_amd64_en-US.msi' + Sha256 = '7B327108055C4B5BA9445E3B1AFCC4DC5EDD373BAA83EBE6DCB0B1CE57EE3FC2' + File = 'rewrite_amd64_en-US.msi' +} +$Handlers = @{ + HttpBridge = @{ + Url = 'https://github.com/lextudio/httpbridge/releases/download/httpbridge_v10.0.0-rc.1/httpbridge_x64_en_10.0.0-dev.msi' + Sha256 = '35E06DC2EEBBDA4C6756787FA6650B56504684907242ED760B855D1D0248709F' + File = 'httpbridge_x64_en_10.0.0-dev.msi' + } + HttpPlatformHandler = @{ + Url = 'https://download.microsoft.com/download/8/1/3/813AC4E6-9203-4F7A-8DD5-F3D54D10C5CD/httpPlatformHandler_amd64.msi' + Sha256 = '90F8D4905A0AB4F2C95223B3C79E2807A0B74507747D240E43C4302E8DB4B5EF' + File = 'httpPlatformHandler_amd64.msi' + } +} + +function Install-VerifiedMsi { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Url, + [Parameter(Mandatory)][string]$Sha256, + [Parameter(Mandatory)][string]$FileName + ) + $msi = Join-Path $env:TEMP $FileName + + # Reuse an already-downloaded MSI only if its hash matches; otherwise + # (missing, or mismatch -> possibly corrupt/partial/tampered) (re)download. + $needsDownload = $true + if (Test-Path $msi) { + if ((Get-FileHash -Path $msi -Algorithm SHA256).Hash -ieq $Sha256) { + Write-Host "$Name MSI already downloaded and verified -- reusing $msi." + $needsDownload = $false + } else { + Write-Host "$Name MSI at $msi failed the pinned hash (corrupt/partial?); re-downloading." -ForegroundColor Yellow + Remove-Item $msi -Force + } + } + if ($needsDownload) { + Write-Host "Downloading $Name from $Url ..." + try { + Invoke-WebRequest -Uri $Url -OutFile $msi -UseBasicParsing + } catch { + throw "Failed to download $Name from $Url. Check internet connectivity and that the URL is reachable. Original: $($_.Exception.Message)" + } + $actual = (Get-FileHash -Path $msi -Algorithm SHA256).Hash + if ($actual -ine $Sha256) { + Remove-Item $msi -Force -ErrorAction SilentlyContinue + throw "$Name failed SHA-256 verification.`n Expected: $Sha256`n Actual: $actual`nThe download may be corrupt or tampered with; aborting." + } + Write-Host "$Name verified (SHA-256 match)." + } + + Write-Host "Installing $Name ..." + Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart" -Wait +} + +# Precondition: the IIS web server role must already be installed +# (setup-vm-prereqs.ps1 does that). This script only adds URL Rewrite + the httpPlatform handler. +if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) { + throw "IIS (W3SVC) is not installed. Run setup-vm-prereqs.ps1 first, or enable the IIS role via Enable-WindowsOptionalFeature." +} + +# Precondition: IIS 10+ is required. 05-deploy-api.ps1 sets NPM_CONFIG_CACHE on +# the App Pool's environmentVariables, a collection added in IIS 10.0. +$iisMajor = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\InetStp" -Name MajorVersion -ErrorAction SilentlyContinue).MajorVersion +if ($iisMajor -and $iisMajor -lt 10) { + throw "IIS $iisMajor detected; this install requires IIS 10 or newer (App Pool environment variables, used to scope the npm cache, were added in IIS 10). Use Windows 10/11 or Windows Server 2016+." +} +Import-Module WebAdministration + +# IIS URL Rewrite Module. +# Still required by the FE web.config SPA fallback (06-deploy-fe.ps1 rewrites to +# index.html). The API no longer uses a rewrite rule under httpPlatform. +$rewriteDll = "$env:SystemRoot\System32\inetsrv\rewrite.dll" +if (Test-Path $rewriteDll) { + Write-Host "URL Rewrite Module already installed." +} else { + Install-VerifiedMsi -Name 'IIS URL Rewrite Module' -Url $UrlRewrite.Url -Sha256 $UrlRewrite.Sha256 -FileName $UrlRewrite.File + if (-not (Test-Path $rewriteDll)) { + throw "URL Rewrite Module install failed (rewrite.dll not present)." + } +} + +# Unlock system.webServer/handlers so app-level web.configs can register the +# httpPlatform handler. IIS locks this by default; without it, the API returns +# HTTP 500.19 (0x80070021). +& "$env:SystemRoot\System32\inetsrv\appcmd.exe" unlock config -section:system.webServer/handlers | Out-Null +Write-Host "Unlocked system.webServer/handlers section." + +# httpPlatform handler. Both HttpBridge and Microsoft HttpPlatformHandler register +# a global module named 'httpPlatformHandler'. Skip if already present (re-run safe; +# switching handlers needs a manual uninstall first). +if (Get-WebGlobalModule -Name 'httpPlatformHandler' -ErrorAction SilentlyContinue) { + # Both handlers register the same module name, so detect which MSI is installed + # to tell whether it matches the requested -HttpHandler and warn if it does not + # (switching handlers needs a manual uninstall of the current MSI first). + $uninstallKeys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + $installedName = Get-ItemProperty $uninstallKeys -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match 'HTTP\s*Bridge|HTTP\s*Platform\s*Handler' } | + Select-Object -First 1 -ExpandProperty DisplayName + $installedHandler = + if ($installedName -match 'Bridge') { 'HttpBridge' } + elseif ($installedName -match 'Platform\s*Handler') { 'HttpPlatformHandler' } + else { $null } + if ($installedHandler -and $installedHandler -ne $HttpHandler) { + Write-Warning "The 'httpPlatformHandler' module is already registered from '$installedName' (-HttpHandler $installedHandler), but -HttpHandler $HttpHandler was requested. Keeping the installed handler. To switch, uninstall the current handler MSI first (Programs and Features, or msiexec /x), then re-run." + } else { + Write-Host "httpPlatform handler already registered (global module 'httpPlatformHandler')." + } +} else { + $h = $Handlers[$HttpHandler] + Install-VerifiedMsi -Name $HttpHandler -Url $h.Url -Sha256 $h.Sha256 -FileName $h.File + if (-not (Get-WebGlobalModule -Name 'httpPlatformHandler' -ErrorAction SilentlyContinue)) { + throw "$HttpHandler install completed but the 'httpPlatformHandler' global module is not registered. Check the MSI installed correctly." + } +} + +Write-Host "" +Write-Host "SUCCESS: URL Rewrite + $HttpHandler installed; IIS config unlocked." -ForegroundColor Green +Write-Host "The API and FE deploy as standalone sites (05-deploy-api.ps1, 06-deploy-fe.ps1)." diff --git a/windows-install/02-prereqs-sql.ps1 b/windows-install/02-prereqs-sql.ps1 new file mode 100644 index 00000000..5b5cdeee --- /dev/null +++ b/windows-install/02-prereqs-sql.ps1 @@ -0,0 +1,277 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS +Configures SQL Server for the Ed-Fi Admin App: enables Mixed Mode auth, TCP/IP +protocol, and the `sa` login with a known password. + +.DESCRIPTION +Addresses two SQL Server defaults that block the Admin App API from connecting: +- Mixed Mode disabled (Windows-only auth) — fails because the app uses SQL Auth +- TCP/IP disabled — fails because the `mssql` Node driver requires TCP + +Auto-detects the installed SQL Server major version from the registry. +Restarts the MSSQLSERVER service once at the end. Idempotent — safe to re-run. + +.PARAMETER SaPassword +The password to assign to the sa login. Used only for server-level bootstrap +(Mixed Mode verification and database creation); the Admin App itself does NOT +connect as sa -- see AppDbUsername/AppDbPassword. + +.PARAMETER AppDbUsername +The dedicated, least-privilege SQL login the Admin App connects as at runtime. +It is made db_owner of the Admin App database only (not a server sysadmin like +sa). Referenced later in production.js as MSSQL_DB_USERNAME. Default: edfi_adminapp. + +.PARAMETER AppDbPassword +The password for the dedicated Admin App login. Referenced later in production.js +as MSSQL_DB_PASSWORD. CHECK_POLICY is enforced on this login, so a weak password +is rejected at creation time. + +.PARAMETER InstanceName +SQL Server instance name. Defaults to MSSQLSERVER (the default instance). + +.PARAMETER DatabaseName +Name of the Admin App database to create (if it doesn't already exist). +Default: sbaa (the name the Admin App expects out of the box). + +.EXAMPLE +.\02-prereqs-sql.ps1 -SaPassword 'EdFi-AdminApp-Local!2026' -AppDbPassword 'EdFi-App-Local!2026' +.\02-prereqs-sql.ps1 -SaPassword 'EdFi-AdminApp-Local!2026' -AppDbPassword 'EdFi-App-Local!2026' -DatabaseName 'myadminapp' +#> + +param( + # Not Mandatory: PowerShell auto-prompts all Mandatory params before the body + # runs, so a weak sa password would only be rejected after the app-DB prompt. + # Instead they're prompted, unwrapped, and strength-checked one at a time below. + [SecureString]$SaPassword, + [SecureString]$AppDbPassword, + + [string]$AppDbUsername = "edfi_adminapp", + [string]$InstanceName = "MSSQLSERVER", + [string]$DatabaseName = "sbaa" +) + +$ErrorActionPreference = 'Stop' + +# Reject a weak SQL login password the moment each is resolved (whether passed as a +# param or prompted), before any registry or SQL work, so a weak password fails +# immediately and next to the prompt that set it -- not later, after an unrelated +# prompt, as an opaque CHECK_POLICY rejection during CREATE/ALTER LOGIN. Mirrors the +# Windows policy CHECK_POLICY enforces: length >= 8 and at least 3 of the 4 character +# categories (uppercase/lowercase/digit/symbol). -cmatch keeps the upper/lower test +# case-sensitive; AllowEmptyString lets an empty password reach the length check with +# a clear message instead of a parameter-binding error. +function Test-SqlPasswordComplexity { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Password, + [Parameter(Mandatory = $true)][string]$Label + ) + $categories = 0 + if ($Password -cmatch '[A-Z]') { $categories++ } + if ($Password -cmatch '[a-z]') { $categories++ } + if ($Password -match '[0-9]') { $categories++ } + if ($Password -match '[^A-Za-z0-9]') { $categories++ } + if ($Password.Length -lt 8 -or $categories -lt 3) { + throw "The $Label password does not meet the SQL Server password policy (CHECK_POLICY): use at least 8 characters and at least 3 of uppercase, lowercase, digit, and symbol." + } +} + +# Prompt (if omitted), unwrap, and strength-check each secret in turn. Unwrap to new +# locals -- assigning back to the [SecureString]-typed parameters would re-trigger +# their type conversion and fail. Point-of-use plaintext (SQLCMDPASSWORD, the inline +# T-SQL) is unavoidable, so it lives in locals, never on a command line. +if (-not $SaPassword) { $SaPassword = Read-Host -AsSecureString "SQL Server 'sa' password" } +$SaPasswordPlain = [System.Net.NetworkCredential]::new('', $SaPassword).Password +Test-SqlPasswordComplexity -Password $SaPasswordPlain -Label "sa (-SaPassword)" + +if (-not $AppDbPassword) { $AppDbPassword = Read-Host -AsSecureString "Admin App DB login '$AppDbUsername' password" } +$AppDbPasswordPlain = [System.Net.NetworkCredential]::new('', $AppDbPassword).Password +Test-SqlPasswordComplexity -Password $AppDbPasswordPlain -Label "Admin App DB login (-AppDbPassword)" + +# Find the SQL Server version-specific registry key +$verKey = Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server" -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -like "MSSQL*.$InstanceName" } | + Select-Object -First 1 + +if (-not $verKey) { + throw "Could not find a SQL Server install for instance '$InstanceName'. Is SQL Server installed?" +} + +# Precondition: sqlcmd is used throughout to configure and verify the instance. +if (-not (Get-Command sqlcmd -ErrorAction SilentlyContinue)) { + throw "sqlcmd is not on PATH. Install the SQL Server command-line tools before running this script." +} + +$verName = $verKey.PSChildName +Write-Host "Detected SQL Server version key: $verName" + +$registryChanged = $false + +# Mixed Mode + TCP/IP registry writes. Wrapped so a permissions/instance error +# surfaces an actionable message instead of a raw registry exception. +try { + # Mixed Mode authentication — only set if not already 2 + $lmPath = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$verName\MSSQLServer" + $currentMode = (Get-ItemProperty -Path $lmPath -Name "LoginMode" -ErrorAction SilentlyContinue).LoginMode + if ($currentMode -eq 2) { + Write-Host "Mixed Mode already enabled (LoginMode=2)." + } else { + Set-ItemProperty -Path $lmPath -Name "LoginMode" -Value 2 + Write-Host "Mixed Mode authentication enabled (LoginMode was $currentMode)." + $registryChanged = $true + } + + # TCP/IP protocol — only set values that aren't already correct + $tcpBase = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$verName\MSSQLServer\SuperSocketNetLib\Tcp" + $rootEnabled = (Get-ItemProperty -Path $tcpBase -Name "Enabled" -ErrorAction SilentlyContinue).Enabled + if ($rootEnabled -ne 1) { + Set-ItemProperty -Path $tcpBase -Name "Enabled" -Value 1 + $registryChanged = $true + } + Get-ChildItem $tcpBase | ForEach-Object { + $cur = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue + if ($cur.Enabled -ne 1) { Set-ItemProperty -Path $_.PSPath -Name "Enabled" -Value 1 -ErrorAction SilentlyContinue; $script:registryChanged = $true } + if ($cur.Active -ne 1) { Set-ItemProperty -Path $_.PSPath -Name "Active" -Value 1 -ErrorAction SilentlyContinue; $script:registryChanged = $true } + if ($cur.TcpDynamicPorts -ne "") { Set-ItemProperty -Path $_.PSPath -Name "TcpDynamicPorts" -Value "" -ErrorAction SilentlyContinue; $script:registryChanged = $true } + if ($cur.TcpPort -ne "1433") { Set-ItemProperty -Path $_.PSPath -Name "TcpPort" -Value "1433" -ErrorAction SilentlyContinue; $script:registryChanged = $true } + } + Write-Host "TCP/IP settings checked/applied." +} catch { + throw "Failed to update SQL Server registry for instance '$InstanceName'. Ensure you're running as administrator and the instance name is correct. Original: $($_.Exception.Message)" +} + +# Restart only if registry changes happened +if ($registryChanged) { + Write-Host "Restarting SQL Server to apply registry changes..." + try { + Restart-Service -Name $InstanceName -Force + } catch { + throw "Failed to restart the '$InstanceName' service. Check the service exists and isn't blocked by dependent services. Original: $($_.Exception.Message)" + } +} else { + Write-Host "No registry changes -- skipping service restart." +} + +# Helper: run sqlcmd, return exit code, swallow stderr without tripping +# $ErrorActionPreference=Stop. PS 5.1 wraps native command stderr in +# NativeCommandError records and the script-wide Stop preference treats those +# as terminating, so we temporarily relax the preference around the call. +# Every call gets a query timeout (-t) so we never hang on a partially-up +# server. +function Invoke-Sqlcmd-Quiet { + param( + [string[]]$SqlArgs, + [string]$Password, + [switch]$FailOnSqlError + ) + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + # Pass the password via SQLCMDPASSWORD instead of -P so it never lands on the + # sqlcmd process command line (visible in the process list); cleared right + # after the call. Windows-auth (-E) callers pass no -Password. + if ($Password) { $env:SQLCMDPASSWORD = $Password } + # -b makes sqlcmd return a non-zero exit code on a SQL error (severity >= 11), + # so a policy rejection (e.g. a weak password failing CHECK_POLICY) fails + # loudly instead of silently returning 0. Set only on the DDL/provisioning + # calls; the readiness and connection probes below intentionally read exit + # codes and must not treat an expected failure as fatal. + if ($FailOnSqlError) { $SqlArgs += "-b" } + try { + & sqlcmd @SqlArgs -t 10 2>&1 | Out-Null + } finally { + $ErrorActionPreference = $prev + if ($Password) { Remove-Item Env:SQLCMDPASSWORD -ErrorAction SilentlyContinue } + } + return $LASTEXITCODE +} + +# After a service restart, SQL Server's status goes Running before it's +# actually accepting queries. Loop with Windows-auth probes (sa may not be +# enabled yet) until a SELECT 1 succeeds or we time out. +Write-Host "Waiting for SQL Server to accept queries..." +$ready = $false +for ($i = 0; $i -lt 30; $i++) { + $ec = Invoke-Sqlcmd-Quiet @("-S", "(local)", "-E", "-Q", "SELECT 1", "-l", "3") + if ($ec -eq 0) { $ready = $true; break } + Start-Sleep -Seconds 2 +} +if (-not $ready) { + throw "SQL Server did not become ready within ~60 seconds. Check the service is running." +} +Write-Host "SQL Server is responding." + +# Enable sa, set password -- only ALTER LOGIN if current password doesn't work +Write-Host "Checking sa login..." +$ec = Invoke-Sqlcmd-Quiet -SqlArgs @("-S", "tcp:localhost,1433", "-U", "sa", "-Q", "SELECT 1", "-l", "3") -Password $SaPasswordPlain +$saLoginWorks = ($ec -eq 0) + +if ($saLoginWorks) { + Write-Host "sa login already accepts the provided password -- skipping ALTER LOGIN." +} else { + Write-Host "sa login doesn't accept the password -- running ALTER LOGIN..." + $escapedPw = $SaPasswordPlain -replace "'", "''" + $saQuery = "ALTER LOGIN sa WITH PASSWORD = '$escapedPw'; ALTER LOGIN sa ENABLE;" + $ec = Invoke-Sqlcmd-Quiet -SqlArgs @("-S", "(local)", "-E", "-Q", $saQuery) -FailOnSqlError + if ($ec -ne 0) { + throw "Failed to configure sa login (sqlcmd exit code $ec). Verify your Windows user has SQL sysadmin." + } +} + +# Verify TCP listener + SQL Auth +$listener = Get-NetTCPConnection -LocalPort 1433 -State Listen -ErrorAction SilentlyContinue +if (-not $listener) { + throw "No listener on TCP 1433 after restart. Check Windows Firewall." +} + +$ec = Invoke-Sqlcmd-Quiet -SqlArgs @("-S", "tcp:localhost,1433", "-U", "sa", "-Q", "SELECT @@VERSION") -Password $SaPasswordPlain +if ($ec -ne 0) { + throw "SQL Auth over TCP failed (sqlcmd exit code $ec)." +} + +# Create the Admin App database if it doesn't already exist +Write-Host "Ensuring database '$DatabaseName' exists..." +$dbQuery = "IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = N'$DatabaseName') CREATE DATABASE [$DatabaseName];" +$ec = Invoke-Sqlcmd-Quiet -SqlArgs @("-S", "tcp:localhost,1433", "-U", "sa", "-Q", $dbQuery) -Password $SaPasswordPlain -FailOnSqlError +if ($ec -ne 0) { + throw "Failed to create/verify database '$DatabaseName' (sqlcmd exit code $ec)." +} + +Write-Host "Database '$DatabaseName' is present." + +# Provision the dedicated, least-privilege login the Admin App connects as. It is +# made db_owner of the Admin App database ONLY -- it holds no server-level role, +# so unlike sa it cannot touch other databases, create logins, or drop the server. +# db_owner (rather than datareader/datawriter/EXECUTE) is required because the app +# self-migrates on boot (DB_RUN_MIGRATIONS) and the job queue creates tables at +# runtime, both of which need DDL on this database. Idempotent: creates the login +# on first run, re-syncs the password on re-run. Bracket-quoted identifiers are +# escaped to keep a ']' in a custom name from breaking the batch. +Write-Host "Provisioning the Admin App login '$AppDbUsername'..." +$safeUser = $AppDbUsername -replace ']', ']]' +$escapedAppPw = $AppDbPasswordPlain -replace "'", "''" +$provisionQuery = @" +IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'$AppDbUsername') + CREATE LOGIN [$safeUser] WITH PASSWORD = N'$escapedAppPw', CHECK_POLICY = ON; +ELSE + ALTER LOGIN [$safeUser] WITH PASSWORD = N'$escapedAppPw'; +ALTER LOGIN [$safeUser] ENABLE; +USE [$DatabaseName]; +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'$AppDbUsername') + CREATE USER [$safeUser] FOR LOGIN [$safeUser]; +ALTER ROLE db_owner ADD MEMBER [$safeUser]; +"@ +$ec = Invoke-Sqlcmd-Quiet -SqlArgs @("-S", "(local)", "-E", "-Q", $provisionQuery) -FailOnSqlError +if ($ec -ne 0) { + throw "Failed to provision the Admin App login '$AppDbUsername' (sqlcmd exit code $ec). A CHECK_POLICY failure here means the password is too weak; supply a stronger -AppDbPassword." +} + +# Verify the app login can connect over TCP with SQL Auth (how the app connects). +$ec = Invoke-Sqlcmd-Quiet -SqlArgs @("-S", "tcp:localhost,1433", "-U", $AppDbUsername, "-d", $DatabaseName, "-Q", "SELECT 1") -Password $AppDbPasswordPlain +if ($ec -ne 0) { + throw "The Admin App login '$AppDbUsername' could not connect over TCP to '$DatabaseName' (sqlcmd exit code $ec)." +} +Write-Host "Admin App login '$AppDbUsername' is provisioned (db_owner on '$DatabaseName', non-sysadmin) and verified." + +Write-Host "" +Write-Host "SUCCESS: SQL Server is configured for Mixed Mode + TCP/IP." -ForegroundColor Green +Write-Host "The Admin App connects as '$AppDbUsername' (MSSQL_DB_USERNAME) -- a non-sysadmin login, not sa." -ForegroundColor Yellow diff --git a/windows-install/03-prereqs-node.ps1 b/windows-install/03-prereqs-node.ps1 new file mode 100644 index 00000000..daa97146 --- /dev/null +++ b/windows-install/03-prereqs-node.ps1 @@ -0,0 +1,369 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS +Installs/verifies Node.js, remediating a too-old version via nvm-windows when +necessary. + +.DESCRIPTION +This is the only runtime prerequisite the generic Admin App install needs. Java +and Keycloak are NOT installed here; they live in idp-keycloak-setup.ps1 (the +optional local-IdP example path). + +Order of operations (all idempotent): + 1. If a too-old Node is already on PATH, set up nvm-windows and switch to a + current LTS, keeping the previous version recoverable via 'nvm install'. + The required major is auto-detected from the repo's package.json + engines.node when available. + 2. If Node is missing, install Node.js LTS via winget. + +The npm cache override the App Pool needs is configured by +05-deploy-api.ps1 (scoped to the App Pool), not here. + +.PARAMETER SourcePath +The cloned AdminApp repo. When package.json exists there, engines.node is parsed +and used as the floor + nvm install target. Defaults to the parent of the script +directory (this script lives in \windows-install\). + +.PARAMETER MinNodeMajor +Floor enforced when package.json detection fails. Default: 22. + +.PARAMETER NodeLtsVersion +Node version spec to install via nvm when remediating. Default: "22" (bare +major -- nvm resolves to the latest patch on that line). When package.json +detection succeeds, this is overridden with the detected major. + +.PARAMETER AssumeYes +Switch -- bypass the y/N prompt and proceed with the nvm-windows upgrade. For +non-interactive runs (CI, install-all -AutoUpgradeNode). + +.EXAMPLE +.\03-prereqs-node.ps1 +.\03-prereqs-node.ps1 -AssumeYes +#> + +param( + [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), + [int]$MinNodeMajor = 22, + [string]$NodeLtsVersion = "22", + [switch]$AssumeYes +) + +$ErrorActionPreference = 'Stop' + +# --- Verified download helpers ------------------------------------------------- +# Duplicated across the windows-install scripts (no shared module in this folder, +# matching the existing WET pattern). Mirrors Install-VerifiedMsi in +# 01-prereqs-iis.ps1: reuse an already-downloaded file only when its SHA-256 +# matches, otherwise (re)download and verify, aborting on a mismatch. +function Save-VerifiedDownload { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Url, + [Parameter(Mandatory)][string]$Sha256, + [Parameter(Mandatory)][string]$OutFile + ) + $needsDownload = $true + if (Test-Path $OutFile) { + if ((Get-FileHash -Path $OutFile -Algorithm SHA256).Hash -ieq $Sha256) { + Write-Host "$Name already downloaded and verified -- reusing $OutFile." + $needsDownload = $false + } else { + Write-Host "$Name at $OutFile failed the expected hash (corrupt/partial/stale?); re-downloading." -ForegroundColor Yellow + Remove-Item $OutFile -Force + } + } + if ($needsDownload) { + Write-Host "Downloading $Name from $Url ..." + try { + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing + } catch { + throw "Failed to download $Name from $Url. Check internet connectivity and that the URL is reachable. Original: $($_.Exception.Message)" + } + $actual = (Get-FileHash -Path $OutFile -Algorithm SHA256).Hash + if ($actual -ine $Sha256) { + Remove-Item $OutFile -Force -ErrorAction SilentlyContinue + throw "$Name failed SHA-256 verification.`n Expected: $Sha256`n Actual: $actual`nThe download may be corrupt or tampered with; aborting." + } + Write-Host "$Name verified (SHA-256 match)." + } +} + +# Resolve the official SHA-256 for a Node zip from nodejs.org's per-release +# SHASUMS256.txt. Version-agnostic, so it stays correct as the resolved Node +# version changes (no hardcoded hash to maintain). +function Get-NodeZipSha256 { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$FullVer, + [Parameter(Mandatory)][string]$ZipFileName + ) + $shasumsUrl = "https://nodejs.org/dist/v$FullVer/SHASUMS256.txt" + try { + $raw = (Invoke-WebRequest -Uri $shasumsUrl -UseBasicParsing).Content + } catch { + throw "Couldn't fetch Node checksums from $shasumsUrl to verify the download: $($_.Exception.Message)" + } + if ($raw -is [byte[]]) { $raw = [System.Text.Encoding]::ASCII.GetString($raw) } + $line = $raw -split "`n" | Where-Object { $_ -match ("\s" + [regex]::Escape($ZipFileName) + "\s*$") } | Select-Object -First 1 + if (-not $line -or $line -notmatch '^([0-9a-fA-F]{64})\s') { + throw "No SHA-256 entry for $ZipFileName in $shasumsUrl; cannot verify the download." + } + return $Matches[1] +} + +# Auto-detect Node floor + install target from the repo's engines.node when +# available. nvm-windows accepts bare-major versions (e.g., 'nvm install 22'), +# which resolves to the latest 22.x release. +$pkgJsonPath = Join-Path $SourcePath 'package.json' +if (Test-Path $pkgJsonPath) { + try { + $engineSpec = (Get-Content $pkgJsonPath -Raw | ConvertFrom-Json).engines.node + if ($engineSpec -and $engineSpec -match '(\d+)') { + $detected = [int]$Matches[1] + $MinNodeMajor = $detected + $NodeLtsVersion = "$detected" # let nvm pick the latest patch + Write-Host "(Node target set from package.json engines.node='$engineSpec': major $detected)" -ForegroundColor DarkGray + } + } catch { + # Parsing failed; keep the hardcoded fallbacks + } +} + +function Refresh-Path { + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") +} + +# Refresh in case Node or nvm was just installed in another shell +Refresh-Path + +# Detect Node and decide whether a too-old version needs remediation. A missing +# Node is handled by the winget install further down, not here. +$needsRemediation = $false +$node = Get-Command node -ErrorAction SilentlyContinue +if ($node) { + $verRaw = & node --version 2>$null + if ($verRaw -notmatch '^v(\d+)\.') { + Write-Host "Couldn't parse 'node --version' output: '$verRaw'. Skipping Node version check." -ForegroundColor Yellow + } else { + $currentMajor = [int]$Matches[1] + $currentVer = $verRaw -replace '^v', '' + if ($currentMajor -ge $MinNodeMajor) { + Write-Host "Node $verRaw is at or above the required floor ($MinNodeMajor)." -ForegroundColor Green + } else { + $needsRemediation = $true + } + } +} + +if ($needsRemediation) { + Write-Host "" + Write-Host "Node $verRaw is below the required floor of $MinNodeMajor." -ForegroundColor Yellow + Write-Host "" + Write-Host "This script can set up nvm-windows and install Node ${NodeLtsVersion}:" + Write-Host " 1. winget install CoreyButler.NVMforWindows" + Write-Host " 2. nvm install $NodeLtsVersion; nvm use $NodeLtsVersion" + Write-Host " 3. Your previous Node $currentVer stays recoverable via:" + Write-Host " nvm install $currentVer" + Write-Host "" + Write-Host "Note: nvm-windows replaces C:\Program Files\nodejs with a managed symlink." -ForegroundColor DarkGray + Write-Host " Other apps hardcoded to that path keep working; apps depending on a" -ForegroundColor DarkGray + Write-Host " specific Node version may need 'nvm use ' switching." -ForegroundColor DarkGray + Write-Host "" + + if (-not $AssumeYes) { + $reply = Read-Host "Proceed with nvm-windows + Node $NodeLtsVersion setup? (y/N)" + if ($reply -notmatch '^[Yy]') { + throw "Aborted by user. Upgrade Node manually, or re-run with -AssumeYes (or install-all -AutoUpgradeNode)." + } + } + + # Install nvm-windows if missing + if (Get-Command nvm -ErrorAction SilentlyContinue) { + Write-Host "nvm-windows already installed at $((Get-Command nvm).Source)." + } else { + Write-Host "Installing nvm-windows via winget..." + & winget install CoreyButler.NVMforWindows --source winget --accept-source-agreements --accept-package-agreements --silent + if ($LASTEXITCODE -ne 0) { + throw "winget install for nvm-windows failed (exit $LASTEXITCODE). Try the installer at https://github.com/coreybutler/nvm-windows/releases." + } + Refresh-Path + if (-not (Get-Command nvm -ErrorAction SilentlyContinue)) { + throw "winget reported success but 'nvm' is still not on PATH. Open a fresh PowerShell window (as administrator) and re-run 03-prereqs-node.ps1." + } + } + + # nvm install + use the target version + Write-Host "Running: nvm install $NodeLtsVersion" + & nvm install $NodeLtsVersion + if ($LASTEXITCODE -ne 0) { throw "'nvm install $NodeLtsVersion' failed (exit $LASTEXITCODE)." } + + # nvm-windows accepts a bare-major spec for `nvm install` (it resolves to the + # latest patch on that line), but `nvm use` requires the full X.Y.Z. When the + # user (or package.json detection) gave us a bare-major spec, resolve it by + # inspecting nvm-windows's storage root (NVM_HOME, fallback %APPDATA%\nvm), + # which contains one vX.Y.Z\ subdirectory per installed Node version. The + # filesystem is more reliable than parsing `nvm list` output, which can be + # written through Windows console APIs that bypass PowerShell capture. + $useVersion = $NodeLtsVersion + if ($NodeLtsVersion -match '^\d+$') { + $targetMajor = [int]$NodeLtsVersion + + # Find nvm-windows's storage root. settings.txt next to nvm.exe is the + # authoritative source. Fall back to NVM_HOME, then %APPDATA%\nvm. + $candidateRoots = @() + $nvmCmd = Get-Command nvm -ErrorAction SilentlyContinue + if ($nvmCmd) { + $settingsPath = Join-Path (Split-Path $nvmCmd.Source -Parent) 'settings.txt' + if (Test-Path $settingsPath) { + $rootLine = Get-Content $settingsPath | Where-Object { $_ -match '^\s*root\s*:\s*(.+)$' } | Select-Object -First 1 + if ($rootLine -and $rootLine -match '^\s*root\s*:\s*(.+)$') { + $candidateRoots += $Matches[1].Trim() + } + } + # nvm-windows sometimes stores versions next to nvm.exe itself + $candidateRoots += (Split-Path $nvmCmd.Source -Parent) + } + if ($env:NVM_HOME) { $candidateRoots += $env:NVM_HOME } + $candidateRoots += (Join-Path $env:APPDATA 'nvm') + $candidateRoots = $candidateRoots | Where-Object { $_ } | Select-Object -Unique + + $matching = @() + $rootUsed = $null + foreach ($root in $candidateRoots) { + if (-not (Test-Path $root)) { continue } + Get-ChildItem $root -Directory -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.Name -match '^v?(\d+\.\d+\.\d+)$') { + try { + $v = [version]$Matches[1] + if ($v.Major -eq $targetMajor) { + $matching += $v + if (-not $rootUsed) { $rootUsed = $root } + } + } catch {} + } + } + } + $latest = $matching | Sort-Object -Descending | Select-Object -First 1 + + # AV-fallback: if nvm install reported success but no version directory + # appeared, an antivirus / EDR (Defender, CrowdStrike, etc.) most likely + # consumed the extracted files. Recover by downloading the Node zip from + # nodejs.org directly and dropping it into nvm's root with the correct + # vX.Y.Z naming. nvm-windows then sees it like any other installed version. + if (-not $latest) { + Write-Host "" + Write-Host "nvm install reported success but no v$targetMajor.* directory appeared." -ForegroundColor Yellow + Write-Host "Falling back to direct download from nodejs.org (typical cause: AV/EDR" -ForegroundColor Yellow + Write-Host "quarantining extracted files mid-install)." -ForegroundColor Yellow + + # Resolve a concrete X.Y.Z. For bare-major, query nodejs.org's release + # index and pick the latest patch on that line. + if ($NodeLtsVersion -match '^\d+\.\d+\.\d+$') { + $fullVer = $NodeLtsVersion + } else { + try { + $index = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json' -UseBasicParsing -TimeoutSec 30 + $best = $index | + Where-Object { $_.version -match "^v$targetMajor\." } | + Sort-Object { [version]($_.version -replace '^v','') } -Descending | + Select-Object -First 1 + if (-not $best) { throw "No Node $targetMajor.x releases listed at nodejs.org/dist/index.json." } + $fullVer = $best.version -replace '^v', '' + } catch { + throw "Couldn't resolve latest Node $targetMajor.x via nodejs.org: $_" + } + } + + # Pick which nvm root to write into -- prefer one we already confirmed exists + $writeRoot = $candidateRoots | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $writeRoot) { + throw "No nvm root directory found among: $($candidateRoots -join ', ')" + } + + $zipName = "node-v$fullVer-win-x64.zip" + $url = "https://nodejs.org/dist/v$fullVer/$zipName" + $zip = Join-Path $env:TEMP "node-v$fullVer.zip" + $tmp = Join-Path $env:TEMP "node-v$fullVer-extract" + $dst = Join-Path $writeRoot "v$fullVer" + + # Verify the download against nodejs.org's official SHASUMS256.txt for + # this exact version. The checksum covers file content, so the canonical + # zip name is used for the lookup even though it is saved locally under a + # shorter name. + $expectedSha = Get-NodeZipSha256 -FullVer $fullVer -ZipFileName $zipName + Save-VerifiedDownload -Name "Node $fullVer" -Url $url -Sha256 $expectedSha -OutFile $zip + if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force } + Write-Host "Extracting to $tmp" + Expand-Archive -Path $zip -DestinationPath $tmp + $inner = Get-ChildItem $tmp -Directory | Select-Object -First 1 + if (-not $inner) { throw "Zip extraction produced no inner directory in $tmp." } + if (Test-Path $dst) { Remove-Item $dst -Recurse -Force } + # Copy + delete instead of Move-Item -- AV/EDR can hold transient locks + # on freshly-extracted files that block the delete-source half of a + # move. Copy-Item only needs read on the source, which is more tolerant. + # robocopy is the second-line fallback because it retries through locks. + Write-Host "Copying to $dst" + try { + Copy-Item -Path $inner.FullName -Destination $dst -Recurse -Force -ErrorAction Stop + } catch { + Write-Host "Copy-Item failed; retrying via robocopy..." + & robocopy $inner.FullName $dst /E /R:5 /W:2 /NFL /NDL /NJH /NJS | Out-Null + if ($LASTEXITCODE -ge 8) { throw "robocopy fallback failed (exit $LASTEXITCODE)." } + } + if (-not (Test-Path (Join-Path $dst 'node.exe'))) { + throw "Manual install completed but no node.exe found at $dst." + } + Remove-Item $zip -ErrorAction SilentlyContinue + Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue + + Write-Host "Manual install complete at $dst" -ForegroundColor Green + Write-Host "If this happens repeatedly, consider adding '$writeRoot' to your AV exclusions." -ForegroundColor DarkGray + + $useVersion = $fullVer + } else { + $useVersion = $latest.ToString() + Write-Host "Resolved '$NodeLtsVersion' to '$useVersion' for 'nvm use' (from $rootUsed)." + } + } + + Write-Host "Running: nvm use $useVersion" + & nvm use $useVersion + if ($LASTEXITCODE -ne 0) { throw "'nvm use $useVersion' failed (exit $LASTEXITCODE)." } + + # Refresh PATH so child processes inherit the new node symlink target + Refresh-Path + + # Verify + $newVer = & node --version 2>$null + if ($newVer -notmatch '^v(\d+)\.') { + throw "Couldn't verify new node version (got: '$newVer'). Open a fresh PowerShell window and re-run install-all.ps1." + } + $newMajor = [int]$Matches[1] + if ($newMajor -lt $MinNodeMajor) { + throw "Node is still $newVer after nvm setup -- 'nvm use' may not have taken effect in this shell. Open a fresh PowerShell window and re-run." + } + + Write-Host "" + Write-Host "SUCCESS: Node is now $newVer." -ForegroundColor Green + Write-Host "To switch back to the previous version later: nvm use $currentVer (after 'nvm install $currentVer' if it was uninstalled)" -ForegroundColor DarkGray +} + +# Install Node.js LTS if it is still missing. +$node = Get-Command node -ErrorAction SilentlyContinue +if ($node) { + Write-Host "Node already on PATH: $(node --version) at $($node.Source)" +} else { + Write-Host "Installing Node.js LTS via winget..." + & winget install OpenJS.NodeJS.LTS --source winget --accept-source-agreements --accept-package-agreements --silent + if ($LASTEXITCODE -ne 0) { + throw "Node install failed (winget exit code $LASTEXITCODE). If this is the msstore cert issue, the --source winget flag should have skipped it. Check `winget search Node.js` to debug." + } + # Refresh PATH so subsequent steps in this same shell can use node/npm + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") + Write-Host "Node installed." +} + +Write-Host "" +Write-Host "SUCCESS: Node runtime prepared." -ForegroundColor Green +Write-Host "Open a fresh PowerShell window to pick up the new PATH." diff --git a/windows-install/04-build.ps1 b/windows-install/04-build.ps1 new file mode 100644 index 00000000..387aa1c6 --- /dev/null +++ b/windows-install/04-build.ps1 @@ -0,0 +1,165 @@ +<# +.SYNOPSIS +Runs `npm ci --legacy-peer-deps`, `npm run build:api`, and `npm run build:fe` +in the Ed-Fi-AdminApp repo. + +.DESCRIPTION +Wraps the slow / chatty manual build step so the install can be one-shot. +Output streams to the console as it runs (no log capture), so failures are +visible immediately. + +The `--legacy-peer-deps` flag is a workaround for the Storybook 8 vs 10 peer +conflict in the repo (see project tickets). When the upstream conflict is +resolved, this flag can be removed. + +Does NOT require elevation, but does require Node + npm on PATH. If you just +installed Node via script 03, open a fresh PowerShell window before running this. + +.PARAMETER SourcePath +The cloned Ed-Fi-AdminApp repo. Defaults to the parent of the script +directory (this script lives in \windows-install\). + +.PARAMETER SkipInstall +Switch — skip `npm ci`. Useful if node_modules is already populated and you +only need to rebuild. + +.PARAMETER Force +Switch -- always run npm ci + builds even if artifacts already exist. +By default, the script skips the build when main.js and dist\packages\fe\index.html +are already present and newer than package.json (heuristic for "build is current"). + +.PARAMETER ViteApiUrl +URL the FE will call for API requests. Written into packages\fe\.env as +VITE_API_URL before building. Default: http://localhost:3333. + +.PARAMETER ViteBasePath +URL path the FE is served from. Written into packages\fe\.env as +VITE_BASE_PATH before building. Default: "/" (the FE is served from the root +of its own HTTP site). + +.PARAMETER ViteIdpAccountUrl +The IdP account-management URL the FE links to. Default (Keycloak example): +http://localhost:8080/realms/edfi/account/. + +.EXAMPLE +.\04-build.ps1 +.\04-build.ps1 -SourcePath C:\Ed-Fi\Ed-Fi-AdminApp +.\04-build.ps1 -SkipInstall +.\04-build.ps1 -Force +#> + +param( + [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), + [switch]$SkipInstall, + [switch]$Force, + [string]$ViteApiUrl = "http://localhost:3333", + [string]$ViteBasePath = "/", + [string]$ViteIdpAccountUrl = "http://localhost:8080/realms/edfi/account/" +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path "$SourcePath\package.json")) { + throw "package.json not found at $SourcePath. Is this the right path?" +} + +# Refresh PATH from registry in case Node was installed in this shell session +# (the current process's $env:Path is set at shell startup and doesn't auto-refresh). +$env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") + +# Verify Node is on PATH +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + throw "node is not on PATH. Run script 03-prereqs-node.ps1 first, or open a fresh PowerShell window." +} + +$apiMainJs = "$SourcePath\dist\packages\api\main.js" +$feIndex = "$SourcePath\dist\packages\fe\index.html" +$apiBuilt = Test-Path $apiMainJs +$feBuilt = Test-Path $feIndex +$pkgJson = Get-Item "$SourcePath\package.json" + +$buildIsCurrent = $false +if ($apiBuilt -and $feBuilt) { + $mainJs = Get-Item $apiMainJs + if ($mainJs.LastWriteTime -gt $pkgJson.LastWriteTime) { + $buildIsCurrent = $true + } +} + +# The timestamp heuristic above does not know whether the existing FE bundle was +# built for the requested VITE_API_URL. A stale bundle built for a different API +# URL/scheme breaks at runtime under the enforcing CSP (connect-src), so only +# treat the build as current when the last-built .env matches the requested URL. +$envFile = "$SourcePath\packages\fe\.env" +$feConfigCurrent = $false +if (Test-Path $envFile) { + $m = Select-String -Path $envFile -Pattern '^VITE_API_URL=(.*)$' | Select-Object -First 1 + if ($m -and $m.Matches.Groups[1].Value -eq $ViteApiUrl) { $feConfigCurrent = $true } +} + +if ($buildIsCurrent -and $feConfigCurrent -and -not $Force) { + Write-Host "Build artifacts present, current, and built for $ViteApiUrl -- skipping build." -ForegroundColor Green + Write-Host " API entry: $apiMainJs" + Write-Host " FE output: $SourcePath\dist\packages\fe\" + Write-Host "Pass -Force to rebuild anyway." -ForegroundColor DarkGray + return +} +if ($buildIsCurrent -and -not $feConfigCurrent -and -not $Force) { + Write-Host "FE build config changed (VITE_API_URL now $ViteApiUrl) -- rebuilding the frontend bundle." -ForegroundColor Cyan +} + +# Ensure packages\fe\.env exists with the right Vite values before building. +# Vite reads these at build time and bakes paths/URLs into the bundle, so +# updating .env after build has no effect. +$template = "$SourcePath\packages\fe\.copyme.env.local" +try { + if (-not (Test-Path $envFile) -and (Test-Path $template)) { + Copy-Item $template $envFile + Write-Host "Seeded packages\fe\.env from .copyme.env.local" + } + if (Test-Path $envFile) { + $envText = Get-Content $envFile -Raw + $envText = $envText -replace 'VITE_API_URL=.*', "VITE_API_URL=$ViteApiUrl" + $envText = $envText -replace 'VITE_BASE_PATH=.*', "VITE_BASE_PATH=`"$ViteBasePath`"" + $envText = $envText -replace 'VITE_IDP_ACCOUNT_URL=.*', "VITE_IDP_ACCOUNT_URL=$ViteIdpAccountUrl" + Set-Content $envFile -Value $envText -Encoding UTF8 + Write-Host "Updated packages\fe\.env (VITE_API_URL=$ViteApiUrl, VITE_BASE_PATH=$ViteBasePath)" + } +} catch { + throw "Failed to write the frontend build config at $envFile. Check the path is writable. Original: $($_.Exception.Message)" +} + +Push-Location $SourcePath +try { + if (-not $SkipInstall) { + Write-Host "Running: npm ci --legacy-peer-deps" -ForegroundColor Cyan + & npm ci --legacy-peer-deps + if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE" } + } else { + Write-Host "Skipping npm ci (-SkipInstall)." + } + + Write-Host "" + Write-Host "Running: npm run build:api" -ForegroundColor Cyan + & npm run build:api + if ($LASTEXITCODE -ne 0) { throw "build:api failed with exit code $LASTEXITCODE" } + + Write-Host "" + # nx caches fe:build and does not hash .env, so a changed VITE_API_URL alone + # would otherwise serve a stale cached bundle. Clear the cache when the FE + # config is not current so the bundle is genuinely rebuilt for the new URL. + if (-not $feConfigCurrent) { + Write-Host "Clearing the nx cache (FE config changed)..." -ForegroundColor Cyan + & npx nx reset + } + Write-Host "Running: npm run build:fe" -ForegroundColor Cyan + & npm run build:fe + if ($LASTEXITCODE -ne 0) { throw "build:fe failed with exit code $LASTEXITCODE" } + + Write-Host "" + Write-Host "SUCCESS: Build complete." -ForegroundColor Green + Write-Host " API entry: $apiMainJs" + Write-Host " FE output: $SourcePath\dist\packages\fe\" +} finally { + Pop-Location +} diff --git a/windows-install/05-deploy-api.ps1 b/windows-install/05-deploy-api.ps1 new file mode 100644 index 00000000..0abc5ba6 --- /dev/null +++ b/windows-install/05-deploy-api.ps1 @@ -0,0 +1,785 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS +Deploys the Ed-Fi Admin App API to IIS via the httpPlatform handler. + +.DESCRIPTION +- Copies built API files from source to destination +- Creates/configures the IIS App Pool (LoadUserProfile=true, no managed runtime) +- Creates or updates the IIS application (under a parent site) or standalone site +- Writes the httpPlatform web.config (handler + block) +- Patches production.js with bare-metal values (DB, API/FE URLs, OIDC) +- Sets icacls permissions for the App Pool user + +Run AFTER: + - 02-prereqs-sql.ps1 (SQL ready) + - 01-prereqs-iis.ps1 (IIS + httpPlatform handler ready) + - 03-prereqs-node.ps1 (Node, npm cache ready) + - `npm ci --legacy-peer-deps` and `npm run build:api` in the source repo + +.PARAMETER SourcePath +The built API output folder. Typically the repo root, containing main.js + +packages\ + node_modules\. + +.PARAMETER DestPath +Where to deploy. Default: C:\inetpub\EdFi-AdminApp-API (a dedicated directory, +not nested under another site's root). + +.PARAMETER AppPoolName +Name of the IIS App Pool. Default: EdFi-AdminApp-API. + +.PARAMETER StandalonePort +HTTP port for the standalone API site (EdFi-AdminApp-API). Default: 3333. + +.PARAMETER AppDbUsername +The dedicated least-privilege SQL login the Admin App connects as, provisioned by +02-prereqs-sql.ps1 (db_owner on the app DB, not sa). Written into production.js as +MSSQL_DB_USERNAME. Default: edfi_adminapp. + +.PARAMETER AppDbPassword +Password for the dedicated Admin App login (set in 02-prereqs-sql.ps1). Written +into production.js as MSSQL_DB_PASSWORD. + +.PARAMETER DatabaseName +SQL Server database name. Default: sbaa. Must match what 02-prereqs-sql.ps1 created. + +.PARAMETER OidcIssuer +OIDC issuer URL. Default (Keycloak example): http://localhost:8080/realms/edfi. + +.PARAMETER OidcClientId +OIDC client id. Default (Keycloak example): edfiadminapp. + +.PARAMETER OidcClientSecret +OIDC client secret (the secret configured on the client in your IdP). + +.PARAMETER OidcScope +OIDC scopes requested at login. Default: 'openid email profile'. + +.PARAMETER AdminUsername +Email seeded as the admin user. Default: admin@example.com. + +.PARAMETER DevErrors +Expose detailed IIS error responses to every client. Off by default (remote +clients get generic errors; local requests still see detail). Enable only to +troubleshoot a remote issue. + +.EXAMPLE +.\05-deploy-api.ps1 -SourcePath C:\Ed-Fi\Ed-Fi-AdminApp -AppDbPassword 'EdFi-App-Local!2026' -OidcClientSecret 'RBsHTSb...' +#> + +param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + + [string]$DestPath = "C:\inetpub\EdFi-AdminApp-API", + [string]$AppPoolName = "EdFi-AdminApp-API", + # The API deploys as a standalone HTTP site named $AppPoolName on this port. + [int]$StandalonePort = 3333, + # npm cache folder, granted to the App Pool identity and set as the pool's + # NPM_CONFIG_CACHE so npm under the App Pool writes there (not the unwritable profile). + [string]$NpmCachePath = "C:\npm-cache", + + # Which database engine production.js should be configured for. + # 'mssql' -> requires -AppDbPassword. + # 'pgsql' -> requires -PgDbPassword (host/port/user/db default to the + # docker-compose setup under windows-install\docker\). Engine-specific + # password validation is enforced in the body of the script; -DbEngine + # itself has a default, so it should not be Mandatory. + [ValidateSet('mssql','pgsql')] + [string]$DbEngine = 'mssql', + + # SQL Server credentials the Admin App connects as at runtime. Required only + # when -DbEngine is 'mssql'. This is the dedicated least-privilege login + # provisioned by 02-prereqs-sql.ps1 (db_owner on the app DB, not sa). + [string]$AppDbUsername = "edfi_adminapp", + [SecureString]$AppDbPassword, + + [string]$DatabaseName = "sbaa", + + # PostgreSQL connection details. Required only when -DbEngine is 'pgsql'. + # Defaults match the docker-compose setup at windows-install\docker\ where + # the dedicated app user 'edfiadminapp' is provisioned by init/01-...sh. + [string]$PgDbHost = "localhost", + [int]$PgDbPort = 5432, + [string]$PgDbUsername = "edfiadminapp", + [SecureString]$PgDbPassword, + + # OIDC settings written into production.js. Defaults are the local-Keycloak + # example; override for any other provider (Entra, Google, Auth0, ...). + [string]$OidcIssuer = "http://localhost:8080/realms/edfi", + [string]$OidcClientId = "edfiadminapp", + + [Parameter(Mandatory = $true)] + [SecureString]$OidcClientSecret, + + [string]$OidcScope = "openid email profile", + + # URLs baked into production.js (MY_URL/FE_URL). Defaults are https on the + # mirror ports; TLS is always-on (see -HttpsPort and the cert params). + [string]$ApiUrl = "https://localhost:3443", + [string]$FeUrl = "https://localhost:4443", + [string]$AdminUsername = "admin@example.com", + + # Yopass: pass a non-empty URL to enable Yopass (one-time-share for newly- + # created Ed-Fi API client credentials). Default is empty -> Yopass is + # disabled and the AdminApp falls back to displaying credentials inline, + # which is a documented and supported mode (USE_YOPASS=false). + [string]$YopassUrl = "", + + # Data-at-rest encryption key (64 hex chars / 32 bytes) the AdminApp uses to + # encrypt stored ODS/API environment secrets (aes-256-cbc). Empty -> reuse an + # already-deployed non-default key, or generate a fresh one on a clean box. A + # post-patch guard rejects the shipped default. Losing or changing this key + # makes previously-encrypted environment secrets unrecoverable. + [string]$DbEncryptionKey = "", + + # Escape hatch for the key-rotation guard: proceed even when a freshly + # generated key would orphan existing encrypted environments (accepts data loss). + [switch]$ForceKeyRotation, + + # Expose detailed IIS error responses to every client. Off by default: remote + # clients get generic errors while local requests still see detail + # (errorMode="DetailedLocalOnly"). Set only for troubleshooting a remote issue. + [switch]$DevErrors, + + # TLS. HTTPS is always-on: the site gets an https binding on -HttpsPort. + # Certificate precedence: -CertificateThumbprint (an existing LocalMachine\My + # cert) -> -CertificatePfxPath (+ -CertificatePassword; imported) -> a self-signed + # cert auto-generated for localhost (keeps the local quick-start working; browsers + # warn on the untrusted cert). The HTTP site stays bound only to 301-redirect to HTTPS. + [int]$HttpsPort = 3443, + [string]$CertificateThumbprint = "", + [string]$CertificatePfxPath = "", + [SecureString]$CertificatePassword, + + # By default the auto-generated self-signed cert is added to LocalMachine\Root so + # local browsers trust it (no "Not Secure" warning). Set this to skip that where + # policy forbids adding trusted roots; the browser will then warn. Only affects the + # self-signed path -- a supplied real cert is never added to Root. + [switch]$SkipSelfSignedTrust, + + # SSL verification for the API's OUTBOUND HTTPS calls (to the ODS/API, AdminApi, + # and Yopass). Secure by default (verification on). Set this to disable it when an + # upstream uses a self-signed/dev certificate that Node's CA store won't trust + # (Node ignores the Windows cert store); prefer NODE_EXTRA_CA_CERTS over this + # where possible. Do not disable in production. + [switch]$DisableSslVerification +) + +$ErrorActionPreference = 'Stop' + +# Precondition: IIS + the WebAdministration module must be available +# (01-prereqs-iis.ps1 installs the IIS pieces). Fail early with an actionable +# message instead of a cryptic Import-Module error. +try { + Import-Module WebAdministration -ErrorAction Stop +} catch { + throw "IIS / the WebAdministration module isn't available. Ensure IIS is installed (setup-vm-prereqs.ps1) and run 01-prereqs-iis.ps1 before deploying." +} + +# Engine-specific required arg validation. Each engine needs its own password +# parameter; the other one is irrelevant and ignored. +if ($DbEngine -eq 'mssql' -and -not $AppDbPassword) { + throw "-AppDbPassword is required when -DbEngine is 'mssql'." +} +if ($DbEngine -eq 'pgsql' -and -not $PgDbPassword) { + throw "-PgDbPassword is required when -DbEngine is 'pgsql'." +} + +# Secrets arrive as SecureString (kept off the command line); unwrap the ones +# supplied to plaintext locals for sqlcmd -P and the production.js patch. +# Point-of-use plaintext is unavoidable. DbEncryptionKey stays a plain string: +# it's generated/reused internally, not a caller-supplied credential. +# Use new locals -- assigning back to the [SecureString]-typed parameters would +# re-trigger their type conversion and fail. +$AppDbPasswordPlain = if ($AppDbPassword) { [System.Net.NetworkCredential]::new('', $AppDbPassword).Password } else { $null } +$PgDbPasswordPlain = if ($PgDbPassword) { [System.Net.NetworkCredential]::new('', $PgDbPassword).Password } else { $null } +$OidcClientSecretPlain = if ($OidcClientSecret) { [System.Net.NetworkCredential]::new('', $OidcClientSecret).Password } else { $null } + +$apiBuildDir = "$SourcePath\dist\packages\api" +if (-not (Test-Path "$apiBuildDir\main.js")) { + throw "Build output not found at $apiBuildDir\main.js. Did you run 'npm run build:api'?" +} + +# Selective copy. The deployment needs three pieces, NOT a full source-tree mirror: +# 1. Built API output (main.js + assets\) from dist\packages\api\ +# 2. Config files (production.js etc.) from packages\api\config\ +# 3. node_modules (runtime deps) from repo root +# Each piece is mirrored independently so /MIR doesn't wipe sibling content +# (web.config, logs\, the other source piece). +# Capture any already-deployed non-default encryption key BEFORE the file-copy +# phase below overwrites production.js. The source ships a stub production.js that +# the config copy would otherwise clobber, defeating key reuse and silently +# rotating the key on every re-run. Reusing it keeps previously-encrypted ODS/API +# environment secrets decryptable across a reinstall. +$defaultKey = 'bbeadc2d4d15f5c9cfc2239b682cca392b233ee6979b6b9578d256aa01a7c565' +$existingDeployedKey = '' +$deployedProdJs = "$DestPath\packages\api\config\production.js" +if (Test-Path $deployedProdJs) { + if ((Get-Content $deployedProdJs -Raw) -match "KEY: '([0-9a-f]{64})'" -and $Matches[1] -ne $defaultKey) { + $existingDeployedKey = $Matches[1] + } +} + +New-Item -ItemType Directory -Path $DestPath -Force | Out-Null + +Write-Host "Copying built API output..." +& robocopy $apiBuildDir $DestPath /E /NFL /NDL /NJH /NJS /XF web.config /XD logs packages node_modules | Out-Null +if ($LASTEXITCODE -ge 8) { throw "Failed to copy the built API output from $apiBuildDir to $DestPath (robocopy exit $LASTEXITCODE). Check free disk space and that the destination isn't locked by a running app pool." } + +Write-Host "Copying api/config..." +New-Item -ItemType Directory -Path "$DestPath\packages\api" -Force | Out-Null +& robocopy "$SourcePath\packages\api\config" "$DestPath\packages\api\config" /E /NFL /NDL /NJH /NJS | Out-Null +if ($LASTEXITCODE -ge 8) { throw "Failed to copy api/config from $SourcePath\packages\api\config to $DestPath\packages\api\config (robocopy exit $LASTEXITCODE)." } + +Write-Host "Copying node_modules (this takes a minute)..." +& robocopy "$SourcePath\node_modules" "$DestPath\node_modules" /E /NFL /NDL /NJH /NJS | Out-Null +if ($LASTEXITCODE -ge 8) { throw "Failed to copy node_modules from $SourcePath\node_modules to $DestPath\node_modules (robocopy exit $LASTEXITCODE). Check free disk space." } + +# App Pool +try { + if (-not (Test-Path "IIS:\AppPools\$AppPoolName")) { + Write-Host "Creating App Pool '$AppPoolName'..." + New-WebAppPool -Name $AppPoolName | Out-Null + } + Set-ItemProperty -Path "IIS:\AppPools\$AppPoolName" -Name "processModel.loadUserProfile" -Value $true + Set-ItemProperty -Path "IIS:\AppPools\$AppPoolName" -Name "managedRuntimeVersion" -Value "" + Write-Host "App Pool '$AppPoolName' configured (LoadUserProfile=true)." +} catch { + throw "Failed to create/configure the IIS App Pool '$AppPoolName'. Is IIS running and the WAS service started? Original: $($_.Exception.Message)" +} + +# IIS standalone HTTP site (named after the App Pool, e.g. EdFi-AdminApp-API) +try { + if (Get-Website -Name $AppPoolName -ErrorAction SilentlyContinue) { + Write-Host "Site '$AppPoolName' exists. Updating physical path..." + Set-ItemProperty -Path "IIS:\Sites\$AppPoolName" -Name "physicalPath" -Value $DestPath + Set-ItemProperty -Path "IIS:\Sites\$AppPoolName" -Name "applicationPool" -Value $AppPoolName + } else { + New-Website -Name $AppPoolName -Port $StandalonePort -PhysicalPath $DestPath -ApplicationPool $AppPoolName | Out-Null + Write-Host "Standalone site '$AppPoolName' created on HTTP port $StandalonePort." + } +} catch { + throw "Failed to create/update the IIS site '$AppPoolName' on port $StandalonePort. Is the port already in use by another site (check 00-check-prereqs.ps1)? Original: $($_.Exception.Message)" +} + +# Resolve the TLS certificate for the HTTPS binding. Precedence: an explicit +# thumbprint (already in LocalMachine\My) -> an imported PFX -> a self-signed cert +# generated for localhost + this host. The self-signed path keeps the local +# quick-start working with zero cert setup (an untrusted-cert browser warning is +# expected). Returns the resolved certificate thumbprint. WET-duplicated in +# 06-deploy-fe.ps1 (windows-install has no shared module). +function Resolve-HttpsCertificate { + param( + [string]$Thumbprint, + [string]$PfxPath, + [SecureString]$PfxPassword, + [switch]$SkipTrust + ) + $storePath = 'Cert:\LocalMachine\My' + $friendlyName = 'Ed-Fi Admin App self-signed' + + if ($Thumbprint) { + $clean = ($Thumbprint -replace '[^0-9A-Fa-f]', '') + $cert = Get-Item "$storePath\$clean" -ErrorAction SilentlyContinue + if (-not $cert) { + throw "No certificate with thumbprint '$clean' found in $storePath. Import it into LocalMachine\My first, or omit -CertificateThumbprint to auto-generate a self-signed cert." + } + Write-Host "Using the supplied certificate ($($cert.Thumbprint))." + return $cert.Thumbprint + } + + if ($PfxPath) { + if (-not (Test-Path $PfxPath)) { throw "PFX file not found at '$PfxPath'." } + $importParams = @{ FilePath = $PfxPath; CertStoreLocation = $storePath } + if ($PfxPassword) { $importParams.Password = $PfxPassword } + $cert = Import-PfxCertificate @importParams + Write-Host "Imported the supplied PFX ($($cert.Thumbprint))." + return $cert.Thumbprint + } + + # Self-signed fallback. Reuse a still-valid one we created before so re-runs + # (and the other site's deploy) share a single cert; else generate a fresh one. + $cert = Get-ChildItem $storePath | + Where-Object { $_.FriendlyName -eq $friendlyName -and $_.NotAfter -gt (Get-Date) } | + Sort-Object NotAfter -Descending | Select-Object -First 1 + if ($cert) { + Write-Host "Reusing the existing self-signed certificate ($($cert.Thumbprint))." + } else { + Write-Host "Generating a self-signed certificate for HTTPS (localhost + $env:COMPUTERNAME)..." + $cert = New-SelfSignedCertificate -DnsName 'localhost', $env:COMPUTERNAME ` + -CertStoreLocation $storePath -FriendlyName $friendlyName -NotAfter (Get-Date).AddYears(5) + } + # Trust the self-signed cert on this machine (add the public cert to + # LocalMachine\Root) so local browsers don't show "Not Secure". Only the + # self-signed path does this -- a supplied real cert is already CA-trusted. Skip + # with -SkipTrust where policy forbids adding trusted roots. Idempotent; a + # public-only copy carrying the same FriendlyName is stored so uninstall finds it. + if (-not $SkipTrust) { + $rootStore = [System.Security.Cryptography.X509Certificates.X509Store]::new('Root', 'LocalMachine') + $rootStore.Open('ReadWrite') + try { + $found = $rootStore.Certificates.Find( + [System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false) + if ($found.Count -eq 0) { + $pub = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.RawData) + $pub.FriendlyName = $friendlyName + $rootStore.Add($pub) + Write-Host "Trusted the self-signed certificate (added to LocalMachine\Root)." + } + } finally { + $rootStore.Close() + } + } + return $cert.Thumbprint +} + +# Add (idempotently) an HTTPS binding on the site and attach the cert. Mirror-port +# model: API and FE each have their own HTTPS port, so no SNI/hostname is needed +# (SslFlags 0). The cert is (re)bound every run so a replaced/rotated cert takes +# effect. WET-duplicated in 06-deploy-fe.ps1. +function Set-HttpsBinding { + param( + [Parameter(Mandatory)][string]$SiteName, + [Parameter(Mandatory)][int]$HttpsPort, + [Parameter(Mandatory)][string]$Thumbprint + ) + if (-not (Get-WebBinding -Name $SiteName -Protocol https -Port $HttpsPort -ErrorAction SilentlyContinue)) { + New-WebBinding -Name $SiteName -Protocol https -Port $HttpsPort -SslFlags 0 | Out-Null + Write-Host "Added HTTPS binding on port $HttpsPort to site '$SiteName'." + } + $sslPath = "IIS:\SslBindings\0.0.0.0!$HttpsPort" + if (Test-Path $sslPath) { Remove-Item $sslPath -ErrorAction SilentlyContinue } + Get-Item "Cert:\LocalMachine\My\$Thumbprint" | New-Item -Path $sslPath | Out-Null + Write-Host "Bound certificate $Thumbprint to 0.0.0.0:$HttpsPort." +} + +# TLS (always-on): resolve the cert and add the HTTPS binding. The HTTP site created +# above stays only to 301-redirect to HTTPS (redirect rule added to web.config in T3.2). +$certThumbprint = Resolve-HttpsCertificate -Thumbprint $CertificateThumbprint -PfxPath $CertificatePfxPath -PfxPassword $CertificatePassword -SkipTrust:$SkipSelfSignedTrust +Set-HttpsBinding -SiteName $AppPoolName -HttpsPort $HttpsPort -Thumbprint $certThumbprint + +# web.config -- IIS hosts Node via the httpPlatform handler (reverse proxy to a +# loopback port IIS assigns through HTTP_PLATFORM_PORT). httpPlatform launches node +# AS the App Pool virtual account, so node must live where that identity can execute +# it: a machine-wide location, NOT under a user profile. nvm-windows points +# \nodejs at the active version via a symlink and can resolve into +# C:\Users\\... -- which the App Pool can't traverse, failing with Access +# Denied / HTTP 502.5. Resolve the PATH node, follow its symlink to the real target, +# reject a user-profile path, and fall back to a machine-wide install. +$nodeExe = $null +$nodeCandidates = @() +$nodeCmd = Get-Command node -ErrorAction SilentlyContinue +if ($nodeCmd) { $nodeCandidates += $nodeCmd.Source } +$nodeCandidates += "C:\Program Files\nodejs\node.exe" +foreach ($candidate in ($nodeCandidates | Where-Object { $_ } | Select-Object -Unique)) { + if (-not (Test-Path $candidate)) { continue } + $link = (Get-Item $candidate -ErrorAction SilentlyContinue).Target + $real = if ($link) { @($link)[0] } else { $candidate } + if ($real -like "$env:SystemDrive\Users\*") { + Write-Host "Skipping node at $real (under a user profile; the IIS App Pool can't execute it)." -ForegroundColor DarkGray + continue + } + $nodeExe = $real + break +} +if (-not $nodeExe) { + throw "No IIS-accessible node.exe found. Install Node machine-wide (e.g. winget OpenJS.NodeJS.LTS -> C:\Program Files\nodejs) so the IIS App Pool identity can execute it. Node under a user profile (an nvm-windows default) is not hostable by IIS. Re-run 03-prereqs-node.ps1 if needed." +} +Write-Host "httpPlatform will launch node at: $nodeExe" + +$webConfig = @' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +'@ +$webConfig = $webConfig.Replace('__NODE_EXE__', $nodeExe) +$webConfig = $webConfig.Replace('__HTTPS_PORT__', "$HttpsPort") + +# Detailed IIS errors are off for remote clients by default (DetailedLocalOnly: +# local requests still see detail, which keeps on-box debugging usable). -DevErrors +# opts into full detail for every client when troubleshooting a remote issue. +$errorMode = if ($DevErrors) { 'Detailed' } else { 'DetailedLocalOnly' } +$webConfig = $webConfig.Replace('__ERROR_MODE__', $errorMode) + +$webConfigPath = "$DestPath\web.config" +$webConfigChanged = $true +if (Test-Path $webConfigPath) { + $existing = Get-Content $webConfigPath -Raw + if ($existing -eq $webConfig) { + $webConfigChanged = $false + Write-Host "web.config already matches — not rewriting." + } +} +if ($webConfigChanged) { + try { + Set-Content -Path $webConfigPath -Value $webConfig -Encoding UTF8 + } catch { + throw "Failed to write web.config at $webConfigPath. Check the destination is writable. Original: $($_.Exception.Message)" + } + Write-Host "web.config written." +} + +# The source repo ships production.js as a thin stub (only FE_URL, DB_SSL, +# ENABLE_OPEN_API, WHITELISTED_REDIRECTS) and production.js-edfi as the full +# Ed-Fi template (DB_SECRET_VALUE, OIDC config, etc.). The full template is +# what the API actually needs; always overwrite from it before patching. +$prodJs = "$DestPath\packages\api\config\production.js" +$prodJsTemplate = "$DestPath\packages\api\config\production.js-edfi" + +# Resolve the data-at-rest encryption key. Precedence: an explicit -DbEncryptionKey +# wins; else reuse the key captured from the previously-deployed production.js +# (grabbed before the copy phase clobbered it); a clean box with no prior key +# generates a fresh one. Rotating this key makes previously-encrypted ODS/API +# environment secrets unrecoverable, which the guard below defends against. +$freshKeyGenerated = $false +if (-not $DbEncryptionKey -and $existingDeployedKey) { + $DbEncryptionKey = $existingDeployedKey + Write-Host "Reusing the existing per-install data-encryption key." +} +if (-not $DbEncryptionKey) { + $keyBytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($keyBytes) + $DbEncryptionKey = ($keyBytes | ForEach-Object { $_.ToString('x2') }) -join '' + $freshKeyGenerated = $true + Write-Host "Generated a per-install data-encryption key." +} + +# Guard (defense-in-depth): a freshly generated key cannot decrypt environment +# secrets written under a prior install's key. If we're deploying a NEW key but +# the DB already holds environment rows, fail loudly instead of letting the app +# break with a generic "unexpected error". MSSQL only (the engine tested here); +# a pgsql guard is a follow-up. Best-effort: an unreachable DB / missing table is +# treated as "no data at risk". +if ($freshKeyGenerated -and $DbEngine -eq 'mssql' -and -not $ForceKeyRotation) { + $envCount = 0 + # Pass the password via SQLCMDPASSWORD instead of -P so it stays off the + # sqlcmd process command line; cleared in the finally. + $env:SQLCMDPASSWORD = $AppDbPasswordPlain + try { + $out = & sqlcmd -S "tcp:localhost,1433" -U $AppDbUsername -d $DatabaseName -C -h -1 -W -t 10 ` + -Q "SET NOCOUNT ON; IF OBJECT_ID('sb_environment','U') IS NOT NULL SELECT COUNT(*) FROM sb_environment ELSE SELECT 0;" 2>$null + if ($LASTEXITCODE -eq 0 -and $out) { $envCount = [int]("$($out | Select-Object -First 1)").Trim() } + } catch { + Write-Warning "Could not verify existing encrypted environments before deploying a new key: $($_.Exception.Message)" + } finally { + Remove-Item Env:SQLCMDPASSWORD -ErrorAction SilentlyContinue + } + if ($envCount -gt 0) { + throw @" +A new data-at-rest encryption key was generated, but database '$DatabaseName' already +holds $envCount encrypted environment(s) from a previous install. The new key CANNOT +decrypt them -- the Admin App would fail with a generic error. Choose one: + * Run uninstall.ps1 (drops '$DatabaseName'), then reinstall -- clean slate. + * Pass the ORIGINAL key via -DbEncryptionKey to keep the existing environments. + * Pass -ForceKeyRotation to proceed anyway and abandon the existing encrypted data. +"@ + } +} + +if (Test-Path $prodJsTemplate) { + try { + Copy-Item $prodJsTemplate $prodJs -Force + } catch { + throw "Failed to seed production.js from the -edfi template at $prodJsTemplate. Check the config folder is writable. Original: $($_.Exception.Message)" + } + Write-Host "Seeded production.js from production.js-edfi template." +} elseif (-not (Test-Path $prodJs)) { + throw "Neither production.js nor production.js-edfi found in deployed config folder." +} + +# Configure the deployed API. Two delivery paths: +# 1. NODE_CONFIG (built here, injected on the App Pool env below): a JSON +# document node-config deep-merges OVER production.js at load time. It +# carries every value that is plain data -- DB creds, URLs, OIDC, yopass, +# admin user -- so those need no fragile text patching. +# 2. Two residual production.js text patches that can't be expressed as JSON +# data: the per-install encryption KEY (install-all reads it back for the +# summary) and API_PORT (a JS expression reading a runtime env var). Both +# are guarded so a template-formatting change fails loudly instead of +# silently shipping a default (PR #234 Architecture concern #4 / T2.8). + +# Guard helper: apply a regex replacement, aborting if the anchor is absent. +# Anchors key on the config NAME (not the default value), so they survive value +# drift -- detecting the silent no-op that is the whole point of the guard. +function Set-ConfigAnchor { + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Text, + [Parameter(Mandatory)][string]$Pattern, + [Parameter(Mandatory)][string]$Replacement, + [Parameter(Mandatory)][string]$Label + ) + if ($Text -notmatch $Pattern) { + throw "production.js patch '$Label' matched no anchor. The template (production.js-edfi) formatting likely changed, so this patch would silently no-op and could ship a default. Pattern: $Pattern" + } + return [regex]::Replace($Text, $Pattern, $Replacement) +} + +# Build the NODE_CONFIG document from the resolved values. node-config deep-merges +# objects, so partial DB_SECRET_VALUE / AUTH0_CONFIG_SECRET_VALUE objects keep +# their untouched siblings (unused engine keys, MACHINE_AUDIENCE). A single-element +# WHITELISTED_REDIRECTS array serializes correctly here (nested arrays don't +# collapse in ConvertTo-Json; verified on PS 5.1). +$nodeConfig = @{ + MY_URL = $ApiUrl + FE_URL = $FeUrl + WHITELISTED_REDIRECTS = @($FeUrl) + ADMIN_USERNAME = $AdminUsername + USE_YOPASS = [bool]$YopassUrl + YOPASS_URL = $YopassUrl + SSL_VERIFICATION = -not $DisableSslVerification + SAMPLE_OIDC_CONFIG = @{ + issuer = $OidcIssuer + clientId = $OidcClientId + clientSecret = $OidcClientSecretPlain + scope = $OidcScope + } + AUTH0_CONFIG_SECRET_VALUE = @{ + ISSUER = $OidcIssuer + CLIENT_ID = $OidcClientId + CLIENT_SECRET = $OidcClientSecretPlain + } +} +if ($DbEngine -eq 'mssql') { + $nodeConfig.DB_ENGINE = 'mssql' + $nodeConfig.DB_TRUST_CERTIFICATE = $true + $nodeConfig.DB_SECRET_VALUE = @{ + MSSQL_DB_HOST = 'localhost' + MSSQL_DB_DATABASE = $DatabaseName + MSSQL_DB_USERNAME = $AppDbUsername + MSSQL_DB_PASSWORD = $AppDbPasswordPlain + } +} else { + $nodeConfig.DB_ENGINE = 'pgsql' + $nodeConfig.DB_SSL = $false + $nodeConfig.DB_SECRET_VALUE = @{ + DB_HOST = $PgDbHost + DB_PORT = $PgDbPort + DB_USERNAME = $PgDbUsername + DB_DATABASE = $DatabaseName + DB_PASSWORD = $PgDbPasswordPlain + } +} +$nodeConfigJson = $nodeConfig | ConvertTo-Json -Depth 5 -Compress +if ($DisableSslVerification) { + Write-Warning "SSL_VERIFICATION is DISABLED: the API will NOT verify TLS certificates on outbound HTTPS calls (ODS/API, AdminApi, Yopass). Use only for self-signed upstreams in non-production; prefer NODE_EXTRA_CA_CERTS." +} + +# Patch + scrub production.js. NODE_CONFIG overrides these at runtime, but the +# moved secrets still sit at their template defaults on disk; scrub them so a +# NODE_CONFIG load failure fails SAFE (bad creds -> loud error) rather than +# falling back to a well-known default. Only write if something changed. +$prodJsChanged = $false +if (Test-Path $prodJs) { + $original = Get-Content $prodJs -Raw + $c = $original + $scrubMarker = 'set-via-NODE_CONFIG' + + # Residual patch 1: API_PORT must read the IIS-assigned loopback port at + # runtime -- a JS expression, so it can't live in NODE_CONFIG (data only). + # Match any current value so a re-run (already patched) stays idempotent; + # the guard fires only if the API_PORT key vanished entirely. + $c = Set-ConfigAnchor $c "API_PORT:\s*[^,\r\n]+," "API_PORT: process.env.HTTP_PLATFORM_PORT || 3333," 'API_PORT' + + # Residual patch 2: per-install encryption key. Kept in production.js because + # install-all.ps1 reads it back for the (ACL-locked) install summary. The + # existing default-key check below is its silent-no-op guard. + $c = $c.Replace("KEY: '$defaultKey',", "KEY: '$DbEncryptionKey',") + if ($c -match $defaultKey) { + throw "The default DB_ENCRYPTION_SECRET_VALUE.KEY is still present in $prodJs after patching. Refusing to deploy with the well-known default key." + } + + # Scrub the default secrets for values now delivered via NODE_CONFIG. Each + # regex keys on the name and matches ANY value, so it neutralizes the default + # AND stays idempotent on re-run. Both engines' password defaults are scrubbed + # regardless of the active engine, so no default secret is left in the file. + $c = Set-ConfigAnchor $c "(clientSecret:\s*')[^']*(')" ('${1}' + $scrubMarker + '${2}') 'clientSecret' + $c = Set-ConfigAnchor $c "(CLIENT_SECRET:\s*')[^']*(')" ('${1}' + $scrubMarker + '${2}') 'CLIENT_SECRET' + $c = Set-ConfigAnchor $c "(MSSQL_DB_PASSWORD:\s*')[^']*(')" ('${1}' + $scrubMarker + '${2}') 'MSSQL_DB_PASSWORD' + $c = Set-ConfigAnchor $c "(?$null 2>$null + $oidcUpsertExit = $LASTEXITCODE + } catch { + $oidcUpsertExit = 1 + } finally { + Remove-Item Env:SQLCMDPASSWORD -ErrorAction SilentlyContinue + } + if ($oidcUpsertExit -eq 0) { + Write-Host "OIDC connection row reconciled with the supplied settings." + } else { + Write-Host "Could not reconcile the OIDC connection row (sqlcmd exit $oidcUpsertExit). If login uses stale OIDC settings, update the [oidc] row manually." -ForegroundColor Yellow + } + } else { + # PG best-effort: 05 has no docker awareness, so reach the DB via psql on + # PATH. Probe for the table first (absent on a pre-boot first install) so + # the UPDATE can't error on a missing table -- the boot seed covers that. + if (Get-Command psql -ErrorAction SilentlyContinue) { + $env:PGPASSWORD = $PgDbPasswordPlain + try { + $reg = 'SELECT to_regclass(''oidc'');' | & psql -h $PgDbHost -p $PgDbPort -U $PgDbUsername -d $DatabaseName -tA 2>$null + if ($LASTEXITCODE -eq 0 -and "$reg".Trim() -ne '') { + $oidcUpsert = @" +INSERT INTO "oidc" ("issuer", "clientId", "clientSecret", "scope") + SELECT '$oidcIssuerSql', '$oidcClientIdSql', '$oidcSecretSql', '$oidcScopeSql' + WHERE NOT EXISTS (SELECT 1 FROM "oidc" WHERE "clientId" = '$oidcClientIdSql'); +UPDATE "oidc" SET "issuer" = '$oidcIssuerSql', "clientSecret" = '$oidcSecretSql', "scope" = '$oidcScopeSql' + WHERE "clientId" = '$oidcClientIdSql'; +"@ + $oidcUpsert | & psql -h $PgDbHost -p $PgDbPort -U $PgDbUsername -d $DatabaseName -v ON_ERROR_STOP=1 1>$null 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "OIDC connection row reconciled with the supplied settings." + } else { + Write-Host "Could not reconcile the OIDC connection row (psql exit $LASTEXITCODE). Update the `"oidc`" row manually if login uses stale settings." -ForegroundColor Yellow + } + } else { + Write-Host "OIDC table not present yet; the boot-time seed will create it from NODE_CONFIG." -ForegroundColor DarkGray + } + } finally { + Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue + } + } else { + Write-Host "psql not on PATH; skipping OIDC-row reconciliation. If you re-deployed corrected OIDC settings on Postgres, update the `"oidc`" row manually." -ForegroundColor Yellow + } + } +} + +# Trigger startup only if something actually changed +if ($webConfigChanged -or $prodJsChanged) { + (Get-Item "$DestPath\web.config").LastWriteTime = Get-Date + Write-Host "Touched web.config to recycle the app pool." +} else { + Write-Host "No file changes — skipping app pool recycle." +} + +Write-Host "" +Write-Host "SUCCESS: Admin App API deployed." -ForegroundColor Green +Write-Host "URL: https://localhost:${HttpsPort}/api/teams (expect 401 without a bearer token; HTTP :${StandalonePort} redirects to HTTPS)." diff --git a/windows-install/06-deploy-fe.ps1 b/windows-install/06-deploy-fe.ps1 new file mode 100644 index 00000000..886ec549 --- /dev/null +++ b/windows-install/06-deploy-fe.ps1 @@ -0,0 +1,267 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS +Deploys the Ed-Fi Admin App frontend to IIS. + +.DESCRIPTION +- Copies built FE files (index.html + assets\) to the IIS folder +- Creates or updates the IIS site under a dedicated App Pool (started explicitly) +- Writes web.config with the React Router SPA rewrite rule + security headers + +Run AFTER `npm run build:fe` produces dist\packages\fe\ in the source repo. + +.PARAMETER SourcePath +Path to the Vite build output, e.g. C:\Ed-Fi\Ed-Fi-AdminApp\dist\packages\fe. + +.PARAMETER DestPath +Where to deploy. Default: C:\inetpub\EdFi-AdminApp-FE (a dedicated directory, +not nested under another site's root). + +.PARAMETER SiteName +IIS site name. Default: EdFi-AdminApp-FE. + +.PARAMETER Port +HTTP port. Default: 4200. + +.PARAMETER ApiUrl +Base URL of the API the FE bundle calls. Only its origin (scheme://host:port) is +used, to populate the Content-Security-Policy connect-src. Must match the +VITE_API_URL baked into the bundle at build time. Default: https://localhost:3443. + +.PARAMETER AppPoolName +Dedicated IIS App Pool for the FE site, created and started here so the SPA does +not depend on DefaultAppPool (which is often Stopped after a reboot). Default: +EdFi-AdminApp-FE. + +.EXAMPLE +.\06-deploy-fe.ps1 -SourcePath C:\Ed-Fi\Ed-Fi-AdminApp\dist\packages\fe +#> + +param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [string]$DestPath = "C:\inetpub\EdFi-AdminApp-FE", + [string]$SiteName = "EdFi-AdminApp-FE", + [int]$Port = 4200, + [string]$ApiUrl = "https://localhost:3443", + [string]$AppPoolName = "EdFi-AdminApp-FE", + + # TLS (see 05-deploy-api.ps1 for the certificate model). HTTPS always-on on + # -HttpsPort; self-signed fallback for local. HTTP stays only to 301-redirect. + [int]$HttpsPort = 4443, + [string]$CertificateThumbprint = "", + [string]$CertificatePfxPath = "", + [SecureString]$CertificatePassword, + + # By default the auto-generated self-signed cert is added to LocalMachine\Root so + # local browsers trust it (no "Not Secure" warning). Set this to skip that where + # policy forbids adding trusted roots; the browser will then warn. Only affects the + # self-signed path -- a supplied real cert is never added to Root. + [switch]$SkipSelfSignedTrust +) + +$ErrorActionPreference = 'Stop' + +# Precondition: IIS + the WebAdministration module must be available +# (01-prereqs-iis.ps1 installs the IIS pieces). +try { + Import-Module WebAdministration -ErrorAction Stop +} catch { + throw "IIS / the WebAdministration module isn't available. Ensure IIS is installed (setup-vm-prereqs.ps1) and run 01-prereqs-iis.ps1 before deploying." +} + +if (-not (Test-Path "$SourcePath\index.html")) { + throw "index.html not found at $SourcePath. Did you run 'npm run build:fe'?" +} + +Write-Host "Copying FE files to $DestPath..." +New-Item -ItemType Directory -Path $DestPath -Force | Out-Null +& robocopy $SourcePath $DestPath /MIR /NFL /NDL /NJH /NJS | Out-Null +if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit code $LASTEXITCODE" } + +# Dedicated App Pool for the FE. Without one, New-Website binds the site to +# DefaultAppPool, which is often Stopped after a reboot or recycle -> the SPA 503s +# until it is started by hand. A dedicated pool (autoStart on by default) started +# explicitly here keeps the FE reachable on its own. +try { + if (-not (Test-Path "IIS:\AppPools\$AppPoolName")) { + Write-Host "Creating App Pool '$AppPoolName'..." + New-WebAppPool -Name $AppPoolName | Out-Null + } + # Static content -- no managed runtime needed. + Set-ItemProperty -Path "IIS:\AppPools\$AppPoolName" -Name "managedRuntimeVersion" -Value "" + Write-Host "App Pool '$AppPoolName' configured." +} catch { + throw "Failed to create/configure the IIS App Pool '$AppPoolName'. Is IIS running and the WAS service started? Original: $($_.Exception.Message)" +} + +if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { + Write-Host "Site '$SiteName' exists. Updating physical path and app pool..." + Set-ItemProperty -Path "IIS:\Sites\$SiteName" -Name "physicalPath" -Value $DestPath + Set-ItemProperty -Path "IIS:\Sites\$SiteName" -Name "applicationPool" -Value $AppPoolName +} else { + New-Website -Name $SiteName -Port $Port -PhysicalPath $DestPath -ApplicationPool $AppPoolName | Out-Null + Write-Host "Site '$SiteName' created on HTTP port $Port (App Pool '$AppPoolName')." +} + +# Ensure the pool is running so the site serves immediately. +if ((Get-WebAppPoolState -Name $AppPoolName -ErrorAction SilentlyContinue).Value -ne 'Started') { + Start-WebAppPool -Name $AppPoolName | Out-Null + Write-Host "Started App Pool '$AppPoolName'." +} + +# Resolve the TLS certificate for the HTTPS binding. Precedence: an explicit +# thumbprint (already in LocalMachine\My) -> an imported PFX -> a self-signed cert +# generated for localhost + this host. The self-signed path keeps the local +# quick-start working with zero cert setup (an untrusted-cert browser warning is +# expected). Returns the resolved certificate thumbprint. WET-duplicated in +# 05-deploy-api.ps1 (windows-install has no shared module); when install-all runs +# 05 first, this reuses the self-signed cert 05 created (matched by FriendlyName). +function Resolve-HttpsCertificate { + param( + [string]$Thumbprint, + [string]$PfxPath, + [SecureString]$PfxPassword, + [switch]$SkipTrust + ) + $storePath = 'Cert:\LocalMachine\My' + $friendlyName = 'Ed-Fi Admin App self-signed' + + if ($Thumbprint) { + $clean = ($Thumbprint -replace '[^0-9A-Fa-f]', '') + $cert = Get-Item "$storePath\$clean" -ErrorAction SilentlyContinue + if (-not $cert) { + throw "No certificate with thumbprint '$clean' found in $storePath. Import it into LocalMachine\My first, or omit -CertificateThumbprint to auto-generate a self-signed cert." + } + Write-Host "Using the supplied certificate ($($cert.Thumbprint))." + return $cert.Thumbprint + } + + if ($PfxPath) { + if (-not (Test-Path $PfxPath)) { throw "PFX file not found at '$PfxPath'." } + $importParams = @{ FilePath = $PfxPath; CertStoreLocation = $storePath } + if ($PfxPassword) { $importParams.Password = $PfxPassword } + $cert = Import-PfxCertificate @importParams + Write-Host "Imported the supplied PFX ($($cert.Thumbprint))." + return $cert.Thumbprint + } + + # Self-signed fallback. Reuse a still-valid one we created before so re-runs + # (and the other site's deploy) share a single cert; else generate a fresh one. + $cert = Get-ChildItem $storePath | + Where-Object { $_.FriendlyName -eq $friendlyName -and $_.NotAfter -gt (Get-Date) } | + Sort-Object NotAfter -Descending | Select-Object -First 1 + if ($cert) { + Write-Host "Reusing the existing self-signed certificate ($($cert.Thumbprint))." + } else { + Write-Host "Generating a self-signed certificate for HTTPS (localhost + $env:COMPUTERNAME)..." + $cert = New-SelfSignedCertificate -DnsName 'localhost', $env:COMPUTERNAME ` + -CertStoreLocation $storePath -FriendlyName $friendlyName -NotAfter (Get-Date).AddYears(5) + } + # Trust the self-signed cert on this machine (add the public cert to + # LocalMachine\Root) so local browsers don't show "Not Secure". Only the + # self-signed path does this -- a supplied real cert is already CA-trusted. Skip + # with -SkipTrust where policy forbids adding trusted roots. Idempotent; a + # public-only copy carrying the same FriendlyName is stored so uninstall finds it. + if (-not $SkipTrust) { + $rootStore = [System.Security.Cryptography.X509Certificates.X509Store]::new('Root', 'LocalMachine') + $rootStore.Open('ReadWrite') + try { + $found = $rootStore.Certificates.Find( + [System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false) + if ($found.Count -eq 0) { + $pub = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.RawData) + $pub.FriendlyName = $friendlyName + $rootStore.Add($pub) + Write-Host "Trusted the self-signed certificate (added to LocalMachine\Root)." + } + } finally { + $rootStore.Close() + } + } + return $cert.Thumbprint +} + +# Add (idempotently) an HTTPS binding on the site and attach the cert. Mirror-port +# model: API and FE each have their own HTTPS port, so no SNI/hostname is needed +# (SslFlags 0). The cert is (re)bound every run so a replaced/rotated cert takes +# effect. WET-duplicated in 05-deploy-api.ps1. +function Set-HttpsBinding { + param( + [Parameter(Mandatory)][string]$SiteName, + [Parameter(Mandatory)][int]$HttpsPort, + [Parameter(Mandatory)][string]$Thumbprint + ) + if (-not (Get-WebBinding -Name $SiteName -Protocol https -Port $HttpsPort -ErrorAction SilentlyContinue)) { + New-WebBinding -Name $SiteName -Protocol https -Port $HttpsPort -SslFlags 0 | Out-Null + Write-Host "Added HTTPS binding on port $HttpsPort to site '$SiteName'." + } + $sslPath = "IIS:\SslBindings\0.0.0.0!$HttpsPort" + if (Test-Path $sslPath) { Remove-Item $sslPath -ErrorAction SilentlyContinue } + Get-Item "Cert:\LocalMachine\My\$Thumbprint" | New-Item -Path $sslPath | Out-Null + Write-Host "Bound certificate $Thumbprint to 0.0.0.0:$HttpsPort." +} + +# TLS (always-on): resolve the cert and add the HTTPS binding. The HTTP site created +# above stays only to 301-redirect to HTTPS (redirect rule added to web.config in T3.2). +$certThumbprint = Resolve-HttpsCertificate -Thumbprint $CertificateThumbprint -PfxPath $CertificatePfxPath -PfxPassword $CertificatePassword -SkipTrust:$SkipSelfSignedTrust +Set-HttpsBinding -SiteName $SiteName -HttpsPort $HttpsPort -Thumbprint $certThumbprint + +$webConfig = @' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +'@ + +# The CSP connect-src must name the exact API origin (scheme://host:port), so the +# browser allows the FE's XHR calls to the API. Strip any path/query from -ApiUrl. +$apiOrigin = ([Uri]$ApiUrl).GetLeftPart([System.UriPartial]::Authority) +$webConfig = $webConfig.Replace('__API_ORIGIN__', $apiOrigin) +$webConfig = $webConfig.Replace('__HTTPS_PORT__', "$HttpsPort") + +$webConfigPath = "$DestPath\web.config" +if ((Test-Path $webConfigPath) -and ((Get-Content $webConfigPath -Raw) -eq $webConfig)) { + Write-Host "web.config already matches — not rewriting." +} else { + Set-Content -Path $webConfigPath -Value $webConfig -Encoding UTF8 + Write-Host "web.config written." +} + +Write-Host "" +Write-Host "SUCCESS: FE deployed at https://localhost:$HttpsPort/ (HTTP :$Port redirects here)." -ForegroundColor Green diff --git a/windows-install/README.md b/windows-install/README.md new file mode 100644 index 00000000..8bccdbcb --- /dev/null +++ b/windows-install/README.md @@ -0,0 +1,279 @@ +# Ed-Fi Admin App — Windows IIS Installation Scripts + +Automates the gaps in the official [Windows IIS Installation docs](https://docs.ed-fi.org/reference/admin-app/getting-started/windows-iis-installation). Designed to run on a clean Windows VM and produce a working Admin App at `https://localhost:4443/` with no manual workarounds. + +TLS is on by default. The API and frontend deploy as two independent IIS sites over HTTPS — `https://localhost:3443` (API) and `https://localhost:4443` (FE) — and each keeps an HTTP binding (`3333` / `4200`) that issues a 301 redirect to HTTPS. When no certificate is supplied, a self-signed one is generated and trusted on the local machine automatically. See [TLS / HTTPS](#tls--https) for real-cert options and the self-signed caveat. + +--- + +## Getting the scripts onto the VM + +The scripts live in this repo. On a fresh VM, get the source there first by either: + +- `git clone https://github.com/Ed-Fi-Alliance-OSS/Ed-Fi-AdminApp.git C:\Ed-Fi\Ed-Fi-AdminApp` (needs Git — install manually with `winget install --id Git.Git -e`, or use the ZIP option below), **or** +- Download the repo as a ZIP from GitHub and extract to `C:\Ed-Fi\Ed-Fi-AdminApp`. `setup-vm-prereqs.ps1` will install Git for you afterwards. + +Then open an **elevated PowerShell** and `cd C:\Ed-Fi\Ed-Fi-AdminApp\windows-install`. + +## Before you start + +**These scripts install Admin App only.** They assume an Ed-Fi ODS/API is already installed and reachable; they provision only the Admin App database (`sbaa`), **not** `EdFi_Admin` or `EdFi_Security`. You point the Admin App at your existing ODS/API by adding an Environment after sign-in (see [Next steps after install](#next-steps-after-install)). Installing the Admin App database in its own SQL Server instance, separate from the ODS/API databases, is recommended. + +- **Windows 10/11 Pro or Windows Server 2016+**, with **administrator rights** (every command runs in an *elevated* PowerShell). The standalone-site environment variables require **IIS 10 or newer**. +- **SQL Server default instance (`MSSQLSERVER`).** The quick-start targets the default instance. `02-prereqs-sql.ps1` accepts `-InstanceName`, but the end-to-end path is validated only against the default instance — a named/non-default instance is not covered here. +- **Internet access** — the scripts download Node, Keycloak, and npm packages. +- **~10 GB free disk**. +- **Docker is not required for the default path.** The SQL Server default path needs no Docker at all. **Docker Desktop** is only needed if you opt into `-UsePostgresDocker` or `-SetupYopassDocker`, in which case it must be **installed, running, and in Linux-container mode** (the pre-flight check verifies this when those flags are set). +- **Allow 15–20 minutes** for a fresh end-to-end install (the build phase alone takes several minutes). +- The passwords below are **yours to choose** — wherever you see `'your-…'`, replace it with a password you pick. + +## Quick start (local Keycloak) + +`install-all.ps1` is the "run everything" path. Pick the identity provider with the mandatory **`-IdpProvider`** (`keycloak` | `microsoft` | `google` | `other`). `keycloak` stands up a local Keycloak as the example IdP; for an external provider see [Other identity providers](#other-identity-providers). + +```powershell +# One-time, current-process-only bypass so the first script can run. It affects only +# this PowerShell session; setup-vm-prereqs.ps1 then Unblock-File's the repo scripts so +# the rest run without changing your machine-wide execution policy. +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force + +# 1. OS prereqs (IIS, SQL Server, Git). ONLY on a fresh VM. Scans before it +# installs, so re-running on a prepared VM is a no-op. Reboot if winget asks. +.\setup-vm-prereqs.ps1 + +# 2. Full Admin App install (local Keycloak). The pre-flight check tells you if +# step 1 was actually needed. +.\install-all.ps1 -IdpProvider keycloak -SaPassword 'your-sa-password' -KeycloakAdminPassword 'your-keycloak-admin-password' -OidcClientSecret 'your-client-secret' -TestUserPassword 'your-keycloak-user-password' +``` + +When `install-all.ps1` finishes, open `https://localhost:4443/` and sign in with `admin@example.com` (or whatever you passed to `-AdminUsername`) and your `-TestUserPassword`. A green `INSTALL COMPLETE` banner and a written `install-summary.txt` (in the parent of the repo dir, e.g. `C:\Ed-Fi\install-summary.txt`) confirm success. + +### Notes on the parameters + +- **`-SaPassword`**: SQL Server `sa` login password. Must satisfy the Windows password policy `CHECK_POLICY` enforces — length ≥ 8 and at least 3 of 4 character categories (uppercase, lowercase, digit, symbol). Weak passwords are rejected up front with guidance. The same rule applies to every SQL login the scripts create. +- **`-IdpProvider`** *(mandatory)*: `keycloak` | `microsoft` | `google` | `other`. `keycloak` runs the local example IdP; the others target an external OIDC provider (see [Other identity providers](#other-identity-providers)). +- **`-OidcClientSecret`** *(all modes)*: the OIDC client secret. For `keycloak` it's the secret set on the `edfiadminapp` client (you pick it, 32+ chars recommended); for external providers it's the secret from your app registration. +- **`-KeycloakAdminPassword`** *(keycloak only)*: Password for the master-realm admin user auto-created when Keycloak first starts. +- **`-TestUserPassword`** *(keycloak only)*: Password for the seeded `admin@example.com` user in the `edfi` realm — what you type on the Keycloak login screen. + +#### Database engine selection + +- **`-DbEngine`**: `mssql` (default) or `pgsql`. Drives the database prereq path and how `production.js` gets patched. Everything else is identical. + +--- + +## The scripts + +Numbered scripts map to the official guide's section order. The **generic path** (00–06) is all the Admin App itself needs; the **local IdP example** (`idp-keycloak-*`) is optional. + +### Generic path + +| Script | Purpose | +|---|---| +| `00-check-prereqs.ps1` | Read-only diagnostic. `[PASS]`/`[FAIL]`/`[INFO]`/`[RISK]` per prereq. `[RISK]` flags collisions with existing software (shared SQL instance, older `java` on PATH, ports 3333/4200/3443/4443 in use). Exit 0 = clean, 1 = blocking, 2 = ready-with-risks. | +| `01-prereqs-iis.ps1` | URL Rewrite Module + the HTTP hosting handler (HttpBridge by default, or HttpPlatformHandler via `-HttpHandler`); unlocks the `handlers` section. HTTPS bindings are added at deploy time by `05`/`06`. | +| `02-prereqs-sql.ps1` | SQL Server config: Mixed Mode + TCP/IP + `sa` login + creates the `sbaa` database + provisions a dedicated least-privilege app login (`edfi_adminapp`, `db_owner` on `sbaa` only, not a server sysadmin) that the app connects as. | +| `03-prereqs-node.ps1` | Node.js install (if missing) and nvm-windows remediation of a too-old version. No Java, no Keycloak. | +| `04-build.ps1` | `npm ci --legacy-peer-deps`, then `build:api` and `build:fe`. Seeds `packages\fe\.env` (VITE_*) before building. Skips if artifacts are current (override with `-Force`). | +| `05-deploy-api.ps1` | Deploys the API to the standalone site `EdFi-AdminApp-API` (HTTPS `3443`; HTTP `3333` redirects to it). Seeds/patches `production.js`, writes `web.config`, configures the App Pool, and sets `NPM_CONFIG_CACHE` on the App Pool. | +| `06-deploy-fe.ps1` | Deploys the FE to the standalone site `EdFi-AdminApp-FE` (HTTPS `4443`; HTTP `4200` redirects to it) with a SPA-fallback `web.config`. | + +### Local IdP example (optional — Keycloak) + +| Script | Purpose | +|---|---| +| `idp-keycloak-setup.ps1` | One run = a ready local Keycloak: installs a JDK if needed, downloads Keycloak, starts it (via `idp-keycloak-start.ps1`), then provisions the `edfi` realm, `edfiadminapp` client, and test user. | +| `idp-keycloak-start.ps1` | Starts Keycloak in the background (bootstraps the master admin on first run, waits for readiness). Use to relaunch after a reboot. | + +### Transversal + +| Script | Purpose | +|---|---| +| `setup-vm-prereqs.ps1` | OS-level installs only: IIS features, SQL Server Developer, Git. Scans first, installs only what's missing. | +| `install-all.ps1` | Master orchestrator. Pick the IdP with `-IdpProvider` (keycloak/microsoft/google/other). Pre-flight check + all phases + smoke test. | +| `yopass-docker.ps1` | Optional. Stands up a local Yopass + memcached stack via `docker\docker-compose.yopass.yml`. Only runs with `install-all -SetupYopassDocker` (or directly). | +| `uninstall.ps1` | Reverses the generic install: IIS sites/App Pool/files, the `sbaa` DB, docker Postgres + Yopass stacks, `C:\npm-cache`. Detects Keycloak leftovers and suggests `uninstall-keycloak.ps1` (does not touch them). Per-step OK/SKIP/WARN/FAIL ledger. | +| `uninstall-keycloak.ps1` | Tears down the local Keycloak IdP: stops the process, deletes the install dir, unsets `JAVA_HOME`. Leaves the JDK install in place. | + +### Per-section mapping to the official guide + +| Guide section/step | Script(s) | +|---|---| +| Prereqs: IIS + URL Rewrite + hosting handler | `01-prereqs-iis.ps1` | +| Prereqs: Node.js | `03-prereqs-node.ps1` | +| Prereqs: SQL Server / PostgreSQL (+ `sbaa`) | `02-prereqs-sql.ps1` | +| Prereqs: Identity Provider | `idp-keycloak-setup.ps1` (local example) or your own IdP | +| Backend API → build | `04-build.ps1` (also builds the FE) | +| Backend API → deploy (site, web.config, handler mappings, App Pool, dirs) | `05-deploy-api.ps1` (handler-mapping unlock done by `01-prereqs-iis.ps1`) | +| Frontend → configure `.env` + build | `04-build.ps1` (Vite bakes vars at **build** time, not deploy) | +| Frontend → deploy (site, SPA rewrite) | `06-deploy-fe.ps1` | + +> The guide's "configure Handler Mappings manually in IIS Manager" step is automated: `01-prereqs-iis.ps1` unlocks the `handlers` section and `05-deploy-api.ps1` declares the httpPlatform handler in `web.config`. + +--- + +## Other identity providers + +The Admin App's auth engine is provider-agnostic (generic OIDC discovery). Keycloak is only the example IdP. To use an external provider, run `install-all.ps1` with `-IdpProvider microsoft | google | other`: it deploys everything and **skips** the local Keycloak step, configuring the API against your provider instead. + +```powershell +.\install-all.ps1 -IdpProvider microsoft ` + -SaPassword 'your-sa-password' ` + -OidcIssuer 'https://login.microsoftonline.com//v2.0' ` + -OidcClientId '' ` + -OidcClientSecret 'your-client-secret' ` + -AdminUsername 'you@yourtenant.onmicrosoft.com' +``` + +- `keycloak`/`google` default `-OidcIssuer`; `microsoft`/`other` require it. `-ViteIdpAccountUrl` is defaulted per provider (`other` requires it). `-OidcScope` defaults to `openid email profile`. +- **Where to find `-OidcIssuer`:** for Entra, the App Registration → *Endpoints* → "OpenID Connect metadata document" URL, minus the trailing `/.well-known/openid-configuration` (typically `https://login.microsoftonline.com//v2.0`). For Google it's `https://accounts.google.com` (the default). +- **You register the OIDC client yourself** in the provider's portal (no script can provision Entra/Google). `install-all` validates the issuer's discovery endpoint and, at the end of the install, prints the exact URIs to register. The redirect URI is `https://localhost:3443/api/auth/callback/`, where `` is the `oidc` database row id `install-all` resolves and prints ("OIDC redirect callback id resolved to ``") — register `callback/`, not a hardcoded `callback/1`. Post-logout is `https://localhost:3443/api/auth/post-logout` and the allowed origin is `https://localhost:4443`. +- A user must exist in the provider whose **email/username claim equals `-AdminUsername`** — the script seeds that user in the `[user]` table with the admin role, but the identity lives in your IdP. For Entra specifically, the app registration must emit an `email` claim; see [Entra: "Invalid email from IdP" after sign-in](#entra-invalid-email-from-idp-after-sign-in). + +You can also drive the per-section scripts manually (`00`→`06`), passing `-Oidc*` to `05-deploy-api.ps1` and `-ViteIdpAccountUrl` to `04-build.ps1`. When you do, the OIDC **client secret, issuer, client id, and admin username must match** between the identity-provider step (`idp-keycloak-setup.ps1` or your external provider) and `05-deploy-api.ps1` — a mismatch surfaces as a login failure, not an install error. Open a **fresh** elevated PowerShell after `03-prereqs-node.ps1` installs Node, so the updated `PATH` is in effect before `04-build.ps1` runs. + +--- + +## Uninstalling + +```powershell +.\uninstall.ps1 # prompts before doing anything +.\uninstall.ps1 -SaPassword 'your-sa-password' -Force # SQL Auth to drop the DB + non-interactive +.\uninstall.ps1 -KeepDatabase -KeepNpmCache # selective teardown + +.\uninstall-keycloak.ps1 # remove the local Keycloak IdP (separate) +``` + +`uninstall.ps1` covers the generic install and, at the end, flags any Keycloak leftovers and points you at `uninstall-keycloak.ps1`. See `Get-Help .\uninstall.ps1 -Full` / `Get-Help .\uninstall-keycloak.ps1 -Full` for all flags. + +--- + +## What `install-all.ps1` does, in order + +1. **Node runtime** (`03-prereqs-node.ps1`) — installs/remediates Node up front (idempotent), so a stale Node doesn't fail the pre-flight. +2. **Pre-flight check** (`00-check-prereqs.ps1`) — aborts on FAIL; prompts on RISK (unless `-AcceptRisks`). Skipped with `-SkipPreflightCheck`. +3. **Phase 1 — prereqs**: database (mssql Mixed Mode + TCP/IP + `sbaa`, or pgsql/docker) and IIS (`01-prereqs-iis.ps1`). Optional Yopass docker with `-SetupYopassDocker`. +4. **Phase 2 — build** (`04-build.ps1`): `npm ci` + `build:api` + `build:fe`. +5. **Phase 3 — deploy**: `idp-keycloak-setup.ps1` (JDK + Keycloak download + start + realm/client/user), then `05-deploy-api.ps1` and `06-deploy-fe.ps1`. +6. **Smoke test**: hits `https://localhost:3443/api/teams` (expects 401), waits for the `[user]` table, and ensures the admin user has `roleId=2`. +7. **Writes** `install-summary.txt` in the parent of the repo directory. + +Re-running on a working install is mostly a no-op — most steps detect existing state and skip. + +### Re-run flags + +- `-OnlyPhase1` — stop after prereqs +- `-SkipPhase1` — prereqs already done +- `-SkipPhase2` — build artifacts already present +- `-SkipPreflightCheck` — skip `00-check-prereqs` +- `-AcceptRisks` — bypass the y/N confirmation on `[RISK]` items (non-interactive) +- `-AutoUpgradeNode` — when `03-prereqs-node.ps1` finds a too-old Node, skip its y/N prompt and remediate via nvm-windows automatically + +### Advanced flags + +- **Yopass** — Yopass creates one-time, self-destructing links for sharing newly-created Ed-Fi API client secrets, so a secret goes over a link that expires on first view instead of being pasted into chat or email. **Disabled** by default; `-YopassUrl ''` to use an existing Yopass; `-SetupYopassDocker` to stand one up locally (`-YopassPort`, default 8082). The two are mutually exclusive. A locally stood-up Yopass is only reachable by people who can reach `-YopassPort` on this host — behind a firewall, the recipient must be on the same network (or the port must be exposed appropriately). See the [Yopass administrator's guide](https://docs.ed-fi.org/reference/admin-app/system-administrators/yopass-administrators-guide/). +- `-IncludeAudienceMapper` — adds a Keycloak audience mapper; only needed for direct bearer-token API access (Postman/curl/CI). The browser login flow doesn't need it. +- `-EnableDirectAccessGrants` — enables the OAuth password grant on the Keycloak client. **Testing only.** +- `-DisableSslVerification` — turns off TLS-certificate verification on the API's outbound calls to the ODS/API and Admin API. **Local dev only** (a networked box is left MITM-exposed). Use it when your ODS/API presents a self-signed or dev certificate; the secure alternative is `NODE_EXTRA_CA_CERTS`. See [What these scripts don't do](#what-these-scripts-dont-do) for the full note. + +--- + +## End-state URLs + +- **Admin App (FE)**: `https://localhost:4443/` (HTTP `http://localhost:4200/` redirects here) +- **API**: `https://localhost:3443/` (HTTP `http://localhost:3333/` redirects here) +- **Keycloak admin console**: `http://localhost:8080/admin/` +- **Keycloak `edfi` realm**: `http://localhost:8080/realms/edfi/` + +--- + +## TLS / HTTPS + +Both IIS sites are served over HTTPS by default — API on `3443`, FE on `4443` — and each keeps an HTTP binding (`3333` / `4200`) that returns a 301 redirect to its HTTPS URL. + +**Certificate resolution** (in order of precedence): + +1. `-CertificateThumbprint` — bind an existing certificate already in `LocalMachine\My`. +2. `-CertificatePfxPath` + `-CertificatePassword` — import and bind a PFX you supply. +3. None supplied → a **self-signed** certificate (CN/SAN `localhost` + the machine name) is generated, bound, and added to `LocalMachine\Root` so local browsers trust it. Opt out of the trust step with `-SkipSelfSignedTrust`. + +On `install-all.ps1` the ports are `-HttpsApiPort` (default `3443`) and `-HttpsFePort` (default `4443`); the standalone `05-deploy-api.ps1` and `06-deploy-fe.ps1` each take a single `-HttpsPort` (default `3443` and `4443` respectively). The certificate parameters above are shared across all three. + +**Self-signed caveat.** A self-signed certificate is auto-trusted only on this machine, so other machines browsing to it still see a trust warning. Supply a real certificate (thumbprint or PFX) for anything beyond this host. A certificate imported into `LocalMachine\Root` by hand (rather than by these scripts) won't carry the friendly name `uninstall.ps1` matches on, so uninstall won't remove it. + +**Security headers.** Each site emits a baseline set of response headers. The API sets `X-Content-Type-Options: nosniff` and `X-Frame-Options` in-app (`main.ts`); IIS adds the rest on both sites — `Strict-Transport-Security` (HSTS), `Referrer-Policy`, and a `Content-Security-Policy` (**enforcing**, not report-only) — and removes `X-Powered-By`. The API's CSP is `default-src 'none'` (it serves only JSON in production); the FE's allows its own origin plus the API origin for `connect-src`. + +For the outbound direction — the certificate the API expects from the ODS/API and Admin API it calls — see the upstream TLS verification note under [What these scripts don't do](#what-these-scripts-dont-do). + +--- + +## Next steps after install + +Signing in gets you an empty Admin App. To make it useful: + +1. **Add an Environment** pointing at your Ed-Fi ODS/API (its Discovery URL) and its Admin API. If either presents a self-signed or dev certificate — common for a local ODS/API — see the upstream TLS verification note under [What these scripts don't do](#what-these-scripts-dont-do). +2. **Create API client credentials** for the applications that will call the ODS/API (optionally shared via Yopass — see [Advanced flags](#advanced-flags)). +3. **Assign claim sets and roles** as your deployment requires. + +See the [Admin App User's Guide](https://docs.ed-fi.org/reference/admin-app/) for the full first-run walkthrough. + +--- + +## Known issues / things to know + +### Keycloak bootstrap admin is first-run only + +`KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` are honored only the **first time** Keycloak starts against an empty data directory. Re-running later with a different `-KeycloakAdminPassword` leaves the existing master admin unchanged and provisioning fails to authenticate. `idp-keycloak-setup.ps1` detects this (`invalid_grant`) and prints recovery options: + +- **A:** Re-run with the original admin password. +- **B:** Wipe Keycloak state and bootstrap fresh (loses realm/client/user — recreated automatically): + +```powershell +.\uninstall-keycloak.ps1 -Force +.\install-all.ps1 ... -KeycloakAdminPassword '' -SkipPhase1 +``` + +`-OidcClientSecret` and `-TestUserPassword` are idempotently updatable on every re-run — both the Keycloak client and the `oidc` database row are reconciled (UPSERT) on each run, so a changed secret takes effect without a manual reset. + +### Entra: "Invalid email from IdP" after sign-in + +The Admin App requires an `email` claim in the OIDC userinfo/token. If Entra authenticates the user but the app then errors with `Invalid email from IdP`, the app registration isn't emitting an email claim. In the Entra app registration, add an `email` optional claim (Token configuration) or a claim mapper, then sign in again. See the dedicated Entra setup guide for the full configuration rather than treating this as a one-off fix. + +### Rate limit can trip during heavy debugging + +Default in `production.js` is 10 requests / 60s. Recycle the App Pool to clear state, or bump it for dev: + +```powershell +$f = "C:\inetpub\EdFi-AdminApp-API\packages\api\config\production.js" +(Get-Content $f -Raw).Replace("RATE_LIMIT_LIMIT: 10,", "RATE_LIMIT_LIMIT: 1000,") | Set-Content $f -Encoding UTF8 +Restart-WebAppPool -Name "EdFi-AdminApp-API" +``` + +### npm cache is scoped to the App Pool + +`05-deploy-api.ps1` sets `NPM_CONFIG_CACHE` on the `EdFi-AdminApp-API` App Pool's environment (not machine-wide) and grants that identity write access, so npm run under the App Pool identity has a writable cache without affecting other npm usage on the machine. Requires IIS 10+. + +### Node version requirement + +`package.json` declares `engines.node: ">=22.0.0"` (practical floor ~22.12+). `03-prereqs-node.ps1` reads `engines.node` at runtime and tracks whatever the repo declares. If a too-old Node is found, it sets up nvm-windows + installs the latest patch on the required major; if AV/EDR consumes the nvm-extracted files, it **falls back to a direct download from nodejs.org**. + +### What these scripts don't do + +- **Upstream TLS verification (adding an Environment)** — the API verifies the TLS certificate of the ODS/API and Admin API it connects to (`SSL_VERIFICATION` is on by default). If those use a self-signed or dev certificate — common for a local ODS/API — adding an Environment fails with a certificate error (`DEPTH_ZERO_SELF_SIGNED_CERT`) in the API log (`logs\node-stdout*.log`). For a local/dev install, either pass `-DisableSslVerification` to `install-all.ps1` / `05-deploy-api.ps1` (turns verification off — local dev only), or keep it on and make Node trust the upstream certificate via `NODE_EXTRA_CA_CERTS` (the certificate's `.pem` path) or `--use-system-ca` (Node 22.15+, honors the Windows certificate store). Leave verification on (no flag) for production, where upstreams should present trusted certificates. +- **Production hardening** — real certs, secrets management, log rotation, etc. The optional dockerized Yopass runs HTTP-only behind localhost and is not production-hardened. +- **Keycloak in production mode** — the example IdP runs in `start-dev` (HTTP, embedded H2 database, hostname strictness off) and is for local development only. For anything beyond local dev, run `kc.bat start` with `--hostname`, a real database (e.g. PostgreSQL), and TLS. By default Keycloak is started via `Start-Process` and does not survive a reboot; pass `-RegisterKeycloakStartupTask` to `install-all.ps1` (or `-RegisterStartupTask` to `idp-keycloak-start.ps1`, elevated) to register a startup task that relaunches it on boot. +- **Secret rotation** — not enabled; the AdminApp has no automation to pick up rotated secrets. + +--- + +## Defaults + +| Parameter | Default | +| --- | --- | +| `-DbEngine` | `mssql` | +| `-SourcePath` | Parent of `windows-install\` (typically `C:\Ed-Fi\Ed-Fi-AdminApp`) | +| `-DatabaseName` | `sbaa` | +| `-AdminUsername` | `admin@example.com` | +| `-PostgresHost` / `-PostgresPort` / `-PostgresAppUser` | `localhost` / `5432` / `edfiadminapp` *(pgsql only)* | + +Per-script parameters (ports, OIDC settings, Keycloak install path, etc.) have defaults documented in each script's `param()` block — run `Get-Help .\