From 64e9f9d413d440778ea9d8bf7c50917c10e93cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Pe=C3=B1a?= Date: Tue, 21 Jul 2026 02:35:11 -0400 Subject: [PATCH 1/2] docs: tweak parser pitch wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee0e717..2329815 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Finitum is an open-source personal finance tracker. Instead of scraping your bank or asking for your credentials, it uses the emails your bank already sends you: you set up a one-time forwarding rule, and every purchase, withdrawal, and transfer notification is parsed into a transaction (amount, merchant, date, category) and shown on your dashboard. - **No inbox access.** Finitum never reads your email account. You forward only the bank notifications you choose; Google sign-in is optional and used for login only. -- **Community bank parsers.** Every bank's email format is described in a single JSON file -- no engine code needed. Adding your bank is a JSON block plus a couple of test fixtures. See [Adding a bank](docs/adding-a-bank.md). +- **Quick-implementation bank parsers.** Every bank's email format is described in a single JSON file -- no engine code needed. Adding your bank is a JSON block plus a couple of test fixtures. See [Adding a bank](docs/adding-a-bank.md). - **Self-hostable.** One `docker compose up` runs the whole stack. A hosted instance lives at [finitum.app](https://finitum.app). - **Privacy first.** Raw email content is processed in real time and never stored -- only the extracted transaction data. From f51fbbbb008e79efd6f017acda7a672e5b39eb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Pe=C3=B1a?= Date: Tue, 21 Jul 2026 02:35:11 -0400 Subject: [PATCH 2/2] ci: harden deploy for dedicated app/deploy users - Deploy step runs a single root-owned finitum-deploy script on the server via a deploy user restricted by sudoers to exactly that command; no paths, users, or registry logins in the workflow. - docker-compose.prod.yml consumes the immutable sha tag via ${IMAGE_TAG:-latest} instead of always deploying mutable latest. - Document the security model in docs/deployment.md. --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++------------ docker-compose.prod.yml | 8 ++++---- docs/deployment.md | 25 ++++++++++++++----------- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 551b88a..314402d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,17 +6,18 @@ on: - main pull_request: -# A newer push to main supersedes an in-flight build/deploy. -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read env: IMAGE: ghcr.io/richardhapb/finitum-api +# Lint/build for the same ref supersede each other. Deploy is handled by a +# SEPARATE, non-cancelable group below so a push never kills a deploy mid-`up -d`. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: lint-test: runs-on: ubuntu-latest @@ -47,6 +48,9 @@ jobs: permissions: contents: read packages: write + outputs: + # Immutable tag consumed by deploy. Single source of truth for what shipped. + sha_tag: sha-${{ github.sha }} steps: - name: Checkout repository uses: actions/checkout@v5 @@ -68,7 +72,7 @@ jobs: images: ${{ env.IMAGE }} tags: | type=raw,value=latest - type=sha,prefix=sha-,format=short + type=sha,prefix=sha-,format=long - name: Build and push uses: docker/build-push-action@v6 @@ -84,17 +88,26 @@ jobs: needs: build-push if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + # Deploys serialize but NEVER cancel each other — half a `docker compose up` + # is worse than a queued deploy. + concurrency: + group: deploy-production + cancel-in-progress: false steps: - - name: Deploy to EC2 + - name: Deploy to VPS uses: appleboy/ssh-action@v1 + env: + IMAGE_TAG: ${{ needs.build-push.outputs.sha_tag }} with: host: ${{ secrets.HOST_ADDRESS }} username: ${{ secrets.HOST_USER }} key: ${{ secrets.HOST_SSH_KEY }} + # appleboy/ssh-action does not forward the runner env by default; + # pass what the remote script needs explicitly. + envs: IMAGE_TAG + # All deploy details (paths, users, compose invocation) live in a + # root-owned script on the server; the SSH user's sudoers is limited + # to exactly that script. Nothing sensitive appears in this file. script: | set -euo pipefail - cd ~/finitum - git pull --ff-only - docker compose -f docker-compose.yml -f docker-compose.prod.yml pull - docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d - docker image prune -f + sudo /usr/local/sbin/finitum-deploy "$IMAGE_TAG" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f1ff736..dfb6413 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,7 +1,7 @@ # Production overrides - use with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up services: api: - image: ghcr.io/richardhapb/finitum-api:latest + image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest} command: granian --interface asgi --workers ${API_WORKERS:-2} --host 0.0.0.0 --port 9090 api.server:app --access-log ports: ["127.0.0.1:9090:9090"] env_file: .env.prod @@ -12,17 +12,17 @@ services: volumes: !reset [] worker: - image: ghcr.io/richardhapb/finitum-api:latest + image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest} command: celery -A tasks.app:celery worker --loglevel=INFO --concurrency=${CELERY_CONCURRENCY:-2} env_file: .env.prod volumes: !reset [] beat: - image: ghcr.io/richardhapb/finitum-api:latest + image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest} env_file: .env.prod volumes: !reset [] alembic: env_file: .env.prod - image: ghcr.io/richardhapb/finitum-api:latest + image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest} volumes: !reset [] diff --git a/docs/deployment.md b/docs/deployment.md index 81bcee1..9bb1838 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -7,24 +7,27 @@ Operator notes for the CI/CD pipeline and the reference deployment ([finitum.app Every push to `main` runs [.github/workflows/ci.yml](../.github/workflows/ci.yml): 1. **lint-test** -- `ruff check` + `pytest` (also runs on pull requests, as a gate). -2. **build-push** -- builds a locked image (`uv.lock` + `uv sync --frozen`) and pushes `ghcr.io/richardhapb/finitum-api` tagged `latest` and `sha-`. -3. **deploy** -- SSHes into the VPS and runs: +2. **build-push** -- builds a locked image (`uv.lock` + `uv sync --frozen`) and pushes `ghcr.io/richardhapb/finitum-api` tagged `latest` and `sha-`. The immutable sha tag is what deploys. +3. **deploy** -- SSHes into the VPS as a low-privilege deploy user and runs a single command: ```bash - cd ~/finitum - git pull --ff-only - docker compose -f docker-compose.yml -f docker-compose.prod.yml pull - docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d - docker image prune -f + sudo /usr/local/sbin/finitum-deploy "$IMAGE_TAG" ``` -The image is self-contained (code + deps + alembic migrations baked in); `git pull` on the server only refreshes the compose/env files. Prod uses `volumes: !reset []` so no host source is mounted -- the server runs exactly what CI built. +All environment-specific detail (paths, users, the compose invocation) lives in the root-owned `finitum-deploy` script on the server -- the public workflow file contains no infrastructure information. The script validates the image tag, does `git pull --ff-only` as the app user (only compose/config files come from git; app code lives in the image), then `docker compose pull` + `up -d` with `IMAGE_TAG` exported, and prunes only this repo's images. Prod uses `volumes: !reset []` so no host source is mounted -- the server runs exactly what CI built. + +## Security model + +- The application files are owned by a dedicated **app user** with `nologin` -- nobody can SSH in as it, and its `.env` files are mode 0600. +- CI authenticates as a separate **deploy user** whose sudoers entry permits exactly one command: the `finitum-deploy` script. A leaked deploy key can trigger a deploy and nothing else. Its SSH key is restricted (`no-port-forwarding,no-agent-forwarding,no-X11-forwarding`). +- The deploy script whitelists tags (`latest` or `sha-`), so arguments can't be used to smuggle commands across the sudo boundary. +- The server repo's `origin` uses HTTPS (anonymous read-only -- the repo is public), so no GitHub credentials exist on the box. ## One-time setup -- **Make the package public**: GitHub → Packages → `finitum-api` → Package settings → Change visibility → **Public**. The server then pulls with no auth. -- **Add repository secrets** (Settings → Secrets and variables → Actions): `HOST_ADDRESS`, `HOST_USER`, and `HOST_SSH_KEY` (a private key whose public half is in the server's `~/.ssh/authorized_keys`). -- **On the server**: clone the repo to `~/finitum` on `main`, populate `.env.prod` (including `CREDENTIALS_ENCRYPTION_KEY` -- back it up, losing it makes stored Google tokens unrecoverable), and confirm `docker compose version` ≥ 2.24 (required for `!reset`). +- **Repository secrets** (Settings → Secrets and variables → Actions): `HOST_ADDRESS`, `HOST_USER` (the deploy user), and `HOST_SSH_KEY` (its private key). +- **On the server** (as root): create the `nologin` app user owning the app directory (clone the repo there on `main` over HTTPS, populate `.env.prod`; back up `CREDENTIALS_ENCRYPTION_KEY` -- losing it makes stored Google tokens unrecoverable); install `finitum-deploy` to `/usr/local/sbin` (root-owned, mode 0700) with the app path/user set inside; create the deploy user with the restricted authorized_keys entry and a sudoers file allowing only that script (`visudo -cf` it). Confirm `docker compose version` ≥ 2.24 (required for `!reset`). +- **Smoke test** without deploying: `sudo /usr/local/sbin/finitum-deploy latest --check` (validates git access and compose config, changes nothing). ## Email worker