# Sprint Planner — Administrator Manual This guide walks an administrator through the initial configuration and the day-to-day operation of the Sprint Planner web application. It assumes a Linux host with Docker and Docker Compose v2 installed. For architecture, schema, and feature history, see [SPEC.md](../SPEC.md) in the repository root. --- ## 1. Prerequisites - A Linux (or macOS / Windows with WSL2) host with: - Docker Engine 24+ and the `docker compose` plugin. - Outbound HTTPS access during build (Composer + npm pull dependencies). - One of the following for sign-in: - **Microsoft Entra ID tenant** with permission to register an application (preferred for production), or - **Local admin credentials** (email + password) — useful for initial setup, on-prem-only deployments, or development. - A directory on the host to hold the persistent SQLite database and PHP session files. The compose file mounts `./data` next to `docker-compose.yml`; nothing else is needed for storage. --- ## 2. Getting the code ```bash git clone sprint_planer_web cd sprint_planer_web ``` All commands in the rest of this manual are run from the repository root. --- ## 3. Initial configuration of `.env` The application reads its configuration from a single `.env` file in the repository root. Start by copying the template: ```bash cp .env.example .env ``` Open `.env` in an editor and fill in the values described below. The file must be readable by the user that runs `docker compose`; on a shared host restrict it with `chmod 600 .env`. ### 3.1 Entra ID (Microsoft 365) — production sign-in If your team will sign in with their Microsoft 365 / Entra accounts: | Variable | What to put there | |-----------------------|-------------------| | `ENTRA_TENANT_ID` | Directory (tenant) ID — a GUID, visible on the Entra "Overview" page. | | `ENTRA_CLIENT_ID` | Application (client) ID of the app registration. | | `ENTRA_CLIENT_SECRET` | A client secret you generated for the app registration. Treat as sensitive. | In the Entra app registration, configure: - **Redirect URI** (Web platform): `{APP_BASE_URL}/auth/callback`, e.g. `https://sprint.example.com/auth/callback`. - **ID tokens** issued from the auth endpoint: enabled. - **Supported account types**: usually "Accounts in this organisational directory only". - **API permissions**: `openid`, `profile`, `email` (delegated, default for OIDC). Grant admin consent if your tenant requires it. Leave Entra fields blank if you only intend to use the local-admin fallback. The OIDC sign-in button still appears, but clicking it will fail until the variables are populated — keep `LOCAL_ADMIN_EMAIL` / `LOCAL_ADMIN_PASSWORD_HASH` filled if you skip Entra entirely. ### 3.2 Application URL ``` APP_BASE_URL=https://sprint.example.com ``` The base URL the application is reachable at, **without** trailing slash. This is used to construct the OIDC redirect URI and must match exactly what is registered in Entra. For local testing the default `http://localhost:8080` is fine — but the compose file ships the app on port `8088`, so use `http://localhost:8088` if you have not edited `docker-compose.yml`. ### 3.3 Database and session storage paths ``` DB_PATH=/var/www/data/app.sqlite SESSION_PATH=/var/www/data/sessions ``` Leave the defaults unless you are also remapping the volume. The parent directory `/var/www/data` is the volume mount point inside the container and corresponds to `./data/` on the host. ### 3.4 Environment mode ``` APP_ENV=production ``` `production` silences verbose PHP errors. Any other value (e.g. `dev`) turns them on — useful when troubleshooting in a non-public install. ### 3.5 Reverse proxy and HTTPS ``` TRUSTED_PROXIES=10.0.0.0/8,192.168.0.0/16 ``` Set this when the application is exposed through a reverse proxy or load balancer (Nginx, Traefik, Caddy, an AWS ALB, etc.). Provide a comma-separated list of CIDRs that contains every hop between the public internet and the PHP container. Bare addresses without `/n` are treated as host masks (`/32` for IPv4, `/128` for IPv6). When the immediate peer matches one of those CIDRs the application will: - walk `X-Forwarded-For` rightmost-to-leftmost to pick the originating client IP — this is what lands in the audit log and the local-admin login-throttle bucket (R01-N06 / R01-N07); and - honour `X-Forwarded-Proto: https` so the session cookie keeps its `Secure` flag and the response carries `Strict-Transport-Security` even when the proxy → container link is plain HTTP (R01-N05). Leave the variable blank when the container is exposed directly. With no trusted proxies configured the application ignores both forwarded headers, so a hostile client cannot lie its way into a different audit IP or into HTTPS-only behaviour. **Proxy contract.** Whichever proxy you put in front of the app must: 1. Forward the original client IP in `X-Forwarded-For` (most reverse proxies do this by default, but verify); 2. Forward the original scheme in `X-Forwarded-Proto` — this is the single signal the application uses to decide whether the user-facing request was over TLS; 3. Be reachable by the PHP container at an address that falls inside one of the CIDRs you listed in `TRUSTED_PROXIES`. If `APP_BASE_URL` is `https://…` and the application detects that the live request is genuinely over HTTP (either no proxy at all, or a trusted proxy that explicitly reports `X-Forwarded-Proto: http`), it issues a permanent redirect to the canonical HTTPS URL. `/healthz` is exempt so liveness probes keep working over either scheme. The redirect is suppressed when the proxy is silent about the scheme — there is no safe way to distinguish "real HTTP request" from "TLS-terminating proxy that forgot to set the header", and a redirect in the latter case would infinite-loop. A typical Nginx snippet: ```nginx location / { proxy_pass http://sprint_planer:80; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } ``` ### 3.6 Nominating the first administrator (OIDC) Historically the first user to complete sign-in via *any* path was promoted to administrator. On a public-facing first deploy that is a land-grab risk — anyone with a valid Entra account in your tenant could win the race against the intended operator and lock everyone else out (see `doc/REVIEW_01.md` finding R01-N03). The OIDC sign-in path now auto-promotes only when the signing user matches an explicit env-bootstrap value: ``` BOOTSTRAP_ADMIN_OID=00000000-0000-0000-0000-000000000000 BOOTSTRAP_ADMIN_EMAIL=admin@example.com ``` | Variable | What to put there | |-------------------------|-------------------| | `BOOTSTRAP_ADMIN_OID` | The Entra `oid` claim (a GUID, immutable for the lifetime of the user) — preferred when known. Visible on the user's "Object ID" line in the Entra portal. | | `BOOTSTRAP_ADMIN_EMAIL` | The user's primary email. Accepted as a fallback when only the email is on hand. Matched case-insensitively, after trimming. | Either / both / neither may be set: - **Both set**: the signing user is promoted on a match against either field. - **One set**: only that channel matters. - **Neither set**: the OIDC path will *never* auto-promote. In that case bootstrap the first administrator via the local-admin fallback (§3.7) or by setting `users.is_admin = 1` directly in `app.sqlite`. Auto-promotion additionally requires that no administrator already exists (`countAdmins() === 0`). Once a first admin is in place, subsequent promotions go through the **Users** page (§5.1). The promotion is recorded in the audit log as `BOOTSTRAP_ADMIN` with `via=oidc`. The local-admin fallback (§3.7) is itself an explicit env-bootstrap and does not require the variables above — its `BOOTSTRAP_ADMIN` audit row is tagged `via=local`. ### 3.7 Local admin fallback (optional) ``` LOCAL_ADMIN_EMAIL=admin@example.com LOCAL_ADMIN_PASSWORD_HASH='$2y$10$...' # output of password_hash() LOCAL_ADMIN_NAME=Local Admin ``` If both `LOCAL_ADMIN_EMAIL` and `LOCAL_ADMIN_PASSWORD_HASH` are set, a second sign-in form appears at `/auth/local`. The submitted password is verified with PHP's `password_verify()` against the stored bcrypt hash — the plaintext password is never written to disk. **Generating the hash.** Pick whichever one-liner matches what you have installed; both prompt interactively so the password isn't kept in shell history: ```bash # Host PHP 8 (any minor): php -r 'echo password_hash(readline("Password: "), PASSWORD_DEFAULT), PHP_EOL;' # No host PHP — borrow the runtime image: docker run --rm -it php:8.3-cli php -r \ 'echo password_hash(readline("Password: "), PASSWORD_DEFAULT), PHP_EOL;' ``` Each invocation prints a different `$2y$10$...` string for the same input — that's the per-hash random salt. Either one verifies. Paste the result into `.env` between **single quotes** so the shell that launches `docker compose` doesn't try to expand the `$` segments inside the hash: ``` LOCAL_ADMIN_PASSWORD_HASH='$2y$10$abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnop' ``` Then: - Pick a long, unique passphrase you do not reuse anywhere else. - Make sure `.env` is `chmod 600` and owned by a single trusted user. - Rotate the passphrase by regenerating the hash, replacing the line in `.env`, and restarting the container — active sessions stay valid until they expire (the hash is only consulted at sign-in time). The local-admin user is recorded in the database as `entra_oid = "local:"` with `is_admin = 1`, and is always promoted to admin on every successful local sign-in (so a manual demotion in the Users page is undone on the next local sign-in). When the `users` table was empty before the sign-in, an audit entry of type `BOOTSTRAP_ADMIN` with `via=local` is also recorded. For OIDC-only deployments — where you don't want the local fallback at all — leave `LOCAL_ADMIN_EMAIL` / `LOCAL_ADMIN_PASSWORD_HASH` blank and nominate the first admin via the OIDC bootstrap (§3.6). To disable the fallback later, blank both variables and restart. --- ## 4. Running the containers The project ships a multi-stage Dockerfile (Node-based Tailwind build → PHP 8.3 + Apache runtime) and a `docker-compose.yml` that wires the container to the host. ### 4.1 First start ```bash docker compose up --build ``` What happens: 1. Compose builds the image. The Node stage compiles `assets/css/input.css` into `public/assets/css/app.css`. The PHP stage installs Composer dependencies and copies the application source into `/var/www/html`. 2. The container's entrypoint (`bin/docker-entrypoint.sh`) runs `php bin/migrate.php` to create `app.sqlite` inside the volume and apply every file in `migrations/` BEFORE Apache binds the port. If a migration fails, the container exits non-zero and Apache never starts — check `docker compose logs` for the migration's stderr. 3. Apache then starts on port 80, exposed on the host as port **8088** (see `docker-compose.yml`). The web request path itself never applies SQL — it only checks that `schema_version` matches the on-disk migration set and refuses to serve (503 Service Unavailable, `Retry-After: 30`) when something is pending. A forgotten deploy step therefore produces a loud, fast-failing 503 instead of silent stale-schema serving (R01-N22). Open `http://:8088`. If you used the local-admin fallback, sign in at `/auth/local`. Otherwise click the Entra sign-in CTA on `/`. ### 4.2 Running detached (recommended for production) ```bash docker compose up -d --build ``` Logs are then read with: ```bash docker compose logs -f ``` Stopping the stack: ```bash docker compose down ``` `down` keeps the `./data` volume intact — your database and sessions survive. To wipe and start over, stop the stack and remove `./data` manually (see §5.4). ### 4.3 Rebuilding after changes You only need to rebuild the image when the build inputs change: - `Dockerfile`, `composer.json` / `composer.lock`, or `package.json` / `package-lock.json` - Tailwind sources under `assets/css/` or any view file that introduces new utility classes (the css-builder stage scans the views). ```bash docker compose build --no-cache && docker compose up -d ``` For pure PHP / view edits without a rebuild, restart the container: ```bash docker compose restart ``` ### 4.4 Health check The application exposes an unauthenticated `GET /healthz` route that returns `200 OK` with a small JSON body. Use it from a load balancer or uptime monitor: ```bash curl -fsS http://:8088/healthz ``` ### 4.5 Changing the host port Edit the `ports` line in `docker-compose.yml`: ```yaml ports: - "8088:80" ``` Change `8088` to any free port on the host. After editing, `docker compose up -d` is enough — no rebuild required. Update `APP_BASE_URL` in `.env` and the redirect URI in Entra to match. --- ## 5. Day-to-day administration ### 5.1 Promoting and demoting administrators Once the bootstrap admin is signed in, additional administrators are managed through the web UI: 1. Open the hamburger menu → **Users**. 2. Toggle the admin checkbox next to a user and confirm. Guardrails enforced server-side: - An administrator cannot demote themselves while signed in (avoids locking yourself out). - The last remaining administrator cannot be demoted (avoids locking the org out). If both guardrails fire at once — i.e. you are the only admin — the only safe escape is to sign in via the local-admin fallback (if configured) and promote a second account first. ### 5.2 Backups Everything that needs a backup lives under `./data/`: - `app.sqlite` — the database, including audit log. - `sessions/` — active PHP session files. Losing these only forces re-login, no data loss. A safe nightly backup is a straight file copy while the container is running. SQLite handles concurrent reads fine; for a guaranteed consistent snapshot, prefer `sqlite3` `.backup`: ```bash docker compose exec app sqlite3 /var/www/data/app.sqlite \ ".backup /var/www/data/app.sqlite.bak" cp ./data/app.sqlite.bak /your/offsite/backup/location/ ``` ### 5.3 Inspecting the audit log Every create / update / delete on a domain entity is recorded, plus sign-in events. Admins can browse and filter the log via the hamburger menu → **Audit log**, with filters by entity type, action, user, and date range, and collapsible JSON diffs per row. ### 5.4 Resetting the application To wipe all data and start from a blank slate: ```bash docker compose down rm -rf ./data docker compose up -d ``` Migrations run on the next container start (entrypoint), before Apache binds the port. The first administrator is re-seeded as described in §3.6 (OIDC bootstrap) or §3.7 (local-admin fallback) — there is no longer an unconditional "first sign-in becomes admin" path. ### 5.5 Updating to a new release ```bash git pull docker compose build --no-cache docker compose up -d ``` Schema migrations under `migrations/` run from the entrypoint as the container starts (R01-N22) — `bin/docker-entrypoint.sh` calls `php bin/migrate.php` before `apache2-foreground`. Apache only binds the port if migrations succeed. Always take a backup of `./data/app.sqlite` before pulling. **Running migrations outside Docker.** If you deploy the code without the container (e.g. a bare PHP-FPM host), apply migrations manually as part of the release procedure, BEFORE restarting the web server: ```bash php bin/migrate.php # → migrate: schema already current (version N) # → or: migrate: applied K migration(s): … schema now at version N ``` If a deploy ever skips this step, the request path will return `503 Service Unavailable` with `Retry-After: 30` until the operator runs `bin/migrate.php`. The application server log carries a single line listing the pending filenames. The 503 is intentional: the alternative — auto-migrating on a request that may then crash mid-DDL — is the failure mode this design rules out. **Composer dependency cadence (R01-N16).** The XLSX import wizard is backed by [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet), which has a long history of XML-related advisories. The `composer.json` caret range (`^3.4`) lets minor upgrades land on each `docker compose build --no-cache`, but the operator is responsible for rebuilding promptly when a new release ships. Recommended cadence: rebuild after any `git pull`, and at minimum monthly even on a quiet branch. Run the auditor to surface known CVEs in the currently locked versions: ```bash ./bin/audit.sh # → "No security vulnerability advisories found." when clean. # → exit 1 + a vulnerability table when an advisory matches. ``` The script wraps `composer audit --locked` inside the runtime image so the audit reflects the exact dependency tree the live container runs. A weekly cron is a low-friction option: ```cron # /etc/cron.d/sprint-planner-audit 0 7 * * 1 www-data cd /opt/sprint-planer-web && ./bin/audit.sh \ || mail -s 'sprint-planer composer advisory' admin@example.com ``` ### 5.6 Tabbed sign-in note (R01-N17) The OIDC handshake stores its `state` and PKCE `code_verifier` in the PHP session under fixed keys. If you start a sign-in flow in two browser tabs at the same time, the second tab overwrites the first tab's state — when you finish the first tab, the callback rejects the state mismatch and you are bounced back to `/?auth_error=1`. This is not a security issue (the rejection is the correct OIDC behaviour), but it can be confusing. **Complete one sign-in at a time**, or close the older tab before starting a fresh login. --- ## 6. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `/auth/login` returns "redirect URI mismatch" | The redirect URI registered in Entra does not equal `{APP_BASE_URL}/auth/callback`. | Update either `APP_BASE_URL` in `.env` or the Entra app registration. | | `/auth/local` returns 404 | `LOCAL_ADMIN_EMAIL` or `LOCAL_ADMIN_PASSWORD_HASH` is blank. | Set both (see §3.7 for the hash recipe), then `docker compose restart`. | | Sign-in succeeds but every page shows "not authorised" | The user has no `is_admin` flag and is trying to access an admin-only page. | Promote them via Users (logged in as another admin). | | Container restarts in a loop | Most often a malformed `.env` line or a permission problem on `./data`. | `docker compose logs` will show the PHP fatal. Check that `./data` is writable by the `www-data` user inside the container (uid 33 on the Apache image). | | CSS looks broken / unstyled | The Node build stage was skipped or used a stale layer. | `docker compose build --no-cache` and restart. | | OIDC works but PHP 8.4 deprecation warnings appear in logs | Known upstream issue in `jumbojett/openid-connect-php`. | Harmless on PHP 8.3 (the shipped runtime); ignore until upstream releases a fix. | For anything else, check the audit log first — most user-facing errors also leave a trail there with the exact request that failed. --- ## 7. Where to go next - [SPEC.md](../SPEC.md) — full specification, schema, route list, and build phase history. Read this if you intend to modify the code. - [ACCEPTANCE.md](../ACCEPTANCE.md) — manual acceptance checklist used to validate releases.