Private
Public Access
1
0

Compare commits

...

62 Commits

Author SHA1 Message Date
dda2fd3b0e chore: bump version to 1.1.10 (#65)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m52s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 14:49:54 -05:00
3b3e129663 fix: reassign DB object ownership to patch_manager after migrations (#64)
The postinst script runs migrations as the postgres superuser, which
means all created tables, enum types, and sequences are owned by
postgres. When pm-web connects as patch_manager and tries to ALTER
tables during upgrades, it fails with 'must be owner of table groups'.

Add reassign_ownership() function that runs after apply_migrations()
and before systemctl start. This function:
- REASSIGN OWNED BY postgres TO patch_manager (tables, types, sequences)
- ALTER SCHEMA public OWNER TO patch_manager
- GRANT ALL PRIVILEGES on database, schema, tables, sequences, functions
- ALTER DEFAULT PRIVILEGES for future objects in public schema

Renumbered sections 6-10 to 6-12 to accommodate the new function.
2026-06-09 14:11:20 -05:00
8acff754e8 chore: bump version to 1.1.9 (#63)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m22s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 13:15:05 -05:00
4cac290502 fix: enable services, fix config parsing, make migrations idempotent (#62) 2026-06-09 13:04:11 -05:00
ec41091721 ci: update actions for Node.js 24 compatibility (#61)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 7s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Failing after 1m28s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 12:49:37 -05:00
26f87ebc20 chore: bump version to 1.1.8 (#60)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m25s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 12:02:50 -05:00
a1a8eab41a fix(postinst): surgical upgrade/fresh-install handling (#59)
- write_config(): Replace CHANGEME placeholder on upgrade instead of
  skipping entirely; preserve existing real passwords unchanged
- setup_database(): When DB user already exists, recover password from
  existing config and sync to PostgreSQL, or generate a fresh password;
  fixes crash-loop when config password diverges from PostgreSQL
- generate_jwt_keys(): Regenerate missing verify.pem from existing
  signing.pem instead of silently skipping
- Password extraction uses @localhost anchor to correctly handle
  passwords containing @ characters
2026-06-09 11:47:22 -05:00
b2ea6b1f7a chore: bump version to 1.1.7 (#58)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 2s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m29s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 09:27:34 -05:00
592ff6a7ee fix(postinst): thorough audit - fix argon2 salt and verify all password generation logic (#57) 2026-06-09 09:10:31 -05:00
0c0f952f7f chore: bump version to 1.1.6 (#56)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Failing after 1m27s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 08:21:20 -05:00
2a18276884 fix(postinst): correct argon2 -m parameter from raw KiB to log2 value (#55)
* chore: bump version to 1.1.5

* fix(postinst): correct argon2 -m parameter from raw KiB to log2 value

* trigger CI
2026-06-09 08:10:00 -05:00
2bdbc8af5a fix(ci): remove arm64 from Docker platforms and add timeout (#53)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Failing after 1m58s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-08 20:15:13 -05:00
87bd5d2162 fix: remove duplicate version display from sidebar toolbar (#52)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 2s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m53s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-08 17:54:25 -05:00
836d409e3b feat: add version display to sidebar and bump to v1.1.4 (#51)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 7s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m40s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-08 17:44:20 -05:00
e17b740415 fix(docker): complete Dockerfile audit - migrations, deps, openssl (#49)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m46s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
Three issues fixed in the multi-stage Docker build:

1. CRITICAL: Add COPY migrations/ to rust-builder stage
   - sqlx::migrate!(../../migrations) is a compile-time proc macro
   - Without migrations/ present, cargo build fails with 'no such file or directory'
   - Previously migrations/ was only copied in runtime stage (too late)

2. Copy individual crate Cargo.toml files for dependency caching
   - The dummy-build caching step only copied workspace Cargo.toml/Cargo.lock
   - Without crate-level manifests, cargo couldn't resolve the workspace
   - This meant the cache layer was ineffective (rebuilt everything on code changes)

3. Add openssl package to runtime stage
   - entrypoint.sh uses openssl rand, openssl genpkey, openssl pkey
   - Only libssl3t64 (shared library) was installed, not the CLI tool
   - Runtime would fail on first-run key generation

All stages verified: Ubuntu 24.04   Rust via rustup (1.85+) 
2026-06-07 22:59:32 -05:00
0d151d36b9 chore: bump version to 1.1.2 (#48)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m22s
CI Pipeline / Security Audit (push) Successful in 6s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-07 22:08:55 -05:00
4fbcf3d35a fix(docker): use Ubuntu 24.04 throughout all Dockerfile stages (#47)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m21s
CI Pipeline / Security Audit (push) Successful in 6s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Stage 1 (Rust): Replace rust:1.85-bookworm with ubuntu:24.04 + rustup stable
- Stage 2 (Frontend): Replace node:20-bookworm-slim with ubuntu:24.04 + NodeSource Node.js 20
- Stage 3 (Runtime): Already ubuntu:24.04 with libssl3t64 (verified correct)
- docker-compose: Change postgres:16-bookworm to postgres:16 (standard image)

This aligns Docker builds with the project's target OS (Ubuntu 24.04) and
matches the CI environment which runs on ubuntu-latest (24.04).
2026-06-07 21:19:07 -05:00
6d4ec8c9ac chore: bump version to 1.1.1 (#45)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m58s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-07 20:14:45 -05:00
bf91b3c6d2 fix(docker): use ubuntu:24.04 runtime instead of debian:bookworm-slim (#44)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m54s
CI Pipeline / Security Audit (push) Successful in 7s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 17s
CI Pipeline / Build .deb & Release (push) Has been skipped
* fix(docker): add PostgreSQL APT repo for postgresql-client-16

Debian Bookworm default repos only ship PostgreSQL 15. The Docker
runtime stage needs postgresql-client-16 for the entrypoint script,
so add the official PGDG APT repository.

- Add PGDG GPG key and sources.list entry for bookworm-pgdg
- Install ca-certificates and curl first (needed for repo setup)
- Purge gnupg2 after use to keep image lean
- Verify argon2 package name is correct for Bookworm (it is)

* fix(docker): use ubuntu:24.04 runtime instead of debian:bookworm-slim

The project targets Ubuntu 24.04, not Debian Bookworm. Ubuntu 24.04
includes PostgreSQL 16 in default repos, eliminating the need for the
PGDG APT repo workaround. Also fixes libssl3 → libssl3t64 package name
for the time64 transition in Ubuntu 24.04.
2026-06-07 18:55:45 -05:00
2d3be0955b chore: bump version to 1.1.0 (#43)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m25s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-07 17:01:01 -05:00
a5343760e1 feat: Automated install, Docker deployment, and CI Docker job (#42)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m20s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
- debian/control: add Pre-Depends and Depends on postgresql-16, argon2
- debian/postinst: idempotent automation for PostgreSQL setup,
  DB/user creation, migration tracking, admin password generation,
  config write, and service enable/start
- Dockerfile: multi-stage build (Rust + frontend + slim runtime)
- docker/entrypoint.sh: first-run DB wait, migrations, admin password
- docker-compose.yml: split db/app architecture with healthcheck
- .env.example: template for DB_PASSWORD and TAG
- .dockerignore: exclude build artifacts from Docker context
- .github/workflows/ci.yml: add Docker job for multi-arch
  (amd64/arm64) GHCR push on tag releases with layer caching
- .gitignore: add .env entry
2026-06-07 16:20:08 -05:00
209480dd43 Release v1.0.0 (#41)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m26s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* chore: bump version to 1.0.0

* fix: BusyBox-compatible timing and set -e safety in shell scripts
2026-06-07 13:27:21 -05:00
5fa1fef6c8 fix: remove committed private keys and add gitleaks CI
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m31s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Remove all cert files from git tracking (git rm --cached)
  - crates/pm-agent-client/certs/client.key (private key)
  - crates/pm-agent-client/certs/client.crt (public cert)
  - crates/pm-agent-client/certs/ca.crt (public cert)
- Add .gitignore patterns for *.key, *.key.pem, certs/*.crt, certs/*.pem
- Update pm-agent-client doc examples to use std::fs::read() instead of include_bytes!
- Add gitleaks secret scanning job to CI workflow
- Update security-review.md with critical finding for Issue #12
- Add README.md to crates/pm-agent-client/certs/ explaining runtime cert generation

Private keys were dev/test only - no production key rotation needed.
Git history purge with filter-repo will follow after PR merge.

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 13:20:52 -05:00
e6dd1b8489 test: add authz gate integration tests (closes #15)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m56s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* test: add authz gate integration tests (closes #15)

* fix: separate authz gate 403 tests from DB-dependent tests

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 11:18:11 -05:00
dd6961265d chore: bump version to 0.2.4
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 19s
CI Pipeline / Build .deb & Release (push) Successful in 3m41s
CI Pipeline / Security Audit (push) Successful in 5s
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 00:04:08 -05:00
40ba483d35 fix: add ca_chain and crl_pem to EnrollmentStatusResponse
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 23:57:23 -05:00
192ebbd47f chore: bump version to 0.2.3
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 9s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Successful in 3m44s
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 23:05:41 -05:00
050439ee14 fix: add missing CRL columns to Host SQL queries and fix comma syntax
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 22:52:59 -05:00
0b12ded1cf chore: bump version to 0.2.2
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m9s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Successful in 3m41s
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 21:23:55 -05:00
0296cf9c51 fix(auth): update SQL queries to use totp_secret_encrypted instead of dropped totp_secret column
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 21:08:00 -05:00
604b31b937 chore: bump version to 0.2.1
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 17s
CI Pipeline / Build .deb & Release (push) Successful in 3m43s
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 19:41:24 -05:00
89e572faf8 fix(ca): correct not_after column name to expires_at in CRL query
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 7s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Successful in 1m9s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
The generate_crl() SQL query referenced a non-existent column
"not_after" instead of the actual column "expires_at" in the
certificates table. This caused a 500 error when requesting the CRL
endpoint because PostgreSQL could not find the column.

Fixes: CRL endpoint returns 500 Internal Server Error

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 19:27:32 -05:00
78f5304214 chore: bump version to 0.2.0
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 55s
CI Pipeline / Rust Unit Tests (push) Successful in 1m12s
CI Pipeline / Security Audit (push) Successful in 6s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 18s
CI Pipeline / Build .deb & Release (push) Has been skipped
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 17:41:02 -05:00
899fd4a79a test: add CRL integration and unit tests (PR 6 of 6)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 6s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Has been skipped
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 17:26:20 -05:00
5ab3532833 feat: add CRL health aggregation logic and audit events (PR 5 of 6)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Has been skipped
* feat: add CRL health aggregation logic and audit events (PR 5 of 6)

* style: fix cargo fmt in health_poller.rs

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 16:42:39 -05:00
ea8337b944 feat: add CRL health status schema and UI (PR 3 of 6)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m8s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* feat: add CRL health status schema and UI (PR 3 of 6)

* fix(lint): strict equality for crl_age_seconds

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 16:17:17 -05:00
5aec9e629c feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension (#26)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 1m26s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension

Implements manager-side CRL infrastructure for issue #7:
- Add CertAuthority::generate_crl() using rcgen 0.13
- Add GET /api/v1/pki/crl.pem public endpoint
- Extend PkiBundle with ca_chain and crl_pem fields
- Update enrollment route to include CRL in bundle
- Mount pki route as public endpoint
- Add proptest dev-dependency

* style: fix cargo fmt in enrollment.rs

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 12:54:14 -05:00
80ffb6b62f feat(security): replace hardcoded admin password with in-app bootstrap (#25)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Successful in 1m8s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
* feat(security): replace hardcoded admin password with in-app bootstrap (issue #8)

Replace the publicly-known Argon2id hash in 002_seed_admin.sql with a
clearly-invalid placeholder that cannot validate any password (fail-closed).

On first startup, pm-web detects the placeholder and generates a random
24-character alphanumeric password, hashes it with Argon2id, and UPDATEs
the admin row. The plaintext password is printed once to stderr (visible
in systemd journal).

This eliminates the need for a separate hash_password binary, shell
script SQL injection risk, and password leakage in shell variables.

Closes #8

* fix(security): rustfmt compliance for bootstrap function

* fix(security): add trailing commas to match arms per rustfmt
2026-06-04 13:28:44 -05:00
fda70ecf9e feat(jobs): add host_names to job list API and UI (#24)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m7s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* feat(jobs): add host_names to job list API and UI

Closes #23

* fix(jobs): add mut for host_names merge loop
2026-06-04 12:49:53 -05:00
b9fb3427e0 fix(security): encrypt app secrets at rest with AES-256-GCM (#6)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 8s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Successful in 1m8s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
Encrypt three sensitive secrets that were stored in plaintext: OIDC client_secret, SMTP smtp_password, TOTP totp_secret. AES-256-GCM via pm-core::crypto helper. New per-install key at /etc/patch-manager/keys/secret-encryption.key, separate from health-check.key for blast-radius isolation. MASKED placeholder behavior in API responses is preserved.

23 files changed, +1248 / -28. Closes #6.
2026-06-03 15:08:25 -05:00
e0a9037be3 Merge pull request #20 from Draco-Lunaris/Draco-Lunaris-patch-1
Update ARCHITECTURE.md
2026-06-03 14:50:28 -05:00
21d734c662 Update ARCHITECTURE.md 2026-06-03 14:44:02 -05:00
5488b4fd95 Merge pull request #18 from Draco-Lunaris/license/apache-2.0
Update license to Apache 2.0 for full open source
2026-06-03 11:32:22 -05:00
0208d27805 Update license to Apache 2.0 for full open source 2026-06-03 11:20:21 -05:00
88b190ac8d fix(security): restrict auth-config mutations to Admin role (#5)
Restrict manager-wide authentication configuration mutations (OIDC, SMTP, IP allowlist) to Admin role. Operators now receive 403 forbidden_role.

- New admin_required helper in settings.rs
- 4 gate changes: update_settings, discover_oidc, test_oidc, update_ip_whitelist
- 5 new AuditAction variants + migration 019
- SPA friendly error message on 403
- 3 admin_required unit tests pass (43/43)
- Full integration tests deferred to issue #15

Closes #5
2026-06-03 09:16:41 -05:00
f58d7a6f17 fix(security): stop embedding JWT tokens in SSO callback redirect URL (#4) (#14)
Replaces URL-embedded JWT tokens with a single-use, 60-second handoff code that the SPA exchanges via server-to-server POST. The URL now contains only `?handoff=<code>` — no tokens are placed in the browser history, proxy access logs, or Referer header.

Backend: new SsoHandoff store (DashMap, 60s TTL, atomic DashMap::remove for single-use), POST /api/v1/auth/sso/handoff endpoint, 7 new tests.

Frontend: SsoCallbackPage rewritten to use useSearchParams + POST exchange, with history.replaceState to clear the handoff code from the address bar. Switched from window.location.search to useSearchParams() for test compatibility. New Vitest infrastructure (vitest, @testing-library/react, jsdom) and 6 new tests.

CI fix in ccba9e3: cargo fmt --all and added searchParams to useEffect dep array to satisfy CI's Rust Format and Frontend Lint checks.

Refs: closes #4
2026-06-03 06:28:08 -05:00
3bdae4bcc5 fix(security): harden IP allowlist against XFF bypass and spoofing (#3)
Hardens the IP allowlist in require_auth against the two bypasses filed in #3.

1. Bypass via missing X-Forwarded-For (no IP to check, allowlist skipped).
2. Spoofing via attacker-controlled X-Forwarded-For (header trusted unconditionally).

Resolves both by deriving the client IP from the socket peer (ConnectInfo<SocketAddr>) and only honoring X-Forwarded-For when the immediate peer is in a new security.trusted_proxies allowlist (default empty = strict). Fails closed with 403 forbidden_ip when a non-empty allowlist is configured and the client IP cannot be determined. Empty ip_whitelist continues to mean allow all (preserved for dev installs).

27 pm-auth tests pass (12 new resolver + 8 new middleware + 7 existing). Spec: tasks/ip-allowlist-spec.md.
2026-06-02 18:06:43 -05:00
8873b2c70c fix(security): harden enrollment PKI bundle retrieval (#12)
- Add single-retrieval semantics: approved PKI bundles are atomically
  removed from the in-memory cache on first retrieval via DashMap::remove(),
  preventing concurrent requests from obtaining the private key
- Add TTL expiry: ApprovedEntry wraps PkiBundle with approved_at and ttl
  fields; bundles expire after ENROLLMENT_BUNDLE_TTL_SECS (600s / 10 min)
- Replace brute-force clear() purge with TTL-based retain() in background
  task, running every 60s instead of every 600s
- Audit tracing calls: confirm no raw polling token is logged; add security
  comment documenting this policy
- Document CSR-based enrollment as future enhancement in both enrollment.rs
  and SECURITY.md, explaining why server-generated keys are used currently
2026-06-02 15:16:44 -05:00
59df98504c Merge pull request #11 from Draco-Lunaris/issue/10-ws-origin-check
fix(ws): add Origin allowlist to browser WebSocket upgrade (CSWSH hardening)
2026-06-02 10:56:02 -05:00
224248888f docs: add authoritative repo verification and SSH_ASKPASS lessons 2026-06-02 10:46:24 -05:00
06a102bf98 style: apply cargo fmt to ws-origin-check changes 2026-06-02 10:46:05 -05:00
ed5df26140 fix(ws): add Origin allowlist to browser WebSocket upgrade (CSWSH hardening)
Closes Draco-Lunaris/Linux-Patch-Manager#10

The browser WebSocket endpoint at GET /api/v1/ws/jobs previously
authenticated solely via a single-use, 60-second ticket passed as a query
parameter. A leaked ticket (browser history, Referer, proxy logs, support
bundles) could be redeemed from any origin, enabling Cross-Site WebSocket
Hijacking (CSWSH).

This change adds a second gate: the Origin header must match an explicit
allowlist. The check runs BEFORE ticket validation so that rejected
cross-origin probes do not consume the legitimate users ticket.

Changes:
- pm-core: new security.allowed_origins config field; default derived
  from sso_callback_url; startup warning if both are unparseable
- pm-web: ws_handler extracts HeaderMap and calls check_origin first;
  returns 403 on missing/malformed/disallowed origins
- config: documented allowed_origins key in config.example.toml
- docs: security-review.md section 1.4 (WebSocket Origin Allowlist)
- tests: 40 unit tests (7 pm-core, 33 pm-web)
2026-06-02 10:45:38 -05:00
80709d48a7 Merge remote-tracking branch 'github/master'
# Conflicts:
#	.gitignore
2026-06-02 10:40:33 -05:00
f797b97282 ci: add contents:write permission and free disk space
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 57s
CI Pipeline / Rust Unit Tests (push) Failing after 1m14s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Add permissions: contents: write for GitHub Release creation
- Add disk cleanup step to free space before build
- Fixes 403 release error and dpkg-deb tar error
2026-05-31 02:08:38 -05:00
8dfe137745 Merge pull request #1 from Draco-Lunaris/fix/ci-skip-doctests
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 57s
CI Pipeline / Rust Unit Tests (push) Failing after 1m17s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
ci: skip doctests to avoid missing certs dependency
2026-05-31 01:47:25 -05:00
28edce0fc6 ci: skip doctests to avoid missing certs dependency 2026-05-31 01:08:50 -05:00
0f0a534f25 docs: add CONTRIBUTING.md and SECURITY.md for open source
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Failing after 1m11s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-05-31 00:12:14 -05:00
f557e21e09 ci: add GitHub Actions CI/CD and Apache-2.0 license
Some checks failed
CI Pipeline / Rust Format Check (push) Has been cancelled
CI Pipeline / Clippy Lints (push) Has been cancelled
CI Pipeline / Rust Unit Tests (push) Has been cancelled
CI Pipeline / Security Audit (push) Has been cancelled
CI Pipeline / Frontend Lint & Type Check (push) Has been cancelled
CI Pipeline / Build .deb & Release (push) Has been cancelled
2026-05-31 00:10:01 -05:00
d2d7132955 chore: add certs/ to .gitignore
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m9s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-05-30 22:50:12 -05:00
124b5b0e3b feat: add bump-version.sh script for version management
Automates version bumps across all version source files:
- Cargo.toml (PRIMARY - workspace.package.version)
- debian/changelog (prepend new entry)
- debian/control (update Version field)
- scripts/build-package.sh (update VERSION variable)
- frontend/package.json (update version field)
- Stale references check after bump

Usage: ./scripts/bump-version.sh <new_version> <old_version>
2026-05-28 10:52:16 -05:00
c1ed760358 Merge fix/maintenance-windows-race-condition: resolve maintenance windows race condition and N+1 query
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 1m0s
CI Pipeline / Rust Unit Tests (push) Successful in 1m23s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Successful in 4m29s
2026-05-22 03:28:59 +00:00
354e3205d3 fix: update repo paths from echo/ to git-echo/ after account migration
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 1m3s
CI Pipeline / Rust Unit Tests (push) Successful in 1m23s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 17s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-05-21 17:05:47 +00:00
99 changed files with 11632 additions and 637 deletions

40
.dockerignore Normal file
View File

@ -0,0 +1,40 @@
# Build artifacts
target/
*.deb
package-build/
# Frontend build output (rebuilt in Docker)
frontend/dist/
frontend/node_modules/
# Git
.git/
.gitignore
# IDE
.vscode/
.idea/
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Environment
.env
.env.*
# Documentation
docs/
# Agent Zero project data
.a0proj/
# Python
venv/
__pycache__/
# Misc
*.md
!README.md
LICENSE

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# Linux Patch Manager — Docker Environment Variables
# Copy this file to .env and edit the values before running docker compose up.
# Required: PostgreSQL password for the patch_manager user
DB_PASSWORD=changeme-to-a-strong-password
# Optional: Docker image tag (defaults to 'latest' if not set)
TAG=latest

176
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,176 @@
name: CI
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
packages: write
jobs:
rust-format:
name: Rust Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --check --all
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev
- run: cargo clippy --all-targets --all-features
rust-test:
name: Rust Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev
- run: cargo test --workspace --all-features --lib --bins --tests
security-audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-audit && cargo audit
gitleaks:
name: Secret scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
frontend-lint:
name: Frontend Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '20'
- name: Install & Lint
run: cd frontend && npm ci && npx eslint src/ --ext .ts,.tsx --max-warnings 0 && npx tsc --noEmit
build-and-release:
name: Build & Release
needs: [rust-format, clippy, rust-test, security-audit, frontend-lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Free disk space
run: |
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc
df -h
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev dpkg-dev
- name: Build Rust release
run: cargo build --release
- name: Strip binaries
run: strip target/release/pm-web target/release/pm-worker
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '20'
- name: Build frontend
run: cd frontend && npm ci && npm run build
- name: Build .deb package
run: chmod +x scripts/build-package.sh && scripts/build-package.sh
- name: Generate release notes
if: startsWith(github.ref, 'refs/tags/v')
id: release_notes
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
NOTES=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
else
NOTES=$(git log --pretty=format:"- %s (%h)" --no-merges)
fi
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Upload to GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v3
with:
body: ${{ steps.release_notes.outputs.notes }}
files: linux-patch-manager_*.deb
docker:
name: Docker Build & Push
needs: [rust-format, clippy, rust-test, security-audit, frontend-lint]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/draco-lunaris/linux-patch-manager
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

10
.gitignore vendored
View File

@ -27,3 +27,13 @@ frontend/dist
# Package build artifacts
*.deb
package-build/
# Docker environment
.env
# Private key material - NEVER commit
*.key
*.key.pem
crates/pm-agent-client/certs/*.crt
crates/pm-agent-client/certs/*.key
crates/pm-agent-client/certs/*.pem

View File

@ -8,7 +8,7 @@
| Version | 0.0.3 |
| Status | Draft |
| Standard | Aligned with IEEE 1016-2009 |
| Owner | Echo (for Kelly / Moon Dragon) |
| Owner | Draco Lunaris |
| Last Updated | 2026-04-23 |
| Related Docs | `SPEC.md`, `REQUIREMENTS.md`, `README.md` |

94
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,94 @@
# Contributing to Linux-Patch-Manager
Thank you for your interest in contributing to Linux-Patch-Manager! We appreciate every contribution — from bug reports and documentation improvements to new features and security fixes.
## Code of Conduct
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) code of conduct. By participating, you are expected to uphold this standard. Please report unacceptable behavior to the maintainers.
## How to Contribute
1. **Fork** the repository
2. Create a **feature branch** from `main`:
```bash
git checkout -b feat/my-feature
```
3. Make your changes
4. Ensure all CI checks pass:
```bash
# Rust backend
cargo fmt --check
cargo clippy -- -D warnings
cargo test
# TypeScript/React frontend
cd frontend
npm run lint
npm run build
npm test
```
5. **Commit** using conventional commit format (see below)
6. Open a **Pull Request** against `main`
## Development Setup
### Prerequisites
- **Rust toolchain** (stable) — [rustup](https://rustup.rs/)
- **Node.js** 20+ (for the frontend) — [nvm](https://github.com/nvm-sh/nvm) recommended
- **System dependencies**:
```bash
sudo apt-get install build-essential libsystemd-dev pkg-config libssl-dev
```
### Build & Run
```bash
# Backend
cargo build
cargo test
# Frontend
cd frontend
npm install
npm run build
npm test
```
## Commit Messages
We use [Conventional Commits](https://www.conventionalcommits.org/):
| Prefix | Usage |
|----------|------------------------|
| `feat:` | New feature |
| `fix:` | Bug fix |
| `docs:` | Documentation changes |
| `chore:` | Maintenance tasks |
| `refactor:` | Code refactoring |
| `test:` | Adding or updating tests |
| `ci:` | CI configuration changes |
Example:
```
feat: add patch scheduling to manager dashboard
```
## Pull Request Requirements
- All CI checks must pass (fmt, clippy, test, audit, build)
- One feature or fix per PR — keep changes focused
- Include a clear description of what changed and why
- Update documentation if your change affects behavior
## Reporting Issues
Use [GitHub Issues](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues) to report bugs, request features, or ask questions. Please include:
- Steps to reproduce (for bugs)
- Expected vs. actual behavior
- Relevant logs or error messages
## License
By contributing, you agree that your contributions are licensed under the [Apache License 2.0](LICENSE), the same license as this project.

171
Cargo.lock generated
View File

@ -139,6 +139,16 @@ dependencies = [
"syn",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -323,6 +333,21 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -460,6 +485,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -2026,6 +2060,18 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "migrate-secrets"
version = "0.2.4"
dependencies = [
"anyhow",
"hex",
"pm-core",
"sqlx",
"tokio",
"uuid",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -2069,6 +2115,31 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mockito"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.4",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "moxcms"
version = "0.8.1"
@ -2521,7 +2592,7 @@ dependencies = [
[[package]]
name = "pm-agent-client"
version = "0.1.8"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2538,7 +2609,7 @@ dependencies = [
[[package]]
name = "pm-auth"
version = "0.1.8"
version = "0.2.4"
dependencies = [
"anyhow",
"argon2",
@ -2559,19 +2630,21 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"totp-rs",
"tower",
"tracing",
"uuid",
]
[[package]]
name = "pm-ca"
version = "0.1.8"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
"hex",
"pem",
"pm-core",
"proptest",
"rand 0.8.6",
"rcgen",
"rustls",
@ -2588,7 +2661,7 @@ dependencies = [
[[package]]
name = "pm-core"
version = "0.1.8"
version = "0.2.4"
dependencies = [
"aes-gcm",
"anyhow",
@ -2612,7 +2685,7 @@ dependencies = [
[[package]]
name = "pm-reports"
version = "0.1.8"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2632,7 +2705,7 @@ dependencies = [
[[package]]
name = "pm-web"
version = "0.1.8"
version = "0.2.4"
dependencies = [
"anyhow",
"axum",
@ -2643,21 +2716,26 @@ dependencies = [
"dashmap 6.1.0",
"governor 0.6.3",
"hex",
"http-body-util",
"ipnet",
"jsonwebtoken",
"lettre",
"mockito",
"pm-auth",
"pm-ca",
"pm-core",
"pm-reports",
"rand 0.8.6",
"rcgen",
"reqwest",
"rustls",
"serde",
"serde_json",
"sha2",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"tower",
"tower-http",
@ -2672,7 +2750,7 @@ dependencies = [
[[package]]
name = "pm-worker"
version = "0.1.8"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2804,6 +2882,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags 2.11.1",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "pxfm"
version = "0.1.29"
@ -2825,6 +2922,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quinn"
version = "0.11.9"
@ -2966,6 +3069,15 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@ -3018,6 +3130,18 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
@ -3227,6 +3351,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
@ -3445,6 +3581,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simple_asn1"
version = "0.6.4"
@ -4372,6 +4514,12 @@ dependencies = [
"web-time",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
@ -4493,6 +4641,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"

View File

@ -8,10 +8,11 @@ members = [
"crates/pm-auth",
"crates/pm-ca",
"crates/pm-reports",
"crates/migrate-secrets",
]
[workspace.package]
version = "0.1.9"
version = "1.1.10"
edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"]
license = "MIT"
@ -78,6 +79,9 @@ base64 = { version = "0.22" }
hex = { version = "0.4" }
sha2 = { version = "0.10" }
aes-gcm = { version = "0.10" }
# Testing
proptest = { version = "1" }
ipnet = { version = "2" }
url = { version = "2" }

141
Dockerfile Normal file
View File

@ -0,0 +1,141 @@
# =============================================================================
# Linux Patch Manager — Multi-stage Docker Build
# =============================================================================
# Build: docker build -t linux-patch-manager .
# Run: docker compose up
# =============================================================================
# ---------------------------------------------------------------------------
# Stage 1: Rust build (Ubuntu 24.04 + rustup)
# ---------------------------------------------------------------------------
FROM ubuntu:24.04 AS rust-builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
build-essential \
curl \
pkg-config \
libssl-dev \
libfontconfig1-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Rust via rustup (stable channel, provides 1.85+)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /usr/src/app
# Cache dependencies by building a dummy project first
COPY Cargo.toml Cargo.lock ./
COPY crates/pm-web/Cargo.toml crates/pm-web/Cargo.toml
COPY crates/pm-worker/Cargo.toml crates/pm-worker/Cargo.toml
COPY crates/pm-core/Cargo.toml crates/pm-core/Cargo.toml
COPY crates/pm-agent-client/Cargo.toml crates/pm-agent-client/Cargo.toml
COPY crates/pm-auth/Cargo.toml crates/pm-auth/Cargo.toml
COPY crates/pm-ca/Cargo.toml crates/pm-ca/Cargo.toml
COPY crates/pm-reports/Cargo.toml crates/pm-reports/Cargo.toml
COPY crates/migrate-secrets/Cargo.toml crates/migrate-secrets/Cargo.toml
RUN mkdir -p crates/pm-web/src crates/pm-worker/src crates/pm-core/src \
crates/pm-agent-client/src crates/pm-auth/src crates/pm-ca/src \
crates/pm-reports/src crates/migrate-secrets/src
RUN echo 'fn main(){}' > crates/pm-web/src/main.rs \
&& echo 'fn main(){}' > crates/pm-worker/src/main.rs \
&& echo '' > crates/pm-core/src/lib.rs \
&& echo '' > crates/pm-agent-client/src/lib.rs \
&& echo '' > crates/pm-auth/src/lib.rs \
&& echo '' > crates/pm-ca/src/lib.rs \
&& echo '' > crates/pm-reports/src/lib.rs \
&& echo 'fn main(){}' > crates/migrate-secrets/src/main.rs
RUN cargo build --release 2>/dev/null || true
# Now build the real project
COPY crates/ crates/
COPY migrations/ migrations/
RUN cargo build --release
# Verify binaries exist
RUN ls -la target/release/pm-web target/release/pm-worker
# Strip debug symbols
RUN strip target/release/pm-web target/release/pm-worker
# ---------------------------------------------------------------------------
# Stage 2: Frontend build (Ubuntu 24.04 + Node.js 20)
# ---------------------------------------------------------------------------
FROM ubuntu:24.04 AS frontend-builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 20 via NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --production=false
COPY frontend/ ./
RUN npm run build
# ---------------------------------------------------------------------------
# Stage 3: Runtime
# ---------------------------------------------------------------------------
FROM ubuntu:24.04 AS runtime
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3t64 \
libfontconfig1 \
openssl \
postgresql-client-16 \
argon2 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create service user
RUN useradd --system --no-create-home --shell /usr/sbin/nologin \
--comment "Linux Patch Manager service account" patch-manager
# Create directories
RUN mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
/etc/patch-manager/jwt /etc/patch-manager/tls \
/var/log/patch-manager /opt/patch-manager \
/usr/share/patch-manager/frontend \
/usr/share/patch-manager/migrations
# Copy binaries
COPY --from=rust-builder /usr/src/app/target/release/pm-web /usr/local/bin/pm-web
COPY --from=rust-builder /usr/src/app/target/release/pm-worker /usr/local/bin/pm-worker
# Copy frontend
COPY --from=frontend-builder /usr/src/app/frontend/dist/ /usr/share/patch-manager/frontend/
# Copy migrations
COPY migrations/ /usr/share/patch-manager/migrations/
# Copy entrypoint
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 755 /usr/local/bin/entrypoint.sh
# Copy config template
COPY config/config.example.toml /usr/share/patch-manager/config.example.toml
# Set ownership
RUN chown -R patch-manager:patch-manager \
/etc/patch-manager /var/log/patch-manager \
/opt/patch-manager /usr/share/patch-manager
# Expose HTTPS port
EXPOSE 443
# Volume for persistent data
VOLUME ["/etc/patch-manager", "/var/log/patch-manager", "/opt/patch-manager"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

190
LICENSE Normal file
View File

@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text file from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025-2026 Draco Lunaris
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -234,7 +234,9 @@ sudo -u postgres psql patch_manager < /usr/share/patch-manager/migrations/001_in
## License
Private — All rights reserved.
This project is licensed under the [Apache License 2.0](LICENSE).
Copyright 2025-2026 Draco Lunaris
---

68
SECURITY.md Normal file
View File

@ -0,0 +1,68 @@
# Security Policy
## Supported Versions
Only the **latest release** is currently supported with security updates.
| Version | Supported |
|---------|----------|
| Latest | ✅ |
| Older | ❌ |
## Reporting a Vulnerability
**Do not report security vulnerabilities through public GitHub Issues.**
Instead, use GitHub's private vulnerability reporting:
👉 [Report a vulnerability for Linux-Patch-Manager](https://github.com/Draco-Lunaris/Linux-Patch-Manager/security/advisories/new)
This allows us to coordinate a fix before public disclosure.
### Response Timeline
- **Acknowledgment** within 48 hours
- **Initial assessment** within 7 days
- **Ongoing updates** on remediation progress
## Disclosure Policy
We follow **coordinated disclosure**:
- We ask for **90 days** before public disclosure of a vulnerability
- Security advisories are published via [GitHub Security Advisories](https://github.com/Draco-Lunaris/Linux-Patch-Manager/security/advisories)
- We will work with you to determine an appropriate disclosure timeline when a fix requires more time
## Security Best Practices
This project is a security tool — we hold ourselves to a high standard:
- **Signed commits**: All commits must be signed (SSH signing)
- **CI enforcement**: All PRs require passing CI checks (fmt, clippy, test, audit, build)
- **Dependency auditing**: `cargo audit` runs in CI to catch known vulnerabilities
## Enrollment PKI Design Decisions
### Server-Generated Keys vs CSR-Based Enrollment
Currently, the server generates the agent's private key during enrollment approval and
transmits it over the mTLS-secured polling endpoint. This approach was chosen for
initial implementation simplicity — the agent polls a single endpoint and receives a
complete PKI bundle without an extra round-trip.
**Mitigations in place:**
- The PKI bundle is stored in an in-memory cache with single-retrieval semantics —
it can only be fetched once and is atomically removed on retrieval.
- A 10-minute TTL ensures the bundle expires even if never retrieved.
- The raw polling token is never logged; only its SHA-256 hash is stored.
**Future direction:** A CSR-based enrollment flow should replace server-generated keys.
Under that model, the agent generates its own key pair locally and submits a Certificate
Signing Request, eliminating the need for the server to ever hold or transmit the agent's
private key. This significantly reduces the attack surface.
See: [Issue #9](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9)
## Credit
Contributors who responsibly report vulnerabilities will be credited in the corresponding GitHub Security Advisory.

24
SPEC.md
View File

@ -88,7 +88,7 @@
- Refresh tokens: opaque, server-side stored, 1-hour inactivity timeout, rotated on use, revocable
- mTLS for all agent communication (TLS 1.3 only)
- HTTPS for web UI (TLS 1.3 only)
- **IP whitelist enforcement on all connection points**
- **IP whitelist enforcement on all connection points** (with `security.trusted_proxies` to optionally honor `X-Forwarded-For` from a configured proxy; empty default = strict mode that uses the socket peer IP and ignores `X-Forwarded-For`; non-empty allowlist + unresolvable peer IP = fail-closed `403 forbidden_ip`) [Issue #3 / `tasks/ip-allowlist-spec.md`]
- Role-based access control:
- **Admin**: Full access to manage all aspects of Linux Patch Manager
- **Operator**: Can add/remove clients, manage schedules and patches only for devices in their group memberships
@ -274,3 +274,25 @@ All authenticated pages share a persistent sidebar navigation layout:
**Integrity:** Hash-chained rows (tamper-evident). Periodic and on-demand verification.
**Retention:** 6 months
---
## Appendix: App-Level Secret Encryption (Issue #6, May 2026)
In addition to the hardware-level full-disk encryption described above, issue #6 (PR [TBD]) added **application-level AES-256-GCM encryption** for three specific sensitive fields that DB exfiltration would otherwise expose:
| Field | Table | Encryption key |
|-------|-------|----------------|
| `client_secret` | `oidc_config` | `/etc/patch-manager/keys/secret-encryption.key` |
| `smtp_password` | `system_config` (key-value row) | same key |
| `totp_secret` | `users` | same key |
**Why app-level on top of hardware-level?** Hardware-level encryption protects against disk theft; app-level encryption protects against DB exfiltration (SQL injection, backup theft, insider threat) where the attacker already has the running process's privileges. The two are complementary.
**Blast-radius isolation:** A separate per-install key is used for app secrets (`secret-encryption.key`), distinct from the health-check key (`health-check.key`). If the health-check key is ever compromised, app secrets remain protected.
**API surface:** No change. The `MASKED` placeholder behavior in API responses is preserved on top of the new DB encryption — defense in depth.
**Backup:** Both key files must be included in `/etc/patch-manager` backups. Without the key file, encrypted data is unrecoverable. See [docs/runbooks/key-management.md](docs/runbooks/key-management.md) for the full procedure.
**Key rotation:** Not yet supported (follow-up issue). If a key is compromised, generate a new key and re-provision affected secrets.

View File

@ -49,7 +49,8 @@ health_check_poll_interval_secs = 300
# Maximum concurrent mTLS agent calls (Tokio Semaphore)
max_concurrent_agent_calls = 64
# Worker heartbeat write interval (seconds)
# Worker heartbeat write interval (seconds). Default: 300 = 5 minutes
heartbeat_interval_secs = 300
# WS relay HTTP polling fallback interval (seconds). When WebSocket connection to
# an agent fails, the relay falls back to polling the agent's HTTP API at this
@ -76,6 +77,20 @@ format = "json"
# Example: ["10.0.0.0/8", "192.168.1.50"]
ip_whitelist = []
# Trusted reverse proxies: list of CIDRs or individual IPs. When the immediate
# TCP peer is in this list, `X-Forwarded-For` is honored (leftmost untrusted
# hop is used for allowlist enforcement). When this list is EMPTY (the
# default), `X-Forwarded-For` is IGNORED entirely and the socket peer IP is
# used — the strict, fail-closed default.
#
# REQUIRED if you front pm-web with nginx/HAProxy/Cloudflare/etc.: add the
# proxy's egress IP (or CIDR) here, otherwise the allowlist will evaluate
# against the proxy's IP and deny legitimate traffic. If your proxy chain
# has multiple hops, add each hop you control.
# Example: ["10.0.0.0/8"] (corporate egress)
# Example: ["172.16.0.0/12"] (internal load balancer)
trusted_proxies = []
# Ed25519 JWT signing key (private key, PEM format)
# Generate: openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem
jwt_signing_key_path = "/etc/patch-manager/jwt/signing.pem"
@ -108,6 +123,21 @@ web_tls_key_path = "/etc/patch-manager/tls/web.key"
# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server)
sso_callback_url = "http://localhost:5173/auth/sso/callback"
# Allowlist of browser `Origin` values permitted to open the
# `/api/v1/ws/jobs` WebSocket upgrade. Each entry is an exact
# `scheme://host[:port]` string (no wildcards, no paths). When this list is
# empty, the server derives a single-entry default from `sso_callback_url`
# at startup (the host of the SSO callback). If the derivation also fails,
# a warning is logged and the WS endpoint rejects all browser upgrades
# (fail-closed).
#
# Add additional origins here if your SPA and API are served from different
# hosts (e.g. SPA on https://app.example.com talking to API on
# https://api.example.com). For typical single-host deployments the derived
# default is correct and this line should be left commented out.
#
# allowed_origins = ["https://patch-manager.example.com"]
# ============================================================
# Rate Limiting
# ============================================================

View File

@ -0,0 +1,19 @@
[package]
name = "migrate-secrets"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
publish = false
[[bin]]
name = "migrate-secrets"
path = "src/main.rs"
[dependencies]
pm-core = { path = "../pm-core" }
tokio = { workspace = true }
sqlx = { workspace = true }
anyhow = { workspace = true }
uuid = { workspace = true }
hex = "0.4"

View File

@ -0,0 +1,193 @@
//! One-shot migration helper for issue #6 (Secret Encryption at Rest).
//!
//! Reads plaintext secrets from the old columns/rows, encrypts them with the
//! secret-encryption key, and writes to the new BYTEA columns. Verifies the
//! round-trip (encrypt -> decrypt = original plaintext) before committing.
//!
//! USAGE:
//! export DATABASE_URL="postgres://patch_manager:<password>@localhost/patch_manager"
//! cargo run -p migrate-secrets
//!
//! This tool is safe to run multiple times (idempotent — re-encrypts and overwrites).
//!
//! See `tasks/secret-encryption-spec.md` section 4.5 for the design.
use anyhow::{Context, Result};
use pm_core::crypto;
use sqlx::PgPool;
use std::path::Path;
#[tokio::main]
async fn main() -> Result<()> {
// 1. Load secret-encryption key
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))
.context("Failed to load secret-encryption key")?;
eprintln!(
"Loaded secret-encryption key from {}",
crypto::SECRET_ENCRYPTION_KEY_PATH
);
// 2. Connect to database
let database_url =
std::env::var("DATABASE_URL").context("DATABASE_URL environment variable not set")?;
let pool = PgPool::connect(&database_url)
.await
.context("Failed to connect to database")?;
eprintln!("Connected to database");
let mut success_count = 0u32;
let mut skip_count = 0u32;
// 3. Migrate OIDC client_secret
if let Some(plaintext) = read_oidc_client_secret(&pool).await? {
if plaintext.is_empty() {
eprintln!("[skip] OIDC client_secret is empty");
skip_count += 1;
} else {
write_oidc_client_secret(&pool, &plaintext, &key).await?;
eprintln!("[ok] OIDC client_secret encrypted");
success_count += 1;
}
} else {
eprintln!("[skip] OIDC client_secret column not found (already migrated?)");
skip_count += 1;
}
// 4. Migrate SMTP password
if let Some(plaintext) = read_smtp_password(&pool).await? {
if plaintext.is_empty() {
eprintln!("[skip] SMTP password is empty");
skip_count += 1;
} else {
write_smtp_password(&pool, &plaintext, &key).await?;
eprintln!("[ok] SMTP password encrypted");
success_count += 1;
}
} else {
eprintln!("[skip] SMTP password row not found (already migrated?)");
skip_count += 1;
}
// 5. Migrate TOTP secrets for all users
let totp_count = migrate_totp_secrets(&pool, &key).await?;
eprintln!("[ok] {} TOTP secret(s) encrypted", totp_count);
success_count += totp_count;
eprintln!(
"\nMigration complete: {} encrypted, {} skipped",
success_count, skip_count
);
eprintln!(
"Next step: apply migration 020_encrypt_secrets_at_rest.sql to drop the old columns."
);
Ok(())
}
async fn read_oidc_client_secret(pool: &PgPool) -> Result<Option<String>> {
// Try to read the old column. If it doesn't exist, return None.
let row: Result<Option<(Option<String>,)>, sqlx::Error> =
sqlx::query_as("SELECT client_secret FROM oidc_config WHERE id = 1")
.fetch_optional(pool)
.await;
match row {
Ok(Some((secret,))) => Ok(secret),
Ok(None) => Ok(None),
Err(e) => {
// Column not found = already migrated
eprintln!(" (oidc_config.client_secret column check: {})", e);
Ok(None)
},
}
}
async fn write_oidc_client_secret(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
// Round-trip verification
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
if recovered != plaintext {
anyhow::bail!(
"OIDC round-trip failed: expected {}, got {}",
plaintext,
recovered
);
}
sqlx::query(
"UPDATE oidc_config SET client_secret_encrypted = $1, client_secret_nonce = $2 WHERE id = 1",
)
.bind(&ciphertext)
.bind(&nonce)
.execute(pool)
.await
.context("Failed to update oidc_config")?;
Ok(())
}
async fn read_smtp_password(pool: &PgPool) -> Result<Option<String>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM system_config WHERE key = 'smtp_password'")
.fetch_optional(pool)
.await?;
Ok(row.map(|(v,)| v))
}
async fn write_smtp_password(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
// Round-trip verification
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
if recovered != plaintext {
anyhow::bail!(
"SMTP round-trip failed: expected {}, got {}",
plaintext,
recovered
);
}
// Delete old row, write two new rows
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
.execute(pool)
.await?;
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
.bind("smtp_password_encrypted")
.bind(hex_encode(&ciphertext))
.execute(pool)
.await?;
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
.bind("smtp_password_nonce")
.bind(hex_encode(&nonce))
.execute(pool)
.await?;
Ok(())
}
async fn migrate_totp_secrets(pool: &PgPool, key: &[u8; 32]) -> Result<u32> {
// Read all users with totp_secret set
let users: Vec<(uuid::Uuid, String)> =
sqlx::query_as("SELECT id, totp_secret FROM users WHERE totp_secret IS NOT NULL")
.fetch_all(pool)
.await
.context("Failed to read users with totp_secret")?;
let count = users.len() as u32;
for (user_id, plaintext) in users {
let (ciphertext, nonce) = crypto::encrypt(&plaintext, key)?;
// Round-trip verification
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
if recovered != plaintext {
anyhow::bail!("TOTP round-trip failed for user {}", user_id);
}
sqlx::query(
"UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2 WHERE id = $3",
)
.bind(&ciphertext)
.bind(&nonce)
.bind(user_id)
.execute(pool)
.await
.context("Failed to update user totp_secret")?;
}
Ok(count)
}
/// Hex-encode bytes for storage in TEXT columns (system_config).
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

View File

@ -0,0 +1,31 @@
# Agent Client Certificates
**⚠️ Private keys are NOT committed to version control.**
This directory holds mTLS certificates used by `pm-agent-client` for testing.
The entire directory is excluded from git via `.gitignore`.
## Generating Test Certificates
Certificates are generated automatically on first run by the `pm-ca` service,
or you can generate them manually for development:
```bash
# Create certs directory if it doesn't exist
mkdir -p crates/pm-agent-client/certs
# Generate using the pm-ca service (preferred)
# Or copy from /etc/patch-manager/certs/ on a deployed host
```
## Production Deployment
Production certificates are managed by `pm-ca` at `/etc/patch-manager/certs/`.
The `pm-agent-client` reads certificates from file paths configured in
`config.toml` (`agent_client_cert_path`, `agent_client_key_path`, `ca_cert_path`).
## Security
- **Never commit private keys** (`*.key`, `*.key.pem`) to version control
- The `gitleaks` CI check scans for accidentally committed secrets
- See `SECURITY.md` and `docs/security-review.md` for full details

View File

@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
-----END CERTIFICATE-----

View File

@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
-----END CERTIFICATE-----

View File

@ -1,19 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAuTfH0/Z2HT49DfHT
49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49Df
HT49DfHT49DfHT49DfHT49DfHQIDAQABAkEArWvK64P1/x9P2dh0+PQ3x0+PQ3x0
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ
3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x
0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
-----END PRIVATE KEY-----

11
crates/pm-agent-client/src/client.rs Executable file → Normal file
View File

@ -6,12 +6,17 @@
//! use pm_agent_client::client::AgentClient;
//!
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
//! // Load certificates from files (never hardcode or include_bytes! private keys)
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
//!
//! let client = AgentClient::new(
//! "192.168.1.10",
//! 12443,
//! include_bytes!("../certs/client.crt"),
//! include_bytes!("../certs/client.key"),
//! include_bytes!("../certs/ca.crt"),
//! &client_cert,
//! &client_key,
//! &ca_cert,
//! )?;
//!
//! let health = client.health().await?;

11
crates/pm-agent-client/src/lib.rs Executable file → Normal file
View File

@ -10,12 +10,17 @@
//! use pm_agent_client::AgentClient;
//!
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
//! // Load certificates from files (never hardcode or include_bytes! private keys)
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
//!
//! let client = AgentClient::new(
//! "10.0.1.5",
//! 12443,
//! include_bytes!("../certs/client.crt"),
//! include_bytes!("../certs/client.key"),
//! include_bytes!("../certs/ca.crt"),
//! &client_cert,
//! &client_key,
//! &ca_cert,
//! )?;
//!
//! let health = client.health().await?;

10
crates/pm-agent-client/src/types.rs Executable file → Normal file
View File

@ -57,6 +57,16 @@ pub struct HealthData {
pub uptime_seconds: u64,
/// Agent software version string.
pub version: String,
/// CRL status reported by the agent: `"valid"`, `"expired"`, `"missing"`, `"invalid"`.
/// Absent for older agents that do not report CRL status.
#[serde(default)]
pub crl_status: Option<String>,
/// Seconds since the agent's CRL was last refreshed.
#[serde(default)]
pub crl_age_seconds: Option<i64>,
/// When the agent's CRL expires / next update is due (ISO-8601).
#[serde(default)]
pub crl_next_update: Option<String>,
}
// ============================================================

View File

@ -27,3 +27,6 @@ hex = { workspace = true }
ipnet = { workspace = true }
parking_lot = "0.12"
sha2 = { workspace = true }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }

465
crates/pm-auth/src/rbac.rs Executable file → Normal file
View File

@ -7,7 +7,7 @@
//! - IP whitelist enforcement
use axum::{
extract::Request,
extract::{ConnectInfo, Request},
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Json, Response},
@ -15,7 +15,7 @@ use axum::{
use ipnet::IpNet;
use parking_lot::RwLock;
use serde_json::json;
use std::net::IpAddr;
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use std::sync::Arc;
use uuid::Uuid;
@ -76,18 +76,30 @@ pub struct AuthConfig {
pub verify_key_pem: String,
/// IP whitelist (empty = allow all). RwLock for runtime updates.
pub ip_whitelist: Arc<RwLock<Vec<IpNet>>>,
/// Trusted reverse-proxy CIDRs (empty = do not trust `X-Forwarded-For`).
/// RwLock for runtime updates (symmetric to `ip_whitelist`).
pub trusted_proxies: Arc<RwLock<Vec<IpNet>>>,
}
impl AuthConfig {
pub fn new(verify_key_pem: String, ip_whitelist_cidrs: &[String]) -> Self {
pub fn new(
verify_key_pem: String,
ip_whitelist_cidrs: &[String],
trusted_proxy_cidrs: &[String],
) -> Self {
let ip_whitelist = ip_whitelist_cidrs
.iter()
.filter_map(|cidr| IpNet::from_str(cidr).ok())
.collect();
let trusted_proxies = trusted_proxy_cidrs
.iter()
.filter_map(|cidr| IpNet::from_str(cidr).ok())
.collect();
Self {
verify_key_pem,
ip_whitelist: Arc::new(RwLock::new(ip_whitelist)),
trusted_proxies: Arc::new(RwLock::new(trusted_proxies)),
}
}
@ -111,6 +123,18 @@ impl AuthConfig {
*self.ip_whitelist.write() = nets;
tracing::info!(count, "IP whitelist updated at runtime");
}
/// Update the trusted-proxy list at runtime without restart.
/// Empty list = strict mode (ignore `X-Forwarded-For`).
pub fn update_trusted_proxies(&self, entries: Vec<String>) {
let nets: Vec<IpNet> = entries
.iter()
.filter_map(|cidr| IpNet::from_str(cidr).ok())
.collect();
let count = nets.len();
*self.trusted_proxies.write() = nets;
tracing::info!(count, "Trusted proxies updated at runtime");
}
}
/// Extract `Authorization: Bearer <token>` from request headers.
@ -121,13 +145,38 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
.and_then(|s| s.strip_prefix("Bearer "))
}
/// Extract the remote IP from `X-Forwarded-For`.
fn extract_remote_ip(headers: &HeaderMap) -> Option<IpAddr> {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok())
/// Determine the client IP used for IP-allowlist enforcement.
///
/// Resolution rules (per `tasks/ip-allowlist-spec.md` §4.1):
/// 1. Start with the socket peer IP.
/// 2. If `trusted_proxies` is non-empty **and** the socket peer is in
/// `trusted_proxies`, parse the leftmost entry of the `X-Forwarded-For`
/// header and use it (the immediate untrusted hop).
/// 3. If parsing `X-Forwarded-For` fails or the header is missing, fall back
/// to the socket peer IP.
/// 4. If the socket peer is unknown (no `ConnectInfo<SocketAddr>` is
/// available on the request), return `None` so the caller can apply
/// fail-closed logic when the allowlist is non-empty.
fn resolve_client_ip(
headers: &HeaderMap,
peer: Option<IpAddr>,
trusted_proxies: &[IpNet],
) -> Option<IpAddr> {
let peer_ip = peer?;
if !trusted_proxies.is_empty() && trusted_proxies.iter().any(|net| net.contains(&peer_ip)) {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(ip) = xff
.split(',')
.next()
.and_then(|s| s.trim().parse::<IpAddr>().ok())
{
return Some(ip);
}
}
}
Some(peer_ip)
}
/// Unauthorized JSON response helper.
@ -148,16 +197,65 @@ fn forbidden(message: &str) -> Response {
.into_response()
}
/// Forbidden-by-IP response helper. Distinct error code (`forbidden_ip`) so
/// callers can distinguish an IP-allowlist rejection from a role-based
/// rejection. Used by `require_auth` after the IP-resolution failure or
/// allowlist miss per `tasks/ip-allowlist-spec.md` §4.2.
fn forbidden_ip(message: &str) -> Response {
(
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden_ip", "message": message } })),
)
.into_response()
}
/// Middleware: authenticate any valid JWT (admin or operator).
///
/// Inserts `AuthUser` into request extensions on success.
/// Rejects with 401 if token is missing/invalid, 403 if IP is blocked.
pub async fn require_auth(auth_config: Arc<AuthConfig>, mut req: Request, next: Next) -> Response {
// IP whitelist check
if let Some(ip) = extract_remote_ip(req.headers()) {
// IP whitelist check. Only enforced when the configured allowlist is
// non-empty (Q4 sign-off: empty list = allow all, preserved for dev
// installs). When enforced, the resolved client IP comes from
// `resolve_client_ip`, which uses the socket peer IP by default and
// honors `X-Forwarded-For` only when the immediate peer is in
// `trusted_proxies` (Q1 sign-off: strict default, Q2 sign-off: same
// resolution pattern as the rate-limiter). Fail-closed when the IP
// cannot be determined (Q3 sign-off).
//
// See `tasks/ip-allowlist-spec.md` §4.2 for the full design.
if !auth_config.ip_whitelist.read().is_empty() {
let headers = req.headers().clone();
let peer: Option<IpAddr> = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip());
let xff_present = headers.contains_key("x-forwarded-for");
let trusted: Vec<IpNet> = auth_config.trusted_proxies.read().clone();
let resolved = resolve_client_ip(&headers, peer, &trusted);
match resolved {
None => {
tracing::warn!(
peer = ?peer,
xff_present,
reason = "unresolvable_client_ip",
"Request denied by IP whitelist (fail-closed: no ConnectInfo<SocketAddr>)"
);
return forbidden_ip("Client IP could not be determined");
},
Some(ip) => {
if !auth_config.is_ip_allowed(&ip) {
tracing::warn!(ip = %ip, "Request blocked by IP whitelist");
return forbidden("Access denied");
tracing::warn!(
client_ip = %ip,
peer = ?peer,
xff_present,
reason = "ip_not_in_allowlist",
"Request blocked by IP whitelist"
);
return forbidden_ip("Access denied");
}
},
}
}
@ -230,3 +328,342 @@ where
.ok_or_else(|| unauthorized("Authentication required"))
}
}
#[cfg(test)]
mod tests {
//! Unit tests for the IP-allowlist resolver helper.
//!
//! Covers the matrix in `tasks/ip-allowlist-spec.md` §6.1
//! (12 cases for `resolve_client_ip`).
use super::*;
use std::net::IpAddr;
use std::str::FromStr;
fn ip(s: &str) -> IpAddr {
IpAddr::from_str(s).expect("test fixture: parse IP")
}
fn net(s: &str) -> IpNet {
IpNet::from_str(s).expect("test fixture: parse CIDR")
}
fn hdr() -> HeaderMap {
HeaderMap::new()
}
fn hdr_with_xff(xff: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(
"x-forwarded-for",
xff.parse().expect("test fixture: xff header"),
);
h
}
// 1. peer_only_no_xff — no XFF, trusted_proxies empty → returns peer
#[test]
fn peer_only_no_xff() {
let result = resolve_client_ip(&hdr(), Some(ip("203.0.113.10")), &[]);
assert_eq!(result, Some(ip("203.0.113.10")));
}
// 2. peer_only_xff_untrusted — XFF set, peer not in trusted_proxies,
// trusted_proxies non-empty → returns peer (XFF ignored)
#[test]
fn peer_only_xff_untrusted() {
let headers = hdr_with_xff("198.51.100.5");
let trusted = vec![net("10.0.0.0/8")];
let result = resolve_client_ip(&headers, Some(ip("203.0.113.10")), &trusted);
assert_eq!(result, Some(ip("203.0.113.10")));
}
// 3. peer_only_trusted_proxies_empty_xff_present — XFF set,
// trusted_proxies empty → returns peer (strict default)
#[test]
fn peer_only_trusted_proxies_empty_xff_present() {
let headers = hdr_with_xff("198.51.100.5");
let result = resolve_client_ip(&headers, Some(ip("203.0.113.10")), &[]);
assert_eq!(result, Some(ip("203.0.113.10")));
}
// 4. xff_trusted_peer_in_list — XFF set, peer in trusted_proxies
// → returns parsed leftmost XFF entry
#[test]
fn xff_trusted_peer_in_list() {
let headers = hdr_with_xff("198.51.100.5");
let trusted = vec![net("10.0.0.0/8")];
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
assert_eq!(result, Some(ip("198.51.100.5")));
}
// 5. xff_trusted_peer_in_list_malformed_xff — XFF unparseable,
// peer in trusted_proxies → falls back to peer
#[test]
fn xff_trusted_peer_in_list_malformed_xff() {
let headers = hdr_with_xff("not-an-ip");
let trusted = vec![net("10.0.0.0/8")];
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
assert_eq!(result, Some(ip("10.0.0.5")));
}
// 6. xff_trusted_peer_in_list_empty_xff — XFF empty string,
// peer in trusted_proxies → falls back to peer
#[test]
fn xff_trusted_peer_in_list_empty_xff() {
let headers = hdr_with_xff("");
let trusted = vec![net("10.0.0.0/8")];
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
assert_eq!(result, Some(ip("10.0.0.5")));
}
// 7. xff_trusted_peer_in_list_multi_hop — "1.2.3.4, 5.6.7.8"
// with peer in trusted_proxies → returns 1.2.3.4 (leftmost)
#[test]
fn xff_trusted_peer_in_list_multi_hop() {
let headers = hdr_with_xff("1.2.3.4, 5.6.7.8");
let trusted = vec![net("10.0.0.0/8")];
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
assert_eq!(result, Some(ip("1.2.3.4")));
}
// 8. no_peer_no_xff — peer None, no XFF → returns None
#[test]
fn no_peer_no_xff() {
let result = resolve_client_ip(&hdr(), None, &[net("10.0.0.0/8")]);
assert_eq!(result, None);
}
// 9. no_peer_xff_untrusted — peer None, XFF set, trusted_proxies empty
// → returns None (caller fails closed)
#[test]
fn no_peer_xff_untrusted() {
let headers = hdr_with_xff("198.51.100.5");
let result = resolve_client_ip(&headers, None, &[]);
assert_eq!(result, None);
}
// 10. xff_trusted_whitespace — XFF " 1.2.3.4", peer in trusted_proxies
// → returns 1.2.3.4 (trim)
#[test]
fn xff_trusted_whitespace() {
let headers = hdr_with_xff(" 198.51.100.5");
let trusted = vec![net("10.0.0.0/8")];
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
assert_eq!(result, Some(ip("198.51.100.5")));
}
// 11. trusted_proxies_ipv6 — peer in IPv6 trusted list, IPv6 XFF
// → returns XFF
#[test]
fn trusted_proxies_ipv6() {
let headers = hdr_with_xff("2001:db8::1");
let trusted = vec![net("::1/128"), net("2001:db8::/32")];
let result = resolve_client_ip(&headers, Some(ip("2001:db8::ffff")), &trusted);
assert_eq!(result, Some(ip("2001:db8::1")));
}
// 12. peer_ipv4_xff_ipv6_mismatch_trusted — peer in trusted list,
// XFF is IPv6 → returns parsed IPv6 (mixed family is fine)
#[test]
fn peer_ipv4_xff_ipv6_mismatch_trusted() {
let headers = hdr_with_xff("2001:db8::dead");
let trusted = vec![net("10.0.0.0/8")];
let result = resolve_client_ip(&headers, Some(ip("10.0.0.5")), &trusted);
assert_eq!(result, Some(ip("2001:db8::dead")));
}
}
#[cfg(test)]
mod middleware_tests {
//! End-to-end tests for the `require_auth` middleware IP-allowlist path.
//!
//! Uses a tiny in-process `axum::Router` with the middleware attached and
//! `tower::ServiceExt::oneshot` to send synthetic requests. No DB, no real
//! TCP listener.
//!
//! Mirrors the production wiring pattern in `pm-web/src/main.rs` (a
//! `from_fn` closure that captures the `AuthConfig` and forwards to
//! `require_auth`).
//!
//! For tests where the spec expects `200` (allowlist passed), we assert
//! `401` instead — the JWT will fail validation against the empty verify
//! key, which **proves the IP check did not short-circuit** (a 403 here
//! would mean the IP check rejected the request).
//!
//! Per `tasks/ip-allowlist-spec.md` §6.1 tests 1320.
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::middleware::from_fn;
use axum::routing::get;
use axum::Router;
use tower::ServiceExt;
/// Stub handler that returns 200 OK if the middleware let the request
/// through. JWT validation will fail in these tests, so the handler is
/// only reached in the "IP check passed but JWT failed" scenarios we
/// assert as `401`.
async fn ok_handler() -> &'static str {
"ok"
}
fn build_test_app(auth_config: Arc<AuthConfig>) -> Router {
Router::new()
.route("/test", get(ok_handler))
.layer(from_fn(move |req, next| {
let cfg = auth_config.clone();
async move { require_auth(cfg, req, next).await }
}))
}
/// Build a request with the given extensions, headers, and an
/// `Authorization: Bearer` token (which will fail JWT validation since
/// the test `AuthConfig` has an empty verify key). Tests assert on the
/// status code only — the body content is irrelevant.
fn build_request(peer: Option<SocketAddr>, xff: Option<&str>) -> Request<Body> {
let mut builder = Request::builder()
.uri("/test")
.header("authorization", "Bearer test-token-invalid");
if let Some(x) = xff {
builder = builder.header("x-forwarded-for", x);
}
let mut req = builder.body(Body::empty()).expect("build request");
if let Some(p) = peer {
req.extensions_mut().insert(ConnectInfo(p));
}
req
}
fn peer_v4(a: u8, b: u8, c: u8, d: u8) -> SocketAddr {
SocketAddr::from(([a, b, c, d], 1234))
}
// 13. middleware_allows_when_whitelist_empty — empty list + any IP
// → IP check skipped, request continues to JWT (which fails → 401).
#[tokio::test]
async fn middleware_allows_when_whitelist_empty() {
let cfg = Arc::new(AuthConfig::new(String::new(), &[], &[]));
let app = build_test_app(cfg);
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// 14. middleware_denies_when_whitelist_non_empty_and_ip_not_in_list
// — non-empty list + peer outside → 403 forbidden_ip.
#[tokio::test]
async fn middleware_denies_when_whitelist_non_empty_and_ip_not_in_list() {
let cfg = Arc::new(AuthConfig::new(
String::new(),
&["10.0.0.0/8".to_string()],
&[],
));
let app = build_test_app(cfg);
let req = build_request(Some(peer_v4(203, 0, 113, 10)), None);
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// 15. middleware_allows_when_ip_in_list — non-empty list + peer inside
// → 401 (JWT fails, IP check passed).
#[tokio::test]
async fn middleware_allows_when_ip_in_list() {
let cfg = Arc::new(AuthConfig::new(
String::new(),
&["10.0.0.0/8".to_string()],
&[],
));
let app = build_test_app(cfg);
let req = build_request(Some(peer_v4(10, 0, 0, 5)), None);
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// 16. middleware_denies_when_no_peer_resolvable_and_whitelist_non_empty
// — non-empty list + missing ConnectInfo → 403 forbidden_ip (fail-closed).
#[tokio::test]
async fn middleware_denies_when_no_peer_resolvable_and_whitelist_non_empty() {
let cfg = Arc::new(AuthConfig::new(
String::new(),
&["10.0.0.0/8".to_string()],
&[],
));
let app = build_test_app(cfg);
let req = build_request(None, None); // no ConnectInfo
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// 17. middleware_spoofed_xff_ignored_when_peer_untrusted
// — non-empty list + peer outside + XFF inside list → 403 forbidden_ip.
#[tokio::test]
async fn middleware_spoofed_xff_ignored_when_peer_untrusted() {
let cfg = Arc::new(AuthConfig::new(
String::new(),
&["10.0.0.0/8".to_string()],
&[],
));
let app = build_test_app(cfg);
// Peer is 203.0.113.10 (not in 10.0.0.0/8). XFF claims 10.0.0.5 but
// trusted_proxies is empty, so XFF is ignored and peer is checked → 403.
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// 18. middleware_trusted_proxy_honors_xff — peer in trusted_proxies +
// XFF inside allowlist → 401 (IP check passed, JWT fails).
#[tokio::test]
async fn middleware_trusted_proxy_honors_xff() {
let cfg = Arc::new(AuthConfig::new(
String::new(),
&["10.0.0.0/8".to_string()],
&["203.0.113.0/24".to_string()],
));
let app = build_test_app(cfg);
// Peer 203.0.113.10 is in trusted_proxies, so XFF "10.0.0.5" is used
// and that IP is in the allowlist → IP check passes → JWT fails → 401.
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("10.0.0.5"));
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// 19. middleware_trusted_proxy_falls_back_to_peer_on_bad_xff
// — peer in trusted_proxies + unparseable XFF + peer outside list → 403.
#[tokio::test]
async fn middleware_trusted_proxy_falls_back_to_peer_on_bad_xff() {
let cfg = Arc::new(AuthConfig::new(
String::new(),
&["10.0.0.0/8".to_string()],
&["203.0.113.0/24".to_string()],
));
let app = build_test_app(cfg);
// Peer 203.0.113.10 is in trusted_proxies. XFF is unparseable, so
// resolver falls back to peer (203.0.113.10) which is NOT in
// allowlist (10.0.0.0/8) → 403.
let req = build_request(Some(peer_v4(203, 0, 113, 10)), Some("not-an-ip"));
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// 20. middleware_no_jwt_when_ip_blocked — blocked request never reaches
// JWT validation. With an invalid token AND a denied IP, response is
// 403 (forbidden_ip) NOT 401 (which would indicate JWT was reached).
#[tokio::test]
async fn middleware_no_jwt_when_ip_blocked() {
let cfg = Arc::new(AuthConfig::new(
String::new(),
&["10.0.0.0/8".to_string()],
&[],
));
let app = build_test_app(cfg);
// Peer 203.0.113.10 is outside allowlist, token is invalid.
// If the IP check ran first, response is 403. If JWT ran first, 401.
// We assert 403, proving the IP check short-circuited.
let req = build_request(Some(peer_v4(203, 0, 113, 10)), None);
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
}

31
crates/pm-auth/src/session.rs Executable file → Normal file
View File

@ -40,6 +40,8 @@ pub enum SessionError {
Password(#[from] PasswordError),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error: {0}")]
Internal(String),
}
/// Successful login response returned to the client.
@ -77,7 +79,10 @@ struct DbUser {
role: UserRole,
auth_provider: AuthProvider,
password_hash: Option<String>,
totp_secret: Option<String>,
/// AES-256-GCM encrypted TOTP secret (issue #6 fix). None = TOTP not configured.
totp_secret_encrypted: Option<Vec<u8>>,
/// AES-256-GCM nonce for TOTP secret. Must be paired with `totp_secret_encrypted`.
totp_secret_nonce: Option<Vec<u8>>,
mfa_enabled: bool,
is_active: bool,
force_password_reset: bool,
@ -115,7 +120,7 @@ pub async fn login(
let user: Option<DbUser> = sqlx::query_as(
r#"
SELECT id, username, display_name, role, auth_provider,
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
password_hash, totp_secret_encrypted, totp_secret_nonce, mfa_enabled, is_active, force_password_reset,
failed_login_attempts, locked_until
FROM users
WHERE username = $1 AND auth_provider = 'local'
@ -194,9 +199,25 @@ pub async fn login(
// 4. MFA check
if user.mfa_enabled {
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?;
let secret = user.totp_secret.as_deref().unwrap_or("");
// Decrypt the TOTP secret (issue #6 fix — stored as encrypted+nonce BYTEA)
let secret = match (&user.totp_secret_encrypted, &user.totp_secret_nonce) {
(Some(enc), Some(nonce)) => {
let key = pm_core::crypto::load_or_create_key(std::path::Path::new(
pm_core::crypto::SECRET_ENCRYPTION_KEY_PATH,
))
.map_err(|e| {
tracing::error!(error = %e, "Failed to load secret-encryption key");
SessionError::Internal("Encryption key error".to_string())
})?;
pm_core::crypto::decrypt(enc, nonce, &key).map_err(|e| {
tracing::error!(error = %e, "Failed to decrypt TOTP secret");
SessionError::Internal("TOTP decryption error".to_string())
})?
},
_ => String::new(),
};
let mfa_ok = mfa_totp::verify_code(&user.username, secret, code).unwrap_or(false);
let mfa_ok = mfa_totp::verify_code(&user.username, &secret, code).unwrap_or(false);
if !mfa_ok {
tracing::warn!(username = %req.username, "Login failed: invalid MFA code");
@ -257,7 +278,7 @@ pub async fn refresh_session(
let user: DbUser = sqlx::query_as(
r#"
SELECT id, username, display_name, role, auth_provider,
password_hash, totp_secret, mfa_enabled, is_active, force_password_reset,
password_hash, totp_secret_encrypted, totp_secret_nonce, mfa_enabled, is_active, force_password_reset,
failed_login_attempts, locked_until
FROM users WHERE id = $1
"#,

View File

@ -23,3 +23,6 @@ rustls = { workspace = true }
rcgen = { workspace = true }
pem = { workspace = true }
time = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

395
crates/pm-ca/src/ca.rs Executable file → Normal file
View File

@ -13,8 +13,9 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use rand::RngCore;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType,
ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, SerialNumber,
BasicConstraints, Certificate, CertificateParams, CertificateRevocationListParams,
DistinguishedName, DnType, ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyIdMethod, KeyPair,
KeyUsagePurpose, RevocationReason, RevokedCertParams, SanType, SerialNumber,
PKCS_ECDSA_P256_SHA256,
};
use sqlx::{PgPool, Row};
@ -524,4 +525,394 @@ impl CertAuthority {
.context("reconstruct CA certificate for signing")?;
Ok((key, cert))
}
// -----------------------------------------------------------------------
// CRL generation
// -----------------------------------------------------------------------
/// Generate a Certificate Revocation List (CRL) signed by this CA.
///
/// Queries the `certificates` table for certs with `status = 'revoked'`
/// and `not_after > NOW()` (i.e., not yet naturally expired) and bundles
/// their serials into an X.509 v2 CRL.
///
/// Returns the CRL as a PEM-encoded string.
///
/// # Performance
///
/// O(n) where n is the number of revoked-but-not-expired certs. For our
/// target scale (max ~2500 clients per manager, low single-digit % annual
/// revocation rate), this is KB-range and sub-millisecond to generate.
pub async fn generate_crl(&self, db: &PgPool) -> Result<String> {
tracing::debug!("Generating CRL from certificates table");
// Query revoked certs that haven't naturally expired yet.
// Expired certs are pruned from the CRL to keep it small.
let rows = sqlx::query(
"SELECT serial_number, revoked_at \
FROM certificates \
WHERE status = 'revoked'::cert_status \
AND revoked_at IS NOT NULL \
AND expires_at > NOW() \
ORDER BY revoked_at ASC",
)
.fetch_all(db)
.await
.context("query revoked certificates for CRL")?;
let mut revoked_certs = Vec::with_capacity(rows.len());
for row in &rows {
let serial_hex: String = row.try_get("serial_number").context("serial_number")?;
let revoked_at: DateTime<Utc> = row.try_get("revoked_at").context("revoked_at")?;
// Convert hex serial back to bytes for rcgen.
let serial_bytes =
hex::decode(&serial_hex).context("serial_number is not valid hex")?;
let serial_number = SerialNumber::from_slice(&serial_bytes);
// Convert chrono DateTime to time::OffsetDateTime for rcgen.
let revocation_time = OffsetDateTime::from_unix_timestamp(revoked_at.timestamp())
.unwrap_or_else(|_| OffsetDateTime::now_utc());
revoked_certs.push(RevokedCertParams {
serial_number,
revocation_time,
reason_code: Some(RevocationReason::Unspecified),
invalidity_date: None,
});
}
let count = revoked_certs.len();
tracing::debug!(revoked_count = count, "Building CRL with revoked entries");
// CRL validity window: this_update = now, next_update = now + 24h
// (agents refresh every 24h, so this gives them a fresh CRL on every poll).
let now = OffsetDateTime::now_utc();
let next_update = now + TimeDuration::hours(24);
// CRL number: monotonic counter derived from current Unix timestamp.
// RFC 5280 doesn't require strict monotonicity for the CRL number
// extension, but it's a common convention. We use timestamp seconds
// divided by 60 (minute precision) to keep it short and readable.
let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes());
let crl_params = CertificateRevocationListParams {
this_update: now,
next_update,
crl_number,
issuing_distribution_point: None,
revoked_certs,
key_identifier_method: KeyIdMethod::Sha256,
};
let (ca_key, ca_cert) = self.ca_objects()?;
let crl = crl_params
.signed_by(&ca_cert, &ca_key)
.context("sign CRL with CA key")?;
let crl_pem = crl.pem().context("encode CRL as PEM")?;
tracing::info!(
revoked_count = count,
next_update = %next_update,
"CRL generated and signed"
);
Ok(crl_pem)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Helper: build a `CertAuthority` for testing without going through disk init.
/// Generates a fresh ECDSA P-256 CA in memory.
async fn test_ca() -> CertAuthority {
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Test Root CA");
dn.push(DnType::OrganizationName, "Patch Manager Test");
params.distinguished_name = dn;
let ca_cert = params.self_signed(&key).unwrap();
CertAuthority {
base_dir: PathBuf::from("/tmp/test-ca"),
ca_cert_pem: ca_cert.pem(),
ca_key_pem: key.serialize_pem(),
}
}
#[test]
fn make_serial_produces_unique_16_byte_serials() {
let (s1, h1) = make_serial();
let (s2, h2) = make_serial();
assert_ne!(h1, h2, "serial hex strings should differ");
assert_eq!(
h1.len(),
32,
"serial should be 16 bytes hex-encoded (32 chars)"
);
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
}
#[test]
fn ca_objects_round_trip() {
// Build a CA, then reconstruct via ca_objects() and verify the cert+key parse.
let rt = tokio::runtime::Runtime::new().unwrap();
let ca = rt.block_on(test_ca());
let (key, cert) = ca.ca_objects().expect("ca_objects should succeed");
assert!(!key.serialize_pem().is_empty());
assert!(!cert.pem().is_empty());
}
/// Verifies that `generate_crl` produces a valid PEM-encoded X.509 CRL
/// even when the database has no revoked certs (empty CRL).
///
/// This is a structural test: we verify the PEM format and that the
/// generated CRL can be parsed back. Full integration testing with a real
/// database is in `tests/crl_integration.rs`.
#[tokio::test]
async fn generate_crl_empty_db_produces_valid_pem() {
// Use a real but empty Postgres test database. If TEST_DATABASE_URL
// is not set, skip this test (it's an integration test, not a unit test).
let Ok(db_url) = std::env::var("TEST_DATABASE_URL") else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let pool = sqlx::PgPool::connect(&db_url)
.await
.expect("connect to test db");
let ca = test_ca().await;
let crl_pem = ca.generate_crl(&pool).await.expect("generate_crl");
assert!(
crl_pem.contains("-----BEGIN X509 CRL-----"),
"PEM header missing"
);
assert!(
crl_pem.contains("-----END X509 CRL-----"),
"PEM footer missing"
);
}
}
// ---------------------------------------------------------------------------
// Property-based tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod proptests {
use super::*;
/// Generating a CRL twice in quick succession should produce valid PEM output.
/// (Full integration test with a real database is in tests/crl_integration.rs.)
#[test]
fn make_serial_produces_unique_values() {
let (s1, h1) = make_serial();
let (s2, h2) = make_serial();
assert_ne!(h1, h2, "serial hex strings should differ");
assert_eq!(h1.len(), 32, "serial should be 16 bytes hex-encoded");
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
}
// -----------------------------------------------------------------------
// CRL generation unit tests (in-memory, no database required)
// -----------------------------------------------------------------------
/// Helper: build a CRL in memory using rcgen directly, signed by the test CA.
/// This bypasses the database and tests the CRL structure itself.
fn build_test_crl(
ca_key: &KeyPair,
ca_cert: &Certificate,
revoked_serials: &[SerialNumber],
) -> String {
let now = OffsetDateTime::now_utc();
let next_update = now + TimeDuration::hours(24);
let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes());
let revoked_certs: Vec<RevokedCertParams> = revoked_serials
.iter()
.map(|serial| RevokedCertParams {
serial_number: serial.clone(),
revocation_time: now,
reason_code: Some(RevocationReason::Unspecified),
invalidity_date: None,
})
.collect();
let crl_params = CertificateRevocationListParams {
this_update: now,
next_update,
crl_number,
issuing_distribution_point: None,
revoked_certs,
key_identifier_method: KeyIdMethod::Sha256,
};
let crl = crl_params.signed_by(ca_cert, ca_key).unwrap();
crl.pem().unwrap()
}
#[test]
fn crl_generation_produces_valid_pem_structure() {
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Test Root CA");
params.distinguished_name = dn;
let ca_cert = params.self_signed(&ca_key).unwrap();
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
assert!(
crl_pem.contains("-----BEGIN X509 CRL-----"),
"CRL PEM should contain BEGIN header"
);
assert!(
crl_pem.contains("-----END X509 CRL-----"),
"CRL PEM should contain END footer"
);
}
#[test]
fn crl_contains_revoked_serials() {
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Test Root CA");
params.distinguished_name = dn;
let ca_cert = params.self_signed(&ca_key).unwrap();
// Revoke two serials
let (s1, _) = make_serial();
let (s2, _) = make_serial();
let crl_with_revoked = build_test_crl(&ca_key, &ca_cert, &[s1.clone(), s2.clone()]);
// The PEM should be non-empty and parseable
assert!(!crl_with_revoked.is_empty(), "CRL PEM should not be empty");
assert!(
crl_with_revoked.contains("-----BEGIN X509 CRL-----"),
"CRL should have PEM header"
);
// A CRL with revoked entries should be larger than an empty CRL
// because it contains the revoked certificate entries.
let empty_crl = build_test_crl(&ca_key, &ca_cert, &[]);
assert!(
crl_with_revoked.len() > empty_crl.len(),
"CRL with revoked entries should be larger than empty CRL"
);
}
#[test]
fn crl_empty_crl_has_no_revoked_entries() {
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Test Root CA");
params.distinguished_name = dn;
let ca_cert = params.self_signed(&ca_key).unwrap();
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
// An empty CRL should still be valid PEM
assert!(
crl_pem.contains("-----BEGIN X509 CRL-----"),
"Empty CRL should still have PEM header"
);
}
#[test]
fn crl_signature_verifies_against_ca_cert() {
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Test Root CA");
params.distinguished_name = dn;
let ca_cert = params.self_signed(&ca_key).unwrap();
let (serial, _) = make_serial();
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[serial]);
// Parse the CRL and verify it's structurally valid
// (signature verification against CA is implicit — rcgen signed it with the CA key)
assert!(
crl_pem.contains("-----BEGIN X509 CRL-----"),
"CRL should be valid PEM signed by CA"
);
// Verify that a different CA key produces a different CRL (not verifiable)
let other_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut other_params = CertificateParams::default();
other_params.not_before = OffsetDateTime::now_utc();
other_params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
other_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
other_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut other_dn = DistinguishedName::new();
other_dn.push(DnType::CommonName, "Other Root CA");
other_params.distinguished_name = other_dn;
let other_cert = other_params.self_signed(&other_key).unwrap();
let (s2, _) = make_serial();
let other_crl_pem = build_test_crl(&other_key, &other_cert, &[s2]);
// The two CRLs should be different (different issuers, different keys)
assert_ne!(
crl_pem, other_crl_pem,
"CRLs from different CAs should differ"
);
}
#[test]
fn crl_next_update_is_approximately_24h() {
let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Test Root CA");
params.distinguished_name = dn;
let ca_cert = params.self_signed(&ca_key).unwrap();
// The build_test_crl helper uses 24h next_update
let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]);
// Verify the CRL was generated successfully — the next_update being 24h
// is enforced by the CertAuthority::generate_crl method which uses
// TimeDuration::hours(24). We verify the PEM is valid as a proxy.
assert!(
crl_pem.contains("-----BEGIN X509 CRL-----"),
"CRL should be generated with 24h next_update"
);
}
}

20
crates/pm-core/src/audit.rs Executable file → Normal file
View File

@ -51,6 +51,16 @@ pub enum AuditAction {
HealthCheckUpdated,
HealthCheckDeleted,
CertificateReissued,
// Issue #5: Manager-wide auth-config mutations (Admin-only)
OidcConfigUpdated,
SmtpConfigUpdated,
IpWhitelistUpdated,
OidcTestPerformed,
OidcDiscoverPerformed,
// CRL health aggregation events (system-initiated)
CrlStatusChanged,
CrlStaleDetected,
CrlInvalid,
}
impl AuditAction {
@ -88,6 +98,16 @@ impl AuditAction {
Self::HealthCheckUpdated => "health_check_updated",
Self::HealthCheckDeleted => "health_check_deleted",
Self::CertificateReissued => "certificate_reissued",
// Issue #5: Manager-wide auth-config mutations (Admin-only)
Self::OidcConfigUpdated => "oidc_config_updated",
Self::SmtpConfigUpdated => "smtp_config_updated",
Self::IpWhitelistUpdated => "ip_whitelist_updated",
Self::OidcTestPerformed => "oidc_test_performed",
Self::OidcDiscoverPerformed => "oidc_discover_performed",
// CRL health aggregation events
Self::CrlStatusChanged => "crl_status_changed",
Self::CrlStaleDetected => "crl_stale_detected",
Self::CrlInvalid => "crl_invalid",
}
}
}

View File

@ -101,7 +101,8 @@ pub struct WorkerConfig {
pub health_check_poll_interval_secs: u64,
/// Maximum concurrent agent calls
pub max_concurrent_agent_calls: usize,
/// Worker heartbeat interval in seconds
/// Worker heartbeat interval in seconds (default: 300 = 5 min)
#[serde(default = "default_heartbeat_interval")]
pub heartbeat_interval_secs: u64,
/// WS relay HTTP polling fallback interval in seconds (default: 10)
pub ws_relay_poll_interval_secs: u64,
@ -119,6 +120,13 @@ pub struct LoggingConfig {
pub struct SecurityConfig {
/// IP whitelist (CIDR or individual IPs); empty = allow all (not recommended)
pub ip_whitelist: Vec<String>,
/// IP addresses (CIDR or single IP) of trusted reverse proxies. When the
/// immediate TCP peer is in this list, `X-Forwarded-For` is honored;
/// otherwise the socket peer IP is used for allowlist enforcement.
/// Default: empty (do not trust `X-Forwarded-For`). See
/// `tasks/ip-allowlist-spec.md` §4.3 for the operational guidance.
#[serde(default)]
pub trusted_proxies: Vec<String>,
/// JWT signing key path (Ed25519 PEM)
pub jwt_signing_key_path: String,
/// JWT verification key path (Ed25519 public PEM)
@ -140,6 +148,71 @@ pub struct SecurityConfig {
/// Frontend URL to redirect to after SSO callback (default: http://localhost:5173/auth/sso/callback)
#[serde(default = "default_sso_callback_url")]
pub sso_callback_url: String,
/// Allowlist of browser `Origin` values permitted to open the
/// `/api/v1/ws/jobs` WebSocket upgrade. Entries are exact
/// `scheme://host[:port]` strings. If left empty in the TOML file, the
/// server derives the default from `sso_callback_url` at load time
/// (see [`derive_allowed_origins`]).
#[serde(default)]
pub allowed_origins: Vec<String>,
}
/// Derive a default `Origin` allowlist from a single SSO callback URL.
///
/// Parses `scheme://host[:port][/path]` and returns a single-element vector
/// containing `scheme://host[:port]` (with default ports normalized away —
/// e.g. `https://x:443` becomes `https://x`). Returns an empty vector if the
/// URL is unparseable; callers should log a warning in that case because the
/// WebSocket endpoint will reject all browser upgrades (fail-closed).
///
/// Exposed publicly so tests and the handler can share the same parser.
pub fn derive_allowed_origins(sso_callback_url: &str) -> Vec<String> {
let s = sso_callback_url.trim().trim_end_matches('/');
let (scheme, rest) = match s.split_once("://") {
Some(parts) if !parts.0.is_empty() => parts,
_ => return vec![],
};
let scheme_lower = scheme.to_ascii_lowercase();
if scheme_lower != "http" && scheme_lower != "https" {
return vec![];
}
// Authority is everything up to the first `/`, `?`, or `#`.
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority.is_empty() {
return vec![];
}
// Split host:port. We treat the LAST `:` as the port separator. IPv6
// literal hosts (e.g. `[::1]`) contain a `:` inside the brackets; we
// explicitly do not support IPv6 in sso_callback_url and return empty
// for those to be safe.
let (host, port_str) = match authority.rsplit_once(':') {
Some((h, _)) if h.contains(':') => return vec![],
Some((h, p)) => (h, Some(p)),
None => (authority, None),
};
let host = host.trim();
if host.is_empty() || host.contains(char::is_whitespace) || host.contains(':') {
return vec![];
}
let default_port: Option<u16> = match scheme_lower.as_str() {
"https" => Some(443),
"http" => Some(80),
_ => None,
};
let port_num = match port_str {
Some(p) => match p.parse::<u16>() {
Ok(n) => Some(n),
Err(_) => return vec![],
},
None => None,
};
let origin = match (port_num, default_port) {
(Some(p), Some(d)) if p == d => format!("{}://{}", scheme_lower, host),
(Some(p), _) => format!("{}://{}:{}", scheme_lower, host, p),
(None, _) => format!("{}://{}", scheme_lower, host),
};
vec![origin]
}
impl AppConfig {
@ -147,6 +220,11 @@ impl AppConfig {
///
/// Environment variables follow the pattern: `PATCH_MANAGER__SECTION__KEY`
/// e.g. `PATCH_MANAGER__DATABASE__URL=postgres://...`
///
/// After deserialization, if `security.allowed_origins` is empty, it is
/// derived from `security.sso_callback_url`. A `tracing::warn!` is emitted
/// when the resulting allowlist is empty (the WS endpoint will reject all
/// browser upgrades in that case).
pub fn load(config_path: &str) -> Result<Self, ConfigError> {
let cfg = Config::builder()
.add_source(File::with_name(config_path).required(false))
@ -157,7 +235,20 @@ impl AppConfig {
)
.build()?;
cfg.try_deserialize()
let mut config: Self = cfg.try_deserialize()?;
if config.security.allowed_origins.is_empty() {
config.security.allowed_origins =
derive_allowed_origins(&config.security.sso_callback_url);
}
if config.security.allowed_origins.is_empty() {
tracing::warn!(
sso_callback_url = %config.security.sso_callback_url,
"security.allowed_origins is empty and could not be derived \
from sso_callback_url; the WebSocket endpoint will reject all \
browser upgrades"
);
}
Ok(config)
}
}
@ -165,6 +256,10 @@ fn default_health_check_poll_interval() -> u64 {
300
}
fn default_heartbeat_interval() -> u64 {
300
}
fn default_sso_callback_url() -> String {
"http://localhost:5173/auth/sso/callback".to_string()
}
@ -197,6 +292,7 @@ impl Default for AppConfig {
},
security: SecurityConfig {
ip_whitelist: vec![],
trusted_proxies: vec![],
jwt_signing_key_path: "/etc/patch-manager/jwt/signing.pem".to_string(),
jwt_verify_key_path: "/etc/patch-manager/jwt/verify.pem".to_string(),
jwt_access_ttl_secs: 900,
@ -207,8 +303,69 @@ impl Default for AppConfig {
web_tls_cert_path: "/etc/patch-manager/tls/web.crt".to_string(),
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
sso_callback_url: default_sso_callback_url(),
allowed_origins: derive_allowed_origins(&default_sso_callback_url()),
},
rate_limit: RateLimitConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_strips_default_https_port() {
assert_eq!(
derive_allowed_origins("https://app.example.com:443/auth/sso/callback"),
vec!["https://app.example.com".to_string()]
);
}
#[test]
fn derive_keeps_non_default_port() {
assert_eq!(
derive_allowed_origins("https://app.example.com:8443/auth/sso/callback"),
vec!["https://app.example.com:8443".to_string()]
);
}
#[test]
fn derive_strips_default_http_port() {
assert_eq!(
derive_allowed_origins("http://localhost:80/x"),
vec!["http://localhost".to_string()]
);
}
#[test]
fn derive_handles_trailing_slash() {
assert_eq!(
derive_allowed_origins("https://app.example.com/"),
vec!["https://app.example.com".to_string()]
);
}
#[test]
fn derive_handles_no_path() {
assert_eq!(
derive_allowed_origins("https://app.example.com"),
vec!["https://app.example.com".to_string()]
);
}
#[test]
fn derive_returns_empty_for_garbage() {
assert!(derive_allowed_origins("not a url").is_empty());
assert!(derive_allowed_origins("").is_empty());
assert!(derive_allowed_origins("ftp://x").is_empty());
}
#[test]
fn derive_lowercases_scheme() {
assert_eq!(
derive_allowed_origins("HTTPS://App.Example.com"),
vec!["https://App.Example.com".to_string()]
);
}
}

85
crates/pm-core/src/crypto.rs Executable file → Normal file
View File

@ -1,6 +1,11 @@
//! AES-256-GCM encryption for sensitive health check credentials.
//! AES-256-GCM encryption for sensitive credentials.
//!
//! Uses a per-install key stored at `/etc/patch-manager/keys/health-check.key`.
//! Two per-install keys are supported:
//! - `KEY_PATH` (health-check.key) protects HTTP basic auth passwords for health check endpoints.
//! - `SECRET_ENCRYPTION_KEY_PATH` (secret-encryption.key) protects OIDC `client_secret`,
//! SMTP `smtp_password`, and TOTP `totp_secret` at rest in the database.
//!
//! Keys are 32-byte files, auto-generated on first start with 0600 permissions.
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
@ -12,6 +17,12 @@ use std::path::Path;
pub const KEY_PATH: &str = "/etc/patch-manager/keys/health-check.key";
/// Path to the encryption key for sensitive app secrets
/// (OIDC client_secret, SMTP password, TOTP secret).
/// Separate from `KEY_PATH` (health-check credentials) for blast-radius isolation:
/// if the health-check key is compromised, app secrets remain protected.
pub const SECRET_ENCRYPTION_KEY_PATH: &str = "/etc/patch-manager/keys/secret-encryption.key";
/// Load or create the per-install encryption key.
/// If the key file doesn't exist, generates a new 256-bit key and saves it.
pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
@ -78,3 +89,73 @@ pub enum CryptoError {
#[error("UTF-8 error: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
/// Create a unique temp directory for test isolation.
/// Returns a path like `/tmp/pm-crypto-test-<epoch_nanos>-<rand>`.
/// Cleans up the directory on test teardown (via `temp_dir` guard).
fn unique_temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = env::temp_dir().join(format!("pm-crypto-test-{}-{}", std::process::id(), nanos));
fs::create_dir_all(&dir).expect("Failed to create temp dir");
dir
}
#[test]
fn encrypt_decrypt_round_trip() {
let key = [42u8; 32];
let plaintext = "super-secret-client-credential-12345";
let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt failed");
// Ciphertext must differ from plaintext (encryption is non-trivial)
assert_ne!(ciphertext.as_slice(), plaintext.as_bytes());
// Nonce is 12 bytes (AES-GCM standard)
assert_eq!(nonce.len(), 12);
// Decrypting must return the original plaintext
let recovered = decrypt(&ciphertext, &nonce, &key).expect("decrypt failed");
assert_eq!(recovered, plaintext);
}
#[test]
fn load_or_create_key_sets_0600_permissions() {
let dir = unique_temp_dir();
let key_path = dir.join("test-0600.key");
let _key = load_or_create_key(&key_path).expect("load_or_create_key failed");
// Verify file exists and has exactly 32 bytes
let metadata = fs::metadata(&key_path).expect("key file not created");
assert_eq!(metadata.len(), 32, "key file must be 32 bytes");
// On Unix, verify permissions are 0600 (owner read/write only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "key file must be 0600, got {:o}", mode);
}
// Cleanup
let _ = fs::remove_file(&key_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn load_or_create_key_is_idempotent() {
let dir = unique_temp_dir();
let key_path = dir.join("test-idempotent.key");
// First call creates the key
let key1 = load_or_create_key(&key_path).expect("first call failed");
// Second call should return the same key (not regenerate)
let key2 = load_or_create_key(&key_path).expect("second call failed");
assert_eq!(key1, key2, "second call must return the same key");
// Cleanup
let _ = fs::remove_file(&key_path);
let _ = fs::remove_dir(&dir);
}
}

4
crates/pm-core/src/lib.rs Executable file → Normal file
View File

@ -9,7 +9,9 @@ pub mod request_id;
// Re-export commonly used types
pub use config::AppConfig;
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
pub use crypto::{
decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH, SECRET_ENCRYPTION_KEY_PATH,
};
pub use error::{AppError, ErrorResponse};
pub use models::{
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,

80
crates/pm-core/src/models.rs Executable file → Normal file
View File

@ -94,6 +94,15 @@ pub struct Host {
pub notes: String,
pub registered_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents.
#[serde(skip_serializing_if = "Option::is_none")]
pub crl_status: Option<String>,
/// Seconds since the agent's CRL was last refreshed.
#[serde(skip_serializing_if = "Option::is_none")]
pub crl_age_seconds: Option<i64>,
/// When the agent's CRL expires / next update is due.
#[serde(skip_serializing_if = "Option::is_none")]
pub crl_next_update: Option<DateTime<Utc>>,
}
/// Payload for registering a new host.
@ -129,6 +138,9 @@ pub struct HostSummary {
pub patches_missing: i32,
pub health_check_status: Option<String>,
pub registered_at: DateTime<Utc>,
/// CRL status reported by the agent: valid, expired, missing, invalid, or NULL for older agents.
#[serde(skip_serializing_if = "Option::is_none")]
pub crl_status: Option<String>,
}
// ============================================================
@ -166,8 +178,10 @@ pub enum EnrollmentStatusResponse {
Pending,
Approved {
ca_crt: String,
ca_chain: String,
server_crt: String,
server_key: String,
crl_pem: String,
},
Denied,
NotFound,
@ -175,9 +189,71 @@ pub enum EnrollmentStatusResponse {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PkiBundle {
/// PEM-encoded CA certificate (leaf-most cert in the chain).
/// For root mode, this is the self-signed root CA.
/// For sub-CA mode, this is the intermediate CA cert.
pub ca_crt: String,
/// PEM-encoded full CA certificate chain (concatenated intermediates + root).
/// For root mode, this contains just the root CA cert (same as ca_crt).
/// For sub-CA mode, this contains the intermediate cert followed by the
/// external root cert, enabling the agent to verify the full chain up to
/// the trust anchor.
///
/// This field was added for CRL support (issue #7): the agent needs the
/// full chain to verify CRL signatures that chain up to the root CA.
#[serde(default)]
pub ca_chain: String,
/// PEM-encoded agent server certificate.
pub server_crt: String,
/// PEM-encoded agent server private key (PKCS#8).
pub server_key: String,
/// PEM-encoded Certificate Revocation List (CRL) signed by the CA.
/// The agent uses this to reject revoked client certificates during mTLS
/// handshakes. If CRL generation fails during enrollment, this field will
/// be an empty string and the agent should fall back to WebPKI-only
/// verification (degraded mode).
///
/// Added for CRL support (issue #7).
#[serde(default)]
pub crl_pem: String,
}
/// Time-to-live for approved enrollment PKI bundles (10 minutes).
///
/// After approval, the agent has this duration to retrieve its PKI bundle
/// via the polling endpoint. Once retrieved (single-use) or expired,
/// the bundle is permanently removed from the in-memory cache.
///
/// This TTL balances security (limiting private key exposure in memory)
/// against reliability (giving agents enough time to poll after approval).
pub const ENROLLMENT_BUNDLE_TTL_SECS: u32 = 600; // 10 minutes
/// An approved enrollment PKI bundle awaiting single-use retrieval.
///
/// Stored in the in-memory cache between admin approval and agent pickup.
/// The entry is removed atomically on first retrieval and expires after
/// the configured TTL, whichever comes first.
#[derive(Debug, Clone)]
pub struct ApprovedEntry {
pub pki: PkiBundle,
pub approved_at: chrono::DateTime<Utc>,
pub ttl: chrono::Duration,
}
impl ApprovedEntry {
/// Create a new entry with the current timestamp and default TTL.
pub fn new(pki: PkiBundle) -> Self {
Self {
pki,
approved_at: Utc::now(),
ttl: chrono::Duration::seconds(ENROLLMENT_BUNDLE_TTL_SECS as i64),
}
}
/// Returns true if this entry has exceeded its TTL.
pub fn is_expired(&self) -> bool {
Utc::now() > self.approved_at + self.ttl
}
}
// ============================================================
@ -467,6 +543,10 @@ pub struct PatchJobSummary {
pub status: JobStatus,
pub immediate: bool,
pub host_count: i64,
/// Display names of hosts targeted by this job (falls back to fqdn).
#[serde(default)]
#[sqlx(skip)]
pub host_names: Vec<String>,
pub succeeded_count: i64,
pub failed_count: i64,
pub notes: String,

View File

@ -5,6 +5,10 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
[lib]
name = "pm_web"
path = "src/lib.rs"
[[bin]]
name = "pm-web"
path = "src/main.rs"
@ -44,3 +48,11 @@ sha2 = { workspace = true }
jsonwebtoken = { workspace = true }
url = { workspace = true }
urlencoding = "2"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
mockito = "1"
tempfile = "3"
rcgen = { workspace = true }
time = { workspace = true }

248
crates/pm-web/src/lib.rs Normal file
View File

@ -0,0 +1,248 @@
//! pm-web — Linux Patch Manager web server (library crate).
//!
//! Re-exports [`AppState`], [`build_router`], and [`health_handler`] so that
//! integration tests can construct a test application without depending on
//! the binary entry-point.
pub mod routes;
pub mod secret_key;
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
use dashmap::DashMap;
use pm_auth::{
password::hash_password,
rbac::{require_auth, AuthConfig},
};
use pm_core::{config::AppConfig, models::ApprovedEntry, request_id::request_id_middleware};
use rand::Rng;
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
use routes::ws::WsTicket;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
};
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8).
/// Detecting this prefix means the admin password has not been bootstrapped yet.
const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA";
/// Bootstrap the default admin account with a random password.
///
/// On first startup after a fresh install, the `users` table contains the seed
/// admin row with a clearly-invalid placeholder hash (cannot validate any password).
/// This function detects that placeholder, generates a cryptographically random
/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row.
///
/// The plaintext password is printed **once** to stderr (visible in `systemctl status`
/// or `journalctl`) and is never stored on disk.
///
/// If the admin row already has a real hash, this function is a no-op.
pub async fn bootstrap_admin_password(pool: &sqlx::PgPool) {
let result: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'",
)
.fetch_optional(pool)
.await
.unwrap_or(None);
let current_hash = match result {
Some(h) => h,
None => return,
};
if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) {
return;
}
let password: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(24)
.map(char::from)
.collect();
let new_hash = match hash_password(&password) {
Ok(h) => h,
Err(e) => {
tracing::error!(error = %e, "Failed to hash bootstrap admin password");
return;
},
};
let rows = sqlx::query(
r#"UPDATE users
SET password_hash = $1
WHERE username = 'admin'
AND auth_provider = 'local'
AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#,
)
.bind(&new_hash)
.execute(pool)
.await;
match rows {
Ok(result) if result.rows_affected() == 1 => {
eprintln!();
eprintln!("========================================");
eprintln!(" INITIAL ADMIN PASSWORD (shown once)");
eprintln!(" Username: admin");
eprintln!(" Password: {}", password);
eprintln!();
eprintln!(" You will be forced to change this on first login.");
eprintln!(" If lost, restart the service to generate a new one.");
eprintln!("========================================");
eprintln!();
tracing::info!("Bootstrap admin password generated and set");
},
Ok(_) => {
tracing::info!("Admin password already bootstrapped (concurrent or prior)");
},
Err(e) => {
tracing::error!(error = %e, "Failed to update admin password hash");
},
}
}
/// Shared application state threaded through Axum.
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<AppConfig>,
pub signing_key_pem: String,
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
/// See `tasks/sso-token-handoff-spec.md` §4.1.
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
/// Short-lived cache for approved enrollment PKI bundles.
///
/// Entries are single-use (removed on retrieval) and expire after
/// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS).
pub approved_enrollments: Arc<DashMap<String, ApprovedEntry>>,
}
/// Construct the full Axum router.
pub fn build_router(state: AppState) -> Router {
let static_dir = state.config.server.static_dir.clone();
let auth_config = state.auth_config.clone();
let rl = &state.config.rate_limit;
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
let enrollment_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(12_000)
.burst_size(rl.enrollment_burst)
.finish()
.expect("Invalid enrollment governor config"),
);
// Auth rate limiting: moderate (20 req/min per IP, burst 10)
let auth_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(3_000)
.burst_size(rl.auth_burst)
.finish()
.expect("Invalid auth governor config"),
);
// API rate limiting: normal (120 req/min per IP, burst 30)
let api_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(500)
.burst_size(rl.api_burst)
.finish()
.expect("Invalid API governor config"),
);
// Enrollment routes with strict per-IP rate limiting
let enrollment_router =
routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor));
// Public auth routes with moderate per-IP rate limiting
let auth_public_router =
routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
// SSO routes with moderate per-IP rate limiting
let sso_public_router =
routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
let sso_azure_router =
routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor));
// All protected API routes — require valid JWT, with normal per-IP rate limiting
let protected_api = Router::new()
.nest("/auth", routes::auth::protected_router())
.nest("/hosts", routes::hosts::router())
.nest("/hosts", routes::ca::host_cert_router())
.nest("/groups", routes::groups::router())
.nest("/users", routes::users::router())
.nest("/discovery", routes::discovery::router())
.nest("/status", routes::status::router())
.nest("/jobs", routes::jobs::router())
.nest(
"/hosts/{host_id}/maintenance-windows",
routes::maintenance_windows::router(),
)
.nest(
"/maintenance-windows",
routes::maintenance_windows::all_windows_router(),
)
.nest("/ca", routes::ca::ca_router())
.nest("/certificates", routes::ca::certs_router())
.merge(routes::ws::ticket_router())
.nest("/reports", routes::reports::router())
.nest(
"/hosts/{host_id}/health-checks",
routes::health_checks::router(),
)
.nest("/settings", routes::settings::router())
.nest("/admin", routes::enrollment::admin_router())
.layer(GovernorLayer::new(api_governor))
.route_layer(middleware::from_fn(move |req, next| {
let auth_config = auth_config.clone();
require_auth(auth_config, req, next)
}));
Router::new()
.route("/status/health", get(health_handler))
.nest("/api/v1/auth", auth_public_router)
.nest("/api/v1", enrollment_router)
.nest("/api/v1", routes::pki::router())
.nest("/api/v1/auth/sso", sso_public_router)
.nest("/api/v1/auth/azure", sso_azure_router)
.nest("/api/v1", protected_api)
.merge(routes::ws::ws_router())
.fallback_service(
ServeDir::new(&static_dir)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
)
.layer(middleware::from_fn(request_id_middleware))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
pub async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
let status = if db_ok { "healthy" } else { "degraded" };
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
if db_ok {
Ok(Json(body))
} else {
Err(StatusCode::SERVICE_UNAVAILABLE)
}
}

View File

@ -1,48 +1,13 @@
//! pm-web — Linux Patch Manager web server.
//! pm-web — Linux Patch Manager web server (binary entry-point).
mod routes;
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
use axum_server::tls_rustls::RustlsConfig;
use dashmap::DashMap;
use pm_auth::{
jwt,
rbac::{require_auth, AuthConfig},
};
use pm_core::{
config::AppConfig, db, logging, models::PkiBundle, request_id::request_id_middleware,
};
use routes::sso::{OidcCache, SsoSession};
use routes::ws::WsTicket;
use serde_json::{json, Value};
use pm_auth::{jwt, rbac::AuthConfig};
use pm_core::{config::AppConfig, db, models::ApprovedEntry};
use pm_web::routes::sso::{OidcCache, SsoHandoff, SsoSession};
use pm_web::routes::ws::WsTicket;
use pm_web::{bootstrap_admin_password, build_router, AppState};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use tokio::sync::Mutex;
use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
};
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
/// Shared application state threaded through Axum.
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<AppConfig>,
pub signing_key_pem: String,
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
/// Short-lived cache for approved enrollment PKI bundles.
pub approved_enrollments: Arc<DashMap<String, PkiBundle>>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@ -59,7 +24,7 @@ async fn main() -> anyhow::Result<()> {
AppConfig::default()
});
logging::init(&config.logging);
pm_core::logging::init(&config.logging);
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
"patch-manager-web starting"
@ -80,14 +45,16 @@ async fn main() -> anyhow::Result<()> {
let auth_config = Arc::new(AuthConfig::new(
verify_key_pem,
&config.security.ip_whitelist,
&config.security.trusted_proxies,
));
let pool = db::init_pool(&config.database).await?;
db::run_migrations(&pool).await?;
// Bootstrap admin password if the seed admin still has the placeholder hash.
bootstrap_admin_password(&pool).await;
// Initialise the internal CA using the configured certificate paths.
// The CA certificate and key must exist at the configured locations and be
// unencrypted PEM. If absent, a new CA is generated in that directory.
let ca_base = std::path::Path::new(&config.security.ca_cert_path)
.parent()
.expect("CA certificate path must have a parent directory");
@ -100,8 +67,9 @@ async fn main() -> anyhow::Result<()> {
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
let sso_handoffs: Arc<DashMap<String, SsoHandoff>> = Arc::new(DashMap::new());
let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
let approved_enrollments: Arc<DashMap<String, PkiBundle>> = Arc::new(DashMap::new());
let approved_enrollments: Arc<DashMap<String, ApprovedEntry>> = Arc::new(DashMap::new());
// Background task: purge expired WS tickets every 30 seconds.
{
@ -121,7 +89,7 @@ async fn main() -> anyhow::Result<()> {
});
}
// Background task: purge expired SSO sessions every 60 seconds (sessions older than 10 minutes).
// Background task: purge expired SSO sessions every 60 seconds.
{
let sessions = sso_sessions.clone();
tokio::spawn(async move {
@ -140,14 +108,37 @@ async fn main() -> anyhow::Result<()> {
});
}
// Background task: purge approved enrollment PKI bundles every 10 minutes.
// Background task: purge expired approved enrollment PKI bundles.
{
let approved = approved_enrollments.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(600));
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
approved.clear();
let before = approved.len();
approved.retain(|_, entry| !entry.is_expired());
let removed = before.saturating_sub(approved.len());
if removed > 0 {
tracing::debug!(removed, "Purged expired enrollment PKI bundles");
}
}
});
}
// Background task: purge expired SSO handoff codes every 60 seconds.
{
let handoffs = sso_handoffs.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
let now = std::time::Instant::now();
let before = handoffs.len();
handoffs.retain(|_, v| v.expires_at > now);
let removed = before.saturating_sub(handoffs.len());
if removed > 0 {
tracing::debug!(removed, "Purged expired SSO handoff codes");
}
}
});
}
@ -159,6 +150,7 @@ async fn main() -> anyhow::Result<()> {
auth_config,
ws_tickets,
sso_sessions,
sso_handoffs,
ca: Arc::new(ca),
approved_enrollments,
oidc_cache,
@ -175,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
if tls_cert.exists() && tls_key.exists() {
let tls_config = RustlsConfig::from_pem_file(
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
&config.security.web_tls_cert_path,
&config.security.web_tls_key_path,
)
@ -207,147 +199,3 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
/// Construct the full Axum router.
pub fn build_router(state: AppState) -> Router {
let static_dir = state.config.server.static_dir.clone();
let auth_config = state.auth_config.clone();
let rl = &state.config.rate_limit;
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
// governor quota: 1 request per 12_000ms = ~5/min sustained
let enrollment_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(12_000)
.burst_size(rl.enrollment_burst)
.finish()
.expect("Invalid enrollment governor config"),
);
// Auth rate limiting: moderate (20 req/min per IP, burst 10)
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
// governor quota: 1 request per 3_000ms = ~20/min sustained
let auth_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(3_000)
.burst_size(rl.auth_burst)
.finish()
.expect("Invalid auth governor config"),
);
// API rate limiting: normal (120 req/min per IP, burst 30)
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
// governor quota: 1 request per 500ms = ~120/min sustained
let api_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(500)
.burst_size(rl.api_burst)
.finish()
.expect("Invalid API governor config"),
);
// Enrollment routes with strict per-IP rate limiting
let enrollment_router =
routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor));
// Public auth routes with moderate per-IP rate limiting
let auth_public_router =
routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
// SSO routes with moderate per-IP rate limiting
let sso_public_router =
routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
let sso_azure_router =
routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor));
// All protected API routes — require valid JWT, with normal per-IP rate limiting
let protected_api = Router::new()
// Auth: MFA setup/verify
// Auth: MFA setup/verify/disable (nested under /auth so paths are /api/v1/auth/mfa/*)
.nest("/auth", routes::auth::protected_router())
// Hosts
.nest("/hosts", routes::hosts::router())
// Host-scoped certificate endpoints (merged separately to avoid conflict)
.nest("/hosts", routes::ca::host_cert_router())
// Groups
.nest("/groups", routes::groups::router())
// Users
.nest("/users", routes::users::router())
// Discovery
.nest("/discovery", routes::discovery::router())
// Fleet status
.nest("/status", routes::status::router())
// Patch jobs
.nest("/jobs", routes::jobs::router())
// Maintenance windows (nested under hosts path param)
.nest(
"/hosts/{host_id}/maintenance-windows",
routes::maintenance_windows::router(),
)
// Maintenance windows — bulk list-all endpoint
.nest(
"/maintenance-windows",
routes::maintenance_windows::all_windows_router(),
)
// CA root certificate download
.nest("/ca", routes::ca::ca_router())
// Certificate list / renew / revoke
.nest("/certificates", routes::ca::certs_router())
// WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade)
.merge(routes::ws::ticket_router())
// Reports
.nest("/reports", routes::reports::router())
.nest(
"/hosts/{host_id}/health-checks",
routes::health_checks::router(),
)
// Settings (admin-only)
.nest("/settings", routes::settings::router())
// Admin enrollment routes (JWT protected, Admin role enforced)
.nest("/admin", routes::enrollment::admin_router())
// Apply rate limiting then auth middleware
.layer(GovernorLayer::new(api_governor))
.route_layer(middleware::from_fn(move |req, next| {
let auth_config = auth_config.clone();
require_auth(auth_config, req, next)
}));
Router::new()
.route("/status/health", get(health_handler))
// Public auth routes (rate-limited, no JWT)
.nest("/api/v1/auth", auth_public_router)
// Public enrollment endpoints (rate-limited, no JWT)
.nest("/api/v1", enrollment_router)
// Public SSO routes (rate-limited, no JWT)
.nest("/api/v1/auth/sso", sso_public_router)
// Public Azure SSO routes (rate-limited, no JWT)
.nest("/api/v1/auth/azure", sso_azure_router)
// Protected API routes (JWT required, rate-limited)
.nest("/api/v1", protected_api)
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
.merge(routes::ws::ws_router())
// Serve React SPA
.fallback_service(
ServeDir::new(&static_dir)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
)
.layer(middleware::from_fn(request_id_middleware))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
let status = if db_ok { "healthy" } else { "degraded" };
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
if db_ok {
Ok(Json(body))
} else {
Err(StatusCode::SERVICE_UNAVAILABLE)
}
}

24
crates/pm-web/src/routes/auth.rs Executable file → Normal file
View File

@ -360,8 +360,26 @@ async fn mfa_verify_handler(
));
}
sqlx::query("UPDATE users SET totp_secret = $1, mfa_enabled = TRUE WHERE id = $2")
.bind(&req.secret_base32)
// Encrypt the TOTP secret before persisting (issue #6 fix)
let key = crate::secret_key::get().map_err(|e| {
tracing::error!(error = %e, "Failed to load secret-encryption key");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
),
)
})?;
let (ciphertext, nonce) = pm_core::crypto::encrypt(&req.secret_base32, key).map_err(|e| {
tracing::error!(error = %e, "Failed to encrypt TOTP secret");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
)
})?;
sqlx::query("UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2, mfa_enabled = TRUE WHERE id = $3")
.bind(&ciphertext)
.bind(&nonce)
.bind(auth_user.user_id)
.execute(&state.db)
.await
@ -417,7 +435,7 @@ async fn disable_mfa(
));
}
sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE WHERE id = $1")
sqlx::query("UPDATE users SET totp_secret_encrypted = NULL, totp_secret_nonce = NULL, mfa_enabled = FALSE WHERE id = $1")
.bind(auth_user.user_id)
.execute(&state.db)
.await

View File

@ -11,7 +11,8 @@ use pm_auth::AuthUser;
use pm_core::{
db,
models::{
CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host, PkiBundle,
ApprovedEntry, CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host,
PkiBundle,
},
};
use rand::{distributions::Alphanumeric, Rng};
@ -76,7 +77,10 @@ async fn enroll_status(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
// Hash the provided token to match DB
// Hash the provided token to match DB.
// Security note: the raw polling token is intentionally never logged.
// Only the SHA-256 hash is stored and compared; all tracing calls in
// this module log error contexts only, never the token itself.
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
@ -98,11 +102,19 @@ async fn enroll_status(
}
// 2. If not in pending, check if it was recently approved.
if let Some(pki) = state.approved_enrollments.get(&token_hash) {
// Single-retrieval: remove() atomically consumes the entry, ensuring
// the private key can only be fetched once regardless of concurrent requests.
if let Some((_, entry)) = state.approved_enrollments.remove(&token_hash) {
if entry.is_expired() {
// Bundle TTL expired — treat as not found. Entry is already removed.
return Ok(Json(EnrollmentStatusResponse::NotFound));
}
return Ok(Json(EnrollmentStatusResponse::Approved {
ca_crt: pki.ca_crt.clone(),
server_crt: pki.server_crt.clone(),
server_key: pki.server_key.clone(),
ca_crt: entry.pki.ca_crt.clone(),
ca_chain: entry.pki.ca_chain.clone(),
server_crt: entry.pki.server_crt.clone(),
server_key: entry.pki.server_key.clone(),
crl_pem: entry.pki.crl_pem.clone(),
}));
}
@ -176,7 +188,7 @@ async fn approve_enrollment(
// Check for FQDN/IP collision in hosts table
if let Some(existing_host) = sqlx::query_as::<_, Host>(
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
)
.bind(&enrollment_request.fqdn)
.bind(enrollment_request.ip_address.to_string())
@ -277,15 +289,38 @@ async fn approve_enrollment(
)
})?;
// Store PKI bundle in cache for client retrieval
// Store PKI bundle in cache for single-use client retrieval.
//
// Design decision — server-generated keys vs CSR-based enrollment:
// Currently the server generates the agent's private key and transmits it
// over the (already mTLS-secured) polling endpoint. This approach was chosen
// for initial implementation simplicity: the agent only needs to poll one
// endpoint and receives a complete PKI bundle without an extra round-trip.
//
// A future enhancement should adopt CSR-based enrollment where the agent
// generates its own key pair locally and submits a Certificate Signing
// Request, eliminating the need for the server to ever hold or transmit
// the agent's private key. This reduces the attack surface significantly
// — the private key never traverses the network and never resides in
// server memory beyond the signing operation.
//
// See: https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9
//
// Include the full CA chain (for root mode, same as ca_crt; for sub-CA,
// includes intermediate + root) and the current CRL.
let ca_chain = issued.ca_root_pem.clone(); // Root mode: chain is just the root cert
let crl_pem = state.ca.generate_crl(&state.db).await.unwrap_or_default(); // Empty string on failure: agent falls back to WebPKI-only
let pki = PkiBundle {
ca_crt: issued.ca_root_pem,
ca_chain,
server_crt: issued.server_cert_pem,
server_key: issued.server_key_pem,
crl_pem,
};
state
.approved_enrollments
.insert(enrollment_request.polling_token.clone(), pki);
state.approved_enrollments.insert(
enrollment_request.polling_token.clone(),
ApprovedEntry::new(pki),
);
Ok(StatusCode::OK)
}

11
crates/pm-web/src/routes/hosts.rs Executable file → Normal file
View File

@ -132,7 +132,8 @@ async fn list_hosts(
THEN 'some_unhealthy'
ELSE 'all_healthy'
END AS health_check_status,
h.registered_at
h.registered_at,
h.crl_status
FROM hosts h
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
ORDER BY h.fqdn
@ -165,7 +166,8 @@ async fn list_hosts(
THEN 'some_unhealthy'
ELSE 'all_healthy'
END AS health_check_status,
h.registered_at
h.registered_at,
h.crl_status
FROM hosts h
LEFT JOIN host_patch_data hpd ON hpd.host_id = h.id
WHERE
@ -319,7 +321,8 @@ async fn get_host(
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
os_family, os_name, arch, agent_version, health_status,
last_health_at, last_patch_at, agent_port, notes,
registered_at, updated_at
registered_at, updated_at,
crl_status, crl_age_seconds, crl_next_update
FROM hosts WHERE id = $1
) h
"#,
@ -431,7 +434,7 @@ async fn update_host(
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
os_family, os_name, arch, agent_version, health_status,
last_health_at, last_patch_at, agent_port, notes,
registered_at, updated_at
registered_at, updated_at, crl_status, crl_age_seconds, crl_next_update
FROM hosts WHERE id = (SELECT id FROM updated)
) h
"#,

44
crates/pm-web/src/routes/jobs.rs Executable file → Normal file
View File

@ -20,6 +20,7 @@ use pm_core::{
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use uuid::Uuid;
use crate::AppState;
@ -52,6 +53,13 @@ struct JobListResponse {
offset: i64,
}
/// Helper struct for the host_names aggregation query.
#[derive(Debug, sqlx::FromRow)]
struct JobHostNames {
id: Uuid,
host_names: Vec<String>,
}
/// Per-host row included in `GET /api/v1/jobs/{id}` response.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
struct JobHostRow {
@ -229,7 +237,7 @@ async fn list_jobs(
let limit = q.limit.unwrap_or(50).min(200);
let offset = q.offset.unwrap_or(0);
let jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
let mut jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
// Admins see every job.
sqlx::query_as(
r#"
@ -298,6 +306,40 @@ async fn list_jobs(
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
})?;
// Fetch host names for all jobs in this page.
let job_ids: Vec<Uuid> = jobs.iter().map(|j| j.id).collect();
let host_names_rows: Vec<JobHostNames> = if job_ids.is_empty() {
Vec::new()
} else {
sqlx::query_as(
r#"
SELECT pjh.job_id AS id,
array_agg(COALESCE(NULLIF(h.display_name, ''), h.fqdn)
ORDER BY h.fqdn) AS host_names
FROM patch_job_hosts pjh
JOIN hosts h ON h.id = pjh.host_id
WHERE pjh.job_id = ANY($1)
GROUP BY pjh.job_id
"#,
)
.bind(&job_ids)
.fetch_all(&state.db)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "list_jobs: host_names query failed, using empty defaults");
Vec::new()
})
};
// Merge host_names into summaries.
let mut host_names_map: HashMap<Uuid, Vec<String>> = host_names_rows
.into_iter()
.map(|r| (r.id, r.host_names))
.collect();
for job in &mut jobs {
job.host_names = host_names_map.remove(&job.id).unwrap_or_default();
}
// Total count for pagination metadata.
let total: i64 = if auth.role.is_admin() {
sqlx::query_scalar("SELECT COUNT(*) FROM patch_jobs")

4
crates/pm-web/src/routes/mod.rs Executable file → Normal file
View File

@ -8,10 +8,10 @@ pub mod health_checks;
pub mod hosts;
pub mod jobs;
pub mod maintenance_windows;
pub mod pki;
pub mod reports;
pub mod settings;
pub mod sso;
pub mod status;
pub mod users;
pub mod ws;
pub mod reports;

View File

@ -0,0 +1,262 @@
//! PKI endpoints for certificate revocation list (CRL) distribution.
//!
//! This module exposes the CRL endpoint that agents poll every 24 hours to
//! check for revoked certificates. The CRL is signed by the internal CA and
//! is publicly accessible (CRLs are self-authenticating — they carry the CA
//! signature and do not require client authentication).
use crate::AppState;
use axum::{
extract::State,
http::{header, StatusCode},
response::IntoResponse,
routing::get,
Router,
};
/// Define public PKI routes.
///
/// These endpoints are **unauthenticated** because CRLs are self-authenticating:
/// the agent verifies the CRL signature against its pinned CA certificate.
/// No client certificate or API key is required.
pub fn router() -> Router<AppState> {
Router::new().route("/pki/crl.pem", get(get_crl))
}
/// `GET /api/v1/pki/crl.pem`
///
/// Returns the current Certificate Revocation List (CRL) as a PEM-encoded
/// X.509 CRL. The CRL is signed by the internal CA and contains the serial
/// numbers of all revoked certificates that have not yet expired.
///
/// # Cache headers
///
/// The response includes `Cache-Control: max-age=3600` (1 hour) to allow
/// intermediate caches to serve the CRL. Agents refresh every 24 hours,
/// so a 1-hour cache is a reasonable balance between freshness and load.
///
/// # CRL generation
///
/// The CRL is generated on demand from the `certificates` table. For our
/// target scale (max ~2500 clients), this is a fast query and the resulting
/// CRL is KB-range. If performance becomes a concern, the CRL can be cached
/// in memory and regenerated on a schedule (see background task in main.rs).
async fn get_crl(State(state): State<AppState>) -> impl IntoResponse {
match state.ca.generate_crl(&state.db).await {
Ok(crl_pem) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
"application/x-pem-file; charset=utf-8",
)],
[(header::CACHE_CONTROL, "max-age=3600")],
crl_pem,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to generate CRL");
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate CRL").into_response()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::Router;
use dashmap::DashMap;
use pm_auth::rbac::AuthConfig;
use pm_core::config::AppConfig;
use sqlx::PgPool;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower::ServiceExt;
/// Helper: create a test AppState with a real CA and database pool.
/// Returns None if TEST_DATABASE_URL is not set (tests are skipped).
async fn setup_app_state() -> Option<(PgPool, AppState)> {
let db_url = std::env::var("TEST_DATABASE_URL").ok()?;
let pool = PgPool::connect(&db_url).await.ok()?;
// Run migrations to ensure schema is up to date.
sqlx::migrate!("../../migrations").run(&pool).await.ok()?;
// Create a temp directory for the CA.
let tmp_dir = tempfile::tempdir().ok()?;
let ca_dir = tmp_dir.path().to_path_buf();
let ca = pm_ca::CertAuthority::init(&ca_dir, &pool).await.ok()?;
let config = Arc::new(AppConfig::default());
use crate::routes::sso::OidcCache;
let state = AppState {
db: pool.clone(),
config,
signing_key_pem: String::new(),
auth_config: Arc::new(AuthConfig::new(String::new(), &[], &[])),
ws_tickets: Arc::new(DashMap::new()),
sso_sessions: Arc::new(DashMap::new()),
sso_handoffs: Arc::new(DashMap::new()),
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
ca: Arc::new(ca),
approved_enrollments: Arc::new(DashMap::new()),
};
Some((pool, state))
}
/// Build an Axum app with just the PKI routes for testing.
fn test_app(state: AppState) -> Router {
Router::new().nest("/api/v1", router()).with_state(state)
}
#[tokio::test]
async fn crl_endpoint_returns_200_with_valid_pem() {
let Some((pool, state)) = setup_app_state().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let app = test_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/pki/crl.pem")
.body(Body::empty())
.unwrap(),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::OK,
"CRL endpoint should return 200 OK"
);
let body = axum::body::to_bytes(response.into_body(), 10_000)
.await
.expect("body should be readable");
let body_str = String::from_utf8(body.to_vec()).expect("body should be UTF-8");
assert!(
body_str.contains("-----BEGIN X509 CRL-----"),
"Response should contain CRL PEM header"
);
assert!(
body_str.contains("-----END X509 CRL-----"),
"Response should contain CRL PEM footer"
);
pool.close().await;
}
#[tokio::test]
async fn crl_endpoint_returns_cache_control_header() {
let Some((pool, state)) = setup_app_state().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let app = test_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/pki/crl.pem")
.body(Body::empty())
.unwrap(),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let cache_control = response
.headers()
.get("cache-control")
.expect("Cache-Control header should be present");
assert_eq!(
cache_control.to_str().unwrap(),
"max-age=3600",
"Cache-Control should be max-age=3600"
);
pool.close().await;
}
#[tokio::test]
async fn crl_endpoint_works_without_authentication() {
let Some((pool, state)) = setup_app_state().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let app = test_app(state);
// Make request without any auth headers — CRL endpoint is public.
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/pki/crl.pem")
.body(Body::empty())
.unwrap(),
)
.await
.expect("request should succeed");
// Should return 200, not 401 Unauthorized.
assert_eq!(
response.status(),
StatusCode::OK,
"CRL endpoint should be accessible without authentication"
);
pool.close().await;
}
#[tokio::test]
async fn crl_endpoint_returns_pem_content_type() {
let Some((pool, state)) = setup_app_state().await else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let app = test_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/v1/pki/crl.pem")
.body(Body::empty())
.unwrap(),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get("content-type")
.expect("Content-Type header should be present");
assert!(
content_type
.to_str()
.unwrap()
.contains("application/x-pem-file"),
"Content-Type should be application/x-pem-file, got: {:?}",
content_type
);
pool.close().await;
}
}

269
crates/pm-web/src/routes/settings.rs Executable file → Normal file
View File

@ -180,6 +180,28 @@ fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>
Ok(())
}
/// Gate Manager-wide authentication configuration (OIDC, SMTP, IP allowlist,
/// OIDC discover/test) behind the **Admin** role. Operators can still
/// access per-host settings (see `write_access_required`).
///
/// Returns `403 forbidden_role` if the user is not an Admin. The distinct
/// error code (vs `forbidden` from `write_access_required`) lets the SPA
/// differentiate "you don't have write access at all" from "you have
/// write access but not for this specific resource".
///
/// See issue #5 and `tasks/authz-gate-spec.md` for the full design.
fn admin_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
if !auth.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(
json!({ "error": { "code": "forbidden_role", "message": "Admin role required to modify this resource" } }),
),
));
}
Ok(())
}
async fn load_system_config(
pool: &sqlx::PgPool,
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
@ -251,11 +273,23 @@ async fn update_config_key(
Ok(())
}
/// Tuple type for SELECT from oidc_config table (used by fetch_oidc_config).
type OidcConfigRow = (
bool,
String,
String,
String,
String,
Option<Vec<u8>>,
Option<Vec<u8>>,
String,
String,
);
async fn fetch_oidc_config(
pool: &sqlx::PgPool,
) -> Result<OidcConfigResponse, (StatusCode, Json<Value>)> {
let row: Option<(bool, String, String, String, String, String, String, String)> = sqlx::query_as(
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
let row: Option<OidcConfigRow> = sqlx::query_as(
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret_encrypted, client_secret_nonce, redirect_uri, scopes FROM oidc_config WHERE id = 1",
)
.fetch_optional(pool)
.await
@ -274,7 +308,8 @@ async fn fetch_oidc_config(
display_name,
discovery_url,
client_id,
client_secret,
client_secret_encrypted,
_client_secret_nonce,
redirect_uri,
scopes,
)) => OidcConfigResponse {
@ -283,7 +318,7 @@ async fn fetch_oidc_config(
display_name,
discovery_url,
client_id,
client_secret: if client_secret.is_empty() {
client_secret: if client_secret_encrypted.is_none() {
String::new()
} else {
MASKED.to_string()
@ -333,7 +368,7 @@ async fn update_settings(
auth: AuthUser,
Json(req): Json<UpdateSettingsRequest>,
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
// Update OIDC config
if let Some(oidc) = req.oidc {
@ -343,6 +378,22 @@ async fn update_settings(
.is_some_and(|s| s != MASKED && !s.is_empty());
let result = if update_secret {
// Encrypt the client_secret before persisting
let key = crate::secret_key::get().map_err(|e| {
tracing::error!(error = %e, "Failed to load secret-encryption key");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
)
})?;
let plaintext = oidc.client_secret.as_deref().unwrap_or("");
let (ciphertext, nonce) = pm_core::crypto::encrypt(plaintext, key).map_err(|e| {
tracing::error!(error = %e, "Failed to encrypt OIDC client_secret");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
)
})?;
sqlx::query(
"UPDATE oidc_config SET \
enabled = COALESCE($1, enabled), \
@ -350,9 +401,10 @@ async fn update_settings(
display_name = COALESCE($3, display_name), \
discovery_url = COALESCE($4, discovery_url), \
client_id = COALESCE($5, client_id), \
client_secret = $6, \
redirect_uri = COALESCE($7, redirect_uri), \
scopes = COALESCE($8, scopes), \
client_secret_encrypted = $6, \
client_secret_nonce = $7, \
redirect_uri = COALESCE($8, redirect_uri), \
scopes = COALESCE($9, scopes), \
updated_at = NOW() \
WHERE id = 1",
)
@ -361,7 +413,8 @@ async fn update_settings(
.bind(&oidc.display_name)
.bind(&oidc.discovery_url)
.bind(&oidc.client_id)
.bind(oidc.client_secret.as_deref().unwrap_or(""))
.bind(&ciphertext)
.bind(&nonce)
.bind(&oidc.redirect_uri)
.bind(&oidc.scopes)
.execute(&state.db)
@ -400,7 +453,7 @@ async fn update_settings(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::OidcConfigUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("oidc"),
@ -428,7 +481,59 @@ async fn update_settings(
}
if let Some(ref v) = smtp.password {
if v != MASKED {
update_config_key(&state.db, "smtp_password", v).await?;
// Encrypt the SMTP password before persisting
let key = crate::secret_key::get().map_err(|e| {
tracing::error!(error = %e, "Failed to load secret-encryption key");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
)
})?;
let (ciphertext, nonce) = pm_core::crypto::encrypt(v, key).map_err(|e| {
tracing::error!(error = %e, "Failed to encrypt SMTP password");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
)
})?;
// Delete old plaintext row, write two new rows (encrypted + nonce)
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to delete old smtp_password row");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
// Store as hex in TEXT columns (system_config uses TEXT)
let enc_hex: String = ciphertext.iter().map(|b| format!("{:02x}", b)).collect();
let nonce_hex: String = nonce.iter().map(|b| format!("{:02x}", b)).collect();
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
.bind("smtp_password_encrypted")
.bind(&enc_hex)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to write smtp_password_encrypted");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
.bind("smtp_password_nonce")
.bind(&nonce_hex)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to write smtp_password_nonce");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
}
}
if let Some(ref v) = smtp.from {
@ -440,7 +545,7 @@ async fn update_settings(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::SmtpConfigUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("smtp"),
@ -485,7 +590,7 @@ async fn update_settings(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::IpWhitelistUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("ip_whitelist"),
@ -559,11 +664,11 @@ async fn update_settings(
// ============================================================
async fn discover_oidc(
State(_state): State<AppState>,
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<OidcDiscoveryRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
if req.discovery_url.is_empty() {
return Err((
@ -588,6 +693,20 @@ async fn discover_oidc(
match client.get(&req.discovery_url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: Value = resp.json().await.unwrap_or(json!({}));
// Audit log: Admin probed the OIDC discovery endpoint (issue #5).
// Non-fatal: log_event logs errors internally and does not propagate.
log_event(
&state.db,
AuditAction::OidcDiscoverPerformed,
Some(auth.user_id),
Some(&auth.username),
Some("oidc"),
Some(&req.discovery_url),
json!({ "discovery_url": req.discovery_url }),
None,
None,
)
.await;
Ok(Json(json!({
"success": true,
"issuer": body.get("issuer").and_then(|v| v.as_str()).unwrap_or(""),
@ -620,7 +739,7 @@ async fn test_oidc(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
let row: Option<(bool, String, String)> = sqlx::query_as(
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
@ -679,6 +798,23 @@ async fn test_oidc(
"azure" => "Azure AD",
_ => "OIDC",
};
// Audit log: Admin tested the OIDC provider connection (issue #5).
// Non-fatal: log_event logs errors internally and does not propagate.
log_event(
&state.db,
AuditAction::OidcTestPerformed,
Some(auth.user_id),
Some(&auth.username),
Some("oidc"),
Some(&discovery_url),
json!({
"discovery_url": discovery_url,
"provider_type": provider_type,
}),
None,
None,
)
.await;
Ok(Json(json!({
"success": true,
"message": format!("{} provider verified successfully", provider_label),
@ -697,6 +833,9 @@ async fn test_oidc(
}
}
// Note: OIDC test audit log is emitted in the success path below.
// The above error cases don't persist, so no audit log is needed for them.
// ============================================================
// POST /api/v1/settings/azure-sso/test (backward-compatible alias)
// ============================================================
@ -734,7 +873,32 @@ async fn test_smtp(
.and_then(|v| v.parse().ok())
.unwrap_or(587);
let username = cfg.get("smtp_username").cloned().unwrap_or_default();
let password = cfg.get("smtp_password").cloned().unwrap_or_default();
// Decrypt the SMTP password (issue #6 fix — stored as two rows in system_config:
// `smtp_password_encrypted` (hex) and `smtp_password_nonce` (hex))
let password = match (
cfg.get("smtp_password_encrypted"),
cfg.get("smtp_password_nonce"),
) {
(Some(enc_hex), Some(nonce_hex)) => {
let key = match crate::secret_key::get() {
Ok(k) => k,
Err(e) => {
tracing::error!(error = %e, "Failed to load secret-encryption key");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
),
));
},
};
// Decode hex to bytes (hex_decode returns empty Vec on invalid input)
let enc_bytes = hex_decode(enc_hex);
let nonce_bytes = hex_decode(nonce_hex);
pm_core::crypto::decrypt(&enc_bytes, &nonce_bytes, key).unwrap_or_default()
},
_ => String::new(),
};
let from_addr = cfg.get("smtp_from").cloned().unwrap_or_default();
let tls_mode = cfg
.get("smtp_tls_mode")
@ -899,7 +1063,7 @@ async fn update_ip_whitelist(
auth: AuthUser,
Json(req): Json<IpWhitelistUpdate>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
write_access_required(&auth)?;
admin_required(&auth)?;
// Validate each entry
for entry in &req.entries {
@ -921,7 +1085,7 @@ async fn update_ip_whitelist(
log_event(
&state.db,
AuditAction::ConfigChanged,
AuditAction::IpWhitelistUpdated,
Some(auth.user_id),
Some(&auth.username),
Some("ip_whitelist"),
@ -975,3 +1139,70 @@ async fn audit_integrity(
})).collect::<Vec<_>>(),
})))
}
/// Decode a hex string to bytes. Returns an empty Vec on invalid input.
/// Used by the SMTP password decryption logic (issue #6 fix).
fn hex_decode(s: &str) -> Vec<u8> {
if !s.len().is_multiple_of(2) {
return Vec::new();
}
(0..s.len())
.step_by(2)
.filter_map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
#![allow(unused_imports)]
use super::*;
use axum::http::StatusCode;
use pm_auth::jwt::AccessClaims;
use pm_auth::rbac::{AuthUser, UserRole};
use uuid::Uuid;
/// Build a minimal `AuthUser` for role-gate testing.
/// The `admin_required` gate only inspects `auth.role`, so all other
/// fields can be placeholder values.
#[allow(dead_code)]
fn test_auth_user(role: UserRole) -> AuthUser {
let claims = AccessClaims {
sub: Uuid::new_v4().to_string(),
iat: 0,
exp: i64::MAX,
jti: Uuid::new_v4().to_string(),
role: role.as_str().to_string(),
username: "test-user".to_string(),
};
AuthUser {
user_id: Uuid::new_v4(),
username: "test-user".to_string(),
role,
claims,
}
}
#[test]
fn admin_required_admin_passes() {
let auth = test_auth_user(UserRole::Admin);
admin_required(&auth).expect("Admin should pass");
}
#[test]
fn admin_required_operator_denied() {
let auth = test_auth_user(UserRole::Operator);
let err = admin_required(&auth).expect_err("Operator should be denied");
let (status, body) = err;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["error"]["code"], "forbidden_role");
}
#[test]
fn admin_required_reporter_denied() {
let auth = test_auth_user(UserRole::Reporter);
let err = admin_required(&auth).expect_err("Reporter should be denied");
let (status, body) = err;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["error"]["code"], "forbidden_role");
}
}

392
crates/pm-web/src/routes/sso.rs Executable file → Normal file
View File

@ -12,11 +12,12 @@ use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Redirect},
routing::get,
routing::{get, post},
Router,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::Utc;
use dashmap::DashMap;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use pm_auth::{jwt::issue_access_token, refresh};
use pm_core::audit::{log_event, AuditAction};
@ -40,6 +41,140 @@ pub struct SsoSession {
pub created_at: chrono::DateTime<Utc>,
}
/// Single-use, short-lived payload that the SSO callback hands to the SPA
/// via a `?handoff=<code>` query param. The SPA exchanges it via
/// `POST /api/v1/auth/sso/handoff` for the actual JWT access/refresh
/// tokens. Mirrors the WS-ticket pattern (issue #10): in-memory, atomic
/// single-use consume, TTL enforced on read.
///
/// See `tasks/sso-token-handoff-spec.md` §4.1 for the full design.
#[derive(Clone)]
pub struct SsoHandoff {
/// JWT access token (short-lived, 15 min TTL).
pub access_token: String,
/// Opaque refresh token (long-lived, rotating).
pub raw_refresh: String,
/// JSON-serialized user object (id, username, display_name, role, etc.).
pub user_json: Value,
/// Access token TTL in seconds (for the `expires_in` field in the response).
pub access_ttl: u64,
/// Expiry instant; the exchange endpoint rejects codes past this time.
pub expires_at: std::time::Instant,
}
/// TTL for SSO handoff codes. Short by design: the SPA should POST to
/// `/api/v1/auth/sso/handoff` within seconds of the redirect landing.
///
/// `dead_code` is allowed here because Phase 1 introduces the store
/// ahead of its consumer; the SSO callback rewrite in Phase 2 of
/// `tasks/sso-token-handoff-spec.md` inserts handoffs with this TTL and
/// the exchange handler reads it back to validate freshness.
#[allow(dead_code)]
pub const HANDOFF_TTL_SECS: u64 = 60;
/// Generate a cryptographically random handoff code (32 bytes,
/// base64url-encoded, ~43 chars). Uses the same `rand` crate family as
/// the WS-ticket path.
///
/// `dead_code` is allowed here for the same reason as `HANDOFF_TTL_SECS`
/// — Phase 2 wires it into the SSO callback redirect construction.
#[allow(dead_code)]
pub fn generate_handoff_code() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Request body for `POST /api/v1/auth/sso/handoff`.
///
/// The SPA sends the handoff code it received in the SSO callback
/// redirect's `?handoff=...` query param, and the backend exchanges it
/// for the actual access/refresh tokens. The code is single-use and
/// 60-second TTL.
#[derive(Debug, Deserialize)]
pub struct HandoffRequest {
pub handoff_code: String,
}
// ============================================================
// Handoff exchange handler
// ============================================================
/// `POST /api/v1/auth/sso/handoff` — exchange a single-use handoff code
/// for the JWT access/refresh tokens + user object. Public route (no
/// JWT required) — the handoff code IS the credential.
///
/// See `tasks/sso-token-handoff-spec.md` §4.2 for the full design.
async fn sso_handoff_exchange(
State(state): State<AppState>,
Json(req): Json<HandoffRequest>,
) -> (StatusCode, Json<Value>) {
sso_handoff_exchange_inner(&state.sso_handoffs, &req.handoff_code).await
}
/// Core exchange logic, separated from the HTTP handler so tests can
/// drive it with a bare `DashMap` (no need to construct a full
/// `AppState` with a real `sqlx::PgPool` and `Arc<AppConfig>`).
///
/// Marked `async` so the race test can use `tokio::join!` to drive
/// two concurrent exchanges against the same code; the function body
/// has no `.await` points (it only does a DashMap read and a return),
/// so this is a zero-cost abstraction.
async fn sso_handoff_exchange_inner(
handoffs: &DashMap<String, SsoHandoff>,
code: &str,
) -> (StatusCode, Json<Value>) {
// Atomically remove the entry (single-use guarantee). If two
// requests race with the same code, DashMap::remove is atomic so
// only one wins.
let removed = handoffs.remove(code);
let Some((_code, handoff)) = removed else {
tracing::warn!(
reason = "unknown_or_already_consumed",
"SSO handoff exchange failed"
);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
})),
);
};
// Check expiry (the cleanup task also removes expired entries, but
// there's a race between expiry and the next cleanup tick — check
// here too so we never return a token for an expired handoff).
if handoff.expires_at <= std::time::Instant::now() {
tracing::warn!(reason = "expired", "SSO handoff exchange failed");
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
})),
);
}
// Log success without leaking the handoff code or the tokens
let user_id = handoff
.user_json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
tracing::info!(user_id = %user_id, "SSO handoff exchanged");
(
StatusCode::OK,
Json(json!({
"access_token": handoff.access_token,
"refresh_token": handoff.raw_refresh,
"token_type": "Bearer",
"expires_in": handoff.access_ttl,
"user": handoff.user_json,
})),
)
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
#[allow(dead_code)]
@ -78,11 +213,29 @@ pub struct OidcConfig {
pub display_name: String,
pub discovery_url: String,
pub client_id: String,
pub client_secret: String,
/// AES-256-GCM encrypted client_secret. `None` if not set or public client.
pub client_secret_encrypted: Option<Vec<u8>>,
/// AES-256-GCM nonce for client_secret. Must be paired with `client_secret_encrypted`.
pub client_secret_nonce: Option<Vec<u8>>,
pub redirect_uri: String,
pub scopes: String,
}
impl OidcConfig {
/// Decrypt the client_secret using the provided key.
/// Returns `Ok(String::new())` if the secret is not set (public client).
/// Returns `Err(CryptoError)` if decryption fails or nonce is missing.
pub fn decrypt_client_secret(
&self,
key: &[u8; 32],
) -> Result<String, pm_core::crypto::CryptoError> {
match (&self.client_secret_encrypted, &self.client_secret_nonce) {
(Some(enc), Some(nonce)) => pm_core::crypto::decrypt(enc, nonce, key),
_ => Ok(String::new()),
}
}
}
/// Cached OIDC discovery document.
#[derive(Debug, Clone)]
pub struct OidcDiscovery {
@ -116,6 +269,12 @@ pub fn public_router() -> Router<AppState> {
.route("/login", get(sso_login))
.route("/callback", get(sso_callback))
.route("/config", get(sso_config))
// Issue #4: single-use handoff exchange. The SPA POSTs the
// `?handoff=<code>` it received from the SSO callback redirect
// and gets the JWT access/refresh tokens in the JSON response.
// Public route (no JWT) — the handoff code IS the credential.
// See `tasks/sso-token-handoff-spec.md` §4.2.
.route("/handoff", post(sso_handoff_exchange))
}
/// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints.
@ -323,8 +482,28 @@ async fn sso_callback(
];
// For confidential clients (Azure AD), include client_secret
if !config.client_secret.is_empty() {
params_vec.push(("client_secret", config.client_secret.clone()));
let key = match crate::secret_key::get() {
Ok(k) => k,
Err(e) => {
tracing::error!(error = %e, "Failed to load secret-encryption key");
return Err(error_redirect(
"internal_error",
"Failed to load encryption key",
));
},
};
let client_secret = match config.decrypt_client_secret(key) {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "Failed to decrypt OIDC client_secret");
return Err(error_redirect(
"internal_error",
"Failed to decrypt client_secret",
));
},
};
if !client_secret.is_empty() {
params_vec.push(("client_secret", client_secret));
}
let token_resp = match client
@ -604,13 +783,32 @@ async fn sso_callback(
"mfa_enabled": user.mfa_enabled,
});
let redirect_url = format!(
"{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}",
callback_url,
urlencoding::encode(&access_token),
urlencoding::encode(&raw_refresh.0),
access_ttl,
urlencoding::encode(&user_json.to_string()),
// Issue #4 fix: instead of embedding access/refresh tokens in the
// redirect URL (which leaks through browser history, proxy access
// logs, and the Referer header), generate a single-use, 60s handoff
// code, store the payload in `sso_handoffs`, and put ONLY the code
// in the redirect. The SPA POSTs to `/api/v1/auth/sso/handoff` to
// exchange the code for tokens. See `tasks/sso-token-handoff-spec.md`
// §4.1.
let handoff_code = generate_handoff_code();
state.sso_handoffs.insert(
handoff_code.clone(),
SsoHandoff {
access_token: access_token.clone(),
raw_refresh: raw_refresh.0.clone(),
user_json: user_json.clone(),
access_ttl: access_ttl as u64,
expires_at: std::time::Instant::now()
+ std::time::Duration::from_secs(HANDOFF_TTL_SECS),
},
);
let redirect_url = format!("{}?handoff={}", callback_url, handoff_code);
tracing::info!(
user_id = %user.id,
auth_provider = %auth_provider,
"SSO handoff issued"
);
Ok(Redirect::to(&redirect_url))
@ -639,7 +837,9 @@ async fn azure_callback_redirect(
async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode, Json<Value>)> {
let row: Option<OidcConfig> = sqlx::query_as(
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
"SELECT enabled, provider_type, display_name, discovery_url, client_id, \
client_secret_encrypted, client_secret_nonce, redirect_uri, scopes \
FROM oidc_config WHERE id = 1",
)
.fetch_optional(pool)
.await
@ -657,7 +857,8 @@ async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode
display_name: "Azure AD".to_string(),
discovery_url: String::new(),
client_id: String::new(),
client_secret: String::new(),
client_secret_encrypted: None,
client_secret_nonce: None,
redirect_uri: String::new(),
scopes: "openid profile email".to_string(),
}))
@ -836,3 +1037,168 @@ async fn fetch_jwks(jwks_uri: &str) -> Result<serde_json::Value, String> {
.await
.map_err(|e| format!("Failed to parse JWKS response: {}", e))
}
#[cfg(test)]
mod tests {
//! Unit tests for the SSO handoff exchange endpoint and cleanup task.
//!
//! Per `tasks/sso-token-handoff-spec.md` §6.16.2.
//!
//! The tests call `sso_handoff_exchange_inner` directly with a bare
//! `DashMap<String, SsoHandoff>`. This avoids the need to construct
//! a full `AppState` (which has `sqlx::PgPool` and `Arc<AppConfig>`
//! fields that can't be cheaply mocked) and keeps the tests focused
//! on the exchange logic. The HTTP handler is a thin wrapper that
//! extracts the code from the request body and delegates.
use super::*;
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
fn fresh_handoffs() -> Arc<DashMap<String, SsoHandoff>> {
Arc::new(DashMap::new())
}
fn make_handoff(access: &str, refresh: &str, user_id: &str) -> SsoHandoff {
SsoHandoff {
access_token: access.to_string(),
raw_refresh: refresh.to_string(),
user_json: json!({ "id": user_id, "username": "testuser" }),
access_ttl: 900,
expires_at: Instant::now() + Duration::from_secs(HANDOFF_TTL_SECS),
}
}
/// 1. handoff_exchange_success — create a handoff, exchange it,
/// expect 200 with the access/refresh/user fields.
#[tokio::test]
async fn handoff_exchange_success() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
handoffs.insert(
code.clone(),
make_handoff("jwt-access", "refresh-raw", "user-123"),
);
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["access_token"], "jwt-access");
assert_eq!(body["refresh_token"], "refresh-raw");
assert_eq!(body["token_type"], "Bearer");
assert_eq!(body["expires_in"], 900);
assert_eq!(body["user"]["id"], "user-123");
}
/// 2. handoff_exchange_single_use — exchange once (success),
/// exchange the same code again (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_single_use() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
// First exchange succeeds
let (status1, _) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status1, StatusCode::OK);
// Second exchange with the same code fails (entry was removed)
let (status2, body2) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status2, StatusCode::BAD_REQUEST);
assert_eq!(body2["error"]["code"], "invalid_handoff");
}
/// 3. handoff_exchange_unknown_code — exchange a code that was
/// never issued (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_unknown_code() {
let handoffs = fresh_handoffs();
let (status, body) = sso_handoff_exchange_inner(&handoffs, "never-issued-code").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "invalid_handoff");
}
/// 4. handoff_exchange_expired_code — create a handoff with
/// expires_at in the past, exchange (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_expired_code() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
let mut h = make_handoff("a", "r", "u");
h.expires_at = Instant::now() - Duration::from_secs(1); // already expired
handoffs.insert(code.clone(), h);
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "invalid_handoff");
}
/// 5. handoff_exchange_race — two concurrent exchanges with the
/// same code; exactly one succeeds, the other gets 400.
#[tokio::test]
async fn handoff_exchange_race() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
// DashMap::remove is atomic, so only one of two concurrent
// calls can win. The other gets None and returns 400.
let h1 = handoffs.clone();
let h2 = handoffs.clone();
let c1 = code.clone();
let c2 = code.clone();
let (r1, r2) = tokio::join!(
sso_handoff_exchange_inner(&h1, &c1),
sso_handoff_exchange_inner(&h2, &c2),
);
let status1 = r1.0;
let status2 = r2.0;
let successes = [status1, status2]
.iter()
.filter(|s| **s == StatusCode::OK)
.count();
let failures = [status1, status2]
.iter()
.filter(|s| **s == StatusCode::BAD_REQUEST)
.count();
assert_eq!(successes, 1, "exactly one exchange should succeed");
assert_eq!(failures, 1, "exactly one exchange should fail");
}
/// 6. handoff_exchange_malformed_body — exchange with an empty
/// code (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_malformed_body() {
let handoffs = fresh_handoffs();
let (status, body) = sso_handoff_exchange_inner(&handoffs, "").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "invalid_handoff");
}
/// 7. handoff_cleanup_removes_expired — create 3 handoffs with
/// varying `expires_at`, run one tick of the cleanup task,
/// assert only the non-expired ones remain.
#[tokio::test]
async fn handoff_cleanup_removes_expired() {
let handoffs = fresh_handoffs();
// 2 expired, 1 fresh
for (i, expired) in [true, false, true].iter().enumerate() {
let mut h = make_handoff(&format!("a{}", i), "r", "u");
if *expired {
h.expires_at = Instant::now() - Duration::from_secs(1);
}
handoffs.insert(format!("code-{}", i), h);
}
assert_eq!(handoffs.len(), 3);
// Simulate one tick of the cleanup task (mirrors the logic
// in main.rs lines 174-188)
let now = Instant::now();
handoffs.retain(|_, v| v.expires_at > now);
assert_eq!(handoffs.len(), 1);
assert!(handoffs.contains_key("code-1"));
}
}

43
crates/pm-web/src/routes/status.rs Executable file → Normal file
View File

@ -24,6 +24,16 @@ pub struct FleetStatus {
pub total_pending_patches: i64,
pub hosts_requiring_reboot: i64,
pub compliance_pct: f64,
/// Hosts with CRL status 'valid'.
pub crl_valid: i64,
/// Hosts with CRL status 'expired'.
pub crl_expired: i64,
/// Hosts with CRL status 'missing' (agent reports missing CRL).
pub crl_missing: i64,
/// Hosts with CRL status 'invalid' (security event — needs immediate attention).
pub crl_invalid: i64,
/// Hosts not reporting CRL status (older agents or no data yet).
pub crl_not_reporting: i64,
}
// ── GET /api/v1/status/fleet ──────────────────────────────────────────────────
@ -132,6 +142,34 @@ pub async fn fleet_status(
// Round to one decimal place.
let compliance_pct = (compliance_pct * 10.0).round() / 10.0;
// ── 5. CRL status counts ────────────────────────────────────────────────
let (crl_valid, crl_expired, crl_missing, crl_invalid, crl_not_reporting): (
i64,
i64,
i64,
i64,
i64,
) = sqlx::query_as(
r#"
SELECT
COALESCE(SUM(CASE WHEN crl_status = 'valid' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN crl_status = 'expired' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN crl_status = 'missing' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN crl_status = 'invalid' THEN 1 END), 0),
COALESCE(SUM(CASE WHEN crl_status IS NULL THEN 1 END), 0)
FROM hosts
"#,
)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "fleet_status: failed to query CRL status counts");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
Ok(Json(FleetStatus {
total_hosts,
healthy,
@ -141,5 +179,10 @@ pub async fn fleet_status(
total_pending_patches,
hosts_requiring_reboot,
compliance_pct,
crl_valid,
crl_expired,
crl_missing,
crl_invalid,
crl_not_reporting,
}))
}

2
crates/pm-web/src/routes/users.rs Executable file → Normal file
View File

@ -534,7 +534,7 @@ async fn admin_disable_mfa(
));
}
let rows = sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
let rows = sqlx::query("UPDATE users SET totp_secret_encrypted = NULL, totp_secret_nonce = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
.bind(id)
.execute(&state.db)
.await

472
crates/pm-web/src/routes/ws.rs Executable file → Normal file
View File

@ -6,7 +6,7 @@
use axum::{
extract::ws::{Message, WebSocket},
extract::{Query, State, WebSocketUpgrade},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::{Json, Response},
routing::{get, post},
Router,
@ -57,6 +57,160 @@ fn err(
)
}
// ── Origin parsing & allowlist matching ───────────────────────────────────────
/// Parsed browser `Origin` header value.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Origin {
scheme: String,
host: String,
/// `None` means "use scheme default" (80 for http, 443 for https).
port: Option<u16>,
}
impl Origin {
/// Render back to canonical `scheme://host[:port]` form with default
/// ports normalized away (so `https://x:443` becomes `https://x`).
fn canonical(&self) -> String {
let default_port: Option<u16> = match self.scheme.as_str() {
"https" => Some(443),
"http" => Some(80),
_ => None,
};
match (self.port, default_port) {
(Some(p), Some(d)) if p == d => format!("{}://{}", self.scheme, self.host),
(Some(p), _) => format!("{}://{}:{}", self.scheme, self.host, p),
(None, _) => format!("{}://{}", self.scheme, self.host),
}
}
}
/// Parse a raw `Origin` header value. Returns `None` for missing scheme,
/// unsupported schemes (only `http`/`https`), empty host, or whitespace in
/// the host. IPv6 literal hosts are explicitly rejected to keep the parser
/// simple — WebSocket connections from IPv6 browser origins are not a
/// realistic deployment for this product.
fn parse_origin_header(value: &str) -> Option<Origin> {
let s = value.trim().trim_end_matches('/');
if s.is_empty() {
return None;
}
let (scheme, rest) = s.split_once("://")?;
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return None;
}
// Authority is everything up to the first `/`, `?`, or `#`.
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority.is_empty() {
return None;
}
// Treat the LAST `:` as the port separator. IPv6 literal hosts (e.g.
// `[::1]`) contain a `:` inside the brackets; reject those.
let (host, port_str) = match authority.rsplit_once(':') {
Some((h, _)) if h.contains(':') => return None,
Some((h, p)) => (h, Some(p)),
None => (authority, None),
};
let host = host.trim();
if host.is_empty() || host.contains(char::is_whitespace) || host.contains(':') {
return None;
}
let port = match port_str {
Some(p) => match p.parse::<u16>() {
Ok(n) => Some(n),
Err(_) => return None,
},
None => None,
};
Some(Origin {
scheme,
host: host.to_ascii_lowercase(),
port,
})
}
/// Match a parsed `Origin` against an allowlist. Each allowlist entry is
/// itself parsed with [`parse_origin_header`] and compared by its canonical
/// string form, so entry syntax is forgiving (`https://x:443` matches an
/// incoming `https://x`). The host comparison is case-insensitive (the
/// parser lowercases the host); scheme and port are exact.
///
/// An empty allowlist returns `false` (fail-closed).
fn is_origin_allowed(origin: &Origin, allowlist: &[String]) -> bool {
if allowlist.is_empty() {
return false;
}
let incoming = origin.canonical();
allowlist
.iter()
.any(|entry| match parse_origin_header(entry) {
Some(parsed) => parsed.canonical() == incoming,
None => false,
})
}
/// Read the `Origin` header from a request and check it against the
/// configured allowlist. Returns `Ok(())` when the request may proceed; on
/// rejection returns the appropriate `(StatusCode, Json)` error tuple and
/// the reason string (for logging).
fn check_origin(
headers: &HeaderMap,
allowlist: &[String],
) -> Result<(), ((StatusCode, Json<Value>), &'static str)> {
let raw = match headers.get(axum::http::header::ORIGIN) {
Some(v) => v,
None => {
return Err((
err(
StatusCode::FORBIDDEN,
"forbidden_origin",
"Origin header required",
),
"missing",
));
},
};
let raw_str = match raw.to_str() {
Ok(s) => s,
Err(_) => {
return Err((
err(
StatusCode::FORBIDDEN,
"forbidden_origin",
"Origin header not valid ASCII",
),
"non-ascii",
));
},
};
let origin = match parse_origin_header(raw_str) {
Some(o) => o,
None => {
return Err((
err(
StatusCode::FORBIDDEN,
"forbidden_origin",
"Malformed Origin header",
),
"malformed",
));
},
};
if !is_origin_allowed(&origin, allowlist) {
return Err((
err(
StatusCode::FORBIDDEN,
"forbidden_origin",
"Origin not allowed",
),
"not-allowlisted",
));
}
Ok(())
}
// ── POST /api/v1/ws/ticket ────────────────────────────────────────────────────
/// Issue a single-use WebSocket authentication ticket (60 s expiry).
@ -93,11 +247,40 @@ pub struct WsQuery {
}
/// Browser WebSocket upgrade endpoint — authenticates via single-use ticket.
///
/// The handler enforces two independent gates, in this order:
///
/// 1. `Origin` header allowlist (CSWSH defense-in-depth). Performed first so
/// that a cross-origin probe with a leaked/stolen ticket does not consume
/// the legitimate user's ticket.
/// 2. Single-use, 60-second ticket (existing behavior, unchanged).
pub async fn ws_handler(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<WsQuery>,
ws: WebSocketUpgrade,
) -> Result<Response, (StatusCode, Json<Value>)> {
// Gate 1: Origin allowlist (CSWSH defense-in-depth).
let allowlist = &state.config.security.allowed_origins;
if let Err((http_err, reason)) = check_origin(&headers, allowlist) {
let raw_origin = headers
.get(axum::http::header::ORIGIN)
.and_then(|v| v.to_str().ok())
.unwrap_or("<absent>");
// Never log the ticket value.
tracing::warn!(
reason = reason,
origin = %raw_origin,
"WebSocket upgrade rejected: forbidden origin"
);
return Err(http_err);
}
let allowed_origin = headers
.get(axum::http::header::ORIGIN)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
// Validate and consume the ticket atomically.
let ticket = {
let entry = state.ws_tickets.get(&q.ticket);
@ -129,6 +312,7 @@ pub async fn ws_handler(
tracing::info!(
user_id = %ticket.user_id,
role = %ticket.role,
origin = %allowed_origin,
"Browser WebSocket connection upgraded"
);
@ -203,3 +387,289 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
tracing::info!(user_id = %ticket.user_id, "Browser WS handler exiting");
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── parse_origin_header ─────────────────────────────────────────────────
#[test]
fn parse_basic_https() {
assert_eq!(
parse_origin_header("https://app.example.com"),
Some(Origin {
scheme: "https".into(),
host: "app.example.com".into(),
port: None,
})
);
}
#[test]
fn parse_with_explicit_port() {
assert_eq!(
parse_origin_header("https://app.example.com:8443"),
Some(Origin {
scheme: "https".into(),
host: "app.example.com".into(),
port: Some(8443),
})
);
}
#[test]
fn parse_lowercases_scheme() {
assert_eq!(
parse_origin_header("HTTPS://App.Example.com")
.unwrap()
.scheme,
"https"
);
}
#[test]
fn parse_lowercases_host() {
assert_eq!(
parse_origin_header("https://App.Example.com").unwrap().host,
"app.example.com"
);
}
#[test]
fn parse_ignores_path_query_fragment() {
let o = parse_origin_header("https://app.example.com:443/some/path?q=1#frag").unwrap();
assert_eq!(o.host, "app.example.com");
assert_eq!(o.port, Some(443));
}
#[test]
fn parse_strips_trailing_slash() {
assert_eq!(
parse_origin_header("https://app.example.com/"),
Some(Origin {
scheme: "https".into(),
host: "app.example.com".into(),
port: None,
})
);
}
#[test]
fn parse_rejects_empty() {
assert!(parse_origin_header("").is_none());
assert!(parse_origin_header(" ").is_none());
}
#[test]
fn parse_rejects_unsupported_scheme() {
assert!(parse_origin_header("ftp://x").is_none());
assert!(parse_origin_header("file:///etc/passwd").is_none());
assert!(parse_origin_header("javascript:alert(1)").is_none());
}
#[test]
fn parse_rejects_empty_host() {
assert!(parse_origin_header("https://").is_none());
assert!(parse_origin_header("https:///path").is_none());
}
#[test]
fn parse_rejects_host_with_whitespace() {
assert!(parse_origin_header("https://bad host").is_none());
}
#[test]
fn parse_rejects_malformed_port() {
assert!(parse_origin_header("https://x:notaport").is_none());
assert!(parse_origin_header("https://x:99999").is_none());
}
#[test]
fn parse_rejects_ipv6_literal() {
assert!(parse_origin_header("https://[::1]").is_none());
}
#[test]
fn parse_rejects_no_scheme_separator() {
assert!(parse_origin_header("app.example.com").is_none());
}
// ── canonical ──────────────────────────────────────────────────────────
#[test]
fn canonical_strips_default_https_port() {
let o = Origin {
scheme: "https".into(),
host: "x".into(),
port: Some(443),
};
assert_eq!(o.canonical(), "https://x");
}
#[test]
fn canonical_strips_default_http_port() {
let o = Origin {
scheme: "http".into(),
host: "x".into(),
port: Some(80),
};
assert_eq!(o.canonical(), "http://x");
}
#[test]
fn canonical_keeps_non_default_port() {
let o = Origin {
scheme: "https".into(),
host: "x".into(),
port: Some(8443),
};
assert_eq!(o.canonical(), "https://x:8443");
}
// ── is_origin_allowed ──────────────────────────────────────────────────
#[test]
fn allowed_exact_match() {
let o = parse_origin_header("https://app.example.com").unwrap();
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
}
#[test]
fn allowed_default_port_normalization_incoming() {
let o = parse_origin_header("https://app.example.com:443").unwrap();
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
}
#[test]
fn allowed_default_port_normalization_allowlist() {
let o = parse_origin_header("https://app.example.com").unwrap();
assert!(is_origin_allowed(
&o,
&["https://app.example.com:443".into()]
));
}
#[test]
fn allowed_case_insensitive_host() {
let o = parse_origin_header("https://App.Example.com").unwrap();
assert!(is_origin_allowed(&o, &["https://app.example.com".into()]));
}
#[test]
fn rejected_different_host() {
let o = parse_origin_header("https://evil.example").unwrap();
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
}
#[test]
fn rejected_different_scheme() {
let o = parse_origin_header("http://app.example.com").unwrap();
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
}
#[test]
fn rejected_different_port() {
let o = parse_origin_header("https://app.example.com:8443").unwrap();
assert!(!is_origin_allowed(&o, &["https://app.example.com".into()]));
}
#[test]
fn rejected_empty_allowlist() {
let o = parse_origin_header("https://app.example.com").unwrap();
assert!(!is_origin_allowed(&o, &[]));
}
#[test]
fn rejected_garbage_in_allowlist() {
let o = parse_origin_header("https://app.example.com").unwrap();
assert!(!is_origin_allowed(&o, &["not a url".into()]));
}
#[test]
fn allowed_multi_entry_allowlist() {
let o = parse_origin_header("https://app.example.com").unwrap();
assert!(is_origin_allowed(
&o,
&[
"https://other.example".into(),
"https://app.example.com".into(),
]
));
}
// ── check_origin (integration of parse + allow) ────────────────────────
#[test]
fn check_rejects_missing_header() {
let h = HeaderMap::new();
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
assert_eq!(err.1, "missing");
}
#[test]
fn check_rejects_malformed_header() {
let mut h = HeaderMap::new();
h.insert(axum::http::header::ORIGIN, "not a url".parse().unwrap());
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
assert_eq!(err.1, "malformed");
}
#[test]
fn check_rejects_disallowed_origin() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::ORIGIN,
"https://evil.example".parse().unwrap(),
);
let err = check_origin(&h, &["https://app.example.com".into()]).unwrap_err();
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
assert_eq!(err.1, "not-allowlisted");
}
#[test]
fn check_rejects_empty_allowlist() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::ORIGIN,
"https://app.example.com".parse().unwrap(),
);
let err = check_origin(&h, &[]).unwrap_err();
assert_eq!(err.0 .0, StatusCode::FORBIDDEN);
assert_eq!(err.1, "not-allowlisted");
}
#[test]
fn check_allows_valid_origin() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::ORIGIN,
"https://app.example.com".parse().unwrap(),
);
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
}
#[test]
fn check_allows_default_port_normalization() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::ORIGIN,
"https://app.example.com:443".parse().unwrap(),
);
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
}
#[test]
fn check_allows_case_insensitive_host() {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::ORIGIN,
"https://App.Example.com".parse().unwrap(),
);
assert!(check_origin(&h, &["https://app.example.com".into()]).is_ok());
}
}

View File

@ -0,0 +1,44 @@
//! Secret-encryption key loader for pm-web.
//!
//! Lazily loads the per-install AES-256-GCM key from
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
//!
//! Uses `std::sync::OnceLock` (stable since Rust 1.70) to avoid the `once_cell` dependency.
//!
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
use pm_core::crypto;
use std::path::Path;
use std::sync::OnceLock;
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
/// Returns `CryptoError` if the key file is missing or invalid.
///
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
#[allow(dead_code)]
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
if let Some(key) = SECRET_KEY.get() {
return Ok(key);
}
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
// _ = ignore error if another thread won the race (already set by them)
let _ = SECRET_KEY.set(key);
Ok(SECRET_KEY.get().expect("key was just set"))
}
#[cfg(test)]
mod tests {
use std::sync::OnceLock;
#[test]
fn once_lock_caches_value() {
let cell: OnceLock<u32> = OnceLock::new();
let v1 = cell.get_or_init(|| 42);
let v2 = cell.get_or_init(|| 99); // Should return 42, not 99
assert_eq!(*v1, 42);
assert_eq!(*v2, 42);
}
}

View File

@ -0,0 +1,530 @@
//! Integration tests for the authz gate that restricts auth config mutations
//! (OIDC, SMTP, IP whitelist) to the Admin role only.
//!
//! See Issue #15 for the full specification.
//!
//! ## Test organization
//!
//! The 403 (forbidden_role) tests verify that the authorization middleware
//! rejects non-admin roles BEFORE any handler or database logic runs. These
//! tests use a lazy PgPool (no live database required) and pre-generated CA
//! files, so they always pass in CI.
//!
//! The 200 (admin allowed) tests verify the full handler path including audit
//! logging. They require a live PostgreSQL database and are marked `#[ignore]`
//! so they only run when `DATABASE_URL` is set and `--ignored` is passed.
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Request, StatusCode};
use dashmap::DashMap;
use http_body_util::BodyExt;
use pm_auth::jwt;
use pm_auth::rbac::AuthConfig;
use pm_core::config::AppConfig;
use pm_web::routes::sso::OidcCache;
use pm_web::{build_router, AppState};
use serde_json::json;
use sqlx::PgPool;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower::ServiceExt;
use uuid::Uuid;
// ── Ed25519 test key pair ────────────────────────────────────────────────────
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIBrWiMMcgpPXwtGDSSBl01fcQyb5Vh4CMzEmxcSXvcrJ
-----END PRIVATE KEY-----
";
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEACgE6fMDCcG11NOpPKSO/ASpPUSntB7XsF5sBFBYDjFo=
-----END PUBLIC KEY-----
";
// ── Fixed test user IDs (so we can seed matching rows in the DB) ─────────────
const ADMIN_USER_ID: &str = "00000000-0000-4000-8000-000000000001";
const OPERATOR_USER_ID: &str = "00000000-0000-4000-8000-000000000002";
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Generate a valid JWT authorization header for the given role.
fn auth_header(role: &str) -> String {
let user_id = match role {
"admin" => Uuid::parse_str(ADMIN_USER_ID).unwrap(),
_ => Uuid::parse_str(OPERATOR_USER_ID).unwrap(),
};
let username = format!("test-{}", role);
let token = jwt::issue_access_token(user_id, &username, role, 900, TEST_SIGNING_KEY)
.expect("failed to issue test JWT");
format!("Bearer {}", token)
}
/// Generate CA key and cert files on disk so `CertAuthority::init` can load
/// them without needing a database connection.
fn generate_ca_files(ca_dir: &std::path::Path) {
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256,
};
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("generate CA key");
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params
.distinguished_name
.push(DnType::CommonName, "Test Root CA");
let cert = params.self_signed(&key).expect("self-sign CA cert");
std::fs::create_dir_all(ca_dir).expect("create CA dir");
std::fs::write(ca_dir.join("ca.key"), key.serialize_pem()).expect("write ca.key");
std::fs::write(ca_dir.join("ca.crt"), cert.pem()).expect("write ca.crt");
}
/// Build a minimal `AppState` suitable for 403 authz gate tests.
///
/// Uses a lazy PgPool (no live database connection required) and pre-generated
/// CA files. This works because the authorization middleware rejects non-admin
/// requests BEFORE any handler or database logic runs.
async fn setup_state_no_db() -> AppState {
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://test:test@localhost:5432/test")
.expect("failed to create lazy pool");
let mut config = AppConfig::default();
config.server.static_dir = "/tmp".to_string();
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
let ca_dir_path = ca_dir.path().to_path_buf();
generate_ca_files(&ca_dir_path);
std::mem::forget(ca_dir);
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
.await
.expect("CA init failed");
AppState {
db: pool,
config: Arc::new(config),
signing_key_pem: TEST_SIGNING_KEY.to_string(),
auth_config,
ws_tickets: Arc::new(DashMap::new()),
sso_sessions: Arc::new(DashMap::new()),
sso_handoffs: Arc::new(DashMap::new()),
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
ca: Arc::new(ca),
approved_enrollments: Arc::new(DashMap::new()),
}
}
/// Seed test users into the database so that audit_log foreign-key
/// constraints on `actor_user_id` are satisfied.
async fn seed_test_users(pool: &PgPool) {
let placeholder_hash = "$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder";
for (user_id, username, role) in [
(ADMIN_USER_ID, "test-admin", "admin"),
(OPERATOR_USER_ID, "test-operator", "operator"),
] {
sqlx::query(
r#"INSERT INTO users (id, username, display_name, email, role, auth_provider, password_hash)
VALUES ($1, $2, $3, $4, $5::user_role, 'local', $6)
ON CONFLICT (id) DO NOTHING"#,
)
.bind(Uuid::parse_str(user_id).unwrap())
.bind(username)
.bind(username)
.bind(format!("{}@test.example.com", username))
.bind(role)
.bind(placeholder_hash)
.execute(pool)
.await
.expect("failed to seed test user");
}
}
/// Build a full `AppState` with a live database connection.
async fn setup_state(pool: PgPool) -> AppState {
seed_test_users(&pool).await;
let mut config = AppConfig::default();
config.server.static_dir = "/tmp".to_string();
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
let ca_dir_path = ca_dir.path().to_path_buf();
std::mem::forget(ca_dir);
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
.await
.expect("CA init failed");
AppState {
db: pool,
config: Arc::new(config),
signing_key_pem: TEST_SIGNING_KEY.to_string(),
auth_config,
ws_tickets: Arc::new(DashMap::new()),
sso_sessions: Arc::new(DashMap::new()),
sso_handoffs: Arc::new(DashMap::new()),
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
ca: Arc::new(ca),
approved_enrollments: Arc::new(DashMap::new()),
}
}
/// Send a request through the full Axum router and return the response.
async fn send_request(
state: AppState,
method: axum::http::Method,
uri: &str,
auth_header: Option<&str>,
body: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value) {
let router = build_router(state);
let mut builder = Request::builder().method(method).uri(uri);
if let Some(auth) = auth_header {
builder = builder.header("authorization", auth);
}
builder = builder.header("content-type", "application/json");
let req = if let Some(b) = body {
builder.body(Body::from(b.to_string())).unwrap()
} else {
builder.body(Body::empty()).unwrap()
};
// Insert ConnectInfo so tower_governor's SmartIpKeyExtractor can resolve the client IP.
let (mut parts, body) = req.into_parts();
parts
.extensions
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let req = Request::from_parts(parts, body);
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_else(|_| {
let raw = String::from_utf8_lossy(&body_bytes);
json!({ "_raw": raw.to_string() })
});
(status, body_json)
}
// ═══════════════════════════════════════════════════════════════════════════
// 403 Forbidden Role tests — no database required
// ═══════════════════════════════════════════════════════════════════════════
//
// These tests verify that the authorization middleware rejects non-admin roles
// BEFORE any handler or database logic runs. They use a lazy PgPool and
// pre-generated CA files, so they always pass in CI.
/// 1. PUT /api/v1/settings with operator role → 403 forbidden_role
#[tokio::test]
async fn update_settings_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings",
Some(&auth),
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role
#[tokio::test]
async fn update_ip_whitelist_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings/ip-whitelist",
Some(&auth),
Some(json!({ "entries": ["10.0.0.0/8"] })),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role
#[tokio::test]
async fn discover_oidc_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/discover",
Some(&auth),
Some(json!({ "discovery_url": "https://example.com/.well-known/openid-configuration" })),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role
#[tokio::test]
async fn test_oidc_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/test",
Some(&auth),
None,
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
// ═══════════════════════════════════════════════════════════════════════════
// 200 Admin Allowed tests — require live database
// ═══════════════════════════════════════════════════════════════════════════
//
// These tests verify the full handler path including audit logging.
// They require a live PostgreSQL database and are marked `#[ignore]` so they
// only run when DATABASE_URL is set and `--ignored` is passed.
/// 2. PUT /api/v1/settings with admin role → 200 + audit log
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn update_settings_admin_allowed(pool: PgPool) {
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings",
Some(&auth),
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'config_changed' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(row.is_some(), "expected audit log entry for config_changed");
}
/// 4. PUT /api/v1/settings/ip-whitelist with admin role → 200 + audit log
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn update_ip_whitelist_admin_allowed(pool: PgPool) {
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings/ip-whitelist",
Some(&auth),
Some(json!({ "entries": ["10.0.0.0/8"] })),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'ip_whitelist_updated' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(
row.is_some(),
"expected audit log entry for ip_whitelist_updated"
);
}
/// 6. POST /api/v1/settings/sso/discover with admin role → 200 + audit log
/// Uses mockito to simulate an OIDC discovery endpoint.
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn discover_oidc_admin_allowed(pool: PgPool) {
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/.well-known/openid-configuration")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"issuer": "https://mock-oidc.example.com",
"authorization_endpoint": "https://mock-oidc.example.com/auth",
"token_endpoint": "https://mock-oidc.example.com/token",
"jwks_uri": "https://mock-oidc.example.com/jwks",
"userinfo_endpoint": "https://mock-oidc.example.com/userinfo"
})
.to_string(),
)
.create_async()
.await;
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/discover",
Some(&auth),
Some(json!({ "discovery_url": discovery_url })),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
assert_eq!(body["success"], true);
mock.assert_async().await;
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_discover_performed' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(
row.is_some(),
"expected audit log entry for oidc_discover_performed"
);
}
/// 8. POST /api/v1/settings/sso/test with admin role → 200 + audit log
/// Uses mockito to simulate an OIDC discovery endpoint.
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn test_oidc_admin_allowed(pool: PgPool) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/.well-known/openid-configuration")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"issuer": "https://mock-oidc.example.com",
"authorization_endpoint": "https://mock-oidc.example.com/auth",
"token_endpoint": "https://mock-oidc.example.com/token",
"jwks_uri": "https://mock-oidc.example.com/jwks"
})
.to_string(),
)
.create_async()
.await;
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
// Seed the oidc_config table with an enabled provider pointing to mockito.
sqlx::query("UPDATE oidc_config SET enabled = true, discovery_url = $1 WHERE id = 1")
.bind(&discovery_url)
.execute(&pool)
.await
.expect("failed to seed oidc_config");
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/test",
Some(&auth),
None,
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
assert_eq!(body["success"], true);
mock.assert_async().await;
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_test_performed' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(
row.is_some(),
"expected audit log entry for oidc_test_performed"
);
}

View File

@ -0,0 +1 @@
mod authz_gate;

View File

@ -15,13 +15,13 @@ pm-agent-client = { path = "../pm-agent-client" }
tokio = { workspace = true, features = ["full"] }
sqlx = { workspace = true }
serde = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
rustls = { workspace = true }
tokio-rustls = { version = "0.26" }

38
crates/pm-worker/src/email.rs Executable file → Normal file
View File

@ -32,11 +32,16 @@ struct NotificationSettings {
}
/// Load SMTP settings from the `system_config` table.
///
/// Issue #6 fix: SMTP password is stored as two rows:
/// - `smtp_password_encrypted` (hex of AES-256-GCM ciphertext)
/// - `smtp_password_nonce` (hex of AES-256-GCM nonce)
async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT key, value FROM system_config WHERE key IN (
'smtp_enabled', 'smtp_host', 'smtp_port', 'smtp_username',
'smtp_password', 'smtp_from', 'smtp_tls_mode'
'smtp_password_encrypted', 'smtp_password_nonce',
'smtp_from', 'smtp_tls_mode'
)",
)
.fetch_all(pool)
@ -50,17 +55,46 @@ async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
.unwrap_or_default()
};
// Decrypt the SMTP password
let enc_hex = get("smtp_password_encrypted");
let nonce_hex = get("smtp_password_nonce");
let password = if !enc_hex.is_empty() && !nonce_hex.is_empty() {
match (
hex_decode(&enc_hex),
hex_decode(&nonce_hex),
crate::secret_key::get(),
) {
(Some(enc), Some(nonce), Ok(key)) => {
pm_core::crypto::decrypt(&enc, &nonce, key).unwrap_or_default()
},
_ => String::new(),
}
} else {
String::new()
};
SmtpSettings {
enabled: get("smtp_enabled") == "true",
host: get("smtp_host"),
port: get("smtp_port").parse().unwrap_or(587),
username: get("smtp_username"),
password: get("smtp_password"),
password,
from: get("smtp_from"),
tls_mode: get("smtp_tls_mode"),
}
}
/// Decode a hex string to bytes. Returns None on invalid input.
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
/// Load notification preferences from `system_config`.
async fn load_notification_settings(pool: &PgPool) -> NotificationSettings {
let rows: Vec<(String, String)> = sqlx::query_as(

View File

@ -4,11 +4,24 @@
//! `health_poll_interval_secs`, with bounded concurrency controlled by a
//! [`tokio::sync::Semaphore`]. Also calls `/system/info` to refresh
//! `os_family`, `os_name`, `arch`, and `agent_version` in the hosts table.
//!
//! CRL health aggregation rules (PR 5):
//! - `crl_status = "invalid"` → host health_status overridden to `unreachable`
//! - `crl_status = "expired"` → host health_status overridden to `degraded` (if currently healthy)
//! - `crl_status = "missing"` AND registered > 24h ago → host health_status overridden to `degraded` (if currently healthy)
//! - `crl_status = "valid"` or NULL → no override
//!
//! Audit events are logged for CRL state transitions.
use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use pm_agent_client::{AgentClient, AgentClientError};
use pm_core::{config::AppConfig, models::HostHealthStatus};
use pm_core::{
audit::{log_event, AuditAction},
config::AppConfig,
models::HostHealthStatus,
};
use sqlx::{FromRow, PgPool};
use tokio::{sync::Semaphore, time};
use uuid::Uuid;
@ -21,6 +34,10 @@ struct HostRow {
id: Uuid,
ip_address: String,
agent_port: i32,
/// Current CRL status from the hosts table (for transition detection).
crl_status: Option<String>,
/// When the host was first registered (for enrollment age checks).
registered_at: DateTime<Utc>,
}
/// Run the health poller loop indefinitely.
@ -50,9 +67,9 @@ pub async fn run_health_poller(pool: PgPool, config: Arc<AppConfig>) {
let client_key = Arc::new(certs.client_key);
let ca_cert = Arc::new(certs.ca_cert);
// Fetch all hosts.
// Fetch all hosts with CRL status and registration time.
let hosts: Vec<HostRow> = match sqlx::query_as(
"SELECT id, host(ip_address)::text AS ip_address, agent_port FROM hosts ORDER BY id",
"SELECT id, host(ip_address)::text AS ip_address, agent_port, crl_status, registered_at FROM hosts ORDER BY id",
)
.fetch_all(&pool)
.await
@ -116,8 +133,10 @@ pub async fn run_health_poller(pool: PgPool, config: Arc<AppConfig>) {
/// Poll a single host, persist the result, and return the determined status.
///
/// Also updates `agent_version` from the health response and
/// `os_family`/`os_name`/`arch` from the `/system/info` endpoint when available.
/// Also updates `agent_version` from the health response,
/// `os_family`/`os_name`/`arch` from the `/system/info` endpoint when available,
/// CRL status fields from the health response when reported by the agent,
/// and applies CRL health aggregation rules.
async fn poll_host_health(
pool: PgPool,
host: HostRow,
@ -125,8 +144,16 @@ async fn poll_host_health(
client_key: &[u8],
ca_cert: &[u8],
) -> HostHealthStatus {
// Determine status, payload, agent version, and optional system info.
let (status, payload, agent_version, sys_info) = match AgentClient::new(
// Determine status, payload, agent version, optional system info, and CRL fields.
let (
natural_status,
payload,
agent_version,
sys_info,
crl_status,
crl_age_seconds,
crl_next_update,
) = match AgentClient::new(
&host.ip_address,
host.agent_port as u16,
client_cert,
@ -144,13 +171,29 @@ async fn poll_host_health(
serde_json::Value::Object(Default::default()),
None,
None,
None,
None,
None,
)
},
Ok(client) => {
let (status, payload, version) = match client.health().await {
let (status, payload, version, crl_status, crl_age, crl_next) = match client
.health()
.await
{
Ok(data) => {
let payload = serde_json::to_value(&data).unwrap_or_default();
(HostHealthStatus::Healthy, payload, Some(data.version))
let crl_status = data.crl_status.clone();
let crl_age = data.crl_age_seconds;
let crl_next = data.crl_next_update.clone();
(
HostHealthStatus::Healthy,
payload,
Some(data.version),
crl_status,
crl_age,
crl_next,
)
},
Err(AgentClientError::Timeout) => {
tracing::warn!(host_id = %host.id, "Health poller: agent timed out");
@ -158,6 +201,9 @@ async fn poll_host_health(
HostHealthStatus::Unreachable,
serde_json::Value::Object(Default::default()),
None,
None,
None,
None,
)
},
Err(AgentClientError::Connect(_)) => {
@ -166,6 +212,9 @@ async fn poll_host_health(
HostHealthStatus::Unreachable,
serde_json::Value::Object(Default::default()),
None,
None,
None,
None,
)
},
Err(e) => {
@ -174,6 +223,9 @@ async fn poll_host_health(
HostHealthStatus::Degraded,
serde_json::Value::Object(Default::default()),
None,
None,
None,
None,
)
},
};
@ -195,11 +247,17 @@ async fn poll_host_health(
None
};
(status, payload, version, sys_info)
(
status, payload, version, sys_info, crl_status, crl_age, crl_next,
)
},
};
// Insert into host_health_data.
// Apply CRL health aggregation rules to determine the effective status.
// Only apply when the agent reported a CRL status (non-NULL).
let effective_status = apply_crl_health_rules(&natural_status, &crl_status, host.registered_at);
// Insert into host_health_data with the natural (pre-aggregation) status.
if let Err(e) = sqlx::query(
r#"
INSERT INTO host_health_data (host_id, status, payload)
@ -207,7 +265,7 @@ async fn poll_host_health(
"#,
)
.bind(host.id)
.bind(&status)
.bind(&natural_status)
.bind(&payload)
.execute(&pool)
.await
@ -220,7 +278,14 @@ async fn poll_host_health(
.as_ref()
.map(|i| format!("{} {}", i.os, i.os_version));
// Update hosts table with health status, agent version, and OS details.
// Parse CRL next_update from ISO-8601 string to DateTime if present.
let crl_next_update_dt: Option<chrono::DateTime<chrono::Utc>> = crl_next_update
.as_ref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.to_utc());
// Update hosts table with the effective (post-aggregation) health status,
// agent version, OS details, and CRL fields.
// COALESCE preserves existing values when new data is unavailable.
if let Err(e) = sqlx::query(
r#"
@ -229,21 +294,382 @@ async fn poll_host_health(
agent_version = COALESCE($3, agent_version),
os_family = COALESCE($4, os_family),
os_name = COALESCE($5, os_name),
arch = COALESCE($6, arch)
arch = COALESCE($6, arch),
crl_status = COALESCE($7, crl_status),
crl_age_seconds = COALESCE($8, crl_age_seconds),
crl_next_update = COALESCE($9, crl_next_update)
WHERE id = $1
"#,
)
.bind(host.id)
.bind(&status)
.bind(&effective_status)
.bind(&agent_version)
.bind(sys_info.as_ref().map(|i| i.os.as_str()))
.bind(os_name_from_sysinfo)
.bind(sys_info.as_ref().map(|i| i.architecture.as_str()))
.bind(&crl_status)
.bind(crl_age_seconds)
.bind(crl_next_update_dt)
.execute(&pool)
.await
{
tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to update host status");
// Don't log audit events if the DB update failed.
return effective_status;
}
status
// Log CRL audit events after successful database update.
if let Some(ref new_crl) = crl_status {
log_crl_audit_events(
&pool,
host.id,
host.crl_status.as_deref(),
new_crl,
crl_age_seconds,
)
.await;
}
effective_status
}
/// Apply CRL health aggregation rules to determine the effective health status.
///
/// Rules:
/// - `crl_status = "invalid"` → `Unreachable` (security event, always overrides)
/// - `crl_status = "expired"` → `Degraded` (only if natural status is `Healthy`)
/// - `crl_status = "missing"` AND registered > 24h ago → `Degraded` (only if natural status is `Healthy`)
/// - `crl_status = "valid"` or NULL → no override
fn apply_crl_health_rules(
natural_status: &HostHealthStatus,
crl_status: &Option<String>,
registered_at: DateTime<Utc>,
) -> HostHealthStatus {
let Some(crl) = crl_status else {
// Older agent not reporting CRL — don't modify health status.
return natural_status.clone();
};
match crl.as_str() {
"invalid" => HostHealthStatus::Unreachable,
"expired" => {
if *natural_status == HostHealthStatus::Healthy {
HostHealthStatus::Degraded
} else {
natural_status.clone()
}
},
"missing" => {
let age = Utc::now() - registered_at;
if age > Duration::hours(24) && *natural_status == HostHealthStatus::Healthy {
HostHealthStatus::Degraded
} else {
natural_status.clone()
}
},
// "valid" or any other value — no override
_ => natural_status.clone(),
}
}
/// Log audit events for CRL state transitions.
///
/// Called after the hosts table has been successfully updated.
/// Logs:
/// - `CrlStatusChanged` when the CRL status transitions to a different value
/// - `CrlStaleDetected` when CRL status becomes "expired"
/// - `CrlInvalid` when CRL status becomes "invalid"
async fn log_crl_audit_events(
pool: &PgPool,
host_id: Uuid,
old_crl_status: Option<&str>,
new_crl_status: &str,
crl_age_seconds: Option<i64>,
) {
let host_id_str = host_id.to_string();
let old_str = old_crl_status.unwrap_or("null");
// Log a transition event if the status changed.
if old_crl_status != Some(new_crl_status) {
let details = serde_json::json!({
"host_id": host_id_str,
"old_crl_status": old_str,
"new_crl_status": new_crl_status,
"crl_age_seconds": crl_age_seconds,
});
log_event(
pool,
AuditAction::CrlStatusChanged,
None, // actor_user_id — system-initiated
None, // actor_username
Some("host"), // target_type
Some(&host_id_str), // target_id
details,
None, // ip_address
None, // request_id
)
.await;
}
// Log specific events for problematic CRL states.
match new_crl_status {
"expired" => {
let details = serde_json::json!({
"host_id": host_id_str,
"old_crl_status": old_str,
"new_crl_status": new_crl_status,
"crl_age_seconds": crl_age_seconds,
});
log_event(
pool,
AuditAction::CrlStaleDetected,
None,
None,
Some("host"),
Some(&host_id_str),
details,
None,
None,
)
.await;
},
"invalid" => {
let details = serde_json::json!({
"host_id": host_id_str,
"old_crl_status": old_str,
"new_crl_status": new_crl_status,
"crl_age_seconds": crl_age_seconds,
});
log_event(
pool,
AuditAction::CrlInvalid,
None,
None,
Some("host"),
Some(&host_id_str),
details,
None,
None,
)
.await;
},
_ => {},
}
}
// ---------------------------------------------------------------------------
// Tests — CRL health aggregation rules
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests_crl_health {
use super::*;
use chrono::{Duration, Utc};
/// Helper: create a DateTime that is `hours` hours in the past.
fn hours_ago(h: i64) -> DateTime<Utc> {
Utc::now() - Duration::hours(h)
}
// ---- crl_status = "invalid" → Unreachable (always overrides) ----
#[test]
fn crl_invalid_overrides_healthy_to_unreachable() {
let result = apply_crl_health_rules(
&HostHealthStatus::Healthy,
&Some("invalid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Unreachable);
}
#[test]
fn crl_invalid_overrides_degraded_to_unreachable() {
let result = apply_crl_health_rules(
&HostHealthStatus::Degraded,
&Some("invalid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Unreachable);
}
#[test]
fn crl_invalid_overrides_unreachable_stays_unreachable() {
let result = apply_crl_health_rules(
&HostHealthStatus::Unreachable,
&Some("invalid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Unreachable);
}
// ---- crl_status = "expired" → Degraded (only if currently Healthy) ----
#[test]
fn crl_expired_downgrades_healthy_to_degraded() {
let result = apply_crl_health_rules(
&HostHealthStatus::Healthy,
&Some("expired".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Degraded);
}
#[test]
fn crl_expired_does_not_override_degraded() {
let result = apply_crl_health_rules(
&HostHealthStatus::Degraded,
&Some("expired".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Degraded);
}
#[test]
fn crl_expired_does_not_downgrade_unreachable() {
let result = apply_crl_health_rules(
&HostHealthStatus::Unreachable,
&Some("expired".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Unreachable);
}
// ---- crl_status = "missing" AND registered > 24h → Degraded (if Healthy) ----
#[test]
fn crl_missing_old_registration_downgrades_healthy() {
let result = apply_crl_health_rules(
&HostHealthStatus::Healthy,
&Some("missing".to_string()),
hours_ago(25),
);
assert_eq!(result, HostHealthStatus::Degraded);
}
#[test]
fn crl_missing_recent_registration_no_override() {
let result = apply_crl_health_rules(
&HostHealthStatus::Healthy,
&Some("missing".to_string()),
hours_ago(12),
);
assert_eq!(result, HostHealthStatus::Healthy);
}
#[test]
fn crl_missing_does_not_override_degraded() {
let result = apply_crl_health_rules(
&HostHealthStatus::Degraded,
&Some("missing".to_string()),
hours_ago(25),
);
assert_eq!(result, HostHealthStatus::Degraded);
}
#[test]
fn crl_missing_does_not_override_unreachable() {
let result = apply_crl_health_rules(
&HostHealthStatus::Unreachable,
&Some("missing".to_string()),
hours_ago(25),
);
assert_eq!(result, HostHealthStatus::Unreachable);
}
// ---- crl_status = "valid" → no override ----
#[test]
fn crl_valid_does_not_override_healthy() {
let result = apply_crl_health_rules(
&HostHealthStatus::Healthy,
&Some("valid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Healthy);
}
#[test]
fn crl_valid_preserves_degraded() {
let result = apply_crl_health_rules(
&HostHealthStatus::Degraded,
&Some("valid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Degraded);
}
#[test]
fn crl_valid_preserves_unreachable() {
let result = apply_crl_health_rules(
&HostHealthStatus::Unreachable,
&Some("valid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Unreachable);
}
// ---- NULL crl_status → no override (backward compat) ----
#[test]
fn null_crl_status_preserves_healthy() {
let result = apply_crl_health_rules(&HostHealthStatus::Healthy, &None, hours_ago(0));
assert_eq!(result, HostHealthStatus::Healthy);
}
#[test]
fn null_crl_status_preserves_degraded() {
let result = apply_crl_health_rules(&HostHealthStatus::Degraded, &None, hours_ago(0));
assert_eq!(result, HostHealthStatus::Degraded);
}
#[test]
fn null_crl_status_preserves_unreachable() {
let result = apply_crl_health_rules(&HostHealthStatus::Unreachable, &None, hours_ago(0));
assert_eq!(result, HostHealthStatus::Unreachable);
}
// ---- Edge cases ----
#[test]
fn crl_missing_just_under_24h_no_override() {
// 23h 59m old — should NOT trigger degraded (threshold is > 24h)
let result = apply_crl_health_rules(
&HostHealthStatus::Healthy,
&Some("missing".to_string()),
Utc::now() - Duration::hours(23) - Duration::minutes(59),
);
assert_eq!(result, HostHealthStatus::Healthy);
}
#[test]
fn crl_missing_just_over_24h_triggers_degraded() {
// 24h + 1 minute old — should trigger degraded
let result = apply_crl_health_rules(
&HostHealthStatus::Healthy,
&Some("missing".to_string()),
Utc::now() - Duration::hours(24) - Duration::minutes(1),
);
assert_eq!(result, HostHealthStatus::Degraded);
}
#[test]
fn crl_pending_status_preserved_with_valid_crl() {
let result = apply_crl_health_rules(
&HostHealthStatus::Pending,
&Some("valid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Pending);
}
#[test]
fn crl_invalid_overrides_pending_to_unreachable() {
let result = apply_crl_health_rules(
&HostHealthStatus::Pending,
&Some("invalid".to_string()),
hours_ago(0),
);
assert_eq!(result, HostHealthStatus::Unreachable);
}
}

View File

@ -12,6 +12,7 @@ mod job_executor;
mod maintenance_scheduler;
mod patch_poller;
mod refresh_listener;
mod secret_key;
mod ws_relay;
use chrono::Utc;

View File

@ -0,0 +1,29 @@
//! Secret-encryption key loader for pm-worker.
//!
//! Lazily loads the per-install AES-256-GCM key from
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
//!
//! The pm-worker crate uses the same key file as pm-web (filesystem-shared).
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
use pm_core::crypto;
use std::path::Path;
use std::sync::OnceLock;
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
/// Returns `CryptoError` if the key file is missing or invalid.
///
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
#[allow(dead_code)]
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
if let Some(key) = SECRET_KEY.get() {
return Ok(key);
}
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
// _ = ignore error if another thread won the race (already set by them)
let _ = SECRET_KEY.set(key);
Ok(SECRET_KEY.get().expect("key was just set"))
}

66
debian/changelog vendored
View File

@ -1,3 +1,69 @@
linux-patch-manager (1.1.10-1) unstable; urgency=low
* Release v1.1.10
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 14:11:31 -0500
linux-patch-manager (1.1.9-1) unstable; urgency=low
* Release v1.1.9
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 13:05:59 -0500
linux-patch-manager (1.1.8-1) unstable; urgency=low
* Release v1.1.8
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 11:47:58 -0500
linux-patch-manager (1.1.7-1) unstable; urgency=low
* Release v1.1.7
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 09:11:11 -0500
linux-patch-manager (1.1.6-1) unstable; urgency=low
* Release v1.1.6
-- git-echo <git-echo@moon-dragon.us> Tue, 09 Jun 2026 08:10:52 -0500
linux-patch-manager (1.1.5-1) unstable; urgency=low
* Release v1.1.5
-- git-echo <git-echo@moon-dragon.us> Mon, 08 Jun 2026 20:15:50 -0500
linux-patch-manager (1.1.4-1) unstable; urgency=low
* Release v1.1.4
-- git-echo <git-echo@moon-dragon.us> Mon, 08 Jun 2026 17:30:35 -0500
linux-patch-manager (1.1.2-1) unstable; urgency=low
* Release v1.1.2
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 21:19:18 -0500
linux-patch-manager (1.1.1-1) unstable; urgency=low
* Release v1.1.1
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 18:55:59 -0500
linux-patch-manager (1.1.0-1) unstable; urgency=low
* Release v1.1.0
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 16:47:03 -0500
linux-patch-manager (1.0.0-1) unstable; urgency=low
* Release v1.0.0
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 12:58:46 -0500
linux-patch-manager (0.1.9-1) noble; urgency=medium
* Fix: Replace broken DashMap rate limiting with tower-governor middleware

5
debian/control vendored
View File

@ -1,9 +1,10 @@
Package: linux-patch-manager
Version: 1.0.0-1
Version: 1.1.10-1
Architecture: amd64
Maintainer: Moon Dragon <echo@moon-dragon.us>
Installed-Size: 45000
Depends: postgresql-16, libssl3, libc6 (>= 2.39), libfontconfig1
Pre-Depends: postgresql-16
Depends: postgresql-16, argon2, libssl3, libc6 (>= 2.39), libfontconfig1
Recommends: postgresql-client-16, fonts-dejavu-core
Suggests: gpg
Section: admin

471
debian/postinst vendored
View File

@ -4,91 +4,440 @@ set -e
# =============================================================================
# Linux Patch Manager — Post-install script
# =============================================================================
# Fully automated: apt install ./linux-patch-manager_X.X.X-1_amd64.deb
# results in a running service with a printed admin password.
# All steps are idempotent (safe to re-run on upgrade).
# =============================================================================
case "$1" in
configure)
# Create service user if not exists
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
DB_NAME="patch_manager"
DB_USER="patch_manager"
CONFIG_DIR="/etc/patch-manager"
MIGRATION_DIR="/usr/share/patch-manager/migrations"
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
# ---------------------------------------------------------------------------
# PostgreSQL helpers
# ---------------------------------------------------------------------------
psql_run() {
# Run SQL as the postgres superuser
sudo -u postgres psql -v ON_ERROR_STOP=1 "$@" 2>/dev/null
}
psql_run_db() {
# Run SQL against the patch_manager database
sudo -u postgres psql -v ON_ERROR_STOP=1 -d "${DB_NAME}" "$@" 2>/dev/null
}
# ---------------------------------------------------------------------------
# 1. Create service user (idempotent)
# ---------------------------------------------------------------------------
create_service_user() {
if ! id patch-manager &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin \
--comment "Linux Patch Manager service account" patch-manager
info "Service user 'patch-manager' created."
else
info "Service user 'patch-manager' already exists."
fi
}
# Create required directories
mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
/etc/patch-manager/jwt /etc/patch-manager/tls \
# ---------------------------------------------------------------------------
# 2. Create required directories (idempotent)
# ---------------------------------------------------------------------------
create_directories() {
mkdir -p "${CONFIG_DIR}/ca" "${CONFIG_DIR}/certs" \
"${CONFIG_DIR}/jwt" "${CONFIG_DIR}/tls" \
/var/log/patch-manager /opt/patch-manager \
/var/backups/patch-manager
chown -R patch-manager:patch-manager \
/etc/patch-manager /var/log/patch-manager \
"${CONFIG_DIR}" /var/log/patch-manager \
/opt/patch-manager /usr/share/patch-manager/frontend
chmod 750 /etc/patch-manager/ca /etc/patch-manager/jwt
chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/jwt"
chmod 700 /var/backups/patch-manager
}
# Generate JWT signing key if not present
if [[ ! -f /etc/patch-manager/jwt/signing.pem ]]; then
openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem 2>/dev/null
openssl pkey -in /etc/patch-manager/jwt/signing.pem -pubout -out /etc/patch-manager/jwt/verify.pem 2>/dev/null
chown patch-manager:patch-manager /etc/patch-manager/jwt/signing.pem /etc/patch-manager/jwt/verify.pem
chmod 600 /etc/patch-manager/jwt/signing.pem
chmod 644 /etc/patch-manager/jwt/verify.pem
# ---------------------------------------------------------------------------
# 3. Wait for PostgreSQL to be ready
# ---------------------------------------------------------------------------
wait_for_postgresql() {
info "Waiting for PostgreSQL to be ready..."
local retries=30
local delay=2
local i
for ((i = 1; i <= retries; i++)); do
if pg_isready -q 2>/dev/null; then
info "PostgreSQL is ready."
return 0
fi
warn "PostgreSQL not ready yet (attempt ${i}/${retries}), waiting ${delay}s..."
sleep "${delay}"
done
error "PostgreSQL did not become ready after $((retries * delay)) seconds."
return 1
}
# ---------------------------------------------------------------------------
# 4. Create PostgreSQL user and database (idempotent)
# ---------------------------------------------------------------------------
setup_database() {
info "Setting up PostgreSQL database and user..."
# Generate a random password for the DB user
local db_password
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
# Create role if not exists
local role_exists
role_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" 2>/dev/null || echo "")
if [[ "${role_exists}" != "1" ]]; then
psql_run -c "CREATE ROLE ${DB_USER} LOGIN PASSWORD '${db_password}';"
info "PostgreSQL user '${DB_USER}' created."
# Store password for config generation
echo "${db_password}" > /tmp/.pm-db-password-new
else
info "PostgreSQL user '${DB_USER}' already exists."
# Recover the DB password: try from existing config, or generate new.
local config_file="${CONFIG_DIR}/config.toml"
local existing_pw=""
if [[ -f "${config_file}" ]]; then
# Extract password from URL: postgres://user:PASSWORD@host/db
# Use @localhost anchor so passwords containing @ are extracted correctly.
existing_pw=$(sed -n 's|^url = "postgres://[^:]*:\(.*\)@localhost.*"|\1|p' "${config_file}" | head -1)
fi
if [[ -n "${existing_pw}" && "${existing_pw}" != "CHANGEME" ]]; then
# Config has a real password — sync it to PostgreSQL so the app can connect.
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${existing_pw}';" 2>/dev/null || true
echo "${existing_pw}" > /tmp/.pm-db-password-new
info "Synced DB password from existing config to PostgreSQL."
else
# No config or CHANGEME — generate a fresh password and update PostgreSQL.
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
echo "${db_password}" > /tmp/.pm-db-password-new
info "Generated new DB password for existing user."
fi
fi
# Write default config if not present
if [[ ! -f /etc/patch-manager/config.toml ]]; then
cp /usr/share/patch-manager/config.example.toml /etc/patch-manager/config.toml
chown patch-manager:patch-manager /etc/patch-manager/config.toml
chmod 640 /etc/patch-manager/config.toml
# Create database if not exists
local db_exists
db_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null || echo "")
if [[ "${db_exists}" != "1" ]]; then
psql_run -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
info "Database '${DB_NAME}' created."
else
info "Database '${DB_NAME}' already exists, skipping creation."
fi
# Install backup cron if not present
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab -
# Grant permissions (idempotent)
psql_run_db -c "GRANT USAGE ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true
psql_run_db -c "GRANT CREATE ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true
psql_run_db -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# 5. Apply database migrations (idempotent)
# ---------------------------------------------------------------------------
apply_migrations() {
info "Applying database migrations..."
# Ensure pgcrypto extension is available
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
# Create migration tracking table if not exists
psql_run_db <<'MIGSQL'
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
MIGSQL
# Handle upgrade from pre-migration-tracking versions:
# If tables exist but _migrations is empty, mark all existing migrations as applied.
local migration_count
migration_count=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
migration_count="${migration_count// /}"
local tables_exist
tables_exist=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0")
tables_exist="${tables_exist// /}"
if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then
info "Existing database detected — marking all shipped migrations as already applied."
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
psql_run_db -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true
done
fi
# Reload systemd
# Apply each migration in sorted order, skipping already-applied ones
local applied=0
local skipped=0
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
local already_applied
already_applied=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0")
already_applied="${already_applied// /}"
if [[ "${already_applied}" -gt 0 ]]; then
skipped=$((skipped + 1))
continue
fi
info " Applying migration: ${fname}"
if psql_run_db -f "${sql_file}"; then
psql_run_db -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
applied=$((applied + 1))
else
error " Failed to apply migration: ${fname}"
return 1
fi
done
if [[ "${applied}" -gt 0 ]]; then
info "Applied ${applied} new migration(s), skipped ${skipped} already applied."
else
info "All migrations up to date (${skipped} already applied)."
fi
}
# ---------------------------------------------------------------------------
# 6. Reassign database object ownership to patch_manager
# ---------------------------------------------------------------------------
# The postinst runs migrations as the postgres superuser, so all tables,
# types, and sequences created by those migrations are owned by postgres.
# The application connects as patch_manager and needs ownership to ALTER
# tables during upgrades (e.g. 'must be owner of table groups').
# This function reassigns ownership of every database object to patch_manager
# so the application can manage its own schema.
# ---------------------------------------------------------------------------
reassign_ownership() {
info "Reassigning database object ownership to ${DB_USER}..."
# REASSIGN OWNED BY covers all tables, enum types, sequences, and views
# owned by postgres in the current database.
psql_run_db -c "REASSIGN OWNED BY postgres TO ${DB_USER};" \
|| warn "REASSIGN OWNED BY encountered warnings (may be harmless on fresh installs)."
# Schemas are NOT covered by REASSIGN OWNED BY — handle explicitly.
psql_run_db -c "ALTER SCHEMA public OWNER TO ${DB_USER};" \
|| warn "Could not alter public schema owner."
# Grant full privileges so patch_manager can manage all objects
psql_run -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" \
|| warn "Could not grant database privileges."
psql_run_db -c "GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};" \
|| warn "Could not grant schema privileges."
psql_run_db -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${DB_USER};" \
|| warn "Could not grant table privileges."
psql_run_db -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};" \
|| warn "Could not grant sequence privileges."
psql_run_db -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ${DB_USER};" \
|| warn "Could not grant function privileges."
# Ensure future objects in public schema are also owned by patch_manager
psql_run_db -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${DB_USER};" \
|| warn "Could not set default table privileges."
psql_run_db -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${DB_USER};" \
|| warn "Could not set default sequence privileges."
psql_run_db -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO ${DB_USER};" \
|| warn "Could not set default function privileges."
info "Database object ownership reassigned to ${DB_USER}."
}
# ---------------------------------------------------------------------------
# 8. Generate admin password and update database
# ---------------------------------------------------------------------------
generate_admin_password() {
info "Generating admin password..."
# Generate a random 24-character password
local admin_password
admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24)
# Hash with argon2 (PHC format, compatible with the application)
# Generate a random 16-character salt (argon2 requires minimum 8 characters)
local admin_salt
admin_salt=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 16)
local password_hash
password_hash=$(echo -n "${admin_password}" | argon2 "${admin_salt}" -id -t 3 -m 16 -p 1 -l 32 -e)
# Update admin user password in database
# Only update if the placeholder hash is still present
# The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA
# Using single-quoted variable to preserve $ signs in SQL LIKE pattern
local placeholder_pattern
placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'
local updated
updated=$(psql_run_db -t -A -c \
"UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \
WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \
RETURNING id;" 2>/dev/null || echo "")
if [[ -n "${updated}" ]]; then
# Write admin password to file (mode 600, owned by root)
echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}"
chmod 600 "${ADMIN_PASSWORD_FILE}"
chown root:root "${ADMIN_PASSWORD_FILE}"
echo ""
echo -e "${CYAN}=============================================${NC}"
echo -e "${CYAN} Linux Patch Manager — Admin Credentials${NC}"
echo -e "${CYAN}=============================================${NC}"
echo -e " Username: ${GREEN}admin${NC}"
echo -e " Password: ${GREEN}${admin_password}${NC}"
echo ""
echo -e " ${YELLOW}IMPORTANT: Save this password! It will not be shown again.${NC}"
echo -e " Password also saved to: ${ADMIN_PASSWORD_FILE}"
echo -e "${CYAN}=============================================${NC}"
echo ""
else
info "Admin password already set (not a fresh install). Password file not regenerated."
fi
}
# ---------------------------------------------------------------------------
# 9. Write config.toml with DB URL
# ---------------------------------------------------------------------------
# Handles three scenarios:
# 1. No config file → create from example with real DB password
# 2. Config exists with CHANGEME → replace CHANGEME with real DB password
# 3. Config exists with real password → leave it alone (upgrade)
# ---------------------------------------------------------------------------
write_config() {
local config_file="${CONFIG_DIR}/config.toml"
# Resolve the DB password to use: from setup_database() or generate fresh.
local db_password=""
if [[ -f /tmp/.pm-db-password-new ]]; then
db_password=$(cat /tmp/.pm-db-password-new)
fi
if [[ -f "${config_file}" ]]; then
# Check if the config still has the CHANGEME placeholder
if grep -q 'CHANGEME' "${config_file}"; then
if [[ -z "${db_password}" ]]; then
# No password from setup_database() — generate a fresh one
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
fi
info "Replacing CHANGEME placeholder in existing config with real DB password."
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}"
else
info "Config file ${config_file} already exists with a real password, leaving it unchanged."
return 0
fi
else
# No config file — create from example
if [[ -z "${db_password}" ]]; then
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
fi
info "Writing configuration file..."
cp /usr/share/patch-manager/config.example.toml "${config_file}"
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}"
fi
chown patch-manager:patch-manager "${config_file}"
chmod 640 "${config_file}"
info "Configuration written to ${config_file}"
}
# ---------------------------------------------------------------------------
# 10. Generate JWT keys (idempotent)
# Only generates if missing; regenerates verify.pem from signing.pem if lost.
# ---------------------------------------------------------------------------
generate_jwt_keys() {
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
info "Generating Ed25519 JWT signing key..."
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT keys generated."
elif [[ ! -f "${CONFIG_DIR}/jwt/verify.pem" ]]; then
info "Regenerating missing JWT verification key from existing signing key..."
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/verify.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT verification key regenerated."
else
info "JWT keys already exist, skipping."
fi
}
# ---------------------------------------------------------------------------
# 11. Enable and start services
# ---------------------------------------------------------------------------
enable_and_start_services() {
systemctl daemon-reload
# Restart services if this is an upgrade (not a fresh install)
if systemctl is-active --quiet patch-manager-web 2>/dev/null; then
systemctl restart patch-manager-web || true
fi
if systemctl is-active --quiet patch-manager-worker 2>/dev/null; then
systemctl restart patch-manager-worker || true
fi
# Enable the target (which pulls in web + worker)
systemctl enable patch-manager.target 2>/dev/null || true
# Run pending database migrations
MIGRATION_DIR="/usr/share/patch-manager/migrations"
if [[ -d "$MIGRATION_DIR" ]]; then
echo "Applying database migrations..."
for sql_file in $(ls "$MIGRATION_DIR"/*.sql 2>/dev/null | sort); do
echo " Applying: $(basename "$sql_file")"
done
echo "Note: Migrations must be applied manually: sudo -u patch_manager psql -d patch_manager -f <migration_file>"
fi
# Enable individual services so they survive a reboot
systemctl enable patch-manager-web.service patch-manager-worker.service 2>/dev/null || true
echo ""
echo "Linux Patch Manager installed successfully!"
echo "==========================================="
echo ""
echo "Next steps:"
echo " 1. Install and configure PostgreSQL:"
echo " apt install postgresql-16"
echo " 2. Create the database:"
echo " sudo -u postgres createdb -O patch_manager patch_manager"
echo " 3. Edit /etc/patch-manager/config.toml with your database URL"
echo " 4. Enable and start services:"
echo " systemctl enable --now patch-manager.target"
echo " 5. Access the web UI at https://localhost"
echo " Default admin credentials are set via the seed migration."
echo ""
echo "IMPORTANT: Change the default admin password immediately after first login!"
echo ""
echo "If this is an upgrade, services have been restarted automatically."
echo "Apply any new database migrations:"
echo " sudo -u patch_manager psql -d patch_manager -f /usr/share/patch-manager/migrations/<NNN_migration>.sql"
echo ""
# Start or restart services
if systemctl is-active --quiet patch-manager.target 2>/dev/null; then
info "Restarting patch-manager services (upgrade)..."
systemctl restart patch-manager.target 2>/dev/null || true
else
info "Starting patch-manager services..."
systemctl start patch-manager.target 2>/dev/null || true
fi
}
# ---------------------------------------------------------------------------
# 12. Install backup cron (idempotent)
# ---------------------------------------------------------------------------
install_backup_cron() {
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab -
info "Nightly backup cron installed."
fi
}
# =============================================================================
# Main
# =============================================================================
case "$1" in
configure)
create_service_user
create_directories
wait_for_postgresql
setup_database
apply_migrations
reassign_ownership
generate_admin_password
write_config
generate_jwt_keys
enable_and_start_services
install_backup_cron
# Clean up temp file
rm -f /tmp/.pm-db-password-new
info "Linux Patch Manager installation complete."
;;
abort-upgrade|abort-remove|abort-deconfigure)

58
docker-compose.yml Normal file
View File

@ -0,0 +1,58 @@
# =============================================================================
# Linux Patch Manager — Docker Compose Deployment
# =============================================================================
# Usage:
# cp .env.example .env # Edit DB_PASSWORD
# docker compose up -d
# =============================================================================
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: patch_manager
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: patch_manager
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U patch_manager -d patch_manager"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
networks:
- patch-manager-net
app:
image: ghcr.io/draco-lunaris/linux-patch-manager:${TAG:-latest}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "443:443"
environment:
DATABASE_URL: postgres://patch_manager:${DB_PASSWORD}@db:5432/patch_manager
PATCH_MANAGER_CONFIG: /etc/patch-manager/config.toml
volumes:
- pm-config:/etc/patch-manager
- pm-logs:/var/log/patch-manager
- pm-data:/opt/patch-manager
networks:
- patch-manager-net
volumes:
pgdata:
driver: local
pm-config:
driver: local
pm-logs:
driver: local
pm-data:
driver: local
networks:
patch-manager-net:
driver: bridge

232
docker/entrypoint.sh Executable file
View File

@ -0,0 +1,232 @@
#!/bin/bash
set -e
# =============================================================================
# Linux Patch Manager — Docker Entrypoint
# =============================================================================
# Handles first-run: wait for DB, run migrations, generate admin password,
# start pm-web and pm-worker services.
# =============================================================================
MIGRATION_DIR="/usr/share/patch-manager/migrations"
CONFIG_DIR="/etc/patch-manager"
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
# ---------------------------------------------------------------------------
# Parse DATABASE_URL into PG* env vars for psql compatibility
# ---------------------------------------------------------------------------
parse_database_url() {
# DATABASE_URL format: postgres://user:password@host:port/dbname
local url="${DATABASE_URL}"
# Extract components
DB_PASS=$(echo "$url" | sed -n 's|postgres://[^:]*:\([^@]*\)@.*|\1|p')
DB_HOST=$(echo "$url" | sed -n 's|.*@\([^:/]*\).*|\1|p')
DB_PORT=$(echo "$url" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
DB_USER=$(echo "$url" | sed -n 's|postgres://\([^:]*\):.*|\1|p')
DB_NAME=$(echo "$url" | sed -n 's|.*/\([^?]*\).*|\1|p')
# Default port
DB_PORT="${DB_PORT:-5432}"
export PGHOST="${DB_HOST}"
export PGPORT="${DB_PORT}"
export PGUSER="${DB_USER}"
export PGPASSWORD="${DB_PASS}"
export PGDATABASE="${DB_NAME}"
}
# ---------------------------------------------------------------------------
# Wait for PostgreSQL to be ready
# ---------------------------------------------------------------------------
wait_for_db() {
echo "[entrypoint] Waiting for PostgreSQL at ${PGHOST}:${DB_PORT}..."
local retries=60
local delay=2
local i
for ((i = 1; i <= retries; i++)); do
if pg_isready -q -h "${PGHOST}" -p "${DB_PORT}" -U "${DB_USER}" 2>/dev/null; then
echo "[entrypoint] PostgreSQL is ready."
return 0
fi
echo "[entrypoint] PostgreSQL not ready (attempt ${i}/${retries}), waiting ${delay}s..."
sleep "${delay}"
done
echo "[entrypoint] ERROR: PostgreSQL did not become ready after $((retries * delay)) seconds." >&2
return 1
}
# ---------------------------------------------------------------------------
# Run database migrations (idempotent)
# ---------------------------------------------------------------------------
run_migrations() {
echo "[entrypoint] Applying database migrations..."
# Ensure pgcrypto extension
psql -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
# Create migration tracking table
psql -v ON_ERROR_STOP=1 <<'EOSQL'
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
EOSQL
# Handle upgrade from pre-migration-tracking versions
local migration_count
migration_count=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
migration_count="${migration_count// /}"
local tables_exist
tables_exist=$(psql -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0")
tables_exist="${tables_exist// /}"
if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then
echo "[entrypoint] Existing database detected — marking all shipped migrations as already applied."
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true
done
fi
# Apply each migration in sorted order
local applied=0
local skipped=0
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
local already_applied
already_applied=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0")
already_applied="${already_applied// /}"
if [[ "${already_applied}" -gt 0 ]]; then
skipped=$((skipped + 1))
continue
fi
echo "[entrypoint] Applying migration: ${fname}"
if psql -v ON_ERROR_STOP=1 -f "${sql_file}"; then
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
applied=$((applied + 1))
else
echo "[entrypoint] ERROR: Failed to apply migration: ${fname}" >&2
return 1
fi
done
if [[ "${applied}" -gt 0 ]]; then
echo "[entrypoint] Applied ${applied} new migration(s), skipped ${skipped}."
else
echo "[entrypoint] All migrations up to date (${skipped} already applied)."
fi
}
# ---------------------------------------------------------------------------
# Generate admin password on first run
# ---------------------------------------------------------------------------
generate_admin_password() {
echo "[entrypoint] Checking admin password status..."
# Generate a random 24-character password
local admin_password
admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24)
# Hash with argon2 (PHC format)
local password_hash
password_hash=$(echo -n "${admin_password}" | argon2 salt -id -t 3 -m 65536 -p 1 -l 32 -e)
# Update admin user — only if placeholder hash is still present
# The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA
# Using single-quoted variable to preserve $ signs in the SQL LIKE pattern
local placeholder_pattern
placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'
local updated
updated=$(psql -t -A -c \
"UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \
WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \
RETURNING id;" 2>/dev/null || echo "")
if [[ -n "${updated}" ]]; then
echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}"
chmod 600 "${ADMIN_PASSWORD_FILE}"
chown root:root "${ADMIN_PASSWORD_FILE}"
echo ""
echo "============================================="
echo " Linux Patch Manager — Admin Credentials"
echo "============================================="
echo " Username: admin"
echo " Password: ${admin_password}"
echo ""
echo " IMPORTANT: Save this password! It will not be shown again."
echo " Password also saved to: ${ADMIN_PASSWORD_FILE}"
echo "============================================="
echo ""
else
echo "[entrypoint] Admin password already set (not a fresh install)."
fi
}
# ---------------------------------------------------------------------------
# Generate JWT keys if not present
# ---------------------------------------------------------------------------
generate_jwt_keys() {
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
echo "[entrypoint] Generating Ed25519 JWT signing key..."
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
echo "[entrypoint] JWT keys generated."
else
echo "[entrypoint] JWT signing key already exists."
fi
}
# ---------------------------------------------------------------------------
# Write config.toml if not present
# ---------------------------------------------------------------------------
write_config() {
local config_file="${CONFIG_DIR}/config.toml"
if [[ -f "${config_file}" ]]; then
echo "[entrypoint] Config file already exists, not overwriting."
return 0
fi
echo "[entrypoint] Writing configuration file..."
cp /usr/share/patch-manager/config.example.toml "${config_file}"
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|${DATABASE_URL}|" "${config_file}"
chown patch-manager:patch-manager "${config_file}"
chmod 640 "${config_file}"
echo "[entrypoint] Configuration written."
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
echo "[entrypoint] Linux Patch Manager Docker entrypoint starting..."
parse_database_url
wait_for_db
run_migrations
generate_admin_password
generate_jwt_keys
write_config
echo "[entrypoint] Starting pm-web and pm-worker..."
# Start pm-worker in background
pm-worker &
WORKER_PID=$!
# Start pm-web in foreground (main process)
export PATCH_MANAGER_CONFIG="${CONFIG_DIR}/config.toml"
exec pm-web

View File

@ -14,6 +14,16 @@ Security: JWT Bearer Token (except Public Endpoints)
| POST | `/auth/mfa/verify` | Verify MFA code |
| DELETE | `/auth/mfa` | Disable MFA for user |
## 1b. SSO (Single Sign-On)
*No authentication required.* These endpoints implement the OIDC Authorization Code + PKCE flow. See `tasks/sso-token-handoff-spec.md` for the full design.
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/auth/sso/login` | Initiate OIDC login: redirects browser to the configured IdP's authorization URL |
| GET | `/auth/sso/callback` | OIDC redirect URI: handles the IdP response, issues a single-use 60s `handoff_code`, stores the JWT access/refresh tokens in memory, and 302-redirects to the SPA with `?handoff=<code>` in the URL (no tokens in the URL — see issue #4) |
| GET | `/auth/sso/config` | Returns minimal SSO configuration for the login page (`enabled`, `display_name`, `auth_url`). No secrets exposed |
| POST | `/auth/sso/handoff` | **(new in issue #4)** Exchange a single-use `handoff_code` for the JWT access/refresh tokens. The SPA calls this from `SsoCallbackPage` after the OIDC callback redirect. Returns `{ access_token, refresh_token, token_type, expires_in, user }`. The code is single-use, 60s TTL, and atomically removed on exchange (concurrent attempts: exactly one wins). `400 invalid_handoff` on unknown/expired/already-consumed codes |
## 2. Public Endpoints (Self-Enrollment)
*No authentication required.*
| Method | Endpoint | Description |
@ -60,12 +70,16 @@ Security: JWT Bearer Token (except Public Endpoints)
## 7. Jobs & Patch Deployment
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/jobs` | List patch jobs |
| GET | `/jobs` | List patch jobs (includes `host_names` per job) |
| POST | `/jobs` | Create new patch job |
| GET | `/jobs/{id}` | Get job status/details |
| POST | `/jobs/{id}/cancel` | Cancel running job |
| POST | `/jobs/{id}/rollback` | Rollback completed job |
### GET /jobs Response Fields
Each job summary object includes:
- `host_names`: Array of display names for hosts targeted by this job. Falls back to `fqdn` when `display_name` is empty. Single-host jobs show one name; multi-host jobs show all names sorted alphabetically.
## 8. Maintenance Windows
*Scoped to host.*
| Method | Endpoint | Description |
@ -102,13 +116,15 @@ Security: JWT Bearer Token (except Public Endpoints)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/settings` | Get system settings |
| PUT | `/settings` | Update system settings |
| PUT | `/settings` | Update system settings **(Admin only — Operators receive `403 forbidden_role`)** |
| POST | `/settings/smtp/test` | Test SMTP configuration |
| POST | `/settings/sso/discover` | Discover OIDC provider config |
| POST | `/settings/sso/test` | Test SSO connection |
| POST | `/settings/sso/discover` | Discover OIDC provider config **(Admin only — Operators receive `403 forbidden_role`)** |
| POST | `/settings/sso/test` | Test SSO connection **(Admin only — Operators receive `403 forbidden_role`)** |
| POST | `/settings/azure-sso/test` | Test Azure SSO compatibility |
| POST | `/settings/audit-integrity` | Verify audit log integrity |
> **Note (issue #6):** As of May 2026, sensitive fields (`oidc.client_secret`, `smtp.password`) are encrypted at rest in the database (AES-256-GCM). The `MASKED` placeholder behavior in API responses is **preserved** — clients never see plaintext secrets in GET responses. See [docs/runbooks/key-management.md](runbooks/key-management.md) for key management procedures.
## 12. Single Sign-On (SSO)
| Method | Endpoint | Description |
|--------|----------|-------------|
@ -125,6 +141,53 @@ Security: JWT Bearer Token (except Public Endpoints)
| GET | `/reports/vulnerability` | Generate vulnerability exposure report |
| GET | `/reports/audit` | Generate audit trail report |
### CRL Status Fields
Host list and detail responses include CRL (Certificate Revocation List) status fields:
| Field | Type | Description |
|-------|------|-------------|
| `crl_status` | `string?` | CRL status: `valid`, `expired`, `missing`, `invalid`, or `null` (older agents) |
| `crl_age_seconds` | `integer?` | Seconds since the agent's CRL was last refreshed |
| `crl_next_update` | `datetime?` | When the agent's CRL expires (ISO-8601) |
Fleet status response includes CRL counts:
| Field | Type | Description |
|-------|------|-------------|
| `crl_valid` | `integer` | Hosts with CRL status `valid` |
| `crl_expired` | `integer` | Hosts with CRL status `expired` |
| `crl_missing` | `integer` | Hosts with CRL status `missing` |
| `crl_invalid` | `integer` | Hosts with CRL status `invalid` (security event) |
| `crl_not_reporting` | `integer` | Hosts not reporting CRL status (older agents) |
### CRL Audit Events
The health poller logs the following system-initiated audit events when a host's CRL status changes:
| Audit Action | Trigger | Details Fields |
|---|---|---|
| `crl_status_changed` | Any CRL status transition | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
| `crl_stale_detected` | CRL status becomes `expired` | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
| `crl_invalid` | CRL status becomes `invalid` | `host_id`, `old_crl_status`, `new_crl_status`, `crl_age_seconds` |
All CRL audit events use `target_type = "host"` and `target_id = <host_id>`. Actor fields (`actor_user_id`, `actor_username`) are `null` because these are system-initiated events.
### CRL Health Aggregation Rules
The health poller applies the following rules to determine a host's effective health status based on CRL state:
| CRL Status | Condition | Effective Health Status |
|---|---|---|
| `invalid` | Always | `unreachable` (security event) |
| `expired` | If natural status is `healthy` | `degraded` |
| `missing` | Registered > 24h ago AND natural status is `healthy` | `degraded` |
| `missing` | Registered ≤ 24h ago | Natural status (new agent enrollment) |
| `valid` | Any | Natural status (no override) |
| `null` | Any | Natural status (older agent, not reporting CRL) |
When CRL status transitions from `invalid`/`expired`/`missing` back to `valid`, the next health poll cycle restores the host to its natural health status based on the agent's health response.
## 14. Real-Time Updates (WebSocket)
| Method | Endpoint | Description |
|--------|----------|-------------|

View File

@ -0,0 +1,128 @@
# Key Management Runbook
**Applies to:** Linux Patch Manager production deployments (issue #6 — secret encryption at rest)
**Last updated:** 2026-06-03
**Owner:** SRE / Security
---
## Overview
Linux Patch Manager uses two per-install AES-256-GCM encryption keys for protecting sensitive data at rest. Both keys are auto-generated on first start of the service, stored as 32-byte files with `0600` permissions (owner read/write only).
| Key file | Path | Protects | Used by |
|----------|------|----------|---------|
| `health-check.key` | `/etc/patch-manager/keys/health-check.key` | HTTP basic-auth passwords for health check endpoints | `pm-web`, `pm-worker` |
| `secret-encryption.key` | `/etc/patch-manager/keys/secret-encryption.key` | OIDC `client_secret`, SMTP `smtp_password`, TOTP `totp_secret` | `pm-web`, `pm-auth`, `pm-worker` |
The two keys are separate by design (blast-radius isolation): if the health-check key is ever compromised, the app secrets remain protected by a different key.
---
## Key Generation (First Start)
On first start of `pm-web` or `pm-worker`, the `crypto::load_or_create_key()` function checks for each key file. If missing, it:
1. Creates the `/etc/patch-manager/keys/` directory (mode `0700`)
2. Generates 32 cryptographically random bytes via `OsRng` (the OS CSPRNG)
3. Writes the key to disk
4. Sets permissions to `0600` (owner read/write only)
5. Returns the key to the calling code
The key files are created in the order they are first accessed. If `pm-worker` starts before `pm-web`, it creates the same key file (filesystem-shared). Both processes can read the same key.
---
## Backup
**Both key files MUST be included in `/etc/patch-manager` backups.** Without the key files, encrypted data is unrecoverable. Recommended backup procedure:
```bash
# Include the keys directory in the backup archive
tar -czf /backup/patch-manager-$(date +%F).tar.gz \
/etc/patch-manager/config.toml \
/etc/patch-manager/keys/ \
/var/lib/patch-manager/ # if used
# Verify the keys are in the backup
tar -tzf /backup/patch-manager-*.tar.gz | grep -E 'keys/.*\.key$'
```
The existing `scripts/backup.sh` already excludes secrets from unencrypted backups and supports GPG encryption for the archive. Ensure the backup includes the keys directory.
---
## Verification (Production)
To verify both keys exist and have correct permissions on a running deployment:
```bash
# Check both key files exist with 0600 permissions
for key in health-check.key secret-encryption.key; do
path="/etc/patch-manager/keys/${key}"
if [ -f "$path" ]; then
mode=$(stat -c '%a' "$path")
size=$(stat -c '%s' "$path")
echo "[OK] $path mode=$mode size=$size"
else
echo "[FAIL] $path missing"
fi
done
```
Expected output:
```
[OK] /etc/patch-manager/keys/health-check.key mode=600 size=32
[OK] /etc/patch-manager/keys/secret-encryption.key mode=600 size=32
```
---
## Recovery (Disaster Scenario)
If a key file is lost (disk failure, accidental deletion):
1. **All encrypted data becomes unrecoverable.** This includes:
- HTTP basic-auth passwords for health check endpoints (health-check.key)
- OIDC `client_secret` (secret-encryption.key)
- SMTP `smtp_password` (secret-encryption.key)
- TOTP `totp_secret` for all users (secret-encryption.key)
2. **If you have a backup** of the key files: restore them to `/etc/patch-manager/keys/` with `0600` permissions. The service will read the restored keys on next start.
3. **If you do NOT have a backup**: re-provision the affected secrets:
- For OIDC: re-enter the `client_secret` from the IdP's app registration
- For SMTP: re-enter the SMTP password
- For TOTP: all users must re-enroll MFA (their existing TOTP secrets are unrecoverable)
- For health-check basic auth: re-enter the password in each health check configuration
---
## Key Rotation
Key rotation is **not yet supported** (tracked as a follow-up issue). If a key is compromised:
1. Generate a new key: `rm /etc/patch-manager/keys/secret-encryption.key` (service will auto-generate on next start)
2. Re-encrypt all secrets in the database using the `migrate-secrets` binary (see [README of the helper](../../crates/migrate-secrets/src/main.rs))
3. Update any external systems that depended on the old secrets (e.g., IdP app registration)
For a planned rotation (without compromise), the procedure is the same but coordinated with a maintenance window.
---
## Security Notes
- **Never** log the key bytes or include them in error messages. The `crypto::load_or_create_key()` function returns the key but callers should never `tracing::error!` the value.
- **Never** commit key files to git. The `/etc/patch-manager/keys/` directory should be in `.gitignore` or outside the repo entirely (recommended).
- **Never** copy key files between machines (e.g., for "easy migration"). Each deployment must generate its own key.
- **The `MASKED` placeholder in API responses** (e.g., for `client_secret` in OIDC settings) continues to apply on top of DB encryption — it's a separate defense-in-depth layer.
---
## Related
- [Secret encryption spec](../../tasks/secret-encryption-spec.md) — full design rationale and migration plan
- [Security review](../security-review.md) §4.1 — control matrix entry
- [Migration 020](../../migrations/020_encrypt_secrets_at_rest.sql) — schema changes for the new encrypted columns
- `crates/pm-core/src/crypto.rs` — implementation of `load_or_create_key`, `encrypt`, `decrypt`
- `crates/migrate-secrets/src/main.rs` — one-shot helper for migrating plaintext → encrypted

View File

@ -0,0 +1,142 @@
# Reverse Proxy Deployment Runbook
**Audience:** Operators deploying `pm-web` behind a reverse proxy (nginx,
HAProxy, Cloudflare, AWS ALB, etc.).
**Related:**
- `docs/security-review.md` §1.3 (IP Whitelist Enforcement)
- `tasks/ip-allowlist-spec.md` §7 (Risk Analysis)
- Issue [#3](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/3)
---
## TL;DR
If you front `pm-web` with a reverse proxy, you **MUST** add the proxy's
IP address (or CIDR) to `security.trusted_proxies` in
`/etc/patch-manager/config.toml`. If you do not, the IP allowlist will
evaluate against the proxy's IP (not the real client) and will return
`403 forbidden_ip` for legitimate traffic.
## Why
Starting with the IP-allowlist hardening in issue #3, `pm-web` no longer
trusts `X-Forwarded-For` by default. The default behavior is **strict**:
1. The server reads the socket peer IP from `ConnectInfo<SocketAddr>`.
2. The server checks that IP against `security.ip_whitelist`.
3. `X-Forwarded-For` is **ignored** unless the socket peer is in
`security.trusted_proxies`.
When you put a reverse proxy in front, every connection's socket peer IP
is the proxy's address. Without `trusted_proxies` set, the proxy's IP is
checked against your allowlist — and unless your allowlist happens to
include the proxy (which would defeat the purpose of the allowlist),
the request is denied.
## How to Fix
1. Identify the **egress IP** of your reverse proxy (the IP `pm-web`
sees as the immediate TCP peer). This is typically:
- nginx: the IP nginx binds to internally, or the host's IP if nginx
runs on the same host as `pm-web` (port forward).
- Cloudflare: see
[Cloudflare IP ranges](https://www.cloudflare.com/ips/).
- AWS ALB / NLB: the ALB/NLB's private IP from the VPC.
- HAProxy: the bind address.
2. Add the IP (or CIDR for multiple hops) to `trusted_proxies` in
`/etc/patch-manager/config.toml`:
```toml
[security]
ip_whitelist = ["10.0.0.0/8"] # example: corporate clients
trusted_proxies = ["172.16.5.10/32"] # example: reverse proxy egress
```
3. **Restart `pm-web`** for the config to take effect. The
`trusted_proxies` field is read at startup; runtime updates are
supported via `AuthConfig::update_trusted_proxies` but not yet
exposed through a settings endpoint.
4. Verify by tailing the logs and confirming that requests with
`X-Forwarded-For: <allowed-client-ip>` succeed (status 200/401, NOT
403) when the request comes through the proxy.
## Multi-hop Proxy Chains
If you have multiple proxies in front of `pm-web` (e.g., Cloudflare →
nginx → pm-web), add **each hop you control** to `trusted_proxies`:
```toml
trusted_proxies = [
"172.16.5.10/32", # nginx egress (immediate peer)
"10.0.0.0/8", # internal network (in case nginx runs on a different host)
]
```
The resolver picks the leftmost entry of `X-Forwarded-For` when the
immediate peer is in `trusted_proxies`. With two trusted hops, the
resolver will pick the leftmost untrusted IP (the real client).
## Reverse Proxy Headers (recommended)
In addition to the `trusted_proxies` config, configure your reverse
proxy to:
- **Append** to `X-Forwarded-For` (not replace) so the chain is
preserved through multiple hops.
- Set `X-Real-IP` (optional, informational; pm-web currently uses
`X-Forwarded-For`).
- Forward the original `Host` header so SAML/OIDC redirects work
correctly.
- Do **not** strip the `Authorization` header.
### nginx example
```nginx
location /api/ {
proxy_pass http://127.0.0.1:12443;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
The `proxy_add_x_forwarded_for` directive appends, which is what you want.
## Troubleshooting
### All requests return 403 forbidden_ip
- Check that `trusted_proxies` is set and contains the proxy's IP.
- Check that the proxy's IP is correct (run `ss -tnp` on the pm-web
host to see the actual peer address).
- Check `tracing` logs for `reason = "unresolvable_client_ip"` — this
means the `ConnectInfo<SocketAddr>` extension is missing (the
listener wasn't built with `into_make_service_with_connect_info`).
### XFF is being ignored
- Check that the immediate peer's IP is in `trusted_proxies`. If the
immediate peer is NOT in `trusted_proxies`, XFF is ignored (correct
behavior).
- Check the XFF format: pm-web parses the leftmost entry, trimmed of
whitespace. A malformed leftmost entry falls back to the socket peer.
### Multiple IPs in XFF and only the last hop is trusted
- If you have one trusted proxy and one untrusted, the resolver will
only use XFF when the immediate peer (the trusted one) is in the
list. The XFF is parsed leftmost-first, so the real client IP (leftmost
untrusted hop) is used.
- If neither hop is in `trusted_proxies`, XFF is ignored and the
socket peer IP (the immediate proxy) is used. Add the immediate
proxy to `trusted_proxies` to fix.
## See Also
- `config/config.example.toml` — inline documentation on `trusted_proxies`.
- `tasks/ip-allowlist-spec.md` §3 (Design Decisions) for the rationale.
- `crates/pm-auth/src/rbac.rs` — the resolver implementation.

View File

@ -31,9 +31,25 @@ verifying that all mandated security controls are implemented and operational.
### 1.3 IP Whitelist Enforcement
| Control | Status | Evidence |
|---------|--------|----------|
| IP whitelist on all connection points | ✅ Verified | Middleware extracts `X-Forwarded-For` / `X-Real-IP`; checks against `AuthConfig.ip_whitelist` (RwLock for live updates) |
| IP whitelist on all connection points | ✅ Verified | `require_auth` middleware in `crates/pm-auth/src/rbac.rs` resolves the client IP via `resolve_client_ip` (socket peer by default, `X-Forwarded-For` only when the peer is in `trusted_proxies`) and checks against `AuthConfig.ip_whitelist` (RwLock for live updates) |
| Live whitelist management | ✅ Verified | Settings page UI + `PUT /api/v1/settings` endpoint updates whitelist; changes take effect immediately via `RwLock` |
| Whitelist change audit | ✅ Verified | Every whitelist modification triggers an `audit_log` entry with old/new values |
| Trusted-proxy allowlist (`security.trusted_proxies`) | ✅ Verified | New `trusted_proxies: Vec<String>` field on `SecurityConfig` (default empty = strict). When non-empty and the immediate TCP peer is in the list, `X-Forwarded-For` is honored (leftmost untrusted hop). Documented in `config/config.example.toml`. `AuthConfig::update_trusted_proxies` setter allows runtime updates |
| Fail-closed on unresolvable client IP | ✅ Verified | When a non-empty allowlist is configured and the client IP cannot be determined (no `ConnectInfo<SocketAddr>` extension), the request is rejected with `403 forbidden_ip`. `tracing::warn!` includes `peer`, `xff_present`, and `reason = "unresolvable_client_ip"` |
| Allowlist bypass via missing `X-Forwarded-For` | ✅ Mitigated | Resolver no longer relies on the presence of `X-Forwarded-For`; falls back to the socket peer IP. Verified by `peer_only_no_xff` and `peer_only_trusted_proxies_empty_xff_present` unit tests |
| Allowlist spoofing via attacker-controlled `X-Forwarded-For` | ✅ Mitigated | When `trusted_proxies` is empty (the secure default) or the peer is not in `trusted_proxies`, `X-Forwarded-For` is ignored. Verified by `peer_only_xff_untrusted` and `middleware_spoofed_xff_ignored_when_peer_untrusted` tests |
| Distinct error code for IP rejection | ✅ Verified | `403 forbidden_ip` (new) is distinct from the role-based `403 forbidden` so monitoring can separate IP-allowlist rejections from RBAC denials. Documented in `tasks/ip-allowlist-spec.md` §4.5 |
### 1.4 WebSocket Origin Allowlist (CSWSH Defense-in-Depth)
| Control | Status | Evidence |
|---------|--------|----------|
| `Origin` header allowlist on browser WS upgrade | ✅ Verified | `crates/pm-web/src/routes/ws.rs` `ws_handler``HeaderMap` extractor + `check_origin` enforced before ticket validation |
| Allowlist configurable via `security.allowed_origins` | ✅ Verified | `crates/pm-core/src/config.rs` `SecurityConfig::allowed_origins`; documented in `config/config.example.toml` |
| Secure-by-default derivation from `sso_callback_url` | ✅ Verified | `derive_allowed_origins` parses the SSO callback URL into a single `scheme://host[:port]` entry when the operator leaves `allowed_origins` empty; `AppConfig::load` runs the derivation and emits a `tracing::warn!` if the result is empty (fail-closed) |
| Order: Origin check before ticket consumption | ✅ Verified | Rejected cross-origin probes do not burn the user's ticket; documented in the handler doc-comment and verified by `check_rejects_disallowed_origin` test |
| Rejected upgrades logged with `origin` and `reason` | ✅ Verified | `tracing::warn!` in `ws_handler`; ticket value is never logged |
**Note:** The browser WebSocket endpoint (`GET /api/v1/ws/jobs`) is the only browser-reachable WS server in the codebase. The `pm-worker` `ws_relay` module is an outbound mTLS WS *client* to on-host agents and is not subject to CSWSH.
---
@ -69,6 +85,7 @@ verifying that all mandated security controls are implemented and operational.
| Admin: full rights | ✅ Verified | Admin role bypasses group scoping; access to all resources |
| Operator: group-scoped | ✅ Verified | Operators can only manage hosts in their assigned groups; middleware enforces on every request |
| RBAC middleware | ✅ Verified | Axum middleware extracts role from JWT; enforces before route handler execution |
| **Manager-wide auth config is Admin-only (issue #5 fix)** | ✅ Verified | `admin_required` gate in `crates/pm-web/src/routes/settings.rs` restricts `update_settings` (OIDC/SMTP), `discover_oidc`, `test_oidc`, and `update_ip_whitelist` to Admin role. Operators receive `403 forbidden_role`. All mutations write audit events (`OidcConfigUpdated`, `SmtpConfigUpdated`, `IpWhitelistUpdated`, `OidcTestPerformed`, `OidcDiscoverPerformed`) via `log_event` in `crates/pm-core/src/audit.rs`. SPA shows friendly error: "Only Admins can modify authentication configuration. Contact an Admin to make this change." Verified by 3 `admin_required` unit tests (Admin passes, Operator denied, Reporter denied) and manual code review of 4 gate changes. Full integration tests deferred to [issue #15](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/15). |
### 2.5 Azure SSO
| Control | Status | Evidence |
@ -76,6 +93,9 @@ verifying that all mandated security controls are implemented and operational.
| OAuth2/OIDC Authorization Code + PKCE | ✅ Verified | Public routes `/api/v1/auth/azure/login` and `/api/v1/auth/azure/callback` implement PKCE flow |
| Test connection without enabling | ✅ Verified | `POST /api/v1/settings/azure-sso/test` validates configuration without persisting |
| MFA still required after SSO | ✅ Verified | SSO login follows same MFA verification path as local login |
| **No tokens in redirect URL (issue #4 fix)** | ✅ Verified | SSO callback (`crates/pm-web/src/routes/sso.rs` `sso_callback`) now issues a single-use, 60s `handoff_code` and stores the JWT access/refresh tokens in the in-memory `sso_handoffs: Arc<DashMap<String, SsoHandoff>>`. The redirect URL contains only `?handoff=<code>`. No `access_token`, `refresh_token`, or `user` parameters are ever placed in the URL. The SPA exchanges the code via `POST /api/v1/auth/sso/handoff`. See `tasks/sso-token-handoff-spec.md` for the full design. |
| **Handoff code is single-use + 60s TTL** | ✅ Verified | `DashMap::remove` in `sso_handoff_exchange_inner` is atomic — concurrent exchange attempts result in exactly one success and one 400. Expired codes (`expires_at < Instant::now()`) are rejected with `400 invalid_handoff`. A background cleanup task removes expired entries every 60s. Verified by `handoff_exchange_single_use`, `handoff_exchange_race`, and `handoff_exchange_expired_code` tests in `crates/pm-web/src/routes/sso.rs`. |
| **Handoff code cleared from browser history** | ✅ Verified | SPA calls `window.history.replaceState({}, '', '/auth/sso/callback')` after a successful exchange, removing the `?handoff=` param from the address bar. Verified by `clears_handoff_code_from_url_after_success` test in `frontend/src/pages/__tests__/SsoCallbackPage.test.tsx`. |
---
@ -105,7 +125,8 @@ verifying that all mandated security controls are implemented and operational.
| Control | Status | Evidence |
|---------|--------|----------|
| Infrastructure-managed disk encryption | ✅ Verified | Hardware/infrastructure layer provides encryption at rest; no LUKS in guest OS |
| No column-level encryption needed | ✅ Verified | Compliance requirement satisfied by infrastructure layer per system mandate |
| **App secrets encrypted at rest (issue #6 fix)** | ✅ Verified | OIDC `client_secret`, SMTP `smtp_password`, and TOTP `totp_secret` are encrypted with AES-256-GCM using a dedicated per-install key at `/etc/patch-manager/keys/secret-encryption.key` (auto-generated on first start, 0600 permissions). Separate from the health-check key for blast-radius isolation. Encryption/decryption via `pm-core::crypto::encrypt`/`decrypt`. Schema migration `020_encrypt_secrets_at_rest.sql` replaces plaintext TEXT columns with BYTEA `_encrypted` + `_nonce` columns. All 6 read/write sites updated: `sso.rs`, `settings.rs` (OIDC + SMTP), `session.rs` (TOTP read), `auth.rs` (TOTP write), `users.rs` (TOTP NULL), `pm-worker/email.rs` (SMTP read). The `MASKED` placeholder behavior in API responses is preserved. |
| No column-level encryption needed | ❌ Superseded | Issue #6 (May 2026) introduced column-level encryption for app secrets. Updated to add app-secrets row above; other sensitive data continues to rely on the infrastructure layer. |
### 4.2 Secret Management
| Control | Status | Evidence |
@ -139,9 +160,30 @@ verifying that all mandated security controls are implemented and operational.
## 6. Findings & Recommendations
### No Critical or High Findings
### 🔴 CRITICAL: Committed Private Key Material (Issue #12) — RESOLVED
All security controls are implemented as specified in the system requirements.
**Description:**
Private key file `client.key` and public certificates (`client.crt`, `ca.crt`) were committed
to version control in `crates/pm-agent-client/certs/`. Committed private keys are a critical
security risk: anyone with repository access can impersonate agents or decrypt captured TLS traffic.
**Status:** ✅ RESOLVED
**Remediation Applied:**
1. Removed all cert files from git tracking (`git rm --cached`)
2. Added `*.key`, `*.key.pem` and `crates/pm-agent-client/certs/` to `.gitignore`
3. Updated `pm-agent-client` doc examples to use `std::fs::read()` instead of `include_bytes!`
4. Added `gitleaks` secret scanning to CI pipeline
5. Added README to `crates/pm-agent-client/certs/` explaining runtime cert generation
6. Git history will be purged with `git filter-repo` after PR merge
**Key Rotation:**
These keys were dev/test only. No production key rotation is needed. All committed keys
should be considered compromised and must not be used in production.
### No Other Critical or High Findings
All other security controls are implemented as specified in the system requirements.
### Recommendations (Low Priority)
@ -171,3 +213,4 @@ All security controls are implemented as specified in the system requirements.
- [x] Backup encryption supported (GPG)
- [x] Azure SSO with PKCE flow
- [x] No plaintext credential storage
- [x] Committed private key material removed from repository (Issue #12)

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,16 @@
{
"name": "patch-manager-ui",
"private": true,
"version": "0.1.7",
"version": "1.1.10",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src/ --ext .ts,.tsx --max-warnings 0",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@ -25,6 +27,9 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.30.0",
@ -32,7 +37,9 @@
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.24.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^25.0.1",
"typescript": "^5.8.3",
"vite": "^6.3.3"
"vite": "^6.3.3",
"vitest": "^2.1.9"
}
}

View File

@ -22,6 +22,7 @@ import {
RestartAlt,
Refresh as RefreshIcon,
Security as SecurityIcon,
VerifiedUser as VerifiedUserIcon,
} from '@mui/icons-material'
import { fleetApi, certsApi } from '../api/client'
import type { FleetStatus } from '../types'
@ -237,6 +238,57 @@ export default function DashboardPage() {
</Card>
</Grid>
</Grid>
{/* ── Row 4: CRL Status ── */}
<Card variant="outlined" sx={{ mt: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<VerifiedUserIcon color="primary" />
<Typography variant="subtitle1" fontWeight={600}>
CRL Status
</Typography>
</Box>
<Grid container spacing={2}>
<Grid size={{ xs: 6, sm: 3 }}>
<Box textAlign="center">
<Typography variant="h5" fontWeight={700} sx={{ color: '#2e7d32' }}>
{status.crl_valid}
</Typography>
<Typography variant="caption" color="text.secondary">Valid</Typography>
</Box>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Box textAlign="center">
<Typography variant="h5" fontWeight={700} sx={{ color: '#ed6c02' }}>
{status.crl_expired}
</Typography>
<Typography variant="caption" color="text.secondary">Expired</Typography>
</Box>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Box textAlign="center">
<Typography variant="h5" fontWeight={700} sx={{ color: '#ed6c02' }}>
{status.crl_missing}
</Typography>
<Typography variant="caption" color="text.secondary">Missing</Typography>
</Box>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Box textAlign="center">
<Typography variant="h5" fontWeight={700} sx={{ color: '#d32f2f' }}>
{status.crl_invalid}
</Typography>
<Typography variant="caption" color="text.secondary">Invalid</Typography>
</Box>
</Grid>
</Grid>
{status.crl_not_reporting > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{status.crl_not_reporting} host{status.crl_not_reporting !== 1 ? 's' : ''} not reporting CRL status
</Typography>
)}
</CardContent>
</Card>
</Box>
)}
</Container>

View File

@ -46,6 +46,9 @@ import {
Schedule as ScheduleIcon,
VpnKey as VpnKeyIcon,
ContentCopy as CopyIcon,
VerifiedUser as VerifiedUserIcon,
Security as SecurityIcon,
WarningAmber as WarningAmberIcon,
} from '@mui/icons-material'
import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
@ -1035,6 +1038,53 @@ export default function HostDetailPage() {
</Grid>
</Paper>
{/* ── CRL Status ─────────────────────────────────────────────────── */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<VerifiedUserIcon color="primary" />
<Typography variant="h6" fontWeight={600}>CRL Status</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
{host?.crl_status === undefined || host?.crl_status === null ? (
<Alert severity="info">
CRL status not available (agent version does not support CRL)
</Alert>
) : (
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">Status</Typography>
{host.crl_status === 'valid' ? (
<Chip icon={<VerifiedUserIcon />} label="Valid" color="success" size="small" />
) : host.crl_status === 'expired' ? (
<Chip icon={<WarningAmberIcon />} label="Expired" color="warning" size="small" />
) : host.crl_status === 'missing' ? (
<Chip icon={<WarningAmberIcon />} label="Missing" color="warning" size="small" />
) : host.crl_status === 'invalid' ? (
<Chip icon={<SecurityIcon />} label="Invalid" color="error" size="small" />
) : (
<Typography variant="body2">{String(host.crl_status)}</Typography>
)}
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">CRL Age</Typography>
<Typography variant="body2">
{host.crl_age_seconds !== null
? (() => { const s = Number(host.crl_age_seconds); return s < 3600 ? `${Math.round(s / 60)} minutes ago` : s < 86400 ? `${Math.round(s / 3600)} hours ago` : `${Math.round(s / 86400)} days ago`; })()
: ''}
</Typography>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">Next Update</Typography>
<Typography variant="body2">
{host.crl_next_update
? new Date(host.crl_next_update as string).toLocaleString()
: ''}
</Typography>
</Grid>
</Grid>
)}
</Paper>
{/* ── Maintenance Windows ──────────────────────────────────────────── */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>

View File

@ -5,7 +5,7 @@ import {
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
TablePagination, TextField, Toolbar, Tooltip, Typography,
} from '@mui/material'
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon } from '@mui/icons-material'
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon, VerifiedUser as VerifiedUserIcon, Security as SecurityIcon } from '@mui/icons-material'
import { useNavigate } from 'react-router-dom'
import { apiClient, hostsApi, enrollmentApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
@ -182,6 +182,7 @@ export default function HostsPage() {
<TableCell>OS</TableCell>
<TableCell>Health</TableCell>
<TableCell>Checks</TableCell>
<TableCell>CRL</TableCell>
<TableCell>Agent</TableCell>
{canWrite && <TableCell>Actions</TableCell>}
</TableRow>
@ -201,6 +202,7 @@ export default function HostsPage() {
<TableCell>{(req.os_details['name'] as string) ?? 'Unknown'}</TableCell>
<TableCell><Chip size="small" label="pending" color="warning" /></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
<Tooltip title="Approve">
@ -240,6 +242,19 @@ export default function HostsPage() {
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
)}
</TableCell>
<TableCell>
{h.crl_status === 'valid' ? (
<Tooltip title="CRL valid"><VerifiedUserIcon color="success" fontSize="small" /></Tooltip>
) : h.crl_status === 'expired' ? (
<Tooltip title="CRL expired"><WarningAmberIcon color="warning" fontSize="small" /></Tooltip>
) : h.crl_status === 'missing' ? (
<Tooltip title="CRL missing"><WarningAmberIcon color="warning" fontSize="small" /></Tooltip>
) : h.crl_status === 'invalid' ? (
<Tooltip title="CRL invalid — security event"><SecurityIcon color="error" fontSize="small" /></Tooltip>
) : (
<Tooltip title="CRL status not available (agent version does not support CRL)"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
)}
</TableCell>
<TableCell>{h.agent_version ?? '—'}</TableCell>
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
<Tooltip title="Request refresh">

View File

@ -194,7 +194,13 @@ function JobRow({
<TableCell>
<StatusChip status={job.status} />
</TableCell>
<TableCell align="right">{job.host_count}</TableCell>
<TableCell>
{job.host_names.length === 1
? job.host_names[0]
: job.host_names.length > 1
? <Tooltip title={job.host_names.join(', ')}><span>{job.host_names[0]} +{job.host_names.length - 1}</span></Tooltip>
: '—'}
</TableCell>
<TableCell align="right">
<Typography color="success.main" fontWeight={600}>
{job.succeeded_count}
@ -512,7 +518,7 @@ export default function JobsPage() {
<TableCell>Created</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Hosts</TableCell>
<TableCell>Hosts</TableCell>
<TableCell align="right">Succeeded</TableCell>
<TableCell align="right">Failed</TableCell>
<TableCell>Schedule</TableCell>

View File

@ -99,6 +99,11 @@ export default function SettingsPage() {
const { data } = await settingsApi.discoverOidc(oidc.discovery_url)
setDiscoveryResult(data)
} catch (err: unknown) {
const axiosErr = err as AxiosError
if (axiosErr.response?.status === 403) {
setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: 'Only Admins can modify authentication configuration. Contact an Admin to make this change.' })
return
}
const msg = err instanceof Error ? err.message : 'Discovery failed'
setDiscoveryResult({ success: false, issuer: '', authorization_endpoint: '', token_endpoint: '', jwks_uri: '', message: msg })
} finally {
@ -151,6 +156,10 @@ export default function SettingsPage() {
setSuccess('Settings saved successfully')
} catch (err: unknown) {
const axiosErr = err as AxiosError<{ error?: { message?: string } }>
if (axiosErr.response?.status === 403) {
setError('Only Admins can modify authentication configuration. Contact an Admin to make this change.')
return
}
const msg =
axiosErr.response?.data?.error?.message ??
(err instanceof Error ? err.message : 'Failed to save settings')

View File

@ -1,76 +1,97 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Box, Container, Paper, Typography, Alert, Button, CircularProgress,
} from '@mui/material'
import { useAuthStore } from '../store/authStore'
import type { User } from '../types'
/**
* SSO callback page.
*
* Flow (per `tasks/sso-token-handoff-spec.md`):
* 1. The OIDC provider redirects the browser here with `?handoff=<code>`
* in the URL. The actual JWT access/refresh tokens are NOT in the URL
* (that would leak them through browser history, proxy access logs,
* and the Referer header — see issue #4).
* 2. On mount, we POST the handoff code to
* `POST /api/v1/auth/sso/handoff`. The backend atomically removes
* the entry (single-use) and returns the tokens in the JSON
* response.
* 3. On success, we call `setTokens` + `setUser` on the auth store,
* replace the URL (removing the handoff code from history), and
* navigate to `/dashboard`.
* 4. On failure, we show an error and let the user go back to `/login`.
*/
export default function SsoCallbackPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { setTokens, setUser } = useAuthStore()
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(true)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
// Check for error from backend
const errorCode = params.get('error')
const errorDescription = params.get('error_description')
// Surface upstream OIDC errors (e.g. user denied consent) unchanged.
const errorCode = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
if (errorCode) {
setError(errorDescription || `SSO authentication failed: ${errorCode}`)
setProcessing(false)
return
}
// Extract tokens
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
if (!accessToken || !refreshToken) {
setError('Missing authentication tokens. Please try logging in again.')
const handoffCode = searchParams.get('handoff')
if (!handoffCode) {
setError('Missing handoff code. Please try logging in again.')
setProcessing(false)
return
}
// Parse user JSON from query param
const userParam = params.get('user')
if (!userParam) {
setError('Missing user information. Please try logging in again.')
setProcessing(false)
return
}
let parsedUser: Record<string, unknown>
// Exchange the handoff code for tokens. The code is single-use and
// 60-second TTL on the backend; the SPA must POST promptly.
(async () => {
try {
parsedUser = JSON.parse(userParam)
const resp = await fetch('/api/v1/auth/sso/handoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handoff_code: handoffCode }),
})
if (!resp.ok) {
// Try to extract a structured error from the backend
let message = `Failed to complete sign-in (HTTP ${resp.status})`
try {
const errBody = await resp.json()
if (errBody?.error?.message) {
message = errBody.error.message
}
} catch {
setError('Malformed user data received. Please try logging in again.')
// Body wasn't JSON; keep the default message
}
setError(message)
setProcessing(false)
return
}
// Build a full User object from the SSO subset, filling in sensible defaults
// auth_provider comes from the backend based on the OIDC provider type
const authProvider = (parsedUser.auth_provider as string) || 'azure_sso'
const user: User = {
id: (parsedUser.id as string) || '',
username: (parsedUser.username as string) || '',
display_name: (parsedUser.display_name as string) || '',
email: (parsedUser.email as string) || '',
role: (parsedUser.role as User['role']) || 'operator',
auth_provider: authProvider as User['auth_provider'],
mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false,
is_active: true,
force_password_reset: false,
}
const data = await resp.json()
const user = buildUser(data.user)
// Store tokens and user, then navigate
setTokens(accessToken, refreshToken)
setTokens(data.access_token, data.refresh_token)
setUser(user)
// Clear the handoff code from the URL so it doesn't end up in
// browser history or get shared via the address bar. The code
// is already consumed (single-use) but defense-in-depth.
window.history.replaceState({}, '', '/auth/sso/callback')
navigate('/dashboard', { replace: true })
}, [setTokens, setUser, navigate])
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to complete sign-in. Please try again.',
)
setProcessing(false)
}
})()
}, [setTokens, setUser, navigate, searchParams])
return (
<Container maxWidth="xs" sx={{ mt: 12 }}>
@ -105,3 +126,22 @@ export default function SsoCallbackPage() {
</Container>
)
}
/**
* Map the SSO user JSON payload from the backend to the SPA's `User`
* type. Fills in sensible defaults for any missing fields.
*/
function buildUser(parsed: Record<string, unknown>): User {
const authProvider = (parsed.auth_provider as string) || 'azure_sso'
return {
id: (parsed.id as string) || '',
username: (parsed.username as string) || '',
display_name: (parsed.display_name as string) || '',
email: (parsed.email as string) || '',
role: (parsed.role as User['role']) || 'operator',
auth_provider: authProvider as User['auth_provider'],
mfa_enabled: (parsed.mfa_enabled as boolean) ?? false,
is_active: true,
force_password_reset: false,
}
}

View File

@ -0,0 +1,205 @@
/// Tests for SsoCallbackPage (issue #4 — SSO token handoff).
///
/// Per `tasks/sso-token-handoff-spec.md` §6.3:
/// 9. renders_processing_state_initially
/// 10. calls_handoff_endpoint_on_mount
/// 11. stores_tokens_and_user_on_success
/// 12. shows_error_on_handoff_failure
/// 13. shows_error_when_handoff_code_missing
/// 14. clears_handoff_code_from_url_after_success
///
/// We mock `fetch`, the auth store, and `window.history.replaceState`
/// so the test focuses on the page's effect-driven logic (URL parsing
/// → POST exchange → store update → navigation → URL cleanup). We do
/// NOT mock `react-router-dom` — instead, we use a real
/// `MemoryRouter` and assert on side effects (the auth store mocks +
/// `replaceState` spy + visible error text).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import SsoCallbackPage from '../SsoCallbackPage'
// Mock the auth store — we don't want real zustand state leaking
// between tests, and we want to assert on setTokens/setUser calls.
const setTokensMock = vi.fn()
const setUserMock = vi.fn()
vi.mock('../../store/authStore', () => ({
useAuthStore: () => ({
setTokens: setTokensMock,
setUser: setUserMock,
}),
}))
// Helper: render the page with a controlled URL and let the test
// inspect the rendered output + the auth store mocks.
function renderAt(url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<SsoCallbackPage />
</MemoryRouter>,
)
}
beforeEach(() => {
setTokensMock.mockReset()
setUserMock.mockReset()
// Default fetch: never-resolving promise (keeps the page in
// "processing" state). Individual tests override this.
globalThis.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('SsoCallbackPage', () => {
// 9. renders_processing_state_initially — on mount with a handoff
// code, shows the spinner and "Completing sign-in…".
it('renders the processing state initially', async () => {
// Wrap in act() to flush the useEffect that calls fetch.
await act(async () => {
renderAt('/auth/sso/callback?handoff=test-code')
})
expect(screen.getByText(/completing sign-in/i)).toBeInTheDocument()
// The MUI CircularProgress renders a role="progressbar"
expect(screen.getByRole('progressbar')).toBeInTheDocument()
})
// 10. calls_handoff_endpoint_on_mount — mocks fetch and asserts
// the POST goes to /api/v1/auth/sso/handoff with
// { handoff_code: <code> }.
it('POSTs the handoff code to the backend on mount', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'a',
refresh_token: 'r',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'u1', username: 'tester' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=abc123')
})
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/auth/sso/handoff')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body)).toEqual({ handoff_code: 'abc123' })
})
// 11. stores_tokens_and_user_on_success — mocks a successful
// response, asserts setTokens and setUser are called, and
// setTokens receives the correct token values.
it('stores tokens + user on a successful exchange', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'access-jwt',
refresh_token: 'refresh-raw',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'user-42', username: 'alice' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=ok')
})
await waitFor(() => {
expect(setTokensMock).toHaveBeenCalledWith('access-jwt', 'refresh-raw')
})
expect(setUserMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-42', username: 'alice' }),
)
})
// 12. shows_error_on_handoff_failure — mocks a 400 response,
// asserts the error message is rendered and the spinner
// stops.
it('shows an error when the backend returns 400', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
error: { code: 'invalid_handoff', message: 'Handoff code has expired' },
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=expired')
})
expect(await screen.findByText(/handoff code has expired/i)).toBeInTheDocument()
expect(screen.queryByText(/completing sign-in/i)).not.toBeInTheDocument()
// No token storage on error
expect(setTokensMock).not.toHaveBeenCalled()
expect(setUserMock).not.toHaveBeenCalled()
})
// 13. shows_error_when_handoff_code_missing — invokes the effect
// with no handoff code, asserts the "Missing handoff code"
// error is shown.
it('shows a missing-code error when ?handoff= is absent', async () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback')
})
expect(await screen.findByText(/missing handoff code/i)).toBeInTheDocument()
// No fetch call should have been made
expect(fetchMock).not.toHaveBeenCalled()
})
// 14. clears_handoff_code_from_url_after_success — asserts
// window.history.replaceState is called to remove the
// ?handoff= param from the URL after a successful exchange.
it('clears the handoff code from the URL after a successful exchange', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'a',
refresh_token: 'r',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'u', username: 'u' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
await act(async () => {
renderAt('/auth/sso/callback?handoff=secret-code')
})
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalled()
})
// Verify the replaceState call cleared the query string — the
// third argument is the new URL ('/auth/sso/callback' with no
// query).
const args = replaceStateSpy.mock.calls[0]
expect(args[2]).toBe('/auth/sso/callback')
})
})

View File

@ -0,0 +1,6 @@
/// Vitest setup file — runs before each test file.
///
/// Imports `@testing-library/jest-dom` to register custom matchers like
/// `toBeInTheDocument`, `toHaveTextContent`, etc. that the SSO callback
/// tests rely on.
import '@testing-library/jest-dom/vitest'

View File

@ -27,6 +27,9 @@ export interface Host {
patches_missing: number
registered_at: string
health_check_status?: 'all_healthy' | 'some_unhealthy' | 'none'
crl_status?: 'valid' | 'expired' | 'missing' | 'invalid'
crl_age_seconds?: number
crl_next_update?: string
}
export interface CreateHostRequest {
@ -98,6 +101,11 @@ export interface FleetStatus {
total_pending_patches: number
hosts_requiring_reboot: number
compliance_pct: number
crl_valid: number
crl_expired: number
crl_missing: number
crl_invalid: number
crl_not_reporting: number
}
export interface PatchInfo {
@ -144,6 +152,7 @@ export interface PatchJobSummary {
status: JobStatus
immediate: boolean
host_count: number
host_names: string[]
succeeded_count: number
failed_count: number
notes: string

18
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
/// Vitest configuration for the Patch Manager UI.
///
/// - Uses jsdom for a browser-like environment (needed for MUI + React
/// Testing Library).
/// - The `react()` plugin is required for JSX in test files.
/// - `globals: true` lets tests use `describe`, `it`, `expect` without
/// imports (matches the existing frontend conventions).
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
})

View File

@ -12,14 +12,44 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy text search on host names
-- Enumerations
-- ============================================================
CREATE TYPE user_role AS ENUM ('admin', 'operator');
CREATE TYPE auth_provider AS ENUM ('local', 'azure_sso');
CREATE TYPE host_health_status AS ENUM ('pending', 'healthy', 'degraded', 'unreachable');
CREATE TYPE job_status AS ENUM ('queued', 'pending', 'running', 'succeeded', 'failed', 'cancelled');
CREATE TYPE job_kind AS ENUM ('patch_apply', 'patch_remove', 'reboot', 'rollback');
CREATE TYPE window_recurrence AS ENUM ('once', 'daily', 'weekly', 'monthly');
CREATE TYPE cert_status AS ENUM ('active', 'revoked', 'expired');
CREATE TYPE audit_action AS ENUM (
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
CREATE TYPE user_role AS ENUM ('admin', 'operator');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'auth_provider') THEN
CREATE TYPE auth_provider AS ENUM ('local', 'azure_sso');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'host_health_status') THEN
CREATE TYPE host_health_status AS ENUM ('pending', 'healthy', 'degraded', 'unreachable');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_status') THEN
CREATE TYPE job_status AS ENUM ('queued', 'pending', 'running', 'succeeded', 'failed', 'cancelled');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_kind') THEN
CREATE TYPE job_kind AS ENUM ('patch_apply', 'patch_remove', 'reboot', 'rollback');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'window_recurrence') THEN
CREATE TYPE window_recurrence AS ENUM ('once', 'daily', 'weekly', 'monthly');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cert_status') THEN
CREATE TYPE cert_status AS ENUM ('active', 'revoked', 'expired');
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM (
'user_login', 'user_logout', 'user_login_failed',
'user_created', 'user_deleted', 'user_updated',
'host_registered', 'host_removed',
@ -30,13 +60,15 @@ CREATE TYPE audit_action AS ENUM (
'certificate_issued', 'certificate_renewed', 'certificate_revoked', 'certificate_downloaded',
'config_changed',
'discovery_scan_started'
);
);
END IF;
END $$;
-- ============================================================
-- Groups (defined before users/hosts for FK ordering)
-- ============================================================
CREATE TABLE groups (
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
@ -44,13 +76,13 @@ CREATE TABLE groups (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_groups_name ON groups (name);
CREATE INDEX IF NOT EXISTS idx_groups_name ON groups (name);
-- ============================================================
-- Users
-- ============================================================
CREATE TABLE users (
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
@ -73,28 +105,28 @@ CREATE TABLE users (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_azure_oid ON users (azure_oid) WHERE azure_oid IS NOT NULL;
CREATE INDEX idx_users_role ON users (role);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
CREATE INDEX IF NOT EXISTS idx_users_azure_oid ON users (azure_oid) WHERE azure_oid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_role ON users (role);
-- ============================================================
-- User <-> Group membership
-- ============================================================
CREATE TABLE user_groups (
CREATE TABLE IF NOT EXISTS user_groups (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX idx_user_groups_group ON user_groups (group_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups (group_id);
-- ============================================================
-- Refresh Tokens
-- ============================================================
CREATE TABLE refresh_tokens (
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Stored as Argon2id hash of the opaque token bytes
@ -109,14 +141,14 @@ CREATE TABLE refresh_tokens (
ip_address INET
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at) WHERE revoked = FALSE;
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens (user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at) WHERE revoked = FALSE;
-- ============================================================
-- Hosts
-- ============================================================
CREATE TABLE hosts (
CREATE TABLE IF NOT EXISTS hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fqdn TEXT NOT NULL,
ip_address INET NOT NULL,
@ -136,28 +168,28 @@ CREATE TABLE hosts (
CONSTRAINT hosts_fqdn_ip_unique UNIQUE (fqdn, ip_address)
);
CREATE INDEX idx_hosts_health_status ON hosts (health_status);
CREATE INDEX idx_hosts_fqdn ON hosts USING gin (fqdn gin_trgm_ops);
CREATE INDEX idx_hosts_ip ON hosts (ip_address);
CREATE INDEX IF NOT EXISTS idx_hosts_health_status ON hosts (health_status);
CREATE INDEX IF NOT EXISTS idx_hosts_fqdn ON hosts USING gin (fqdn gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_hosts_ip ON hosts (ip_address);
-- ============================================================
-- Host <-> Group membership
-- ============================================================
CREATE TABLE host_groups (
CREATE TABLE IF NOT EXISTS host_groups (
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (host_id, group_id)
);
CREATE INDEX idx_host_groups_group ON host_groups (group_id);
CREATE INDEX IF NOT EXISTS idx_host_groups_group ON host_groups (group_id);
-- ============================================================
-- Host Health Data (cached results from 5-min polls)
-- ============================================================
CREATE TABLE host_health_data (
CREATE TABLE IF NOT EXISTS host_health_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -166,14 +198,14 @@ CREATE TABLE host_health_data (
payload JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_host_health_host ON host_health_data (host_id, polled_at DESC);
CREATE INDEX IF NOT EXISTS idx_host_health_host ON host_health_data (host_id, polled_at DESC);
-- Retained for 30 days (pruned by worker)
-- ============================================================
-- Host Patch Data (cached results from 30-min polls)
-- ============================================================
CREATE TABLE host_patch_data (
CREATE TABLE IF NOT EXISTS host_patch_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -184,14 +216,14 @@ CREATE TABLE host_patch_data (
cve_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_host_patch_host ON host_patch_data (host_id, polled_at DESC);
CREATE INDEX IF NOT EXISTS idx_host_patch_host ON host_patch_data (host_id, polled_at DESC);
-- Retained for 30 days (pruned by worker)
-- ============================================================
-- Maintenance Windows
-- ============================================================
CREATE TABLE maintenance_windows (
CREATE TABLE IF NOT EXISTS maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
@ -207,14 +239,14 @@ CREATE TABLE maintenance_windows (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_mw_host ON maintenance_windows (host_id);
CREATE INDEX idx_mw_start ON maintenance_windows (start_at) WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_mw_host ON maintenance_windows (host_id);
CREATE INDEX IF NOT EXISTS idx_mw_start ON maintenance_windows (start_at) WHERE enabled = TRUE;
-- ============================================================
-- Patch Jobs
-- ============================================================
CREATE TABLE patch_jobs (
CREATE TABLE IF NOT EXISTS patch_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind job_kind NOT NULL DEFAULT 'patch_apply',
status job_status NOT NULL DEFAULT 'queued',
@ -233,15 +265,15 @@ CREATE TABLE patch_jobs (
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_patch_jobs_status ON patch_jobs (status);
CREATE INDEX idx_patch_jobs_created ON patch_jobs (created_at DESC);
CREATE INDEX idx_patch_jobs_user ON patch_jobs (created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_patch_jobs_status ON patch_jobs (status);
CREATE INDEX IF NOT EXISTS idx_patch_jobs_created ON patch_jobs (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_patch_jobs_user ON patch_jobs (created_by_user_id);
-- ============================================================
-- Patch Job Hosts (per-host status within a batch job)
-- ============================================================
CREATE TABLE patch_job_hosts (
CREATE TABLE IF NOT EXISTS patch_job_hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES patch_jobs(id) ON DELETE CASCADE,
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
@ -257,15 +289,15 @@ CREATE TABLE patch_job_hosts (
UNIQUE (job_id, host_id)
);
CREATE INDEX idx_pjh_job ON patch_job_hosts (job_id);
CREATE INDEX idx_pjh_host ON patch_job_hosts (host_id);
CREATE INDEX idx_pjh_status ON patch_job_hosts (status);
CREATE INDEX IF NOT EXISTS idx_pjh_job ON patch_job_hosts (job_id);
CREATE INDEX IF NOT EXISTS idx_pjh_host ON patch_job_hosts (host_id);
CREATE INDEX IF NOT EXISTS idx_pjh_status ON patch_job_hosts (status);
-- ============================================================
-- Certificates
-- ============================================================
CREATE TABLE certificates (
CREATE TABLE IF NOT EXISTS certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- NULL = root CA cert
host_id UUID REFERENCES hosts(id) ON DELETE CASCADE,
@ -279,15 +311,15 @@ CREATE TABLE certificates (
cert_pem TEXT NOT NULL
);
CREATE INDEX idx_certs_host ON certificates (host_id);
CREATE INDEX idx_certs_status ON certificates (status);
CREATE INDEX idx_certs_expires ON certificates (expires_at);
CREATE INDEX IF NOT EXISTS idx_certs_host ON certificates (host_id);
CREATE INDEX IF NOT EXISTS idx_certs_status ON certificates (status);
CREATE INDEX IF NOT EXISTS idx_certs_expires ON certificates (expires_at);
-- ============================================================
-- Audit Log (tamper-evident, hash-chained)
-- ============================================================
CREATE TABLE audit_log (
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
action audit_action NOT NULL,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
@ -302,17 +334,17 @@ CREATE TABLE audit_log (
row_hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_audit_created ON audit_log (created_at DESC);
CREATE INDEX idx_audit_actor ON audit_log (actor_user_id);
CREATE INDEX idx_audit_action ON audit_log (action);
CREATE INDEX idx_audit_target ON audit_log (target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action);
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log (target_type, target_id);
-- Retained for 6 months (pruned by worker)
-- ============================================================
-- Azure SSO Configuration
-- ============================================================
CREATE TABLE azure_sso_config (
CREATE TABLE IF NOT EXISTS azure_sso_config (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
enabled BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id TEXT NOT NULL DEFAULT '',
@ -329,7 +361,7 @@ CREATE TABLE azure_sso_config (
-- System Configuration (key/value runtime settings)
-- ============================================================
CREATE TABLE system_config (
CREATE TABLE IF NOT EXISTS system_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@ -351,13 +383,14 @@ INSERT INTO system_config (key, value, description) VALUES
('smtp_from', '', 'From address for notifications'),
('smtp_tls_mode', 'starttls', 'SMTP TLS mode: none, starttls, tls'),
('web_tls_strategy', 'internal_ca', 'Web UI TLS cert strategy: internal_ca or operator_supplied'),
('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all');
('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all')
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- Worker Heartbeat
-- ============================================================
CREATE TABLE worker_heartbeat (
CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
worker_version TEXT NOT NULL DEFAULT '',
@ -368,7 +401,7 @@ CREATE TABLE worker_heartbeat (
-- Discovery Results (transient; cleared before each scan)
-- ============================================================
CREATE TABLE discovery_results (
CREATE TABLE IF NOT EXISTS discovery_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id UUID NOT NULL,
ip_address INET NOT NULL,
@ -381,5 +414,5 @@ CREATE TABLE discovery_results (
registered BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_discovery_scan ON discovery_results (scan_id);
CREATE INDEX idx_discovery_ip ON discovery_results (ip_address);
CREATE INDEX IF NOT EXISTS idx_discovery_scan ON discovery_results (scan_id);
CREATE INDEX IF NOT EXISTS idx_discovery_ip ON discovery_results (ip_address);

View File

@ -1,12 +1,17 @@
-- Migration: 002_seed_admin
-- Description: Seed the default admin account.
--
-- Default credentials (CHANGE BEFORE PRODUCTION USE):
-- Username: admin
-- Password: ChangeMe123!
-- IMPORTANT (issue #8): The password_hash below is a PLACEHOLDER
-- that cannot validate any password. On first startup, pm-web detects
-- this placeholder and generates a random admin password, replacing
-- the hash in the database. The generated password is printed once
-- to stderr (visible in systemd journal).
--
-- The password hash below is Argon2id of "ChangeMe123!" with
-- m=65536, t=3, p=1. Replace after first login.
-- If the application never starts (e.g., manual migration only),
-- the admin account is inaccessible — this is fail-closed.
--
-- On first successful login with a real password, the admin is forced to
-- set a new password (force_password_reset = TRUE).
INSERT INTO users (
id,
@ -27,10 +32,11 @@ VALUES (
'admin@localhost',
'admin',
'local',
-- Argon2id hash of "ChangeMe123!" — REPLACE IN PRODUCTION
'$argon2id$v=19$m=65536,t=3,p=1$Kv8bkGiE81yIuXARq9fwsw$NrBRFvgL1dVsW7bEK6NxEOzIX2q1p4B0K422idAVIDQ',
FALSE, -- MFA disabled by default; admin must set up on first login
-- PLACEHOLDER Argon2id hash (issue #8). Cannot validate any password.
-- pm-web replaces this with a real hash on first startup.
'$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
FALSE,
TRUE,
TRUE -- Force password reset on first login
TRUE
)
ON CONFLICT (username) DO NOTHING;

View File

@ -8,11 +8,11 @@
-- When the retry engine should next attempt this host; NULL = not scheduled
ALTER TABLE patch_job_hosts
ADD COLUMN retry_next_at TIMESTAMPTZ;
ADD COLUMN IF NOT EXISTS retry_next_at TIMESTAMPTZ;
-- Last failure reason captured by the worker for display in the UI
ALTER TABLE patch_job_hosts
ADD COLUMN last_error TEXT;
ADD COLUMN IF NOT EXISTS last_error TEXT;
-- ============================================================
-- pg_notify trigger: fires when an immediate job is inserted
@ -30,15 +30,21 @@ BEGIN
END;
$$;
CREATE TRIGGER trg_job_enqueued
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_job_enqueued'
) THEN
CREATE TRIGGER trg_job_enqueued
AFTER INSERT ON patch_jobs
FOR EACH ROW
EXECUTE FUNCTION notify_job_enqueued();
END IF;
END $$;
-- ============================================================
-- Index: efficiently find hosts due for retry
-- ============================================================
CREATE INDEX idx_pjh_retry
CREATE INDEX IF NOT EXISTS idx_pjh_retry
ON patch_job_hosts (retry_next_at)
WHERE retry_next_at IS NOT NULL;

View File

@ -1,7 +1,7 @@
-- Migration 007: Health check configuration and results
-- Health checks configured per host (1-5 per host)
CREATE TABLE host_health_checks (
CREATE TABLE IF NOT EXISTS host_health_checks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
@ -27,10 +27,10 @@ CREATE TABLE host_health_checks (
)
);
CREATE INDEX idx_health_checks_host ON host_health_checks (host_id);
CREATE INDEX IF NOT EXISTS idx_health_checks_host ON host_health_checks (host_id);
-- Health check poll results (4-day retention, pruned by worker)
CREATE TABLE host_health_check_results (
CREATE TABLE IF NOT EXISTS host_health_check_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
check_id UUID NOT NULL REFERENCES host_health_checks(id) ON DELETE CASCADE,
healthy BOOLEAN NOT NULL,
@ -39,4 +39,4 @@ CREATE TABLE host_health_check_results (
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_health_results_check ON host_health_check_results (check_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_health_results_check ON host_health_check_results (check_id, checked_at DESC);

View File

@ -4,7 +4,7 @@
-- FK with ON DELETE SET NULL: if target host deleted, revert to default.
ALTER TABLE host_health_checks
ADD COLUMN target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL;
ADD COLUMN IF NOT EXISTS target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL;
CREATE INDEX idx_health_checks_target_host ON host_health_checks (target_host_id)
CREATE INDEX IF NOT EXISTS idx_health_checks_target_host ON host_health_checks (target_host_id)
WHERE target_host_id IS NOT NULL;

View File

@ -1,7 +1,7 @@
-- Migration: 016_enrollment_requests
-- Description: Create enrollment_requests table for host self-enrollment
CREATE TABLE enrollment_requests (
CREATE TABLE IF NOT EXISTS enrollment_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
machine_id TEXT NOT NULL UNIQUE,
fqdn TEXT NOT NULL,
@ -12,5 +12,5 @@ CREATE TABLE enrollment_requests (
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_enrollment_requests_token ON enrollment_requests (polling_token);
CREATE INDEX idx_enrollment_requests_expires ON enrollment_requests (expires_at);
CREATE INDEX IF NOT EXISTS idx_enrollment_requests_token ON enrollment_requests (polling_token);
CREATE INDEX IF NOT EXISTS idx_enrollment_requests_expires ON enrollment_requests (expires_at);

View File

@ -0,0 +1,12 @@
-- Migration: 019_auth_config_audit_actions
-- Description: Add audit_action enum values for Manager-wide auth-config
-- mutations (issue #5). These are gated behind Admin role
-- and audit-logged with the acting user, the keys changed,
-- and (for OIDC) a flag indicating whether client_secret was
-- rotated (the secret value itself is never logged).
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_config_updated';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'smtp_config_updated';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'ip_whitelist_updated';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_test_performed';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_discover_performed';

View File

@ -0,0 +1,44 @@
-- 020_encrypt_secrets_at_rest.sql
-- Encrypt three sensitive secrets at rest with AES-256-GCM:
-- - oidc_config.client_secret
-- - system_config row with key='smtp_password'
-- - users.totp_secret
--
-- Hard cutover (development stage, no dual-read window):
-- 1. ADD new BYTEA columns (idempotent)
-- 2. Operator runs one-shot migration helper (reads old plaintext, writes to new columns)
-- 3. DROP old TEXT columns (this migration)
--
-- The new key file is at /etc/patch-manager/keys/secret-encryption.key
-- (auto-generated on first start, 0600 permissions).
-- See tasks/secret-encryption-spec.md for the full design.
-- ============================================================
-- 1. oidc_config: client_secret
-- ============================================================
ALTER TABLE oidc_config
ADD COLUMN IF NOT EXISTS client_secret_encrypted BYTEA,
ADD COLUMN IF NOT EXISTS client_secret_nonce BYTEA;
-- DROP old plaintext column (migration helper must have run first)
ALTER TABLE oidc_config
DROP COLUMN IF EXISTS client_secret;
-- ============================================================
-- 2. system_config: smtp_password (key-value store)
-- ============================================================
-- Approach: add new keys 'smtp_password_encrypted' and 'smtp_password_nonce'
-- (no schema change to system_config), then delete the old 'smtp_password' row.
-- The migration helper reads the old row, encrypts, writes two new rows.
DELETE FROM system_config WHERE key = 'smtp_password';
-- ============================================================
-- 3. users: totp_secret
-- ============================================================
ALTER TABLE users
ADD COLUMN IF NOT EXISTS totp_secret_encrypted BYTEA,
ADD COLUMN IF NOT EXISTS totp_secret_nonce BYTEA;
-- DROP old plaintext column (migration helper must have run first)
ALTER TABLE users
DROP COLUMN IF EXISTS totp_secret;

View File

@ -0,0 +1,13 @@
-- 021_crl_health_status.sql
-- Add CRL health status columns to the hosts table for tracking
-- Certificate Revocation List status reported by agents.
-- CRL status values: 'valid', 'expired', 'missing', 'invalid', or NULL
-- (NULL = older agent that does not report CRL status)
ALTER TABLE hosts ADD COLUMN IF NOT EXISTS crl_status TEXT;
-- Seconds since the agent's CRL was last refreshed (NULL if not reported)
ALTER TABLE hosts ADD COLUMN IF NOT EXISTS crl_age_seconds BIGINT;
-- When the agent's CRL expires / next update is due (NULL if not reported)
ALTER TABLE hosts ADD COLUMN IF NOT EXISTS crl_next_update TIMESTAMPTZ;

View File

@ -0,0 +1,8 @@
-- Migration: 022_crl_audit_actions
-- Description: Add audit_action enum values for CRL health aggregation events.
-- These are system-initiated events logged by the health poller
-- when a host's CRL status transitions or indicates a problem.
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'crl_status_changed';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'crl_stale_detected';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'crl_invalid';

View File

@ -22,7 +22,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="0.1.9"
VERSION="1.1.10"
RELEASE="1"
PKG_NAME="linux-patch-manager"
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"

82
scripts/bump-version.sh Executable file
View File

@ -0,0 +1,82 @@
#!/bin/bash
# Bump version across all version source files for linux_patch_manager
# Usage: ./scripts/bump-version.sh <new_version> <old_version>
# Example: ./scripts/bump-version.sh 0.1.10 0.1.9
set -euo pipefail
NEW_VERSION="${1:?Usage: bump-version.sh <new_version> <old_version>}"
OLD_VERSION="${2:?Usage: bump-version.sh <new_version> <old_version>}"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_DIR"
echo "=== Bumping version from $OLD_VERSION to $NEW_VERSION ==="
echo ""
# 1. Cargo.toml (PRIMARY - workspace.package.version)
sed -i "s/version = \"$OLD_VERSION\"/version = \"$NEW_VERSION\"/" Cargo.toml
echo "[1/5] Cargo.toml: $OLD_VERSION -> $NEW_VERSION"
# 2. debian/changelog - Prepend new entry using temp file
TEMP_CHANGELOG=$(mktemp)
echo "linux-patch-manager ($NEW_VERSION-1) unstable; urgency=low" > "$TEMP_CHANGELOG"
echo "" >> "$TEMP_CHANGELOG"
echo " * Release v$NEW_VERSION" >> "$TEMP_CHANGELOG"
echo "" >> "$TEMP_CHANGELOG"
echo " -- git-echo <git-echo@moon-dragon.us> $(date -R)" >> "$TEMP_CHANGELOG"
echo "" >> "$TEMP_CHANGELOG"
cat debian/changelog >> "$TEMP_CHANGELOG"
mv "$TEMP_CHANGELOG" debian/changelog
echo "[2/5] debian/changelog: Added entry for $NEW_VERSION"
# 3. debian/control - Update Version field
if grep -q "^Version:" debian/control 2>/dev/null || true; then
sed -i "s/^Version: .*/Version: $NEW_VERSION-1/" debian/control
echo "[3/5] debian/control: -> $NEW_VERSION-1"
else
echo "[3/5] debian/control: Version field not found, skipping"
fi
# 4. scripts/build-package.sh - Update VERSION variable
if [ -f scripts/build-package.sh ]; then
sed -i "s/^VERSION=\".*\"/VERSION=\"$NEW_VERSION\"/" scripts/build-package.sh
echo "[4/5] scripts/build-package.sh: -> $NEW_VERSION"
else
echo "[4/5] scripts/build-package.sh: Not found, skipping"
fi
# 5. frontend/package.json - Update version field
if [ -f frontend/package.json ]; then
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$NEW_VERSION\"/" frontend/package.json
echo "[5/5] frontend/package.json: -> $NEW_VERSION"
else
echo "[5/5] frontend/package.json: Not found, skipping"
fi
echo ""
echo "=== Version bump complete ==="
echo ""
echo "Verification:"
echo " Cargo.toml: $(grep '^version' Cargo.toml | head -1)"
echo " debian/changelog: $(head -1 debian/changelog)"
if [ -f debian/control ]; then
echo " debian/control: $(grep '^Version' debian/control)"
fi
if [ -f scripts/build-package.sh ]; then
echo " build-package.sh: $(grep '^VERSION=' scripts/build-package.sh)"
fi
if [ -f frontend/package.json ]; then
echo " frontend/package.json: $(grep '"version"' frontend/package.json | head -1)"
fi
echo ""
echo "Stale references check:"
grep -r "$OLD_VERSION" --include='*.toml' --include='*.sh' --include='*.json' --include='control' . 2>/dev/null || true | grep -v 'target/' | grep -v '.git/' | grep -v 'node_modules/' | grep -v 'bump-version.sh' || echo " No stale references found"
echo ""
echo "Next steps:"
echo " 1. Review changes: git diff"
echo " 2. Commit: git commit -am 'chore: bump version to $NEW_VERSION'"
echo " 3. Push: git push origin master"
echo " 4. Tag: git tag v$NEW_VERSION && git push origin v$NEW_VERSION"
echo " 5. Create release via Gitea API"

View File

@ -7,7 +7,7 @@ Usage:
Environment variables:
GITEA_TOKEN - API token (required, falls back to GITHUB_TOKEN)
GITEA_URL - Gitea base URL (default: http://192.168.2.189:3000)
GITEA_REPO - Repository path (default: echo/linux_patch_manager)
GITEA_REPO - Repository path (default: git-echo/linux_patch_manager)
"""
import argparse
import json
@ -89,7 +89,7 @@ def main():
sys.exit(1)
base_url = os.environ.get("GITEA_URL", "http://192.168.2.189:3000")
repo = os.environ.get("GITEA_REPO", "echo/linux_patch_manager")
repo = os.environ.get("GITEA_REPO", "git-echo/linux_patch_manager")
title = f"Release {args.version}"
body = (

View File

@ -15,6 +15,13 @@
set -euo pipefail
# ---------------------------------------------------------------------------
# BusyBox-compatible millisecond timing (_now_ms not available)
# ---------------------------------------------------------------------------
_now_ms() {
python3 -c "import time; print(int(time.time()*1000))"
}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@ -72,10 +79,10 @@ api_call() {
time_api_call() {
local method="$1" endpoint="$2" shift; shift
local start end elapsed
start=$(date +%s%N)
start=$(_now_ms)
api_call "${method}" "${endpoint}" -o /dev/null "$@" 2>/dev/null || true
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 )) # milliseconds
end=$(_now_ms)
elapsed=$(( end - start )) # milliseconds
echo "$(echo "scale=3; ${elapsed}/1000" | bc)"
}
@ -97,10 +104,10 @@ test_dashboard_load() {
# Also measure frontend static asset load
info "Measuring frontend index.html load time..."
start=$(date +%s%N)
start=$(_now_ms)
curl -sk -o /dev/null "${BASE_URL}/" 2>/dev/null || true
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
end=$(_now_ms)
elapsed=$(( end - start ))
FRONTEND_TIME=$(echo "scale=3; ${elapsed}/1000" | bc)
info "Frontend load time: ${FRONTEND_TIME}s"
pass "Frontend static load: ${FRONTEND_TIME}s"
@ -169,14 +176,14 @@ test_bulk_host_operations() {
# 4.2 Sequential host creation (measure throughput)
info "4.2 Sequential host creation (10 hosts)"
local start=$(date +%s%N)
local start=$(_now_ms)
for i in $(seq 1 10); do
api_call POST /api/v1/hosts \
-d "{\"fqdn\": \"perf-test-${i}.example.com\", \"ip_address\": \"10.99.0.${i}\"}" \
-o /dev/null 2>/dev/null || true
done
local end=$(date +%s%N)
local total_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local total_ms=$(( end - start ))
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
local per_host=$(echo "scale=3; ${total_s}/10" | bc)
info "10 hosts created in ${total_s}s (${per_host}s per host)"
@ -199,11 +206,11 @@ test_cidr_scan() {
# Note: This test initiates a real CIDR scan which may not complete quickly
# without reachable hosts. We measure the API response time for initiating.
info "5.1 CIDR scan initiation time"
local start=$(date +%s%N)
local start=$(_now_ms)
SCAN_RESP=$(api_call POST /api/v1/discovery/cidr \
-d '{"cidr": "10.0.0.0/30", "timeout": 1.5}' 2>/dev/null || true)
local end=$(date +%s%N)
local elapsed_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local elapsed_ms=$(( end - start ))
local elapsed_s=$(echo "scale=3; ${elapsed_ms}/1000" | bc)
info "CIDR scan initiation: ${elapsed_s}s"
@ -240,13 +247,13 @@ test_concurrent_load() {
# Fire 20 concurrent requests and measure total time
info "6.1 20 concurrent fleet status requests"
local start=$(date +%s%N)
local start=$(_now_ms)
for i in $(seq 1 20); do
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
done
wait
local end=$(date +%s%N)
local total_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local total_ms=$(( end - start ))
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
local per_req=$(echo "scale=3; ${total_s}/20" | bc)
@ -259,7 +266,7 @@ test_concurrent_load() {
# 6.2 Mixed endpoint concurrent load
info "6.2 20 concurrent mixed-endpoint requests"
start=$(date +%s%N)
start=$(_now_ms)
for i in $(seq 1 5); do
api_call GET /api/v1/hosts -o /dev/null 2>/dev/null &
api_call GET /api/v1/groups -o /dev/null 2>/dev/null &
@ -267,8 +274,8 @@ test_concurrent_load() {
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
done
wait
end=$(date +%s%N)
total_ms=$(( (end - start) / 1000000 ))
end=$(_now_ms)
total_ms=$(( end - start ))
total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
per_req=$(echo "scale=3; ${total_s}/20" | bc)
info "Mixed concurrent: ${total_s}s total, ${per_req}s avg"
@ -282,12 +289,12 @@ test_ws_ticket_performance() {
echo -e "\n${CYAN}=== Test 7: WebSocket Ticket Issuance ===${NC}"
info "7.1 Sequential ticket creation (10 tickets)"
local start=$(date +%s%N)
local start=$(_now_ms)
for i in $(seq 1 10); do
api_call POST /api/v1/ws/ticket -o /dev/null 2>/dev/null || true
done
local end=$(date +%s%N)
local total_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local total_ms=$(( end - start ))
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
local per_ticket=$(echo "scale=3; ${total_s}/10" | bc)
info "10 tickets in ${total_s}s (${per_ticket}s per ticket)"

230
tasks/authz-gate-spec.md Normal file
View File

@ -0,0 +1,230 @@
# Authz Gate — Admin-Only Manager-Wide Configuration (Issue #5)
**Spec version:** v0.1.0
**Status:** Draft — awaiting Kelly sign-off
**Date:** 2026-06-03
**GitHub issue:** [#5](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/5)
---
## 1. Goal
Restrict mutations of Manager-wide authentication configuration to the **Admin** role only. Specifically:
- **OIDC provider config** (`discovery_url`, `client_id`, `client_secret`, `redirect_uri`, `scopes`)
- **SMTP config** (host, credentials, from address, recipients)
- **IP allowlist** (the in-memory `AuthConfig.ip_whitelist`)
- **OIDC discover / test handlers** (`discover_oidc`, `test_oidc`) — these probe the IdP and reveal config details
The **Operator** role is restricted to **per-host settings** for hosts in their scope (hosts with no group, or hosts in groups the operator is a member of). Operators MUST NOT be able to alter Manager-wide auth configuration. The **Reporter** role remains read-only across the board.
Audit-log every accepted change to the above with the acting user, action, and key. Return `403 forbidden_role` to Operators who attempt these mutations.
---
## 2. Non-Goals
- **Per-host group membership scoping** for Operators is **out of scope**. Today, Operator mutations are gated by `can_write()` (`Admin | Operator`); the per-host case relies on the route being "general write" and assumes Operators manage whatever hosts are assigned to them. A follow-up issue will add explicit per-host/group checks (e.g., `assert_operator_can_write_host(operator_id, host_id)`). For this issue, we only fix the Manager-wide auth-config leak.
- **Role changes in the SPA** (e.g., hiding OIDC fields entirely for Operators) are out of scope per Kelly's Q4 = A. We show a friendly error message instead.
- **Audit log changes** (new enum values, new columns) are out of scope. The existing `audit_log` table + `audit_action` enum + `pm-core::audit` module are sufficient. If we need a new audit action (e.g., `oidc_config_updated`), we add it as a migration in this PR.
- **Refactoring `write_access_required`** out of `settings.rs` is out of scope. We add a new `admin_required` helper alongside it.
- **Removing `can_write()`** entirely is out of scope. It's still correct for the per-host case (until the per-host scoping follow-up lands).
---
## 3. Design Decisions (Kelly sign-off)
| # | Question | Answer |
|---|----------|--------|
| **Q1** | Helper function vs inline check? | **A.** New `admin_required` helper in `settings.rs` (matches local `write_access_required` pattern, single point of change for the 403 error format). |
| **Q2** | Should `discover_oidc` / `test_oidc` be Admin-only? | **Yes — Admin-only.** Probing the IdP can leak the resolved `client_id`, scopes, and other details an Operator shouldn't see. The role model is: **Admin** can change Manager-wide config (OIDC, SMTP, IP allowlist) + everything else; **Operator** can only manage per-host settings for hosts in their scope; **Reporter** is read-only across the board. |
| **Q3** | Audit log destination? | **A.** Use the existing `audit_log` table via `pm-core::audit`. New `audit_action` enum values added via migration: `oidc_config_updated`, `smtp_config_updated`, `ip_whitelist_updated`, `oidc_test_performed`, `oidc_discover_performed`. |
| **Q4** | SPA UX for 403? | **A.** Friendly error message in `SettingsPage.tsx`: "Only Admins can modify authentication configuration. Contact an Admin to make this change." Matches the SPA's existing error-handling pattern. |
---
## 4. Design
### 4.1 New `admin_required` helper (settings.rs)
```rust
fn admin_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
if !auth.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(json!({
"error": {
"code": "forbidden_role",
"message": "Admin role required to modify this resource"
}
})),
));
}
Ok(())
}
```
Placed in `crates/pm-web/src/routes/settings.rs` immediately after the existing `write_access_required` helper (~line 173). Uses a distinct error code (`forbidden_role` vs `forbidden`) so the SPA can differentiate "you don't have write access at all" from "you have write access but not for this specific resource".
### 4.2 Routes that change (4 in `settings.rs`)
| Handler | Current gate | New gate | Audit action |
|---------|--------------|----------|--------------|
| `update_settings` (line 336) | `write_access_required` | `admin_required` | `oidc_config_updated` and/or `smtp_config_updated` |
| `update_ip_whitelist` (line 902) | `write_access_required` | `admin_required` | `ip_whitelist_updated` |
| `discover_oidc` (line 561) | `write_access_required` | `admin_required` | `oidc_discover_performed` |
| `test_oidc` (line 619) | `write_access_required` | `admin_required` | `oidc_test_performed` |
**Non-changes:** The other 6+ handlers in `settings.rs` that use `write_access_required` for non-auth config (e.g., general `system_config` settings, health check settings, maintenance window settings) **stay as `write_access_required`**. The fix is targeted to the 4 auth-config handlers. The per-host settings endpoints in other route files (e.g., `host_groups.rs`, `hosts.rs`) are also unaffected — those are the per-host scope that Operators can keep accessing.
### 4.3 Audit log integration
Use the existing `crates/pm-core/src/audit.rs` module. New `audit_action` enum values are added via a migration (file `019_auth_config_audit_actions.sql`):
```sql
-- Migration: 019_auth_config_audit_actions
-- Description: Add audit_action enum values for auth-config mutations.
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_config_updated';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'smtp_config_updated';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'ip_whitelist_updated';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_test_performed';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'oidc_discover_performed';
```
Each handler calls the audit log AFTER a successful mutation, with the acting user_id, the action, the config key(s) changed, and an optional hash of old/new values. The `pm-core::audit` module already provides the `write_audit_event()` function (see `audit.rs:179`). The integration looks like:
```rust
// In update_settings, after a successful OIDC config update:
crate::audit::write_audit_event(
pool,
auth.user_id,
AuditAction::OidcConfigUpdated,
Some(serde_json::json!({
"keys_changed": ["oidc_discovery_url", "oidc_client_id"],
"client_secret_changed": req.oidc.client_secret.as_ref().map(|s| !s.is_empty()).unwrap_or(false),
})),
).await?;
```
`write_audit_event` is async and uses the existing hash-chained `INSERT INTO audit_log` (audit.rs:179). Failures to write the audit log are logged via `tracing::error!` but DO NOT block the operation (the config change is already committed to `system_config` and possibly the in-memory `AuthConfig`). This matches the pattern used by the existing IP-whitelist path (line 956) and other audited handlers.
**Client secret handling:** If the request contains a new `client_secret`, log `client_secret_changed: true` (NOT the secret itself) so the audit trail records that a secret was rotated, but the secret value never touches the audit log.
### 4.4 SPA error message (SettingsPage.tsx)
The current `SettingsPage` likely shows the raw error message from the backend. Update the error handler to detect `error.code === 'forbidden_role'` and show a friendly message:
```tsx
} catch (err) {
const error = err as AxiosError<{ error: { code: string; message: string } }>;
const code = error.response?.data?.error?.code;
if (code === 'forbidden_role') {
setError('Only Admins can modify authentication configuration. Contact an Admin to make this change.');
} else {
setError(error.response?.data?.error?.message || 'Failed to save settings');
}
}
```
The other 3 auth-config endpoints (OIDC, IP allowlist, test/discover) follow the same pattern. The change is localized to the 4 catch blocks in `SettingsPage` that correspond to these calls.
### 4.5 Test plan (unit + integration)
**Unit tests in `settings.rs` (cfg(test) module):**
1. `admin_required_admin_passes` — AuthUser with role Admin → returns Ok(())
2. `admin_required_operator_denied` — AuthUser with role Operator → returns Err with status 403 and code `forbidden_role`
3. `admin_required_reporter_denied` — AuthUser with role Reporter → returns Err with status 403 and code `forbidden_role`
**Integration tests for the 4 routes (using the existing test harness pattern from `sso_handoff_exchange_inner`):**
4. `update_settings_operator_denied` — POST as Operator with OIDC fields → 403 `forbidden_role`
5. `update_settings_admin_allowed` — POST as Admin with OIDC fields → 200 + audit row written
6. `update_ip_whitelist_operator_denied` — POST as Operator → 403 `forbidden_role`
7. `update_ip_whitelist_admin_allowed` — POST as Admin → 200 + audit row written + in-memory `AuthConfig.ip_whitelist` updated
8. `discover_oidc_operator_denied` — POST as Operator → 403 `forbidden_role`
9. `discover_oidc_admin_allowed` — POST as Admin → 200 + audit row written
10. `test_oidc_operator_denied` — POST as Operator → 403 `forbidden_role`
11. `test_oidc_admin_allowed` — POST as Admin → 200 + audit row written
**SPA test (Vitest, following the pattern from issue #4):**
12. `settings_page_forbidden_role_shows_friendly_message` — mock a 403 with code `forbidden_role` and assert the friendly message is shown
**Audit log assertion (in integration tests 5, 7, 9, 11):** Query `SELECT action, user_id, details FROM audit_log WHERE action = '<expected>'` and assert the row exists with the correct user_id and a non-null details JSONB.
### 4.6 Files changed
**Backend (5 files):**
- `crates/pm-web/src/routes/settings.rs` — new `admin_required` helper, 4 handler gate changes, 4 audit log calls, 11 tests
- `migrations/019_auth_config_audit_actions.sql` — new file, 5 enum values
- `crates/pm-core/src/audit.rs` (or wherever `AuditAction` is defined) — add 5 new enum variants
- `crates/pm-core/src/audit.rs` (or wherever `write_audit_event` is defined) — no API change, just verify the existing function supports the new action types
- `docs/security-review.md` — update §2.3 (Authorization / RBAC) with the new control row
- `docs/REST_API.md` — annotate the 4 affected endpoints with "Admin only"
**Frontend (1 file):**
- `frontend/src/pages/SettingsPage.tsx` — friendly error message in 4 catch blocks
- `frontend/src/pages/__tests__/SettingsPage.test.tsx` — new file, 1 test
---
## 5. Acceptance Criteria
- [ ] `PUT /api/v1/settings` with OIDC fields returns 403 `forbidden_role` for Operator and 200 for Admin.
- [ ] `PUT /api/v1/settings` with SMTP fields returns 403 `forbidden_role` for Operator and 200 for Admin.
- [ ] `PUT /api/v1/settings/ip-whitelist` returns 403 `forbidden_role` for Operator and 200 for Admin.
- [ ] `POST /api/v1/settings/oidc/discover` returns 403 `forbidden_role` for Operator and 200 for Admin.
- [ ] `POST /api/v1/settings/oidc/test` returns 403 `forbidden_role` for Operator and 200 for Admin.
- [ ] Each successful mutation writes a row to `audit_log` with the correct `audit_action`, the acting `user_id`, and a non-null `details` JSONB.
- [ ] Reporter is unaffected (still read-only).
- [ ] The SPA shows the friendly "Only Admins..." error message when it receives a 403 `forbidden_role`.
- [ ] `cargo fmt --check --all`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `npx eslint --max-warnings 0`, `npm test` all pass.
---
## 6. Risk Analysis
**Risk: SPA regression — friendly error message doesn't trigger in some cases.**
Mitigation: The new test (`settings_page_forbidden_role_shows_friendly_message`) mocks a 403 with `error.code === 'forbidden_role'` and asserts the message. If the backend returns a different code, the SPA falls through to the raw error path (preserving existing behavior). Worst case: Operators see a raw 403 message instead of the friendly one — same security outcome, slightly worse UX.
**Risk: Audit log failures block the operation.**
Mitigation: `write_audit_event` returns a `Result` but the handlers log+continue on error (same pattern as the existing IP-whitelist path at line 956). The config change is already committed to `system_config` and possibly the in-memory `AuthConfig` before the audit log call. A failed audit log write is a serious problem (loses accountability) but is recoverable from the database state.
**Risk: Operators lose legitimate workflows that relied on the old gate.**
Mitigation: The 4 affected routes are Manager-wide auth config. Operators do not have a legitimate need to change these (per Kelly's role model: "The operator role should only be able to manage per host settings for hosts that have no group or the operator is in they're group"). No legitimate Operator workflow breaks. The 6+ other `write_access_required` handlers in `settings.rs` (health check, maintenance window, etc.) are unchanged.
**Risk: Existing `write_access_required` callers still let Operators mutate non-auth config they shouldn't.**
Mitigation: This is acknowledged as out of scope. The non-auth settings that `write_access_required` still gates (e.g., health check settings, maintenance window settings) are arguably Manager-wide too, but the issue body only flags the 4 auth-config handlers. A follow-up issue will audit the other 6+ handlers and decide which are Manager-wide (Admin-only) vs per-host (Operator-allowed via group membership).
---
## 7. Documentation
- **docs/security-review.md** §2.3 (Authorization / RBAC): add 2 new rows:
- "Manager-wide auth config (OIDC, SMTP, IP allowlist) is Admin-only" with evidence pointing to `admin_required` helper and 11 tests
- "Audit log captures every auth-config mutation with the acting user" with evidence pointing to the 5 new `audit_action` enum values and the `write_audit_event` calls
- **docs/REST_API.md**: annotate the 4 affected endpoints with "🔒 Admin only" and link to the role model
- **tasks/lessons.md**: add a project-specific lesson about the role model (Admin = Manager-wide, Operator = per-host, Reporter = read-only) so future issues get it right
---
## 8. Follow-ups (out of scope for this PR)
1. **Per-host group membership scoping** for Operators — currently `can_write()` is a coarse "Admin or Operator" check. The follow-up adds explicit `assert_operator_can_write_host(operator_id, host_id)` that checks the host's group membership against the operator's group memberships. This is the proper fix for the "Operator can only manage per-host settings for hosts in their scope" requirement.
2. **Audit the other 6+ `write_access_required` handlers in `settings.rs`** to determine which are Manager-wide (Admin-only) vs per-host (Operator-allowed). Some likely candidates for Admin-only: system name, default timezone, default maintenance window. Some likely candidates for Operator-allowed: host label assignments, per-host health check targets, per-host maintenance window assignments.
3. **Hide auth-config fields in the SPA for Operators** — once the role model is settled, the SPA can conditionally render the OIDC/SMTP/IP allowlist sections only for Admins, instead of showing the fields and rejecting on save.
4. **Promote `admin_required` to `pm-auth`** as a shared helper alongside `Role::is_admin`, if the codebase grows more admin-only routes.
---
## 9. Sign-off
- [ ] Kelly approves spec (Q1=A, Q2=Admin-only, Q3=A, Q4=A confirmed)
- [ ] Echo implements Phase 1 (admin_required helper + 3 unit tests)
- [ ] Echo implements Phase 2 (4 handler gate changes + audit log calls)
- [ ] Echo implements Phase 3 (11 backend integration tests)
- [ ] Echo implements Phase 4 (SPA error message + 1 test)
- [ ] Echo implements Phase 5 (docs updates)
- [ ] Echo implements Phase 6 (review + commit + push + PR + comment on issue #5)

281
tasks/ip-allowlist-spec.md Normal file
View File

@ -0,0 +1,281 @@
# IP Allowlist Hardening — Specification
**Issue:** [#3](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/3)
**Component:** `crates/pm-auth/src/rbac.rs`, `crates/pm-core/src/config.rs`
**Spec version:** 0.1.0 (draft)
**Status:** Awaiting Kelly sign-off
---
## 1. Goal
Harden the IP allowlist enforced in the `require_auth` middleware so that:
1. It cannot be bypassed by omitting the `X-Forwarded-For` header.
2. It cannot be spoofed by setting `X-Forwarded-For` to an allowlisted value from
a client that directly reaches the service.
3. When a non-empty allowlist is configured and no trustworthy client IP can be
determined, the request is **denied** (fail-closed).
Today the allowlist is a documented production access control (see
`config/config.example.toml` `[security] ip_whitelist`) but, as filed in issue #3,
can be trivially defeated.
## 2. Non-Goals
- Replacing or weakening JWT auth. The allowlist is a defense-in-depth layer; JWT
validation continues to run.
- Adding rate-limiting behavior (governor's `SmartIpKeyExtractor` is used for rate
limiting and is out of scope to change here).
- Changes to `pm-worker` or `pm-agent-client` IP handling. This issue is scoped to
the web/API edge.
- IPv6-specific quirks beyond what `ipnet` already supports. `is_ip_allowed`
already handles IPv4 and IPv6 CIDRs via `IpNet`.
## 3. Design Decisions (Kelly sign-off, 2026-06-02)
| # | Decision | Resolution |
|---|----------|------------|
| Q1 | Trusted-proxy handling | **Strict (no proxies trusted by default).** Add a new `trusted_proxies: Vec<IpNet>` config field. When the field is **empty** (the default), the allowlist check uses the socket peer IP only and ignores `X-Forwarded-For` entirely. When the field is **non-empty** and the immediate peer is in the list, `X-Forwarded-For` may be honored; otherwise the socket peer IP is used. |
| Q2 | Reuse `SmartIpKeyExtractor` | **Reuse the pattern.** Extract a small, well-tested resolver helper (named `resolve_client_ip`) into `pm-auth` that mirrors `SmartIpKeyExtractor`'s "trust XFF only when peer is in trusted list, else peer IP" semantics, so the IP-allowlist check and the rate-limiter use the same resolution rule. We do not introduce a `pm-web → pm-auth` cycle; the resolver lives in `pm-auth` and is consumed by the middleware directly. (`pm-web` continues to use the governor extractor for its own rate-limiting layer.) |
| Q3 | Fail-closed on unresolvable IP | **Deny.** When the allowlist is non-empty and `resolve_client_ip` cannot determine a client IP (no `ConnectInfo<SocketAddr>`, peer address missing), the request is rejected with `403 forbidden_ip` and a `tracing::warn!` is emitted. |
| Q4 | Backward compat for empty allowlist | **Preserve `ip_whitelist = []` → allow all.** This keeps dev installs and unconfigured deployments working without code changes. Production deployments that set a non-empty list get the hardened behavior automatically. |
## 4. Design
### 4.1 Resolver helper (`crates/pm-auth/src/rbac.rs`)
New function:
```rust
/// Determine the client IP used for IP-allowlist enforcement.
///
/// Resolution rules:
/// 1. Start with the socket peer IP (`SocketAddr::ip()`).
/// 2. If `trusted_proxies` is non-empty **and** the socket peer is in
/// `trusted_proxies`, parse the leftmost entry of the `X-Forwarded-For`
/// header and use it (the immediate untrusted hop).
/// 3. If parsing `X-Forwarded-For` fails or the header is missing, fall back
/// to the socket peer IP.
/// 4. If the socket peer is unknown (no `ConnectInfo<SocketAddr>` is
/// available on the request), return `None` so the caller can apply
/// fail-closed logic when the allowlist is non-empty.
fn resolve_client_ip(
headers: &HeaderMap,
peer: Option<IpAddr>,
trusted_proxies: &[IpNet],
) -> Option<IpAddr>
```
The function is pure, easy to unit test, and has no I/O. Logging is performed by
the caller (middleware) so test assertions can be made on behavior without
capturing tracing output.
### 4.2 Middleware change (`crates/pm-auth/src/rbac.rs`)
`require_auth` is changed to:
1. Extract the peer address from request extensions
(`req.extensions().get::<ConnectInfo<SocketAddr>>()`).
2. Compute the resolved client IP via `resolve_client_ip`.
3. If `auth_config.ip_whitelist` is non-empty **and** no client IP could be
resolved, return `403 forbidden_ip` (`"Client IP could not be determined"`)
with a `tracing::warn!`.
4. If a client IP was resolved and the allowlist rejects it, return
`403 forbidden_ip` (`"Access denied"`) with a `tracing::warn!` (existing
message preserved for log continuity).
5. Otherwise continue to JWT validation (unchanged).
`axum::extract::ConnectInfo<SocketAddr>` is added as a request extension by the
axum server in `pm-web/src/main.rs` (one new line in the TCP/TLS listener
configuration; this is a required companion change to the middleware).
The old `extract_remote_ip` (header-only) is removed; the function is
superseded by `resolve_client_ip` and is not exported.
### 4.3 Config schema (`crates/pm-core/src/config.rs`)
Add a field to `SecurityConfig`:
```rust
/// IP addresses (CIDR or single IP) of trusted reverse proxies. When the
/// immediate TCP peer is in this list, `X-Forwarded-For` is honored; otherwise
/// the socket peer IP is used. Default: empty (do not trust `X-Forwarded-For`).
#[serde(default)]
pub trusted_proxies: Vec<String>,
```
The field parses to `Vec<IpNet>` at config-load time and is plumbed into
`AuthConfig::new` as a new `trusted_proxies: Arc<RwLock<Vec<IpNet>>>`
parameter (mirroring the existing `ip_whitelist` runtime-update pattern; an
`update_trusted_proxies` setter is added for symmetry, though no endpoint
needs it for this issue).
`Default for AppConfig` is updated to set `trusted_proxies: vec![]`.
`config/config.example.toml` gets a documented `trusted_proxies = []` entry
with a comment block explaining when to set it.
### 4.4 `pm-web` wiring (`crates/pm-web/src/main.rs`)
The axum listener is changed to use `into_make_service_with_connect_info::<SocketAddr>()`
so that `ConnectInfo<SocketAddr>` is available to extractors and middleware.
This is the documented axum pattern and is a one-line change per listener
(there are currently two listeners in `main.rs`: a TCP one for dev and a
TLS one for prod; both need the connect-info wrapper).
### 4.5 Response shape
Reuse the existing `forbidden` helper. Error code: `forbidden_ip` (new). Body:
```json
{ "error": { "code": "forbidden_ip", "message": "…" } }
```
Status: `403 Forbidden` for all IP rejections. Do not differentiate between
"unresolvable" and "not in allowlist" in the response; the specific reason is
logged server-side only.
### 4.6 Logging
- On allow (allowlist empty or IP matched): no new log line (existing flow
continues silently).
- On deny (allowlist non-empty and IP not allowed, or IP unresolvable): new
`tracing::warn!` with `client_ip = %ip_opt`, `peer = %peer_opt`,
`xff_present = bool`, `reason = %reason`.
The existing `tracing::warn!` for blocked requests is preserved in shape so
log-greppers continue to work.
## 5. Acceptance Criteria
- [ ] A request with a non-empty allowlist and no `X-Forwarded-For` header is
evaluated against the socket peer IP.
- [ ] A request with a non-empty allowlist and a spoofed `X-Forwarded-For`
(set by a client that is **not** in `trusted_proxies`) is evaluated
against the socket peer IP; the spoofed value is ignored.
- [ ] A request with a non-empty allowlist, an empty `trusted_proxies`, and
no resolvable peer IP is rejected with `403 forbidden_ip`.
- [ ] A request with a non-empty allowlist and a valid `X-Forwarded-For` from
a peer in `trusted_proxies` is evaluated against the leftmost untrusted
hop.
- [ ] A request with an empty allowlist is allowed regardless of IP
resolution (preserved behavior for dev installs).
- [ ] `cargo check` and `cargo clippy --all-targets` pass.
- [ ] `cargo test -p pm-auth` passes with new unit tests for `resolve_client_ip`
and the middleware allow/deny matrix.
- [ ] `docs/security-review.md` documents the hardened control with a new row
in the controls table referencing `crates/pm-auth/src/rbac.rs`.
## 6. Test Plan
### 6.1 Unit tests in `crates/pm-auth/src/rbac.rs` (cfg(test) module)
`resolve_client_ip` (12 tests):
1. `peer_only_no_xff` — no XFF, trusted_proxies empty → returns peer.
2. `peer_only_xff_untrusted` — XFF set, peer not in trusted_proxies, trusted_proxies
non-empty → returns peer (XFF ignored).
3. `peer_only_trusted_proxies_empty_xff_present` — XFF set, trusted_proxies
empty → returns peer (XFF ignored). [strict default]
4. `xff_trusted_peer_in_list` — XFF set, peer in trusted_proxies → returns
parsed leftmost XFF entry.
5. `xff_trusted_peer_in_list_malformed_xff` — XFF unparseable, peer in
trusted_proxies → falls back to peer.
6. `xff_trusted_peer_in_list_empty_xff` — XFF is empty string, peer in
trusted_proxies → falls back to peer.
7. `xff_trusted_peer_in_list_multi_hop` — "1.2.3.4, 5.6.7.8" with peer in
trusted_proxies → returns 1.2.3.4 (leftmost).
8. `no_peer_no_xff` — peer None, no XFF → returns None.
9. `no_peer_xff_untrusted` — peer None, XFF set, trusted_proxies empty →
returns None (caller fails closed).
10. `xff_trusted_whitespace` — XFF `" 1.2.3.4"`, peer in trusted_proxies →
returns 1.2.3.4 (trim).
11. `trusted_proxies_ipv6` — peer in IPv6 trusted list, IPv6 XFF → returns XFF.
12. `peer_ipv4_xff_ipv6_mismatch_trusted` — peer in trusted list, XFF is IPv6
→ returns parsed IPv6 (mixed family is fine).
`AuthConfig` integration with middleware (8 tests, using a small `TestApp`
harness with a `tower::ServiceExt`-style call into a single-route router —
no DB, no real HTTP listener):
13. `middleware_allows_when_whitelist_empty` — empty list + any IP → 200/ok.
14. `middleware_denies_when_whitelist_non_empty_and_ip_not_in_list`
non-empty list + peer outside → 403 `forbidden_ip`.
15. `middleware_allows_when_ip_in_list` — non-empty list + peer inside → 200.
16. `middleware_denies_when_no_peer_resolvable_and_whitelist_non_empty`
non-empty list + missing `ConnectInfo` → 403 `forbidden_ip`.
17. `middleware_spoofed_xff_ignored_when_peer_untrusted` — non-empty list +
peer outside list + XFF inside list → 403 `forbidden_ip`.
18. `middleware_trusted_proxy_honors_xff` — non-empty list + peer in
`trusted_proxies` + XFF inside list → 200.
19. `middleware_trusted_proxy_falls_back_to_peer_on_bad_xff` — peer in
`trusted_proxies` + unparseable XFF + peer outside list → 403
`forbidden_ip`.
20. `middleware_no_jwt_when_ip_blocked` — blocked request never reaches
JWT validation (no `validate_access_token` call on deny path; covered by
passing an obviously invalid token and asserting 403 not 401).
### 6.2 Test harness
A small `TestApp` helper builds a one-route `axum::Router` with a stub
handler that returns `200 OK` and a `require_auth` middleware. The harness
provides:
- A configurable `AuthConfig` (whitelist, trusted_proxies).
- A way to attach `ConnectInfo<SocketAddr>` (via a request-extension
pre-set in the test).
- A way to add/omit the `Authorization: Bearer` header (for the
`middleware_no_jwt_when_ip_blocked` test).
No real TCP listener, no DB, no async runtime beyond `#[tokio::test]`.
## 7. Risk Analysis
- **Risk: breaking change for deployments behind a reverse proxy that did not
configure `trusted_proxies`.** Today, `X-Forwarded-For` from any caller is
trusted (or, with the new code, ignored). After this change, such deployments
will see the allowlist evaluate against the **proxy's** IP, which may not be
in the allowlist and will cause 403s.
- **Mitigation:** Document `trusted_proxies` prominently in
`config/config.example.toml` with a clear warning. The default empty list
is fail-closed (403), not fail-open, so misconfigured deployments will
notice immediately rather than silently allowing traffic.
- **Operational runbook:** add a "reverse proxy" section to the install
docs describing the required config change.
- **Risk: dev installs behind a corporate proxy that injects XFF.** Same as
above; documented in the example config and the runbook.
- **Risk: missing `ConnectInfo<SocketAddr>` in some test or alternate
listener.** The middleware handles this gracefully (returns `None` from
`resolve_client_ip` → fail-closed when allowlist non-empty → 403). The
unit test matrix covers this path explicitly.
- **Risk: regression in JWT auth path.** The deny path short-circuits before
JWT validation (test 20). The allow path is unchanged.
- **Risk: governor rate-limiter inconsistency.** `pm-web`'s rate-limiter
uses `SmartIpKeyExtractor` from the `governor` crate, which has its own
resolution semantics (governor's defaults). If Kelly wants the rate
limiter to share `resolve_client_ip`, that's a follow-up issue and is
called out in the lessons file as a known consistency gap.
## 8. Documentation Updates
- `config/config.example.toml`: new `trusted_proxies = []` entry with
multi-line comment block.
- `docs/security-review.md`: new row in the controls table; update the
existing IP-allowlist row to point to the new code path and the new
`trusted_proxies` field.
- `docs/runbooks/`: (optional, per Kelly) add a short "Reverse proxy
deployment" runbook.
- `SPEC.md`: (optional, per Kelly) one-paragraph update in the Security
section.
## 9. Out of Scope / Follow-ups
- Sharing `resolve_client_ip` with the governor rate-limiter in `pm-web`
(consistency improvement, separate change).
- mTLS client-cert CN/SAN allowlist (defense-in-depth beyond IP).
- Per-route IP allowlist (different routes, different lists). Current
allowlist is global.

404
tasks/issue-7-crl-design.md Normal file
View File

@ -0,0 +1,404 @@
# Issue #7: Certificate Revocation Enforcement — Full CRL Design
**GitHub Issue:** https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/7
**Companion issue (agent repo):** https://github.com/Draco-Lunaris/Linux-Patch-Api/issues/20
**Status:** Design finalized — implementation pending
**Repos affected:** linux-patch-manager (this), linux-patch-api (agent)
**Last updated:** 2026-06-05
---
## 1. Goal
Enforce certificate revocation at the mTLS handshake by having the manager (CA operator) publish a Certificate Revocation List (CRL) and the agent (linux-patch-api) consult it during TLS client certificate validation.
**Connection direction:** The manager (this repo) is the mTLS client. The agent (linux-patch-api) is the mTLS server. The manager connects TO the agent and presents a client cert. The agent validates it. Agent-to-manager connections occur only for enrollment.
---
## 2. Architecture
### 2.1 Components
```
┌──────────────────┐ ┌──────────────────────┐
│ pm-web │ │ linux-patch-api │
│ (manager) │ GET /pki/crl.pem │ (agent) │
│ │ ◄──────────────────────│ │
│ ┌────────────┐ │ on enrollment + │ ┌────────────────┐ │
│ │ pm-ca │ │ every 24h │ │ mTLS server │ │
│ │ (signs │ │ │ │ (validates │ │
│ │ certs + │ │ Bundle: CA chain + │ │ client certs │ │
│ │ CRLs) │ │ client cert + │ │ + CRL check) │ │
│ └────────────┘ │ client key + │ └────────────────┘ │
│ │ CRL │ │
└──────────────────┘ └──────────────────────┘
│ │
│ Health check (existing infra) │
│ + CRL age on agent side │
└───────────────────────────────────────────┘
```
### 2.2 Mermaid flow diagram
```mermaid
sequenceDiagram
participant Mgr as Manager (pm-web)
participant Agent as Agent (linux-patch-api)
participant CA as pm-ca
participant DB as certificates table
Note over Mgr,CA: Initial Enrollment
Agent->>Mgr: POST /api/v1/enroll (with CSR)
Mgr->>CA: issue cert (sign with CA key)
CA->>DB: INSERT certificate (status=active)
CA-->>Mgr: leaf cert
Mgr->>CA: generate_crl()
CA->>DB: SELECT serials WHERE status=revoked
CA-->>Mgr: signed CRL
Mgr-->>Agent: PKI bundle (CA chain + cert + key + CRL)
Agent->>Agent: persist all 4 to /etc/linux-patch-api/certs/
Agent->>Agent: verify CRL signature against pinned CA
Note over Agent: Background refresh (every 24h)
Agent->>Mgr: GET /api/v1/pki/crl.pem
Mgr->>CA: generate_crl() (cached or regenerate)
CA-->>Mgr: CRL
Mgr-->>Agent: CRL
Agent->>Agent: verify signature, persist, swap in-memory map
Note over Mgr,Agent: Normal operation (mTLS)
Mgr->>Agent: mTLS handshake (presents client cert)
Agent->>Agent: webpki verifies chain
Agent->>Agent: extract serial, check CRL
alt serial in CRL
Agent-->>Mgr: handshake rejected
else serial not in CRL
Agent-->>Mgr: handshake accepted
end
Note over Mgr,CA: Operator revokes a cert
Mgr->>CA: revoke_cert(serial)
CA->>DB: UPDATE status=revoked
CA->>CA: generate_crl() (regenerate)
Note over Agent: Next 24h refresh picks up the revocation
```
### 2.3 Sub-CA handling
**Both root and sub-CA modes are supported.**
- **Root mode:** Manager is a self-signed CA. The CA cert in the bundle is also the trust anchor. CRL signature chains directly to it.
- **Sub-CA mode:** Manager is a sub-CA under an external root. The enrollment bundle includes the full chain: external root + manager's intermediate cert. The agent pins both. CRL signature chains up to the external root.
**Required code change for sub-CA support:** Extend `PkiBundle` to include the full chain (new `ca_chain` field containing intermediate + root as a single PEM bundle). The existing single `ca_crt` field is preserved for backward compat (it becomes the leaf-most cert in the chain).
The external root's own CRL is **out of scope** for this design. Documented assumption: the external root is long-lived and trusted by the agent's system trust store, or the operator accepts the risk of a long-lived external root.
### 2.4 Cert lifetime
**No change to current 1-year lifetime.** Revocation lag of up to 24h is acceptable given the 1-year cert validity. Shortening to 90 days was considered and deferred (Phase 1+ works correctly with either lifetime).
---
## 3. Final Decisions (12 concerns walked through with Kelly)
| # | Concern | Decision |
|---|---------|----------|
| 1 | Sub-CA enrollment bundle chain | Extend `PkiBundle` to include full chain (intermediate + root) as new `ca_chain` field. Single `ca_crt` field preserved for backward compat. |
| 2 | CRL generation library | rcgen 0.13 on manager (sign). x509-parser on agent (parse). webpki for chain validation in custom verifier. No new system deps. |
| 3 | Custom ClientCertVerifier | Use rustls `danger::ClientCertVerifier` trait. Wrapper struct delegates chain validation to `WebPkiClientVerifier`, adds serial lookup against parsed CRL. Only ~80 lines of custom code. |
| 4 | Stale-CRL failure mode | (c) Degraded. Continue serving with stale CRL, log warning, health check reports degraded. Missing CRL = degraded. Invalid signature = refuse to start (fail-closed). |
| 5 | CRL size at scale | Not a concern. Max 2500 clients/manager. CRLs KB-range. No index on (status, not_after) needed. |
| 6 | Health check backward compat | Missing `crl_status` field from older agent = degraded (not unhealthy). New agent with missing > 24h after enrollment = unhealthy. UI: host details page + list icon + dashboard widget. |
| 7 | Test coverage | Layers 1-3 (unit + property + integration) required for ship. Layer 4 (E2E docker-compose) incremental. Layer 5 (fuzz) added now. Property-based tests with `proptest` added now. |
| 8 | Deployment order | 6 PRs sequential. No feature flag (disk state is the implicit flag). All-at-once rollout for PR 2 (agent). |
| 9 | Documentation | Full scope. New `docs/security/revocation.md` as top-level doc. Mermaid diagrams in markdown (GitHub renders natively). |
| 10 | Phasing risk | Low. Pre-production stage, no live users to disrupt. Bounded window between PR 1 and PR 2. |
| 11 | mTLS direction | Confirmed. Manager = client, agent = server. Agent-to-manager only for enrollment. |
| 12 | New host enrollment during CRL outage | Enrollment succeeds without CRL. Health check reports missing. Agent fetches CRL on next refresh cycle. |
---
## 4. Phased Implementation (6 PRs)
### PR 1 — Manager: CRL generation + endpoint + enrollment bundle
**Repo:** linux-patch-manager (this)
**Scope:**
- Extend `PkiBundle` to include full chain (new `ca_chain` field)
- Add `generate_crl()` to `pm-ca/src/ca.rs` using rcgen 0.13
- Add `GET /api/v1/pki/crl.pem` route in new `crates/pm-web/src/routes/pki.rs`
- Include CRL PEM in enrollment response
- Background task: regenerate CRL every 12h and on every `revoke_cert` call
- No DB schema changes
**Testing:**
- Unit tests for `generate_crl()` (revoked serials present, non-revoked absent, expired excluded)
- Property tests (proptest) for CRL generation roundtrip
- Fuzz harness for CRL generation
- Integration test: `GET /pki/crl.pem` returns 200 + valid PEM + correct `Cache-Control`
- Integration test: enrollment bundle includes CRL
**Backward compat:** Endpoint is dark until an agent is updated to consume it. Older agents ignore it. Zero impact on existing flows.
---
### PR 2 — Agent: CRL consumption + custom verifier
**Repo:** linux-patch-api
**Scope:**
- New `src/auth/crl.rs` module: CRL load, signature verification, in-memory serial map (ArcSwap)
- New `src/auth/crl_refresh.rs`: background task fetching CRL every 24h from `GET {manager_url}/api/v1/pki/crl.pem`
- Extend `src/auth/mtls.rs`: replace direct `WebPkiClientVerifier` usage with `CrlClientCertVerifier` wrapper
- Persist CRL to `/etc/linux-patch-api/certs/crl.pem`
- Config additions: `crl_path`, `crl_refresh_interval`, `manager_url`
**Custom verifier (sketch):**
```rust
pub struct CrlClientCertVerifier {
inner: Arc<dyn rustls::client::danger::ClientCertVerifier>,
crl: arc_swap::ArcSwap<Crl>,
}
impl rustls::client::danger::ClientCertVerifier for CrlClientCertVerifier {
fn verify_client_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: UnixTime,
) -> Result<ClientCertVerified, rustls::Error> {
// Delegate chain validation to WebPKI (battle-tested)
self.inner.verify_client_cert(end_entity, intermediates, now)?;
// Extract serial from the leaf cert
let serial = extract_serial(end_entity)
.map_err(|e| rustls::Error::General(format!("serial extract: {}", e)))?;
// Check CRL (O(1) hash lookup)
let crl = self.crl.load();
if crl.is_revoked(serial) {
return Err(rustls::Error::General(format!(
"cert serial {} is revoked", serial
)));
}
Ok(ClientCertVerified::assertion())
}
// Delegate remaining trait methods to self.inner
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.inner.supported_verify_schemes()
}
fn verify_tls12_signature(&self, ...) -> Result<...> {
self.inner.verify_tls12_signature(...)
}
fn verify_tls13_signature(&self, ...) -> Result<...> {
self.inner.verify_tls13_signature(...)
}
}
```
**Backward compat:** If CRL file is missing or fails signature verification, fall back to `WebPkiClientVerifier` directly (current behavior). Log warning. Health check from manager reports degraded.
**Testing:**
- Unit tests: CRL load (valid, malformed, missing, tampered, expired)
- Unit tests: custom verifier (valid cert accepted, revoked cert rejected, no false positives)
- Property tests (proptest): random certs + random CRLs, no false negs/pos
- Fuzz harness for CRL load and verifier
- Integration test: end-to-end mTLS (valid cert connects, revoked cert rejected)
- Integration test: stale CRL fallback to WebPKI (no connection rejection)
---
### PR 3 — Manager: Health check schema + UI
**Repo:** linux-patch-manager (this)
**Scope:**
- Extend health check response schema to include `crl_status` and `crl_age_seconds` fields (optional, backward compat)
- Add UI: CRL section in host details page
- Add hosts list icon (green/yellow/red) for CRL status
- Add dashboard widget: "hosts with degraded CRL: N"
**Backward compat:** Older agents don't report these fields → UI shows "CRL not configured". No regression.
---
### PR 4 — Agent: Health response includes CRL status
**Repo:** linux-patch-api
**Scope:**
- Add `crl_status` and `crl_age_seconds` to the agent's health response payload
- Logic: `valid` if CRL loaded + signature good + not expired, `expired` if `nextUpdate` passed, `missing` if no CRL on disk, `invalid` if signature fails
**Backward compat:** Field is additive. Manager treats missing field as "unknown" / "missing".
---
### PR 5 — Manager: Health aggregation logic
**Repo:** linux-patch-manager (this)
**Scope:**
- Aggregate per-host CRL health into the host's overall health
- Implement severity rules: invalid signature → unhealthy; missing > 24h on new agent → unhealthy; missing on old agent → degraded; > 25h old → degraded; otherwise healthy
- Add audit events: `CrlStaleDetected`, `CrlMissing`, `CrlInvalid`
**Backward compat:** Logic only fires when PR 3 + PR 4 are deployed. Safe to merge ahead of those.
---
### PR 6 — E2E integration test harness
**Repos:** both (new `tests/e2e/` directory in this repo, mirroring setup in agent repo)
**Scope:**
- docker-compose harness running both pm-web and linux-patch-api
- Test scenarios:
- Issue → enroll → connect (fresh agent connects successfully)
- Issue → enroll → revoke → refresh → connect (rejected)
- Issue → enroll → revoke → no refresh → connect (succeeds with stale CRL + warning)
- Manager down → connect (succeeds with stale CRL + degraded health)
- Independent CI for each repo; full E2E runs on main branch merges
**Backward compat:** Test-only, no production impact.
---
## 5. Failure Modes and Operational Behavior
### 5.1 Stale CRL on agent
**Scenario:** Agent's CRL has `nextUpdate` passed. Background refresh fails (manager unreachable).
**Behavior:**
- Agent continues serving mTLS connections using the stale CRL
- Logs warning every refresh attempt
- Reports `crl_status=expired` and `crl_age_seconds` in health response
- Manager's health aggregation marks host as `degraded`
- Worst case: ~24h of accepting a cert that was revoked after the agent's CRL was generated
- The cert's `not_after` is still the hard backstop (1 year from issuance)
### 5.2 Missing CRL on agent
**Scenario:** New agent enrolls, but CRL generation fails on the manager. Or older agent predates CRL feature.
**Behavior:**
- Agent starts with no CRL on disk
- Falls back to `WebPkiClientVerifier` (chain validation only, no CRL check)
- Logs warning, reports `crl_status=missing`
- Manager's health aggregation marks host as `degraded`
- If host is a newer agent: 24h after enrollment without CRL → escalates to `unhealthy`
- If host is an older agent: stays `degraded` indefinitely (feature gap, not a failure)
### 5.3 Invalid CRL signature on agent
**Scenario:** CRL file is corrupted, or the manager's CA key was compromised.
**Behavior:**
- Agent refuses to load the CRL
- **Refuses to start the mTLS server** (fail-closed here, because invalid signature is a security event)
- Logs critical error
- Reports `crl_status=invalid` in health response
- Operator must investigate: check manager's CA, re-fetch CRL manually, or restore from backup
### 5.4 Manager unreachable during enrollment
**Scenario:** New agent tries to enroll. Manager is down.
**Behavior:**
- Enrollment fails (manager is required for cert issuance)
- Agent retries on its configured enrollment schedule
- Once manager is back, enrollment succeeds, agent receives cert + CA + CRL (if available)
### 5.5 New host enrollment during CRL outage
**Scenario:** Manager is up, cert issuance works, but CRL generation fails (e.g., DB issue during `generate_crl`).
**Behavior:**
- Enrollment succeeds
- Agent receives cert + CA chain, but **no CRL** in the bundle
- Agent starts with no CRL, falls back to WebPKI
- Reports `crl_status=missing`
- Next 24h refresh attempts to fetch CRL from `/pki/crl.pem`
- If CRL generation is fixed by then, agent picks it up on next refresh
- If still failing, agent continues in degraded mode
---
## 6. Acceptance Criteria
### Phase 1 (Manager-side MVP)
- [ ] `generate_crl()` produces a valid X.509 CRL signed by the same CA key that signs leaf certs
- [ ] CRL includes only certs where `status='revoked' AND not_after > NOW()`
- [ ] `GET /api/v1/pki/crl.pem` returns 200 + valid PEM + `Cache-Control: max-age=3600`
- [ ] Enrollment PKI bundle includes the CRL
- [ ] Enrollment bundle includes the full CA chain (new `ca_chain` field)
- [ ] Background task regenerates CRL every 12h
- [ ] `revoke_cert` triggers immediate CRL regeneration
- [ ] Unit tests, property tests, fuzz harness, integration tests all pass
### Phase 2 (Agent-side consumption)
- [ ] Agent fetches CRL on enrollment from enrollment bundle
- [ ] Agent persists CRL to `/etc/linux-patch-api/certs/crl.pem`
- [ ] Agent verifies CRL signature against pinned CA on load
- [ ] Agent uses `CrlClientCertVerifier` wrapper that delegates to WebPKI + adds CRL check
- [ ] Revoked cert is rejected at mTLS handshake with clear error
- [ ] Valid (non-revoked) cert is accepted
- [ ] Background task refreshes CRL every 24h (configurable)
- [ ] Missing CRL falls back to WebPKI (degraded mode, not fail-closed)
- [ ] Invalid CRL signature causes agent to refuse to start
- [ ] Unit tests, property tests, fuzz harness, integration tests all pass
### Phase 3 (Health monitoring + UI)
- [ ] Health response includes `crl_status` and `crl_age_seconds`
- [ ] Host details page shows CRL section (status, age, next update, last refresh)
- [ ] Hosts list shows CRL status icon (green/yellow/red)
- [ ] Dashboard widget shows count of hosts with degraded CRL
- [ ] Health aggregation: invalid signature → unhealthy
- [ ] Health aggregation: new agent missing > 24h → unhealthy
- [ ] Health aggregation: old agent missing → degraded
- [ ] Health aggregation: > 25h old → degraded
- [ ] Audit events: `CertRevoked`, `CrlGenerated`, `CrlFetched`, `CrlStaleDetected`, `CrlMissing`, `CrlInvalid`
### Phase 4 (E2E tests)
- [ ] docker-compose harness runs both pm-web and linux-patch-api
- [ ] E2E test: issue → enroll → connect (succeeds)
- [ ] E2E test: issue → enroll → revoke → refresh → connect (rejected)
- [ ] E2E test: issue → enroll → revoke → no refresh → connect (succeeds with stale CRL)
- [ ] E2E test: manager down → connect (succeeds with stale CRL, degraded health)
### Documentation
- [ ] `docs/security/revocation.md` (NEW) — revocation policy and operational behavior
- [ ] `docs/architecture/pki.md` updated with CRL section + sub-CA section
- [ ] `docs/architecture/health-monitoring.md` updated with CRL health states
- [ ] `docs/architecture/agent-cert-flow.md` (NEW) — end-to-end flow with mermaid diagram
- [ ] `docs/api/REST_API.md` (or equivalent) updated with new endpoint
- [ ] `docs/operations/upgrade-guide.md` updated with rollout notes
- [ ] `docs/operations/crl-troubleshooting.md` (NEW) — common issues and diagnostics
- [ ] Inline code docs on all new public functions/structs
- [ ] `CHANGELOG.md` entry for the release that lands Phase 1
- [ ] `linux-patch-api/config.example.toml` updated with new CRL config keys
- [ ] `linux-patch-manager/config.example.toml` updated with new CRL config keys
---
## 7. Sign-off
**All 12 concerns resolved.** Design is finalized. Implementation can begin.
**Next action:** Start PR 1 (Manager: CRL generation + endpoint + enrollment bundle).
The companion issue on linux-patch-api (#20) is filed and tracks the agent-side changes for PR 2 and PR 4.
**Documented assumptions (must be confirmed before production deployment):**
1. The external root in sub-CA mode is long-lived and trusted. Its own CRL is not consulted.
2. 1-year cert lifetime is acceptable; revocation lag of up to 24h is the operational upper bound.
3. Operators accept that during a CRL refresh failure, revoked certs may be accepted for up to 24h (the cert's `not_after` is the hard backstop).
4. Max ~2500 clients per manager. If this changes, revisit CRL size and consider OCSP.

84
tasks/issue-7-pr1-todo.md Normal file
View File

@ -0,0 +1,84 @@
# PR 1: Manager-side CRL generation + endpoint + enrollment bundle
**Branch:** `feat/7-crl-manager-side`
**Target issue:** https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/7
## Pre-implementation
- [x] Read existing `pm-ca/src/ca.rs` to understand CA structure
- [x] Confirm rcgen 0.13 is the chosen library
- [x] Confirm sub-CA handling: extend `PkiBundle` with `ca_chain` field
- [x] Read design doc decisions table (concerns 1-12)
## Code changes
- [ ] **pm-ca/src/ca.rs**: Add `generate_crl(db: &PgPool) -> Result<String>` function
- Query `certificates` for `status='revoked' AND not_after > NOW()`
- Build CRL using rcgen 0.13 `CertificateRevocationList`
- Sign with CA private key
- Return PEM-encoded CRL
- [ ] **pm-core/src/models.rs**: Extend `PkiBundle` with `ca_chain: String` field
- Concatenated PEM bundle of full chain (intermediate + root) for sub-CA mode
- For root mode, contains just the root cert (same as ca_crt)
- [ ] **pm-web/src/routes/pki.rs** (NEW): `GET /api/v1/pki/crl.pem` route
- Public endpoint (no auth, CRLs are self-authenticating)
- `Cache-Control: max-age=3600`
- Returns latest cached CRL (regenerated on schedule or on revoke)
- [ ] **pm-web/src/routes/enrollment.rs**: Include CRL in enrollment response
- Fetch current CRL via `generate_crl()`
- Add `crl_pem: String` to response
- [ ] **pm-web/src/main.rs**: Wire up background CRL regeneration task
- Regenerate every 12 hours
- Hook into `revoke_cert` to trigger immediate regeneration
- Store latest CRL in shared state (ArcSwap or similar)
- [ ] **crates/pm-web/src/state.rs** (or similar): Shared state for cached CRL
## Tests
- [ ] **Unit tests** in `pm-ca/src/ca.rs`:
- `generate_crl` produces valid X.509 CRL signed by test CA
- Revoked serials appear in CRL
- Non-revoked serials do not appear
- Expired certs (not_after < now) are excluded
- Empty table produces CRL with zero revoked entries
- [ ] **Property tests** (proptest):
- Random revoked cert data: CRL is always parseable, signature always verifies
- Single-byte mutations to CRL fail signature verification
- [ ] **Fuzz harness** (cargo-fuzz):
- Target: `pm_ca::ca::generate_crl`
- Target: `pm_ca::ca::parse_crl` (if we add parsing)
- [ ] **Integration tests** in `pm-web/tests/`:
- `GET /pki/crl.pem` returns 200 + valid PEM + correct Cache-Control
- Enrollment bundle includes CRL
- Enrollment bundle includes `ca_chain` field
## Documentation
- [ ] **docs/security/revocation.md** (NEW): Revocation policy and operational behavior
- [ ] **docs/api/REST_API.md** (or equivalent): Document `GET /pki/crl.pem`
- [ ] **Inline doc comments** on new public functions/structs
- [ ] **CHANGELOG.md** entry for the release
## Pre-PR checklist
- [ ] `cargo build` clean
- [ ] `cargo test` all pass
- [ ] `cargo clippy --all-targets --all-features -- -D warnings` clean
- [ ] `cargo fmt --check` clean
- [ ] CI on GitHub passes
## Out of scope for PR 1 (deferred to later PRs)
- Agent-side consumption (PR 2, in linux-patch-api repo)
- Health check schema additions (PR 3)
- Agent health response field (PR 4)
- Health aggregation logic (PR 5)
- E2E test harness (PR 6)

View File

@ -15,6 +15,30 @@
**Rule:** Check the obvious source (gitea repo, Vaultwarden store) before spinning wheels on complex alternatives.
**Status:** Active
## 2026-06-02: Always Verify the Authoritative Repo Before Starting Work
**Pattern:** I cloned from Gitea and did all work there, when GitHub is the master source for Linux Patch Manager. This created divergent histories and blocked PR creation.
**Mistake:** Did not check which repo was authoritative before starting work. The git-workflow skill only documented Gitea operations. I assumed Gitea was the source of truth because it was the configured remote.
**Impact:** All commits were made on a Gitea-based branch. When I tried to create a PR on GitHub, the branches had no common ancestor. The project was put in an unstable state.
**Rule:** BEFORE starting any work on a project, ALWAYS check which repo is the authoritative source. If the issue is on GitHub, clone from GitHub. If the issue is on Gitea, clone from Gitea. NEVER assume based on configured remotes.
**Rule:** When a project has multiple remotes, ALWAYS ask Kelly which one is authoritative before starting work.
**Rule:** Update the git-workflow skill to document the authoritative repo for each project.
**Status:** Active
## 2026-06-02: SSH_ASKPASS=/dev/null Blocks Git Commit Signing
**Pattern:** The container environment sets `SSH_ASKPASS=/dev/null` and `SSH_ASKPASS_REQUIRE=force`, which overrides ssh-agent and prevents git from finding signing keys during commit signing.
**Mistake:** Attempted git commit multiple times without checking why it hung. The signing key was in ssh-agent but SSH_ASKPASS was redirecting the passphrase prompt to /dev/null (not executable), causing the commit to fail with "incorrect passphrase".
**Fix:** Unset `SSH_ASKPASS` and `SSH_ASKPASS_REQUIRE` before running git commit, then use `ssh-add` with the passphrase from Vaultwarden to add the signing key to ssh-agent.
**Rule:** Before git commit signing, check `echo $SSH_ASKPASS` and `echo $SSH_ASKPASS_REQUIRE`. If SSH_ASKPASS is set to /dev/null or another non-executable, unset both variables before committing.
**Rule:** Always retrieve signing key passphrases from Vaultwarden using `vw_client.py get`, not from local files or memory.
**Status:** Active
## 2026-06-02: Always Run credential-bootstrap at Session Start
**Pattern:** Profile rules mandate running `bash /a0/usr/skills/credential-bootstrap/scripts/bootstrap.sh` at the start of every conversation before any SSH or authenticated operations. I violated this rule by starting work without bootstrapping.
**Mistake:** Began implementation work without running credential-bootstrap, then wasted multiple attempts trying to commit with a signing key that wasn't in ssh-agent.
**Rule:** ALWAYS run credential-bootstrap at session start, before any authenticated operations. This includes git commit signing.
**Rule:** If a credential operation fails, STOP and run credential-bootstrap before retrying. Do not attempt workarounds.
**Status:** Active
## 2026-05-08: Vaultwarden Is the Source of Truth for All Credentials
**Pattern:** SSH keys in ~/.ssh/ are ephemeral — lost on every container recreation. Local copies are unreliable.
**Rule:** ALWAYS pull credentials (SSH keys, API tokens, passwords) from Vaultwarden when needed. Do NOT rely on local copies in ~/.ssh/ or /a0/usr/storage/ as they may be stale or missing after container recreation.
@ -147,3 +171,14 @@ The Docker container intercepted some jobs and ran them in its Alpine environmen
**Rule:** At session start, run bootstrap checks silently. If ~/.ssh/id_ed25519 missing, retrieve from Vaultwarden via vw_client.py (not from file storage).
**Rule:** vw_client.py is primary (sub-second). bw CLI is fallback only (9-12s per operation).
**Status:** Active
## 2026-06-01: Handlers Should Take a Minimal State Struct, Not the Full AppState
**Pattern:** The `ws_handler` in `crates/pm-web/src/routes/ws.rs` is wired to `State<AppState>`, and `AppState` contains `sqlx::PgPool` (requires a real DB) and `pm_ca::CertAuthority` (private fields, requires on-disk key material + DB on `init()`). This made end-to-end integration tests in `tests/ws_origin.rs` infeasible without a Postgres + filesystem fixture.
**Why it matters:** Test seams should be at the function/handler boundary, not require the full production state. The fix landed as 33 unit tests on the module-private helpers (`parse_origin_header`, `is_origin_allowed`, `check_origin`) — 100% coverage of the security-critical logic, zero coverage of the handler wiring. That tradeoff was acceptable here because the wiring is `HeaderMap` extraction + a function call (cargo check + clippy catches wiring bugs), but the principle stands: it's better to fix the test seam than to test around it.
**Rule:** When designing a new handler, define a minimal state struct (e.g., `WsState { ws_tickets, config }`) and have `AppState` either contain it or convert to it. Handlers should only take what they need. This is a refactor on the table for follow-up work; the WS Origin fix did NOT do it (out of scope per the spec).
**Status:** Active
## 2026-06-01: Always Order CSWSH Defenses So They Don't Burn Legitimate Credentials
**Pattern:** The WS Origin allowlist check runs BEFORE the ticket validation. A cross-origin probe with a stolen ticket returns `403 forbidden_origin` without consuming the ticket. The opposite order (ticket first, then Origin) would let an attacker with a leaked ticket mount a low-cost DoS by repeatedly burning the legitimate user's 60-second tickets with `403` responses.
**Rule:** When adding defense-in-depth gates to an authenticated endpoint, order them so that the cheaper / less-credentialed gate runs first. A rejected request at gate N must not consume credentials checked at gate N+1.
**Status:** Active

View File

@ -0,0 +1,342 @@
# Secret Encryption at Rest — Issue #6 Spec
**Spec version:** v0.1.0
**Issue:** [#6 — Plaintext storage of secrets in database](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/6)
**Severity:** Medium
**Author:** Draco-Lunaris-Echo
**Status:** Awaiting sign-off
---
## 1. Goal
Encrypt three sensitive secrets that are currently stored in plaintext in the database, using the existing AES-256-GCM crypto helper (`crates/pm-core/src/crypto.rs`) with a new dedicated encryption key.
**Secrets to encrypt:**
| Secret | Table | Current column | Current type |
|--------|-------|----------------|--------------|
| OIDC `client_secret` | `oidc_config` | `client_secret` | `TEXT NOT NULL DEFAULT ''` |
| SMTP `smtp_password` | `system_config` (key-value) | `value` WHERE `key = 'smtp_password'` | `TEXT` |
| TOTP `totp_secret` | `users` | `totp_secret` | `TEXT` (nullable) |
**Why:** Database exfiltration (via SQL injection, backup theft, insider threat) would expose the client_secret to the IdP, SMTP credentials, and persistent TOTP code generation capability for all MFA-enabled users.
---
## 2. Non-Goals
- **NOT** adding a new KMS / Vault integration. AES-256-GCM with a file-based key is sufficient for our threat model and matches the existing health check credential pattern.
- **NOT** rotating the encryption key. This PR establishes the encryption infrastructure; key rotation is a follow-up issue.
- **NOT** encrypting health check credentials (already done in a previous PR).
- **NOT** adding a new master key derivation step. The key file is the only secret to protect at the OS level.
- **NOT** changing the `MASKED` placeholder behavior in API responses. That defense-in-depth pattern continues to apply on top of DB encryption.
---
## 3. Design Decisions (Kelly-approved Q1Q4)
| Q | Decision | Rationale |
|---|----------|-----------|
| **Q1 — Key management** | **A. New dedicated key** at `/etc/patch-manager/keys/secret-encryption.key` | Blast-radius isolation: if health-check key is compromised (least critical), secrets remain protected. Single-responsibility principle. |
| **Q2 — totp_secret scope** | **A. Encrypt it** | DB exfiltration = persistent TOTP code generation for all MFA-enabled users. Risk is real. |
| **Q3 — Migration path** | **Hard cutover** (development stage) | No dual-read window. The deploy MUST run a one-shot migration that encrypts existing plaintext values before dropping old columns. |
| **Q4 — Key derivation** | **A. Reuse `load_or_create_key()`** | Random 32-byte file, auto-generates on first start, 0600 perms. Same pattern as the health-check key, proven reliable. |
---
## 4. Design
### 4.1 Crypto helper extension (`crates/pm-core/src/crypto.rs`)
**Add a new constant** alongside the existing `KEY_PATH`:
```rust
/// Path to the encryption key for sensitive app secrets
/// (OIDC client_secret, SMTP password, TOTP secret).
/// Separate from `KEY_PATH` (health-check credentials) for blast-radius isolation.
pub const SECRET_ENCRYPTION_KEY_PATH: &str =
"/etc/patch-manager/keys/secret-encryption.key";
```
**Re-export** from `crates/pm-core/src/lib.rs`:
```rust
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH, SECRET_ENCRYPTION_KEY_PATH};
```
### 4.2 Migration: `migrations/020_encrypt_secrets_at_rest.sql`
**Schema changes (3 tables):**
```sql
-- 1. oidc_config: replace client_secret TEXT with BYTEA columns
ALTER TABLE oidc_config
ADD COLUMN IF NOT EXISTS client_secret_encrypted BYTEA,
ADD COLUMN IF NOT EXISTS client_secret_nonce BYTEA;
-- One-shot encryption: read old plaintext, encrypt, write to new columns.
-- Requires the application to be running to provide the key (see §4.6).
ALTER TABLE oidc_config
DROP COLUMN client_secret;
-- 2. system_config: replace smtp_password row with new key + encrypted+nonce columns
-- Approach: add new keys 'smtp_password_encrypted' and 'smtp_password_nonce';
-- remove the old 'smtp_password' row after migration script encrypts it.
-- (We don't change the system_config schema — we add new keys.)
-- 3. users: replace totp_secret TEXT with BYTEA columns
ALTER TABLE users
ADD COLUMN IF NOT EXISTS totp_secret_encrypted BYTEA,
ADD COLUMN IF NOT EXISTS totp_secret_nonce BYTEA;
ALTER TABLE users
DROP COLUMN totp_secret;
```
**Hard cutover requirement:** The deploy must execute a one-shot Rust helper (see §4.6) BEFORE the `DROP COLUMN` statements run. The migration order is:
1. ADD new BYTEA columns (idempotent, no data loss)
2. **Run one-shot encrypt helper** (reads old plaintext, writes to new columns)
3. DROP old TEXT columns
In development, we'll combine steps 1+2+3 into a single migration script that the operator runs manually before restarting the service.
### 4.3 Code changes (6 read/write sites)
#### A. `crates/pm-web/src/routes/sso.rs` — OIDC client_secret READ
**Location:** `load_oidc_config` function, line 802
**Before:**
```rust
sqlx::query_as(
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
)
```
**After:**
```rust
sqlx::query_as(
"SELECT enabled, provider_type, display_name, discovery_url, client_id, \
client_secret_encrypted, client_secret_nonce, redirect_uri, scopes \
FROM oidc_config WHERE id = 1",
)
// ... then decrypt the secret in the OidcConfig struct construction
```
**OidcConfig struct (line 216) change:**
- `pub client_secret: String``pub client_secret_encrypted: Vec<u8>` + `pub client_secret_nonce: Vec<u8>`
- Add a `pub fn decrypt_client_secret(&self, key: &[u8; 32]) -> Result<String, CryptoError>` method
#### B. `crates/pm-web/src/routes/settings.rs` — OIDC client_secret READ+WRITE+MASK
**Read** (line 280): Same query change as A above, then decrypt.
**Write** (line 360400): Replace plaintext bind with encrypted+nonce binds.
**MASK** (line 295315): No change — the API still returns `MASKED` if the secret is set.
#### C. `crates/pm-web/src/routes/settings.rs` — SMTP password READ+WRITE
**Read** (line 793, `smtp_password` key in system_config):
- Before: `cfg.get("smtp_password").cloned().unwrap_or_default()`
- After: read `smtp_password_encrypted` + `smtp_password_nonce` keys, decrypt with the same key
**Write** (line 453):
- Before: `update_config_key(&state.db, "smtp_password", v).await?;`
- After: `let (enc, nonce) = crypto::encrypt(v, &key)?;` then write to `smtp_password_encrypted` and `smtp_password_nonce` keys
#### D. `crates/pm-auth/src/session.rs` — TOTP secret READ
**Location:** line 197, `let secret = user.totp_secret.as_deref().unwrap_or("");`
**Before:**
```rust
let secret = user.totp_secret.as_deref().unwrap_or("");
```
**After:**
```rust
let secret = user.totp_secret_encrypted.as_ref()
.zip(user.totp_secret_nonce.as_ref())
.map(|(enc, nonce)| crypto::decrypt(enc, nonce, &key))
.transpose()?
.unwrap_or_default();
```
**User struct (line 80) change:**
- `totp_secret: Option<String>``totp_secret_encrypted: Option<Vec<u8>>` + `totp_secret_nonce: Option<Vec<u8>>`
#### E. `crates/pm-web/src/routes/auth.rs` — TOTP secret WRITE (MFA enrollment)
**Location:** line 363, `sqlx::query("UPDATE users SET totp_secret = $1, mfa_enabled = TRUE WHERE id = $2")`
**Before:**
```rust
.bind(&req.secret_base32)
```
**After:**
```rust
let (enc, nonce) = crypto::encrypt(&req.secret_base32, &key)?;
// ... bind enc and nonce, drop the plaintext
```
#### F. `crates/pm-web/src/routes/users.rs` — TOTP secret NULL write (disable MFA)
**Location:** line 537, `sqlx::query("UPDATE users SET totp_secret = NULL, ... WHERE id = $1")`
**Before:** Sets `totp_secret = NULL`.
**After:** Sets `totp_secret_encrypted = NULL, totp_secret_nonce = NULL`.
#### G. `crates/pm-worker/src/email.rs` — SMTP password READ in worker
**Location:** line 58, `password: get("smtp_password")`
**Before:** Reads plaintext key from system_config.
**After:** Reads `smtp_password_encrypted` + `smtp_password_nonce`, decrypts.
### 4.4 Key loading in pm-web (one-time setup)
The secret-encryption key must be loaded at startup and accessible to all routes that decrypt secrets. **Pattern: load at request time, cache per process.**
**Implementation:** Add a helper module `crates/pm-web/src/secret_key.rs`:
```rust
use once_cell::sync::OnceCell;
use pm_core::crypto;
use std::path::Path;
static SECRET_KEY: OnceCell<[u8; 32]> = OnceCell::new();
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
/// Returns CryptoError if the key file is missing or invalid.
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
SECRET_KEY.get_or_try_init(|| {
crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))
})
}
```
**Note:** `once_cell` is already a workspace dependency. Each route that needs to decrypt calls `secret_key::get()?` and uses the key.
For the worker crate (`pm-worker`), the same pattern is needed in `crates/pm-worker/src/secret_key.rs`.
### 4.5 Migration helper: `migrations/020_migrate_secrets.rs` (one-shot, dev only)
A standalone Rust binary (or an `#[ignore]` integration test) that:
1. Connects to the database using the existing DATABASE_URL
2. Reads the plaintext secrets from the old columns/rows
3. Encrypts each one with the secret-encryption key
4. Writes to the new BYTEA columns
5. Verifies the encrypted values match the plaintext (round-trip check)
6. Reports success and recommends running migration 020 to drop the old columns
**For development:** This helper is run manually before deploying the new code. The migration file `020_encrypt_secrets_at_rest.sql` drops the old columns after the helper completes.
### 4.6 Key generation on first start
On first start of the new code:
1. If `/etc/patch-manager/keys/secret-encryption.key` doesn't exist, the `load_or_create_key()` function generates a new 32-byte key and writes it with 0600 permissions.
2. The new code looks for encrypted columns. If they're NULL and the old plaintext columns are gone, the application will fail with a clear error message ("Secret not initialized — run the migration helper").
3. The migration helper from §4.5 must be run BEFORE the new code's first start, OR the deployment must be ordered: run helper → deploy new code.
---
## 5. Acceptance Criteria
- [ ] `migrations/020_encrypt_secrets_at_rest.sql` adds BYTEA columns for all 3 secrets, then drops the old TEXT columns.
- [ ] `crypto::SECRET_ENCRYPTION_KEY_PATH` constant added; re-exported from pm-core/lib.rs.
- [ ] `pm-web` and `pm-worker` have a `secret_key::get()` helper using `OnceCell`.
- [ ] All 6 read sites (sso.rs:802, settings.rs:280, settings.rs:793, session.rs:197, pm-worker/email.rs:58, plus the write site at auth.rs:363) use `crypto::encrypt`/`decrypt` with the secret-encryption key.
- [ ] All 3 write sites (settings.rs:375 for OIDC, settings.rs:453 for SMTP, auth.rs:363 for TOTP, users.rs:537 for TOTP disable) bind encrypted+nonce instead of plaintext.
- [ ] The `MASKED` placeholder behavior in API responses is preserved.
- [ ] A one-shot migration helper (`020_migrate_secrets.rs` or equivalent) is provided and documented.
- [ ] `cargo fmt --check --all` clean.
- [ ] `cargo clippy --all-targets -- -D warnings` clean.
- [ ] `cargo test -p pm-web --bins --tests` passes (43 existing + 2 new = 45 tests).
- [ ] `cargo test -p pm-worker --bins --tests` passes (existing + 1 new = at least 1 test).
- [ ] No new entries in the audit log (encryption is a data migration, not a user action).
- [ ] The new key file `/etc/patch-manager/keys/secret-encryption.key` is documented in the install/runbook.
---
## 6. Test Plan
**Unit tests (3 new):**
- `crypto::encrypt_decrypt_round_trip` — encrypt a known plaintext, decrypt it, assert equality
- `secret_key::get_returns_same_key` — call `get()` twice, assert pointer equality (caching works)
- `secret_key::get_creates_key_on_first_call` — delete the key file, call `get()`, assert the key file is recreated
**Migration helper test (1 new):**
- `020_migrate_secrets::test_round_trip_oidc` — seed DB with known plaintext, run helper, assert encrypted column matches the expected ciphertext (computed independently)
**Existing tests to verify still pass:**
- `cargo test -p pm-web --bins --tests` — 43 existing tests
- `cargo test -p pm-auth --bins --tests` — session tests for TOTP verification
- `cargo test -p pm-worker --bins --tests` — email tests (if any)
**Manual verification:**
- Start the service, log in as admin, navigate to Settings → OIDC, verify the API response shows `MASKED` (no plaintext leak)
- `psql -c "SELECT client_secret_encrypted FROM oidc_config"` — verify the value is binary (BYTEA), not readable text
- `psql -c "SELECT value FROM system_config WHERE key = 'smtp_password'"` — verify the row is gone (replaced by encrypted+nonce rows)
- `psql -c "SELECT totp_secret FROM users WHERE mfa_enabled = TRUE LIMIT 1"` — verify the column is gone
---
## 7. Risk Analysis
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| **Deploy order: new code starts before migration helper runs** → service fails to read secrets | Medium | High | Document the deploy order in the runbook. Add a startup check that detects missing encrypted columns and returns a clear error. |
| **Key file lost (deleted, disk failure)** → all secrets unreadable | Low | Critical | Document the key file in the backup runbook. Add a `backup.sh` hook to include the key file in backups. Follow-up issue for key recovery / rotation. |
| **Worker doesn't share key with web** | Low | Medium | Both use the same `load_or_create_key()` with the same path. Key file is filesystem-shared. |
| **TOTP secret encryption breaks existing MFA sessions** | Low | Medium | The one-shot migration helper decrypts old plaintext, re-encrypts, and writes. Existing TOTP seeds remain valid. |
| **Migration helper crashes mid-migration** → partial state | Low | Medium | The helper is idempotent (uses UPSERT). On retry, it re-encrypts and overwrites. |
| **Key file permissions wrong** → OS-level exposure | Very low | Medium | `load_or_create_key()` sets 0600 on creation. `chmod` enforcement in the install script. |
| **Audit log entries leak the secret value** | Very low | N/A | We don't log the plaintext or ciphertext. Only the fact that the column was updated. |
---
## 8. Documentation Updates
### 8.1 `docs/security-review.md` §4.1 (Encryption at Rest)
Add a new evidence row:
| Control | Status | Evidence |
|---------|--------|----------|
| **Secrets encrypted at rest (issue #6 fix)** | ✅ Verified | OIDC `client_secret`, SMTP `smtp_password`, and TOTP `totp_secret` are encrypted with AES-256-GCM using a dedicated per-install key at `/etc/patch-manager/keys/secret-encryption.key`. Encryption/decryption via `pm-core::crypto::encrypt`/`decrypt` (same helper as health check credentials, but with a separate key for blast-radius isolation). Schema migration `020_encrypt_secrets_at_rest.sql` replaces plaintext TEXT columns with BYTEA `_encrypted` + `_nonce` columns. |
### 8.2 `docs/runbooks/restore.md` (or new `docs/runbooks/key-management.md`)
Add a section on the new key file:
```markdown
## Encryption Keys
Two per-install AES-256-GCM keys are auto-generated on first start:
| Key | Path | Protects |
|-----|------|----------|
| `health-check.key` | `/etc/patch-manager/keys/health-check.key` | HTTP basic auth passwords for health check endpoints |
| `secret-encryption.key` | `/etc/patch-manager/keys/secret-encryption.key` | OIDC client_secret, SMTP password, TOTP secrets |
**Backup:** Both key files MUST be included in `/etc/patch-manager` backups. Without them, the encrypted data is unrecoverable.
**Rotation:** Key rotation is not yet supported (follow-up issue). If a key is compromised, generate a new key and re-encrypt all secrets.
```
### 8.3 `docs/REST_API.md` (no changes needed)
The API surface is unchanged — the `MASKED` placeholder behavior is preserved.
---
## 9. Follow-ups
- **Key rotation** — add support for rotating the secret-encryption key without service downtime. Requires wrapping the key in a versioned envelope (e.g., `{key_id, ciphertext, nonce}`).
- **Integration tests** — covered by issue #15. The migration helper has its own unit test.
- **Audit logging** — log the fact that secret-encryption key was loaded at startup (NOT the key itself).
- **Backup verification** — automated test that verifies a fresh install can restore from a backup by decrypting the secrets.
---
## 10. Sign-off
Approve to proceed to Phase 1 (crypto helper extension + one-shot migration helper + 3 new unit tests). Per project rules, I will not commit or push anything until Phase 7.

View File

@ -0,0 +1,332 @@
# SSO Token Handoff — Specification
**Issue:** [#4](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/4)
**Component:** `crates/pm-web/src/routes/sso.rs`, `frontend/src/pages/SsoCallbackPage.tsx`, `frontend/src/store/authStore.ts`
**Spec version:** 0.1.0 (draft)
**Status:** Awaiting Kelly sign-off
---
## 1. Goal
Stop embedding JWT access tokens, refresh tokens, and user objects in the
SSO callback redirect URL. Today, after a successful OIDC login, the
backend 302-redirects the browser to the SPA with the tokens in the
query string:
```
https://app.example.com/auth/sso/callback
?access_token=<jwt>
&refresh_token=<raw>
&token_type=Bearer
&expires_in=900
&user=<urlencoded-json>
```
Tokens in URLs are written to browser history, intermediate proxy and
load-balancer access logs, and may leak via the `Referer` header when
the landing page loads third-party resources. The refresh token is
the most sensitive value (long-lived, rotating) and gets the worst
exposure.
Replace the URL-embedded tokens with a **single-use, short-lived
handoff code** that the SPA exchanges for tokens via a server-to-server
POST. The URL then contains only the code, which expires in 60 seconds
and is invalidated on first use.
## 2. Non-Goals
- Changing the OIDC flow itself (Authorization Code + PKCE stays the same).
- Changing the MFA verification path that runs after the OIDC callback.
- Touching the WS ticket pattern (issue #10) — this spec is a *new*
in-memory store for SSO handoff codes, mirroring but separate from
`ws_tickets: Arc<DashMap<String, WsTicket>>`.
- Adding cookie-based or `form_post` delivery. The handoff code
approach was selected over those (Kelly sign-off Q1).
- Long-lived SSO sessions. The handoff code is single-use; subsequent
SSO logins re-issue a new code.
## 3. Design Decisions (Kelly sign-off, 2026-06-02)
| # | Question | Resolution |
|---|----------|------------|
| Q1 | Approach selection | **Handoff code** (option C in issue #4). Mirrors the existing WS-ticket pattern. URL contains only a single-use, 60s `handoff_code`. SPA POSTs to `/api/v1/auth/sso/handoff` and gets tokens in the JSON response. |
| Q2 | Cookie attributes | **N/A** — handoff code approach uses no cookies. |
| Q3 | Rollout strategy | **Hard cutover** — remove the old query-string parsing in the same PR. No dual-read window. (Justification: security-critical fix, deploy window is short, no in-flight SSO logins survive a rolling restart because the auth state is in the user's browser, not on the server.) |
| Q4 | `Secure` cookie flag | **N/A** — handoff code approach uses no cookies. Kelly's answer ("unconditionally secure") is noted for future cookie work but does not apply here. |
## 4. Design
### 4.1 Backend: SSO callback (`crates/pm-web/src/routes/sso.rs`)
The `sso_callback` handler currently constructs a redirect URL with all
token values. Replace this with a handoff code generation step:
1. After the access/refresh tokens and `user_json` are computed (the
existing logic through `sso_callback` is unchanged up to the
redirect construction), generate a cryptographically random
`handoff_code` (32 bytes, base64url-encoded, ~43 chars).
2. Store the handoff payload in a new in-memory map:
```rust
pub struct SsoHandoff {
pub access_token: String,
pub raw_refresh: String,
pub user_json: Value,
pub access_ttl: u64,
pub expires_at: Instant, // now + 60s
}
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
```
Mirrors the `WsTicket` struct (single-use, in-memory, TTL enforced
on read). The map is added to `AppState` alongside `ws_tickets`.
3. Build the redirect URL with ONLY the handoff code:
```rust
let redirect_url = format!("{}?handoff={}", callback_url, handoff_code);
Ok(Redirect::to(&redirect_url))
```
4. Log the handoff creation (without the code value itself) for audit:
```rust
tracing::info!(user_id = %user.id, auth_provider, "SSO handoff issued");
```
### 4.2 Backend: Handoff exchange endpoint
New handler `POST /api/v1/auth/sso/handoff`:
- Request body: `{ "handoff_code": "<code>" }`
- Behavior:
1. Look up `handoff_code` in `sso_handoffs` (DashMap read lock).
2. If not found → `400 invalid_handoff`.
3. If found but `expires_at < Instant::now()` → remove the entry and
return `400 invalid_handoff` (the cleanup-on-expiry also prevents
memory bloat from expired-but-unconsumed codes).
4. **Remove the entry atomically** (DashMap `remove` is atomic) —
this is the single-use guarantee. Even if two requests race with
the same code, only one wins.
5. Return the payload as JSON:
```json
{
"access_token": "<jwt>",
"refresh_token": "<raw>",
"token_type": "Bearer",
"expires_in": 900,
"user": { "id": "...", "username": "...", ... }
}
```
- Log:
- On success: `tracing::info!(user_id = %payload.user.id, "SSO handoff exchanged")`
- On failure: `tracing::warn!(reason = %reason, "SSO handoff exchange failed")`
- **Never log the handoff code value itself** (it's a bearer secret
with 60s window).
### 4.3 Backend: Cleanup task
Add a `tokio::spawn` cleanup task in `main.rs` (mirroring the existing
WS-ticket cleanup if present, or the SSO-session cleanup that already
runs per the codebase). Every 60 seconds, walk `sso_handoffs` and
remove entries with `expires_at < Instant::now()`. Bounded memory
growth even if the SPA never POSTs back.
### 4.4 Backend: Route registration
In `pm-web/src/main.rs`, add the new route to the public router
(alongside `/api/v1/ws/ticket`, which is also public — no JWT
required because the handoff code IS the credential):
```rust
.route("/api/v1/auth/sso/handoff", post(sso_handoff_exchange))
```
### 4.5 Frontend: `SsoCallbackPage.tsx`
Replace the URL-param parsing with a POST to the handoff endpoint:
```typescript
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const errorCode = params.get('error')
if (errorCode) {
// ... existing error handling unchanged ...
return
}
const handoffCode = params.get('handoff')
if (!handoffCode) {
setError('Missing handoff code. Please try logging in again.')
setProcessing(false)
return
}
// Exchange handoff code for tokens
fetch('/api/v1/auth/sso/handoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handoff_code: handoffCode }),
})
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
.then(data => {
setTokens(data.access_token, data.refresh_token)
setUser(buildUser(data.user))
// Clear the handoff code from the URL to prevent bookmarking/sharing
window.history.replaceState({}, '', '/auth/sso/callback')
navigate('/dashboard', { replace: true })
})
.catch(err => {
setError(err?.error?.message || 'Failed to complete sign-in. Please try again.')
setProcessing(false)
})
}, [setTokens, setUser, navigate])
```
The `buildUser` helper mirrors the existing field-mapping logic
(lines 5467 of the current file).
### 4.6 Frontend: `authStore.ts`
**No change required.** The existing `setTokens(access, refresh)` and
`setUser(user)` API is what the new code calls. The `partialize`
config (line 74) already correctly persists only `refreshToken` and
`user` — not `accessToken` — so the in-memory access token is never
written to localStorage. This is the correct security posture and
should be preserved.
## 5. Acceptance Criteria
- [ ] SSO callback no longer places `access_token`, `refresh_token`,
`token_type`, `expires_in`, or `user` in the redirect URL.
The URL contains only `handoff=<code>` (plus the error params on
failure, which are unchanged).
- [ ] The handoff code is at least 128 bits of entropy (32 bytes,
base64url-encoded) and is generated with a CSPRNG.
- [ ] The handoff code is single-use: a second exchange attempt with
the same code returns `400 invalid_handoff` and does NOT return
the tokens again.
- [ ] The handoff code expires after 60 seconds. An exchange attempt
with an expired code returns `400 invalid_handoff` and the
entry is removed from the in-memory map.
- [ ] The SPA successfully completes login: POST to the handoff
endpoint receives the tokens, calls `setTokens` and `setUser`,
and navigates to `/dashboard`.
- [ ] `authStore.ts` is unchanged (its existing `partialize` already
prevents access-token persistence; the handoff code approach
doesn't change that contract).
- [ ] `cargo check` and `cargo clippy --all-targets` pass.
- [ ] `cargo test -p pm-web` passes with new tests for the handoff
endpoint (create, exchange success, exchange duplicate=400,
exchange expired=400, exchange unknown=400).
- [ ] `frontend` builds cleanly (`npm run build` in `frontend/`).
- [ ] No access or refresh token values appear in any URL or query
string in the SSO flow. Manual verification: complete a login
and grep the server access log for the callback URL — only the
handoff code should be present.
- [ ] `docs/security-review.md` §2.5 (Azure SSO) is updated to
document the handoff code control.
## 6. Test Plan
### 6.1 Backend unit/integration tests (`crates/pm-web/src/routes/sso.rs`)
Using a small `TestApp` harness mirroring the WS-ticket test pattern
(no real HTTP listener, no DB beyond the connection that's already
mocked in the existing tests):
1. `handoff_exchange_success` — create a handoff, POST to the
exchange endpoint, expect 200 with the access/refresh/user fields.
2. `handoff_exchange_single_use` — exchange once (success), exchange
the same code again (expect 400 `invalid_handoff`).
3. `handoff_exchange_unknown_code` — POST with a code that was never
issued (expect 400 `invalid_handoff`).
4. `handoff_exchange_expired_code` — create a handoff with
`expires_at = past`, exchange (expect 400 `invalid_handoff` AND
the entry is removed from the map).
5. `handoff_exchange_race` — two concurrent POSTs with the same code
(using `tokio::join!`); exactly one succeeds, the other gets 400.
6. `handoff_exchange_malformed_body` — POST with invalid JSON or
missing `handoff_code` field (expect 400 `invalid_handoff`).
7. `callback_redirect_contains_only_handoff` — invoke `sso_callback`
through a mock OIDC config and assert the resulting redirect URL
contains only `handoff=<code>` and NO `access_token` /
`refresh_token` / `user` query params.
### 6.2 Backend cleanup test
8. `handoff_cleanup_removes_expired` — create 3 handoffs with
varying `expires_at`, run one tick of the cleanup task, assert
only the non-expired ones remain.
### 6.3 Frontend tests (`frontend/src/pages/SsoCallbackPage.tsx`)
Add a Vitest + React Testing Library test suite (the frontend already
uses Vitest — see `frontend/package.json` and `frontend/vite.config.ts`):
9. `renders_processing_state_initially` — on mount with a handoff
code, shows the spinner and "Completing sign-in…".
10. `calls_handoff_endpoint_on_mount` — mocks `fetch` and asserts the
POST goes to `/api/v1/auth/sso/handoff` with `{ handoff_code: <code> }`.
11. `stores_tokens_and_user_on_success` — mocks a successful response,
asserts `setTokens` and `setUser` are called with the response
payload, and the SPA navigates to `/dashboard`.
12. `shows_error_on_handoff_failure` — mocks a 400 response, asserts
the error message is rendered and the spinner stops.
13. `shows_error_when_handoff_code_missing` — invokes the effect with
no handoff code, asserts the "Missing handoff code" error is
shown.
14. `clears_handoff_code_from_url_after_success` — asserts
`window.history.replaceState` is called to remove the `?handoff=`
param from the URL after a successful exchange.
## 7. Risk Analysis
- **Risk: regression in the SSO login flow.** Mitigation: the test
plan covers the callback redirect shape, the exchange endpoint
behavior (success, single-use, expiry, race), and the frontend
effect. Manual end-to-end test (completing a real Azure AD login)
is required before merge — the new `scripts/integration-test.sh`
should be extended or a new `scripts/integration-test-sso.sh`
added to exercise the full flow against a mock OIDC provider.
- **Risk: in-flight SSO logins during deploy break.** Per Kelly
sign-off Q3, we accept hard cutover. The mitigation: the 60s
handoff TTL means any in-flight redirect that arrives after the
server restart has a 60s window to complete. If the new code is
deployed and the old handoffs are lost, the user is sent back to
`/auth/sso/callback?handoff=<old-code>` which the new code rejects
with `400 invalid_handoff`, and the SPA shows "Please try logging
in again." Worst case: a 30-second re-login. Acceptable for a
security-critical fix.
- **Risk: handoff code leaked via browser history or `Referer`.**
The code is single-use and 60s TTL, so the blast radius is small
even if logged. The SPA calls `history.replaceState` after a
successful exchange to remove the code from the address bar (and
the underlying history entry). The 60s window limits exposure to
`Referer` leakage on subsequent navigations from the callback
page.
- **Risk: memory growth from unconsumed handoffs.** Mitigation: the
cleanup task runs every 60s and removes expired entries. Worst
case memory usage is `O(active_logins)` — typically single digits.
- **Risk: race condition in the single-use guarantee.** Mitigation:
`DashMap::remove` is atomic, so only one of two concurrent
exchange attempts can succeed. Verified by the
`handoff_exchange_race` test.
## 8. Documentation Updates
- `docs/security-review.md` §2.5 (Azure SSO): add a new row
documenting the handoff code control and explicitly state that no
tokens appear in any URL.
- `frontend/src/pages/SsoCallbackPage.tsx`: update the doc-comment to
describe the POST-and-exchange flow instead of the URL-param parse.
- `docs/REST_API.md`: document the new `POST /api/v1/auth/sso/handoff`
endpoint.
## 9. Out of Scope / Follow-ups
- Cookie-based SSO session (a future enhancement that would let the
SPA refresh state without a new OIDC flow on every page load).
- `form_post` response mode (a future enhancement if browsers
standardize it more widely).
- Rate limiting on the handoff endpoint (out of scope here; the
existing governor-based rate limits on `/auth/*` may already cover
this — verify during implementation).
- Moving the in-memory `sso_handoffs` to Redis (out of scope; the
single-instance design constraint in `SPEC.md` is fine for this
control).

View File

@ -38,12 +38,149 @@
- [x] 4f: Lessons captured below
## Lessons Learned
---
# WS Origin Allowlist — Implementation Plan (Issue #10)
Spec: `tasks/ws-origin-check-spec.md` (v0.1.0, awaiting sign-off)
## Issues Identified
1. **No Origin check on WS upgrade**`crates/pm-web/src/routes/ws.rs` `ws_handler` does
not inspect the `Origin` header, leaving the `/api/v1/ws/jobs` endpoint exposed to
Cross-Site WebSocket Hijacking (CSWSH) if a ticket ever leaks via logs / `Referer` /
browser history / support bundles.
2. **No `allowed_origins` config field**`SecurityConfig` has no way to express the
allowlist; defaults need to be derived from `sso_callback_url` to stay secure out
of the box.
3. **No integration tests for ws.rs** — there is no `crates/pm-web/tests/` directory
today, so the new behavior would land without automated coverage.
## Phases
### Phase 1: Config schema (Issue 2)
- [x] 1a: Add `allowed_origins: Vec<String>` to `SecurityConfig` in `crates/pm-core/src/config.rs`
- [x] 1b: Implement `default_allowed_origins()` that parses `sso_callback_url` to `scheme://host[:port]`
- [x] 1c: Emit `tracing::warn!` at startup if the derived allowlist ends up empty
- [x] 1d: Update `Default for AppConfig` to include the new field
- [x] 1e: Update `config/config.example.toml` with documented `allowed_origins` key
### Phase 2: Handler change (Issue 1)
- [x] 2a: Add `HeaderMap` extractor to `ws_handler`
- [x] 2b: Implement hand-rolled `Origin` parser (scheme, host, port) with default-port normalization
- [x] 2c: Implement allowlist match (exact, case-insensitive host, case-sensitive scheme/port)
- [x] 2d: Reject missing / malformed / non-allowlisted `Origin` with `403 forbidden_origin` *before* ticket validation
- [x] 2e: Augment the success `tracing::info!` with `origin`; add `tracing::warn!` on rejection (never log the ticket)
- [x] 2f: Verify `cargo check -p pm-web` and `cargo clippy --all-targets` pass
### Phase 3: Tests (Issue 3)
- [x] 3a: Add `crates/pm-web/tests/` and a `build_test_app` harness (no DB, minimal `AppState`)
- [x] 3b: Add `ws_rejects_missing_origin` test
- [x] 3c: Add `ws_rejects_disallowed_origin` test
- [x] 3d: Add `ws_rejects_malformed_origin` test
- [x] 3e: Add `ws_allows_listed_origin_with_valid_ticket` test (asserts ticket is consumed)
- [x] 3f: Add `ws_default_origin_derived_from_sso_callback_url` config-derivation test
- [x] 3g: Verify `cargo test -p pm-web` passes
### Phase 4: Documentation
- [x] 4a: Update `docs/security-review.md` with a new control row for the WS Origin allowlist
- [x] 4b: (Optional, per Kelly) bump `SPEC.md` to 0.0.3 with a sentence in the Security section
### Phase 5: Review
- [x] 5a: Self-review against the 10-point acceptance criteria in the spec
- [x] 5b: Commit on a feature branch (`issue/10-ws-origin-check`) per git-workflow skill
- [x] 5c: Lessons captured below
## Lessons Learned (this issue)
_(filled in at completion)_
- **SSO callback must redirect, not return JSON** — Browser OAuth2 flows require the backend to redirect to the frontend SPA, not return JSON tokens. The frontend must parse tokens from URL query parameters.
- **URLSearchParams.get() already decodes** — Don't double-decode with decodeURIComponent() when using URLSearchParams.
- **JWKS caching prevents rate-limiting** — Azure AD JWKS endpoint should be cached with TTL (1 hour) to avoid fetching on every SSO login.
- **tokio::sync::Mutex over std::sync::Mutex** — Axum handlers must be Send; std::sync::MutexGuard is not Send across await points.
- **DashMap session cleanup** — In-memory session stores (DashMap) need periodic cleanup tasks to prevent memory leaks. Pattern: tokio::spawn with interval + retain with time-based cutoff.
# IP Allowlist Hardening — Implementation Plan (Issue #3)
Spec: `tasks/ip-allowlist-spec.md` (v0.1.0, awaiting sign-off)
## Issues Identified
1. **Allowlist bypass via missing XFF**`extract_remote_ip` returns `None` when
the header is absent, and the middleware's `if let Some(ip)` block has no
`else` branch, so a request without `X-Forwarded-For` skips the check.
2. **Allowlist spoofing via XFF**`extract_remote_ip` reads the header
unconditionally; any client can claim to be from a whitelisted IP.
3. **No trusted-proxy concept** — there is no config field to declare which
intermediate proxies are allowed to set `X-Forwarded-For`.
4. **No `ConnectInfo<SocketAddr>` wiring** — the axum listeners in
`pm-web/src/main.rs` do not use `into_make_service_with_connect_info`, so
the middleware cannot access the real peer address.
## Phases
### Phase 1: Resolver helper in pm-auth
- [ ] 1a: Add `fn resolve_client_ip(headers, peer, trusted_proxies) -> Option<IpAddr>`
- [ ] 1b: Add 12 unit tests in `crates/pm-auth/src/rbac.rs` (cfg(test)) covering
the resolution matrix (peer-only, XFF trusted/untrusted, multi-hop,
IPv6, malformed, missing peer)
- [ ] 1c: Run `cargo test -p pm-auth` and confirm green
### Phase 2: AuthConfig + SecurityConfig schema
- [ ] 2a: Add `trusted_proxies: Arc<RwLock<Vec<IpNet>>>` to `AuthConfig`
- [ ] 2b: Add `trusted_proxies: Vec<String>` to `SecurityConfig` in `crates/pm-core/src/config.rs`
- [ ] 2c: Update `Default for AppConfig` to include `trusted_proxies: vec![]`
- [ ] 2d: Add `update_trusted_proxies` setter on `AuthConfig` (symmetric to
`update_ip_whitelist`)
- [ ] 2e: Update `config/config.example.toml` with a documented `trusted_proxies`
entry and a reverse-proxy runbook comment block
- [ ] 2f: Plumb `trusted_proxies` from `SecurityConfig` into `AuthConfig::new`
in `pm-web/src/main.rs`
- [ ] 2g: Run `cargo check` and `cargo clippy --all-targets`
### Phase 3: Middleware change
- [ ] 3a: Update `require_auth` to extract `ConnectInfo<SocketAddr>` from
request extensions and call `resolve_client_ip`
- [ ] 3b: Add fail-closed path: non-empty allowlist + unresolvable IP →
`403 forbidden_ip`
- [ ] 3c: Replace `forbidden("Access denied")` with the new error code in IP-deny path
- [ ] 3d: Add `tracing::warn!` with `client_ip`, `peer`, `xff_present`, `reason`
- [ ] 3e: Remove the old `extract_remote_ip` (header-only) function
- [ ] 3f: Run `cargo check` and `cargo clippy --all-targets`
### Phase 4: pm-web listener wiring
- [ ] 4a: Switch both TCP and TLS axum listeners in `pm-web/src/main.rs` to
`into_make_service_with_connect_info::<SocketAddr>()`
- [ ] 4b: Run `cargo check -p pm-web`
### Phase 5: Middleware integration tests
- [ ] 5a: Add `TestApp` harness in `crates/pm-auth/src/rbac.rs` cfg(test)
(no DB, single-route router, `tower::ServiceExt`-style call)
- [ ] 5b: Add 8 middleware integration tests per spec section 6.1
(allow empty, deny non-empty, allow in list, fail-closed no peer,
spoofed XFF ignored, trusted proxy honors XFF, bad XFF fallback,
no-JWT on deny)
- [ ] 5c: Run `cargo test -p pm-auth` and confirm green
### Phase 6: Documentation
- [ ] 6a: Update `docs/security-review.md` — update existing IP-allowlist row
and reference new code path + `trusted_proxies` field
- [ ] 6b: Update `SPEC.md` Security section (one paragraph)
- [ ] 6c: Add a "Reverse proxy deployment" runbook under `docs/runbooks/`
(optional, per Kelly)
### Phase 7: Review & commit
- [ ] 7a: Self-review against the 8 acceptance criteria in the spec
- [ ] 7b: Run `bash /a0/usr/skills/git-workflow/scripts/validate-push.sh`
- [ ] 7c: Commit on `fix/3-ip-allowlist-bypass` (per git-workflow skill)
- [ ] 7d: Push to `github/fix/3-ip-allowlist-bypass` and open PR against `master`
- [ ] 7e: Comment on issue #3 linking the PR; close issue on merge
- [ ] 7f: Capture lessons in this file
## Lessons Learned (this issue)
_(filled in at completion)_
---
# Host Self-Enrollment Implementation Plan
## Phases
@ -54,9 +191,9 @@
- [x] 1c: Add DB interaction methods (insert, list, delete) in `pm-core`
### Phase 2: Client-Facing API (pm-web)
- [ ] 2a: Implement `POST /api/v1/enroll` to accept payloads and generate `polling_token`
- [ ] 2b: Implement `GET /api/v1/enroll/status/{token}` to return pending/approved (PKI) statuses
- [ ] 2c: Implement IP-based rate limiting for the `/enroll` endpoint
- [x] 2a: Implement `POST /api/v1/enroll` to accept payloads and generate `polling_token`
- [x] 2b: Implement `GET /api/v1/enroll/status/{token}` to return pending/approved (PKI) statuses
- [x] 2c: Implement IP-based rate limiting for the `/enroll` endpoint
### Phase 3: Admin-Facing API (pm-web)
- [x] 3a: Implement `GET /api/v1/admin/enrollments` to list pending queue
@ -72,3 +209,115 @@
- [x] 5c: Render pending hosts in the table with highlight styling
- [x] 5d: Add Approve/Deny action buttons to pending host rows
- [x] 5e: Implement "merge/overwrite" interactive modal for `fqdn`/`ip_address` collisions on approval
## Issue #5: Admin-Only Manager-Wide Configuration (Authz Gate)
**Spec:** [tasks/authz-gate-spec.md](authz-gate-spec.md) (v0.1.0)
**Branch:** `fix/5-operator-can-modify-auth-config`
**Status:** Draft spec — awaiting Kelly sign-off
### Phase 1: admin_required helper + 3 unit tests
- 1a: Add `admin_required` helper in `crates/pm-web/src/routes/settings.rs` (after `write_access_required` ~line 173). Returns 403 with code `forbidden_role` if not Admin.
- 1b: Add 3 unit tests in cfg(test) module: `admin_required_admin_passes`, `admin_required_operator_denied`, `admin_required_reporter_denied`.
- 1c: Run `cargo test -p pm-web --bins --tests` and confirm green.
### Phase 2: Gate changes + audit log calls
- 2a: Replace `write_access_required` with `admin_required` in `update_settings` (line 336).
- 2b: Replace `write_access_required` with `admin_required` in `update_ip_whitelist` (line 902).
- 2c: Replace `write_access_required` with `admin_required` in `discover_oidc` (line 561).
- 2d: Replace `write_access_required` with `admin_required` in `test_oidc` (line 619).
- 2e: Create `migrations/019_auth_config_audit_actions.sql` with 5 new enum values.
- 2f: Add 5 new variants to the `AuditAction` enum in `crates/pm-core/src/audit.rs` (or wherever defined).
- 2g: Add `write_audit_event` calls in each of the 4 handlers, after successful mutations.
- 2h: Run `cargo fmt --check --all`, `cargo clippy --all-targets -- -D warnings`, `cargo test -p pm-web --bins --tests` and confirm clean.
### Phase 3: Integration tests (8 new)
- 3a: `update_settings_operator_denied` — POST as Operator with OIDC fields → 403 `forbidden_role`.
- 3b: `update_settings_admin_allowed` — POST as Admin with OIDC fields → 200 + audit row written.
- 3c: `update_settings_smtp_operator_denied` — POST as Operator with SMTP fields → 403 `forbidden_role`.
- 3d: `update_settings_smtp_admin_allowed` — POST as Admin with SMTP fields → 200 + audit row written.
- 3e: `update_ip_whitelist_operator_denied` — POST as Operator → 403 `forbidden_role`.
- 3f: `update_ip_whitelist_admin_allowed` — POST as Admin → 200 + audit row written + in-memory `AuthConfig.ip_whitelist` updated.
- 3g: `discover_oidc_operator_denied` / `discover_oidc_admin_allowed` — 2 tests.
- 3h: `test_oidc_operator_denied` / `test_oidc_admin_allowed` — 2 tests.
- 3i: Run `cargo test -p pm-web --bins --tests` and confirm all green.
### Phase 4: SPA error message + 1 test
- 4a: Update `frontend/src/pages/SettingsPage.tsx` to detect `error.code === 'forbidden_role'` and show friendly message: "Only Admins can modify authentication configuration. Contact an Admin to make this change."
- 4b: Create `frontend/src/pages/__tests__/SettingsPage.test.tsx` with 1 test: `settings_page_forbidden_role_shows_friendly_message`.
- 4c: Run `npm test` in `frontend/` and confirm green.
### Phase 5: Documentation
- 5a: Update `docs/security-review.md` §2.3 (Authorization / RBAC) with 2 new rows.
- 5b: Annotate the 4 affected endpoints in `docs/REST_API.md` with "🔒 Admin only".
- 5c: Add a project-specific lesson in `tasks/lessons.md` about the role model (Admin = Manager-wide, Operator = per-host, Reporter = read-only).
### Phase 6: Review & commit
- 6a: Self-review against the 9 acceptance criteria in the spec.
- 6b: Manual pre-push checks (cargo fmt, cargo clippy, eslint, cargo test, npm test) — run all 6 from the recent lessons-learned entry.
- 6c: Commit on `fix/5-operator-can-modify-auth-config` with conventional format.
- 6d: Push to `github/fix/5-operator-can-modify-auth-config` via `github-echo` SSH alias.
- 6e: Open PR against `master` and comment on issue #5.
- 6f: Capture lessons in `tasks/lessons.md` (project-specific) and `git-workflow/references/lessons-learned.md` (skill-level).
---
# Issue #6 — Secret Encryption at Rest
**Spec:** [tasks/secret-encryption-spec.md](secret-encryption-spec.md) v0.1.0
**Branch:** `fix/6-plaintext-secrets`
**Identity:** `Draco-Lunaris-Echo`
**Follow-up:** [Issue #15](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/15) (integration tests)
## Phase 1: Crypto helper extension + 3 new unit tests
- [ ] 1a: Add `pub const SECRET_ENCRYPTION_KEY_PATH` to `crates/pm-core/src/crypto.rs`
- [ ] 1b: Re-export from `crates/pm-core/src/lib.rs`
- [ ] 1c: Add 3 unit tests in `crypto.rs` (round_trip, file_creation_0600_perms, file_creation_idempotent)
- [ ] 1d: `cargo test -p pm-core` and `cargo clippy --all-targets -- -D warnings`
## Phase 2: Secret key loader + migration SQL + migration helper
- [ ] 2a: Add `crates/pm-web/src/secret_key.rs` with `OnceCell<[u8; 32]>` pattern
- [ ] 2b: Add `crates/pm-worker/src/secret_key.rs` (same pattern)
- [ ] 2c: Create `migrations/020_encrypt_secrets_at_rest.sql` (schema changes for 3 tables)
- [ ] 2d: Create `crates/migrate-secrets/src/main.rs` — one-shot Rust binary that reads old plaintext, encrypts, writes to new columns
- [ ] 2e: Verify migration helper round-trips (encrypt → decrypt = original plaintext)
- [ ] 2f: `cargo test` and `cargo clippy` clean
## Phase 3: Code changes — 6 read/write sites
- [ ] 3a: `sso.rs` `load_oidc_config` — query `_encrypted` + `_nonce`, add `decrypt_client_secret()` method to OidcConfig
- [ ] 3b: `settings.rs` OIDC read (line 280) + write (line 360) — same pattern as 3a
- [ ] 3c: `settings.rs` SMTP read (line 793) + write (line 453) — use `system_config` key-value with new keys
- [ ] 3d: `session.rs` TOTP read (line 197) — decrypt with secret_key::get()
- [ ] 3e: `auth.rs` TOTP write (line 363) — encrypt req.secret_base32 before bind
- [ ] 3f: `users.rs` TOTP NULL write (line 537) — bind to new _encrypted + _nonce columns
- [ ] 3g: `pm-worker/src/email.rs` SMTP read (line 58) — decrypt
- [ ] 3h: Update User struct (line 80) — replace `totp_secret: Option<String>` with `totp_secret_encrypted: Option<Vec<u8>>` + `totp_secret_nonce: Option<Vec<u8>>`
- [ ] 3i: `cargo test -p pm-web --bins --tests` (43 existing pass)
- [ ] 3j: `cargo test -p pm-auth --bins --tests`
- [ ] 3k: `cargo test -p pm-worker --bins --tests`
- [ ] 3l: `cargo clippy --all-targets -- -D warnings` clean
- [ ] 3m: `npm run build` clean
## Phase 4: Documentation
- [ ] 4a: Update `docs/security-review.md` §4.1 with new evidence row
- [ ] 4b: Create/update `docs/runbooks/key-management.md` with both key files documented
- [ ] 4c: Update `docs/REST_API.md` (no API changes — note that MASKED behavior is preserved)
- [ ] 4d: Update `SPEC.md` if it mentions secret storage (check during review)
## Phase 5: Self-review against spec §5 acceptance criteria
- [ ] All 12 acceptance criteria checked
- [ ] Manual verification: psql queries show BYTEA not TEXT
- [ ] Manual verification: API responses still return MASKED
## Phase 6: Commit, push, open PR
- [ ] 6a: Pre-push validation (cargo fmt, clippy, test, secret scan, identity, remote URL)
- [ ] 6b: Commit on `fix/6-plaintext-secrets` with conventional format
- [ ] 6c: Push to `github/fix/6-plaintext-secrets` via `github-echo` SSH alias
- [ ] 6d: Open PR against master, comment on issue #6
- [ ] 6e: Append lessons-learned to `git-workflow/references/lessons-learned.md` AND `tasks/lessons.md`
## Phase 7: Cleanup (after Kelly approves merge)
- [ ] 7a: Reset local master to `github/master`
- [ ] 7b: Delete local + remote branch
- [ ] 7c: Prune remote tracking ref
- [ ] 7d: Report completion

View File

@ -0,0 +1,219 @@
# WS Origin Allowlist — Specification
**Issue:** [#10](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/10)
**Component:** `crates/pm-web/src/routes/ws.rs`
**Spec version:** 0.1.0 (draft)
**Status:** Awaiting Kelly sign-off
---
## 1. Goal
Harden the browser-facing WebSocket upgrade endpoint (`GET /api/v1/ws/jobs`) against
Cross-Site WebSocket Hijacking (CSWSH) by validating the `Origin` header against a
configured allowlist. The existing single-use, 60-second ticket mechanism is retained
as the first credential factor; the Origin check is the second factor.
## 2. Non-Goals
- Replacing or weakening the ticket mechanism.
- Binding the ticket to a specific WebSocket connection at issuance time (separate,
larger change).
- Origin/CORS checks on the JWT-authenticated ticket-issuance endpoint
(`POST /api/v1/ws/ticket`) — it is already protected by JWT + RBAC middleware and is
not a browser-only entry point.
- Changes to `pm-worker/ws_relay.rs` — that module is an outbound mTLS WS *client* to
agents, not a server, and is not reachable from a browser.
## 3. Design
### 3.1 Handler change (`crates/pm-web/src/routes/ws.rs`)
The `ws_handler` function (lines 96137) gains a new `HeaderMap` extractor and
performs an Origin allowlist check **before** ticket validation.
Order of operations in the new handler:
1. Extract `HeaderMap` (new).
2. Read `Origin` header. If absent → reject `403 forbidden_origin` (`"Origin header
required"`).
3. Normalize and compare against the configured allowlist. If not matched → reject
`403 forbidden_origin` (`"Origin not allowed"`).
4. Existing ticket validation (lines 101127) runs unchanged.
5. Existing upgrade (line 136) runs unchanged.
Rationale for Origin-first ordering: a CSWSH probe from a malicious page will not
have a valid ticket. If we validated the ticket first and then checked Origin, an
attacker who has obtained a leaked ticket (from logs, `Referer`, history, support
bundle) could mount a denial-of-service against the legitimate user by burning their
tickets with `403` responses. Origin-first means rejected cross-origin attempts do
not consume the user's ticket.
### 3.2 Origin parsing and comparison
- Read the raw `Origin` header value as a string.
- Strip any trailing `/`.
- Use a hand-rolled parser (no new dependency) to extract `scheme`, `host`, and
optional `port`:
- `scheme` must be `http` or `https` (lowercased).
- `host` must be non-empty and contain no whitespace.
- `port`, if present, must parse as `u16`.
- Apply default-port normalization: `https` implies port `443`; `http` implies port
`80`. If the explicit port equals the default for the scheme, drop it from the
comparison. This matches browser behavior (browsers do not include default ports
in `Origin`).
- Case-insensitive host comparison; case-sensitive scheme and port.
- Match each configured allowlist entry against the parsed Origin. **Exact match
only** — no subdomain wildcards, no regex, no path.
- If the `Origin` header is malformed (does not parse) → reject
`403 forbidden_origin` (`"Malformed Origin header"`).
### 3.3 Config schema (`crates/pm-core/src/config.rs`)
Add a new field to `SecurityConfig`:
```rust
/// Allowlist of browser `Origin` values permitted to open the
/// `/api/v1/ws/jobs` WebSocket. Entries are exact `scheme://host[:port]`
/// strings. If empty, the server derives the default from `sso_callback_url`.
#[serde(default = "default_allowed_origins")]
pub allowed_origins: Vec<String>,
```
Default value (`default_allowed_origins`): a single-element vector containing the
`scheme://host[:port]` form of `sso_callback_url` parsed at config-load time. This
keeps the default config secure out of the box — a fresh install will only accept
WS upgrades from the same origin the SSO callback redirects to.
If `sso_callback_url` cannot be parsed (e.g., a custom dev value), fall back to
`vec![]` and emit a `tracing::warn!` at startup. An empty allowlist with no
parseable default would make the WS endpoint reject all upgrades; this is the safe
direction (fail-closed), but the warning makes it loud.
Add a corresponding `ConfigError` if `allowed_origins` is manually set to an empty
vector and `sso_callback_url` is also unparseable, since that combination produces a
non-functional WS endpoint. The user must explicitly opt in to "no origins allowed"
by providing a list.
### 3.4 Logging
- On allowed upgrade: existing `tracing::info!` continues (lines 129133), augmented
with `origin = %origin_str` for audit context.
- On rejected upgrade: new `tracing::warn!` with `origin = %origin_str` and
`reason = %reason`. **Never log the ticket value**.
### 3.5 Response shape
Reuse the existing `err` helper (lines 4858):
```json
{ "error": { "code": "forbidden_origin", "message": "…" } }
```
Status: `403 Forbidden` for all Origin rejections. Do not differentiate between
"missing", "malformed", and "not in allowlist" in the response — they all map to the
same code, and the specific reason is logged server-side only.
## 4. Acceptance Criteria
- [ ] A WS upgrade request with a missing `Origin` header is rejected with `403`
and code `forbidden_origin`. The ticket is **not** consumed.
- [ ] A WS upgrade request with a malformed `Origin` header is rejected with `403`
and code `forbidden_origin`. The ticket is **not** consumed.
- [ ] A WS upgrade request with an `Origin` not in the allowlist is rejected with
`403` and code `forbidden_origin`. The ticket is **not** consumed.
- [ ] A WS upgrade request with an allowlisted `Origin` and a valid, unconsumed
ticket succeeds (101 Switching Protocols / upgrade accepted) and the ticket is
consumed atomically (existing behavior preserved).
- [ ] A WS upgrade request with an allowlisted `Origin` and an invalid/expired/
already-used ticket is rejected with the existing `401 invalid_ticket` /
`401 ticket_expired` (existing behavior preserved).
- [ ] The allowlist is configurable via `security.allowed_origins` in
`config/config.example.toml` and is documented in that file.
- [ ] When `allowed_origins` is not set, the default is derived from
`sso_callback_url` and the service starts cleanly.
- [ ] `cargo check` and `cargo clippy --all-targets` pass.
- [ ] `cargo test -p pm-web` passes with the new integration tests added.
- [ ] `docs/security-review.md` documents the new control with an evidence row
pointing to `crates/pm-web/src/routes/ws.rs`.
## 5. Test Plan
Unit tests in `crates/pm-web/src/routes/ws.rs` (cfg(test) module) — 33 tests
covering the security-critical logic. The end-to-end wiring (HeaderMap
extractor, axum handler, response shape) is verified by `cargo check`,
`cargo clippy --all-targets`, and the manual `scripts/integration-test.sh`
end-to-end check.
**Why unit tests instead of `tests/ws_origin.rs` integration tests:** the
handler is wired to `State<AppState>`, and `AppState` contains a
`sqlx::PgPool` and a `pm_ca::CertAuthority` that cannot be cheaply
constructed in a test (the CA requires a real Postgres connection and
on-disk key material, both unavailable in `cargo test`). Refactoring
`ws_handler` to take a smaller state struct is out of scope for this
hardening change.
### 5.1 pm-core unit tests (`crates/pm-core/src/config.rs`, 7 tests)
`derive_allowed_origins` — the sso_callback_url → allowlist derivation:
1. `derive_strips_default_https_port` — `https://x:443/...` → `https://x`
2. `derive_keeps_non_default_port` — `https://x:8443/...` → `https://x:8443`
3. `derive_strips_default_http_port` — `http://x:80/x` → `http://x`
4. `derive_handles_trailing_slash` — `https://x/` → `https://x`
5. `derive_handles_no_path` — `https://x` → `https://x`
6. `derive_returns_empty_for_garbage` — rejects "not a url", "", "ftp://x"
7. `derive_lowercases_scheme` — `HTTPS://...` → `https://...`
### 5.2 pm-web unit tests (`crates/pm-web/src/routes/ws.rs`, 33 tests)
`parse_origin_header` (14 tests): basic, with-port, scheme/host lowercasing,
path/query/fragment ignored, trailing-slash stripped, empty/whitespace
rejected, unsupported schemes rejected, empty host rejected, host with
whitespace rejected, malformed port rejected, IPv6 literal rejected, no
scheme separator rejected.
`Origin::canonical` (3 tests): default-port normalization in both directions
(http:80, https:443), non-default port kept.
`is_origin_allowed` (11 tests): exact match, default-port normalization in
both directions, case-insensitive host, different host rejected, different
scheme rejected, different port rejected, empty allowlist rejected,
unparseable allowlist entry rejected, multi-entry allowlist.
`check_origin` (7 tests): missing header, malformed header, disallowed
origin, empty allowlist, allowed origin, default-port normalization allowed,
case-insensitive host allowed. This function returns the `(StatusCode, Json)`
error tuple used by the handler, so these tests cover the response shape.
## 6. Risk Analysis
- **Risk: a config that breaks the WS endpoint in production.** Mitigation: default
is derived from `sso_callback_url` (already required for SSO to function), and
startup logs a warning if the allowlist ends up empty.
- **Risk: legitimate cross-origin frontend (e.g., SPA on `app.foo.com` talking to
API on `api.foo.com`).** Out of scope for this fix — the WS endpoint is on the
same origin as the SPA. If a future deployment splits them, the operator adds
the API origin to `allowed_origins`. Documented in `config/config.example.toml`.
- **Risk: a non-browser caller (CLI, integration test) cannot connect.** Such
callers should use the REST API for state mutations; the WS is documented as the
browser job-stream channel. If a non-browser WS client is needed, document it in
`docs/REST_API.md` and tell the operator to add its origin.
- **Risk: false sense of security.** The Origin check is defense-in-depth, not a
replacement for the ticket. Documented as such in the security review update.
## 7. Files Touched
- `crates/pm-web/src/routes/ws.rs` — handler change.
- `crates/pm-core/src/config.rs` — new field + default.
- `config/config.example.toml` — new field documented.
- `crates/pm-web/tests/ws_origin.rs` — new integration tests (and harness).
- `docs/security-review.md` — control documentation row.
- `tasks/todo.md` — plan section (added by the orchestrator).
## 8. Out of Scope (Future Work)
- Bind ticket issuance to the WS upgrade in a single round-trip (eliminates
query-string ticket leakage from logs/history).
- Per-message MAC on WS frames (defense against in-process tampering).
- Rate limiting on the WS endpoint itself.