Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
48a00a8
security: key rate limiter on proxy-set X-Real-IP
goshatch Jul 25, 2026
8572c34
security: need password to change email/password
goshatch Jul 25, 2026
b077266
security: scrub audit snapshots on erasure
goshatch Jul 25, 2026
dafa82a
security: parameterize interval SQL, ban raw
goshatch Jul 25, 2026
f1b74d2
security: bound batch, body and text sizes
goshatch Jul 25, 2026
5d4f521
security: per-user rate limit on write routes
goshatch Jul 25, 2026
6099dd3
security: drop CREATEROLE, guard cleartext DB
goshatch Jul 25, 2026
b9b4f93
security: pin CI actions, replace dead cache
goshatch Jul 25, 2026
b48d9c9
security: nREPL on 0600 unix socket in prod
goshatch Jul 25, 2026
058b90a
test: assert PDF as-of content, not byte size
goshatch Jul 25, 2026
067ccaf
security: isolate backup creds in own user
goshatch Jul 25, 2026
21126f6
security: erasure purge assumes deletion_role
goshatch Jul 25, 2026
28e42f4
security: DB session store, log out everywhere
goshatch Jul 25, 2026
e412089
security: low-severity hardening sweep
goshatch Jul 25, 2026
495cc0e
security: export projects via allowlist
goshatch Jul 25, 2026
be84787
security: infra hardening sweep
goshatch Jul 26, 2026
697f514
security: app hardening sweep round 2
goshatch Jul 26, 2026
82db7a9
security: CSP on public pages, no inline JS
goshatch Jul 26, 2026
2a29d44
security: existing-box catch-up on re-provision
goshatch Jul 26, 2026
212ae06
security: per-instance nREPL socket path
goshatch Jul 26, 2026
a200f58
ci: run migrations without dev deps
goshatch Jul 26, 2026
8a40045
security: deletion_role grant must not inherit
goshatch Jul 26, 2026
929fb2d
docs: INHERIT FALSE note in erasure runbook
goshatch Jul 26, 2026
40568a7
ops: alert on unit failure, fix backup upload
goshatch Jul 26, 2026
5e82b21
docs: verify append-only via policy, not probe
goshatch Jul 26, 2026
363c210
ops: fix unit quoting in failure notifier
goshatch Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/all-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ jobs:
steps:
- uses: actions/checkout@v5

# Third-party actions are pinned to commit SHAs: a moving tag/branch
# can be force-pushed to run arbitrary code in CI.
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22

- name: Setup Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@main
uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
use-flakehub: false
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', 'flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-

- name: Install dependencies
run: nix develop --command make deps
Expand Down
14 changes: 10 additions & 4 deletions .github/workflows/lint-review.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ jobs:
- run: echo "🐧 Job running on ${{ runner.os }} server"
- run: echo "🐙 Using ${{ github.ref }} branch from ${{ github.repository }} repository"

# Git Checkout
# Git Checkout. persist-credentials: false keeps the token out of the
# workspace git config — no later step pushes, and third-party action
# code (SHA-pinned below) must not be able to read it.
- name: Checkout Code
uses: actions/checkout@v5
with:
token: "${{ secrets.PAT || secrets.GITHUB_TOKEN }}"
persist-credentials: false
- run: echo "🐙 ${{ github.repository }} repository was cloned to the runner."

# Third-party actions are pinned to commit SHAs: a moving tag/branch
# can be force-pushed to run arbitrary code in CI.
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22

- name: Setup Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@main
uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
use-flakehub: false
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', 'flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-

- name: Run clj-kondo
run: |
Expand Down
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,16 @@ deploy-dev: upload ## Deploy to the staging instance
sudo ln -nfs releases/$(JAR_BASENAME) current-dev; \
sudo systemctl restart parts-dev'

rollback:
ssh $(HOST) 'set -e; \
rollback: ## Point current back at the previous release
ssh -t $(HOST) 'set -e; \
cd $(REMOTE); \
prev=$$(readlink previous || true); \
if [ -z "$$prev" ]; then \
echo "No previous release to roll back to!" >&2; \
exit 1; \
fi; \
ln -nfs "$$prev" current; \
systemctl restart parts'
sudo ln -nfs "$$prev" current; \
sudo systemctl restart parts'

clean: ## Clean build files
rm -rf ./.cpcache \
Expand Down
26 changes: 16 additions & 10 deletions deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,11 @@
;; Backend async operations (used for token cleanup scheduling)
org.clojure/core.async {:mvn/version "1.7.701"}
;;
;; Production REPL tooling
nrepl/nrepl {:mvn/version "1.3.0"}
cider/cider-nrepl {:mvn/version "0.58.0"
:exclusions [org.clojure/clojuredocs]}
;;
;; Kaocha - for running tests from production REPL
;; https://github.com/lambdaisland/kaocha
lambdaisland/kaocha {:mvn/version "1.91.1392"}}
;; Production REPL: plain nREPL over a unix socket (server/start-nrepl).
;; Editor middleware (cider) and test tooling stay in the :dev/:test
;; aliases — deliberately absent from the production artifact to keep the
;; in-process RCE surface minimal.
nrepl/nrepl {:mvn/version "1.3.0"}}

:aliases
{:run/app
Expand All @@ -81,6 +78,14 @@
{;; Dev-only visualization tool
djblue/portal {:mvn/version "0.62.0"}
;;
;; Editor nREPL middleware (dev-only; prod runs plain nREPL)
cider/cider-nrepl {:mvn/version "0.58.0"
:exclusions [org.clojure/clojuredocs]}
;;
;; Test runner for the dev REPL's `repl` ns helpers (kaocha.repl/watch);
;; the :test aliases carry their own copy
lambdaisland/kaocha {:mvn/version "1.91.1392"}
;;
;; Editor refactoring tools
refactor-nrepl/refactor-nrepl {:mvn/version "3.11.0"}
;;
Expand Down Expand Up @@ -134,6 +139,7 @@
{:deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}}
:main-opts ["-m" "antq.core"]}

;; Run database migrations
;; Run database migrations — production deps only (CI depends on this;
;; the dev `repl` ns requires kaocha, which is a dev/test dependency)
:migrate
{:exec-fn repl/db-migrate}}}
{:exec-fn aps.parts.db/migrate!}}}
121 changes: 112 additions & 9 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,12 @@ journalctl -u parts --since today -o cat | grep -c unhandled-exception

## Raise a test error — verify the pipeline

To confirm errors actually reach you, emit one through the live pipeline. The
production nREPL binds to loopback only (`127.0.0.1:7888`, see
`resources/parts/prod.edn`), so reach it over an SSH tunnel rather than exposing
the port:
To confirm errors actually reach you, emit one through the live pipeline via
the production REPL (see "Production REPL access" below):

```sh
# from your laptop — forward local 7888 to the server's loopback nREPL
ssh -L 7888:localhost:7888 parts
# from your laptop — forward a local TCP port to the server's REPL socket
ssh -L 7888:/run/parts/nrepl.sock parts
# then, in another terminal, connect your nREPL client to localhost:7888
```

Expand Down Expand Up @@ -213,6 +211,13 @@ to Scaleway object storage (`scaleway:parts-prod-backup`):
pg_dump --format=custom | age --recipients-file … | rclone rcat …/parts_prod-<ts>.dump.age
```

The job runs as the dedicated **`parts-backup`** system user, not the app
user: the Scaleway credentials live in `/home/parts-backup/.config/rclone/`
(home `0700`), unreadable by `parts` — an app compromise can't reach the
bucket at all. Its DB access is a read-only postgres role (`pg_read_all_data`,
peer-authenticated). Run any manual rclone command against the bucket as that
user: `sudo -u parts-backup rclone lsf scaleway:parts-prod-backup/`.

The age **private** key never lives on the server — only the public recipient
(`/etc/parts/backup-recipient.age`) does. The private identity stays on your
laptop (`~/.config/parts-backup/identity.txt`), so a server compromise cannot
Expand All @@ -223,9 +228,51 @@ scripts/restore-from-backup.sh ~/Downloads/parts_prod-<ts>.dump.age parts_restor
```

**Append-only by design.** The backup credential on the box has `s3:ListBucket`
+ `s3:PutObject` only — **no Delete**. A compromised server can add backups but
cannot wipe, overwrite, or encrypt them (ransomware / tamper resistance). Keep
it that way: never grant the box's key delete rights.
+ `s3:PutObject` only — **no Delete** (and no `GetObject`). A compromised server
can add backups but cannot wipe, overwrite, read, or encrypt them (ransomware /
tamper resistance). Keep it that way: never grant the box's key delete rights.

**The key cannot read, so the upload must never read.** Because a `HEAD` is
authorized as `GetObject`, any rclone operation that stats an object 403s.
The backup therefore spools the encrypted dump to a temp file and uploads it
with `rclone copyto --s3-no-check-bucket --s3-no-head --no-check-dest` — one
known-size `PutObject`, no reads. Do **not** use `rclone rcat`: streaming with
unknown length becomes a multipart upload whose metadata read-back fails, which
is what made backups report failure nightly from 2026-07-22 (the objects landed
and restored fine; only the exit code lied). Same reason `rclone touch` and a
bare `copyto` fail: both stat first.

**Failure alerting.** `parts-backup.service` (and `parts.service`) carry
`OnFailure=parts-alert@%n.service`, a templated unit that mails the failing
unit's last 40 journal lines via `/usr/local/bin/parts-alert` — which reuses
the app's SMTP settings from `/etc/parts.env`, so alerting is configured in
one place. Test it end to end with a unit that is guaranteed to fail:

```sh
systemd-run --unit=alert-selftest --property=OnFailure=parts-alert@%n.service /bin/false
# an email titled "[parts] unit FAILED on <host>: alert-selftest.service" should arrive
```

If nothing arrives, check `journalctl -u parts-alert@*` — with SMTP unset the
mailer exits 0 with "SMTP not configured", by the same rule as the app.

**Standing check — verify the key really can't delete.** That property lives
in the Scaleway console, not this repo, so re-verify at setup, after any key
rotation, and alongside the retention check. Read the bucket policy:
**Object Storage → parts-prod-backup → Bucket settings → Bucket policy**. The
app principal's statement must list exactly:

```json
"Action": [ "s3:ListBucket", "s3:PutObject" ]
```

No `s3:DeleteObject` (can't destroy), no `s3:GetObject` (can't read back —
this is also why the upload must not stat, see above). The separate
`user_id:` statement with `"Action": "*"` is the owner's own access and is
expected; that principal is you in the console, not the box.

Prefer reading the policy over probing with a write: `rclone deletefile`
needs a stat first, so a denial there proves nothing about delete rights.

**30-day retention (a published promise).** The Privacy Policy and DPA state
that erasure propagates through backups within 30 days. Because the box can't
Expand Down Expand Up @@ -481,6 +528,62 @@ sudo systemctl restart parts && journalctl -u parts -f
Confirm the data is all present (`\dt`, key row counts against the old box), log
in to smoke-test — then, only once verified, retire the old box.

## Erasure least-privilege (`deletion_role`)

Normal operation never hard-DELETEs from the temporal tables (`users`,
`maps`, `map_metadata`, `parts`, `relationships`) — only the erasure purge
does. That invariant is enforced in three layers:

1. **Provisioning** (`bootstrap-prod.sh` / `add-instance.sh`, as the
postgres superuser): creates `deletion_role` (NOLOGIN) and grants the app
role membership **`WITH INHERIT FALSE`** — an inheriting membership hands
the app role every deletion_role privilege passively, silently undoing
the revoke (found live on staging; a re-grant updates the option in
place). Must pre-exist before first boot — the app role holds
`NOCREATEROLE`.
2. **Migration `20260726000000`** (as the app role): grants `deletion_role`
everything the purge touches and `REVOKE DELETE ... FROM CURRENT_USER` on
the temporal tables.
3. **The purge** (`db/erasure.clj`): `SET LOCAL ROLE deletion_role` for the
purge transaction only.

The revoke is a **speed bump, not a wall**: the app role owns the tables and
an owner can re-grant itself DELETE. It still stops every accidental or
injected DELETE in normal query paths (the threat it targets); ownership
separation was considered and deliberately not taken (migration comment has
the full rationale).

**Verify on a running box** (expect *permission denied*, then *DELETE 0*):

```sh
sudo -u postgres psql -d parts_prod -c "SET ROLE parts; DELETE FROM parts WHERE false;"
sudo -u postgres psql -d parts_prod -c "SET ROLE parts; SET ROLE deletion_role; DELETE FROM parts WHERE false;"
```

## Production REPL access

The prod app runs an nREPL on a **unix domain socket**,
`/run/parts/nrepl.sock` (`prod.edn :repl/socket`), permissioned `0600` and
owned by the `parts` user. nREPL has **no authentication** — a connected
client has arbitrary code execution as the app user, including its DB
credentials and environment. The socket gate means "may connect" requires
filesystem access as `parts` (or root), not merely "runs on the box": a
loopback **TCP** REPL would be connectable by *any* local process (the
oauth2-proxy sidecar, a compromised dependency, an SSRF-to-localhost gadget).

Connect from a laptop by forwarding a local port to the socket:

```sh
ssh -L 7888:/run/parts/nrepl.sock parts
# connect your nREPL client to localhost:7888
```

Residual risk, deliberately accepted: code already running *as* `parts` (an
app RCE) can use the socket, but it can already do everything the REPL
offers. `PARTS__REPL__PORT` re-enables a loopback TCP REPL as an explicit
escape hatch — leave it unset. The production artifact ships plain nREPL
only (no cider middleware, no test runner; those are dev aliases).

## Rate limiting & the trusted client IP (`X-Real-IP`)

The per-IP rate limiter (`aps.parts.ratelimit`, on login / register / invite)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
"postcss-nesting": "^13.0.2",
"shadow-cljs": "^2.28.23",
"tailwindcss": "^4.2.4",
"ws": "^7.5.10"
"ws": "^8.18.0"
}
}
18 changes: 16 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions resources/migrations/20260726000000-deletion-role-wiring.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
GRANT DELETE ON parts, relationships, maps, map_metadata, users
TO CURRENT_USER;
--;;
REVOKE SELECT, DELETE ON sessions, session_activations, invitations,
waitlist_signups, policy_acceptances, map_metadata
FROM deletion_role;
--;;
REVOKE SELECT ON parts, relationships FROM deletion_role;
--;;
REVOKE INSERT ON audit_log FROM deletion_role;
--;;
REVOKE USAGE ON SEQUENCE audit_log_id_seq FROM deletion_role;
38 changes: 38 additions & 0 deletions resources/migrations/20260726000000-deletion-role-wiring.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Finish the deletion_role least-privilege wiring (TASK-053).
--
-- The everyday app role loses DELETE on the temporal tables; the erasure
-- purge gains that capability only by SET LOCAL ROLE deletion_role inside
-- its transaction (db/erasure.clj). deletion_role gets every privilege the
-- purge path uses, so assuming the role can't make the purge fail.
--
-- DECISION (owner-vs-connection-role caveat): the app role OWNS these
-- tables, and an owner can re-GRANT itself DELETE — so this REVOKE is a
-- SPEED BUMP, not an airtight wall. It still stops every accidental or
-- injected DELETE running through normal query paths, which is the threat
-- this defends against. Separating table ownership from the connection
-- role would close the gap but is a much larger change (ownership
-- migration, migratus needs DDL as non-owner); deliberately not taken.
--
-- Role management split: CREATE ROLE / role membership need superuser and
-- happen in the provisioning scripts (bootstrap-prod.sh, add-instance.sh —
-- the app role holds NOCREATEROLE). This migration only GRANTs/REVOKEs on
-- tables the connecting role owns, which any owner may do. REVOKE ... FROM
-- CURRENT_USER targets whichever app role runs the migrations on this box
-- (parts on prod, parts_dev on staging, the dev's user locally — where a
-- superuser runs it, the revoke is recorded but superuser bypasses ACLs).

GRANT SELECT, DELETE ON parts, relationships, maps, map_metadata,
sessions, session_activations, invitations,
waitlist_signups, policy_acceptances TO deletion_role;
--;;
-- The audit trigger fires on the purge's own DELETEs and INSERTs rows as
-- the assumed role (which also draws from the id sequence); the scrub and
-- pseudonymization UPDATE it.
GRANT SELECT, INSERT, UPDATE ON audit_log TO deletion_role;
--;;
GRANT USAGE ON SEQUENCE audit_log_id_seq TO deletion_role;
--;;
GRANT SELECT, UPDATE, DELETE ON users TO deletion_role;
--;;
REVOKE DELETE ON parts, relationships, maps, map_metadata, users
FROM CURRENT_USER;
1 change: 1 addition & 0 deletions resources/migrations/20260726000001-auth-sessions.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE auth_sessions;
21 changes: 21 additions & 0 deletions resources/migrations/20260726000001-auth-sessions.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Server-side auth sessions (TASK-023). The browser cookie carries only an
-- opaque random UUID; the session data lives here — so any session can be
-- revoked server-side ("log out everywhere", lost device), and deleting a
-- user cascades their sessions away. Distinct from `sessions`, the clinical
-- timeline entity (ADR-0014).
--
-- `data` is EDN text (exact round-trip of ring's session map, including
-- namespaced keyword keys that JSONB would mangle). `expires_at` is the
-- ABSOLUTE 14-day bound (ADR-0007): writes refresh data, never the deadline.
-- `user_id` is NULL for anonymous sessions (the CSRF token pre-login).
CREATE TABLE auth_sessions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
--;;
CREATE INDEX auth_sessions_user ON auth_sessions (user_id);
--;;
CREATE INDEX auth_sessions_expires ON auth_sessions (expires_at);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE invitations DROP COLUMN expires_at;
Loading