admin-manual.md 23 KB

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

git clone <repository-url> 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:

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:

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:

# 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:<email>" 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.

Operators vs. developers. Everything in this section is the production deployment path: the bare docker compose commands targeting docker-compose.yml only. Developers iterating on code use a separate docker-compose.dev.yml overlay (bind mounts + tailwindcss --watch sidecar) plus a Makefile of wrappers; that story lives in SPEC.md §11 and is not relevant to operating a deployment.

4.1 First start

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://<host>: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)

docker compose up -d --build

Logs are then read with:

docker compose logs -f

Stopping the stack:

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

    docker compose build --no-cache && docker compose up -d
    

For pure PHP / view edits without a rebuild, restart the container:

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:

curl -fsS http://<host>:8088/healthz

4.5 Changing the host port

Edit the ports line in docker-compose.yml:

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.1.1 Honouring privacy / right-to-be-forgotten requests (R01-N23)

Users are never deleted — the users.id column is a foreign key for audit rows and dropping it would break the trail. To honour an erasure-of-personal-data request, mark the user as a former user ("tombstone"):

  1. Open Users.
  2. Click Mark as former user under the user's row.
  3. The live UI now shows (former user) in place of their email and display name, both on this page and anywhere else the application surfaces a user object. The row keeps its position so audit-log joins and the admin counter still work.

Important details:

  • Tombstoning forces is_admin = 0 — a tombstoned admin is nonsensical (the UI hides their identity). The same demotion guard rails apply: you cannot tombstone yourself, and you cannot tombstone the last remaining administrator.
  • The audit log is unchanged. audit_log.user_email still carries the original address verbatim. Erasure regulations under GDPR / similar frameworks typically permit retention of audit data for legal and security purposes — the Sprint Planner takes that position. If your jurisdiction requires audit-row redaction too, you must edit app.sqlite manually and document the redaction procedure separately.
  • A subsequent successful sign-in clears the tombstone (OIDC or local-admin). The marker is a display redaction, not an access control. If access must be revoked, do that in the OIDC tenant (group / app permission) — the Sprint Planner has no such mechanism of its own.
  • Restore is reversible from the same row (via the Restore button). It clears tombstoned_at but does NOT auto-promote the user; if they need admin again, set the admin checkbox separately.

The audit log records both transitions as TOMBSTONE and RESTORE on entity_type = user, so the privacy operation itself is itself auditable.

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:

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:

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

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:

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, 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:

./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:

# /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.

5.7 Session-file garbage collection (R01-N27)

PHP's built-in session GC is probabilistic — it only fires on request traffic with a small probability per request. On a quiet deployment the session directory accumulates stale files for days past the configured gc_maxlifetime (8h). The container's entrypoint compensates by launching a backgrounded find … -mmin +480 -delete sweep every hour:

# bin/docker-entrypoint.sh — see source for the full loop.
find "${SESSION_PATH}" -mindepth 1 -type f -mmin +"${SESSION_GC_MAX_AGE_MINUTES}" -delete

Two env vars override the defaults:

Variable Default Meaning
SESSION_GC_MAX_AGE_MINUTES 480 Delete session files older than N minutes (= 8h).
SESSION_GC_INTERVAL_SECONDS 3600 Sweep every N seconds (= once per hour).

The loop is a child of PID 1 inside the container, so docker stop propagates and reaps it together with Apache. No additional packages are installed — find ships with the php:8.3-apache base image.

If you deploy outside Docker (bare PHP-FPM host), wire an equivalent sweep yourself, e.g. a system cron entry:

17 * * * * www-data find /var/www/data/sessions -mindepth 1 -type f -mmin +480 -delete

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 — full specification, schema, route list, and build phase history. Read this if you intend to modify the code.
  • ACCEPTANCE.md — manual acceptance checklist used to validate releases.