# Halden premium server: installation and administration

*Version 0.8.0 · October 2026 · Halden Risk Management*

One small Python service with a SQLite database runs everything the desktop app needs from a server: sign-in (SSO and password), organisations and users, four-eyes approvals, runs, the audit log, licenses and the optional AI assistant. The fastest production route is the Docker Compose setup in the repository's `deploy/` folder: one command starts the server behind Caddy, which obtains an HTTPS certificate automatically.

All files mentioned here ship with version 0.8.0 in `deploy/`: `Dockerfile`, `compose.yaml`, `Caddyfile`, `.env.example`, `backup.sh`, `halden-admin`, `systemd/` (unit, installer, Caddyfile) and `client/config.json`.

## 1. Architecture

![Deployment architecture: report data stays on the desktop; the server keeps the team record](img/architecture.png)

The app reads and writes report files only on the user's computer; it calls the server over HTTPS for sign-in, approvals, runs and the audit log. For SSO the system browser visits the identity provider and the server's `/v1/auth/sso/*` pages, then hands a one-time code back to the app on `127.0.0.1`. The AI backend is reached only by the server, and only with evidence a user previewed and approved.

## 2. Requirements

| Item | Minimum | Notes |
| --- | --- | --- |
| Server | 1 vCPU, 1 GB RAM, 10 GB disk | Linux VM (Ubuntu 22.04/24.04 or Debian 12). The database stays small: users, events and approvals, no report data. |
| Software | Docker Engine 24+ with Compose, or Python 3.10+ | Python dependencies: openpyxl, lxml, keyring |
| DNS | One name, e.g. `halden.yourbank.eu` | A/AAAA record to the server before the first start |
| Network in | TCP 80 and 443 (UDP 443 optional) | Port 80 is needed for the Let's Encrypt challenge, or use your own certificate |
| Network out | Your identity provider; Let's Encrypt; `api.groq.com` if you use Groq | None needed with a local Ollama model and an internal CA |
| Identity provider | Any OpenID Connect provider | Entra ID, Okta, Google Workspace, Keycloak, ADFS 2019+ |

The desktop app needs HTTPS to reach a remote server; plain HTTP is accepted only on `localhost`.

## 3. Quick start on one machine (pilot)

For a demo or a pilot on one laptop, run the server from the repository with Python. It listens on `127.0.0.1:8765`, which the app accepts over plain HTTP.

```bash
cd engine
python -m venv .venv && . .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt

# organisation + 1-year license + first admin, in one step
python -m premium_server.server --db premium.sqlite3 bootstrap \
    --org-name "Northwind Bank" --admin-email ada@northwind.eu --admin-name "Ada Bakker"
#  -> prints the org id and a temporary password (shown once)

python -m premium_server.server --db premium.sqlite3 serve
```

In the app, open **Connection settings** on the sign-in screen and enter `http://127.0.0.1:8765` (or set `HALDEN_SERVER=http://127.0.0.1:8765` before starting the app). Sign in with the admin e-mail and the temporary password, then choose your own.

To try single sign-on without a real identity provider, start the test provider in a second terminal and connect it:

```bash
python -m premium_server.server dev-idp --port 8790
python -m premium_server.server --db premium.sqlite3 sso-set --org <org id> \
    --issuer http://127.0.0.1:8790 --client-id halden-dev --client-secret dev-secret-change-me \
    --domains northwind.eu --name "Test IdP"
```

The test provider shows a *Pick an account* page with three demo users. It has no passwords; never expose it to a network.

## 4. Production with Docker and Caddy

This is the recommended setup: two containers, the Halden server and Caddy as the HTTPS front door. Data lives in a Docker volume; Caddy renews certificates by itself.

**Step 1. Prepare the machine.** Install Docker Engine with the Compose plugin, point your DNS name at the machine, and open ports 80 and 443. Copy the repository (or just `engine/` and `deploy/`) to the server, for example to `/opt/halden`.

**Step 2. Configure.** In `deploy/`, copy `.env.example` to `.env` and fill it in.

```bash
# deploy/.env
HALDEN_DOMAIN=halden.yourbank.eu
ACME_EMAIL=it-ops@yourbank.eu
HALDEN_OIDC_SECRET=          # client secret of your SSO app (section 7)
RIF_AI_PROVIDER=groq         # or ollama, or leave GROQ_API_KEY empty to switch AI off
GROQ_API_KEY=
```

**Step 3. Start.**

```bash
cd /opt/halden/deploy
docker compose up -d --build
docker compose ps                     # halden should be "healthy"
curl https://halden.yourbank.eu/v1/health
# {"ok": true, "version": "0.9.0"}
```

**Step 4. Create your organisation and first admin.**

```bash
docker compose exec halden halden-admin bootstrap \
    --org-name "Northwind Bank" --admin-email ada@northwind.eu --admin-name "Ada Bakker"
```

The admin signs in with the printed temporary password and must choose a new one. Further users can be added in the app (*Account → Manage users*) or with `halden-admin user-add` (section 6).

### What the files do

| File | Role |
| --- | --- |
| `deploy/Dockerfile` | Python 3.12 slim image with only the server code; runs as an unprivileged user; health check on `/v1/health` |
| `deploy/compose.yaml` | `halden` (read-only container, data in volume `halden-data`), `caddy` (ports 80/443), optional `ollama` profile |
| `deploy/Caddyfile` | HTTPS for `$HALDEN_DOMAIN`, HSTS, 1 MB request limit, proxy to `halden:8765` |
| `deploy/halden-admin` | Wrapper for all admin commands inside the container |
| `deploy/backup.sh` | Consistent online backup, copied out of the container, with retention |

The Caddyfile in full:

```text
{
	email {$ACME_EMAIL}
}

{$HALDEN_DOMAIN} {
	encode zstd gzip
	request_body {
		max_size 1MB
	}
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}
	reverse_proxy halden:8765
}
```

**Internal network only?** If the server must not be reachable from the internet, Let's Encrypt cannot validate it over HTTP. Use your own certificate instead: mount it into the Caddy container and replace the site block's automatic TLS with `tls /certs/halden.crt /certs/halden.key`. Any other reverse proxy (nginx, F5, Azure Application Gateway) works too, as long as it terminates TLS and forwards to port 8765.

**Azure, AWS or GCP.** The same compose file runs on any VM. For a managed container service, run only the `halden` image with a persistent volume mounted at `/data`, set `HALDEN_PUBLIC_URL` to the public HTTPS address, and let the platform's load balancer provide TLS. Run one instance: SQLite is single-node.

## 5. Alternative: a plain Linux VM with systemd

Without Docker, the installer sets up a Python virtual environment and a hardened systemd service on Debian or Ubuntu.

```bash
sudo sh deploy/systemd/install.sh
#  code in /opt/halden/app, venv in /opt/halden/venv, data in /var/lib/halden (mode 700)
sudoedit /etc/halden/premium.env        # at least HALDEN_PUBLIC_URL
sudo systemctl restart halden-premium
sudo halden-admin bootstrap --org-name "Northwind Bank" --admin-email ada@northwind.eu
```

```bash
# /etc/halden/premium.env  (root-owned, chmod 600)
HALDEN_DB=/var/lib/halden/premium.sqlite3
HALDEN_HOST=127.0.0.1
HALDEN_PORT=8765
HALDEN_PUBLIC_URL=https://halden.yourbank.eu
# HALDEN_OIDC_SECRET=
# RIF_AI_PROVIDER=groq
# GROQ_API_KEY=
```

The service runs as user `halden` with `ProtectSystem=strict`, `NoNewPrivileges`, a private `/tmp` and write access only to `/var/lib/halden`. The server binds to 127.0.0.1, so put a TLS proxy in front: install Caddy from its package repository and use `deploy/systemd/Caddyfile` (same as above, proxying to `127.0.0.1:8765`), or configure nginx with `proxy_pass http://127.0.0.1:8765;`.

Logs: `journalctl -u halden-premium -f`. The server never logs passwords, keys, tokens or request bodies.

## 6. Organisations, licenses and users

Each bank is an organisation. A license linked to the organisation gives all its users the premium features; users have one or more roles.

```bash
# Docker: prefix with  docker compose exec halden   ·  VM: prefix with  sudo
halden-admin org-create --name "Northwind Bank"                 # add --no-approval to recommend, not require, approval
halden-admin issue --customer "Northwind Bank" --days 365 --org org_…
halden-admin user-add --org org_… --email mia@northwind.eu --name "Mia Jansen" --roles maker
halden-admin user-add --org org_… --email carl@northwind.eu --name "Carl de Vries" --roles checker
halden-admin user-list --org org_…
```

| Command | Purpose |
| --- | --- |
| `bootstrap` | Organisation, license and first admin in one step |
| `org-create`, `org-list` | Create and list organisations |
| `issue --customer … --days … --org …` | New license (1 to 3,650 days) linked to an organisation; prints the key once |
| `list`, `revoke --digest …` | List licenses (digests only), revoke one immediately |
| `user-add`, `user-list` | Add a user (temporary password shown once) and list users |
| `user-roles --email … --roles …` | Change roles: admin, maker, checker, viewer |
| `user-deactivate`, `user-activate` | Block or restore sign-in; deactivation ends sessions at once |
| `user-reset --email …` | New temporary password; ends all sessions |
| `audit-verify --org …` | Recompute the audit hash chain |
| `org-settings --org … [--session-hours N] [--idle-minutes N] [--approval on/off]` | Session policy and the four-eyes export control (also in the app) |
| `access-review --org … [--csv review.csv]` | Users, roles, last sign-in and flags for a periodic access review |
| `audit-export --org … --out … [--format jsonl/csv] [--state file]` | Audit events with their hash chain; `--state` appends only new events (SIEM) |
| `org-export --org … --out export.json` | All data of the organisation as one JSON file (exit, data request) |
| `org-delete --org … --confirm "<name>"` | Delete the organisation and all its data; prints a deletion receipt |

Most of this is also available to administrators in the app under *Account → Organisation settings*: users, SSO, SCIM tokens, the session policy, the access review and exports. With SSO, users can also be created automatically on first sign-in, or provisioned by your identity provider with SCIM (section 7). Admins keep a password as a break-glass route even when password sign-in is switched off for others.

## 7. Single sign-on

SSO needs one *web application* registered at your identity provider, with one redirect URI, and one `sso-set` command on the server. The server acts as the OpenID Connect client; the desktop app never sees the client secret.

**The redirect URI** is always your public URL plus `/v1/auth/sso/callback`, for example `https://halden.yourbank.eu/v1/auth/sso/callback`.

### Microsoft Entra ID

1. Entra admin center → *App registrations* → *New registration*. Name: *Halden EBA Filler*; account types: *this organizational directory only*; redirect URI: platform **Web**, the URI above.
2. Note the **Application (client) ID** and **Directory (tenant) ID**.
3. *Certificates & secrets* → *New client secret*. Copy the **Value** into `HALDEN_OIDC_SECRET` in `.env` (and restart: `docker compose up -d`). Note its expiry date.
4. *Token configuration* → *Add optional claim* → ID token → **email** (otherwise the user principal name is used).
5. Optional: *Enterprise applications* → the app → *Properties* → *Assignment required* = Yes, then assign the users or groups who may sign in.

Issuer: `https://login.microsoftonline.com/<tenant-id>/v2.0`

### Okta

1. Admin console → *Applications* → *Create App Integration* → **OIDC**, **Web Application**.
2. Grant type: *Authorization Code*. Sign-in redirect URI: the URI above. Assign the users or groups.
3. Copy the **Client ID** and **Client secret**.

Issuer: `https://<your-org>.okta.com` (or a custom authorization server, e.g. `https://<your-org>.okta.com/oauth2/default`)

### Google Workspace

1. Google Cloud console → *APIs & Services* → *OAuth consent screen*: user type **Internal**.
2. *Credentials* → *Create credentials* → *OAuth client ID* → **Web application**; authorized redirect URI: the URI above.
3. Copy the **Client ID** and **Client secret**.

Issuer: `https://accounts.google.com`

Keycloak and other OIDC providers work the same way; the issuer is the URL whose `/.well-known/openid-configuration` returns the provider's metadata (Keycloak: `https://<host>/realms/<realm>`).

### Connect it on the server

```bash
docker compose exec halden halden-admin sso-set --org org_… \
    --issuer https://login.microsoftonline.com/<tenant-id>/v2.0 \
    --client-id <application-id> --client-secret env:HALDEN_OIDC_SECRET \
    --domains northwind.eu --name "Microsoft"
#  prints the configuration and the redirect URI to register
```

| Option | Effect |
| --- | --- |
| `--domains` | E-mail domains that use this SSO (comma-separated). A domain belongs to one organisation. |
| `--name` | Shown to users: “We opened Microsoft in your browser” |
| `--client-secret env:NAME` | Read the secret from the environment at each sign-in (recommended); a literal value is stored in the database instead |
| `--jit-roles viewer` | Create unknown users on their first SSO sign-in with these roles. Without it, users must be added first. |
| `--no-password` | Switch off password sign-in for everyone except admins |
| `--role-map "Halden-Makers=maker;Halden-Checkers=checker"` | Roles from identity-provider groups (names or IDs), synchronised at every sign-in; users in none of the groups are refused |
| `--groups-claim roles` | ID-token claim that holds the groups (default `groups`; Entra app roles use `roles`) |
| `--scopes groups` | Extra scopes to request (Okta needs `groups` for the groups claim) |

`sso-show --org …` prints the configuration without the secret; `sso-test --org …` checks the live provider (discovery, PKCE, signature algorithm, signing keys, secret); `sso-remove --org …` removes it. Changes are recorded in the audit log. Administrators can do all of this in the app as well (*Organisation settings → Single sign-on*), including the live check.

**ID-token signatures** are verified against the provider's signing keys (`jwks_uri`, RS256/384/512 or ES256/384) or with the client secret (HS256). Key rotation at the provider is picked up automatically. The server needs outbound HTTPS to the provider for discovery, keys and the code exchange.

### Roles from groups

- **Entra ID:** *Token configuration → Add groups claim* → *Security groups* (or *Groups assigned to the application*, recommended for large tenants), ID token, *Group ID*. Map the group object IDs: `--role-map "7c1d…=checker;1a2b…=maker"`. Or define *App roles* in the app registration and use `--groups-claim roles` with the role values.
- **Okta:** *Sign On → OpenID Connect ID Token → Groups claim*: name `groups`, filter e.g. *Starts with* `Halden-`; then `--scopes groups --role-map "Halden-Makers=maker;Halden-Checkers=checker"`.
- **Google Workspace** does not put groups in ID tokens; manage roles in Halden or use SCIM.

The last active administrator always keeps the admin role, so a mapping mistake cannot lock the organisation out.

### Provisioning with SCIM 2.0

SCIM lets the identity provider create, rename, deactivate and reactivate Halden accounts. Create a token (shown once) in the app (*Organisation settings → Provisioning*) or with:

```bash
docker compose exec halden halden-admin scim-token --org org_… --label "Entra ID"
#  SCIM base URL: https://halden.yourbank.eu/scim/v2
#  Secret token (shown once): hsc_…
```

- **Entra ID:** *Enterprise applications* → the app → *Provisioning* → *Automatic*; Tenant URL = the base URL, Secret token = the token; map `userPrincipalName` (or `mail`) to `userName`; assign users or groups; start provisioning.
- **Okta:** app → *General* → enable SCIM provisioning; base URL as above, unique identifier `userName`, authentication *HTTP Header* (Bearer); enable *Create*, *Update* and *Deactivate Users*.

Supported: Users with filter on `userName`, `externalId` or e-mail, create, get, replace, patch, delete. A delete deactivates the account (sessions end at once) so the audit trail stays readable. Groups are not provisioned; roles come from the `roles` attribute (values admin, maker, checker, viewer), the `--jit-roles` default (otherwise viewer), or the group mapping at sign-in. `scim-list` and `scim-revoke --id …` manage tokens. Allow the provider's outbound IP ranges to reach `/scim/v2` if you restrict inbound traffic.

**Test it.** In the app, type an e-mail on one of the domains and choose *Continue*: the browser should open your provider. Common errors: *redirect URI mismatch* (the URI at the provider must match exactly, including https and the path), *no e-mail shared* (add the email claim), *no Halden account* (add the user, or use `--jit-roles`). Rotate the client secret before it expires: update `.env` and restart; nothing else changes.

## 8. AI provider (optional)

The AI mapping assistant runs on the server, so no AI key ever reaches a desktop. Leave it unconfigured and the rest of the product works unchanged.

| Provider | Settings in `.env` | Data leaves your network |
| --- | --- | --- |
| Groq cloud | `RIF_AI_PROVIDER=groq`, `GROQ_API_KEY=gsk_…` | Yes: the evidence the user previewed and approved, to `api.groq.com` |
| Local Ollama | `RIF_AI_PROVIDER=ollama`, `RIF_AI_MODEL=llama3.1:8b`, `RIF_OLLAMA_URL=http://ollama:11434` | No |

For Ollama in Docker: `docker compose --profile ollama up -d`, then `docker compose exec ollama ollama pull llama3.1:8b`. A GPU is recommended; on CPU a draft can take a minute.

Guard rails on the server: the license is checked on every request, at most 2 generations run at once, each license or organisation gets 10 per hour, prompt and output sizes are capped, and every draft request is written to the audit log. Restart the `halden` container after changing `.env`.

## 9. Rolling out the desktop app

Users should never have to type a server address. Distribute one small JSON file with the app, and it connects to your server automatically.

```json
{
  "server": "https://halden.yourbank.eu",
  "lock_server": true
}
```

| Platform | Location of the file |
| --- | --- |
| Windows | `%ProgramData%\Halden\EBA Filler\config.json` |
| macOS | `/Library/Application Support/Halden/EBA Filler/config.json` |
| Linux | `/etc/halden/eba-filler.json` |
| Any | `halden.json` next to the app executable |

With `"lock_server": true` users cannot change the address and the *Connection settings* link disappears. Without it, the file sets the default and users can still change it. The environment variable `HALDEN_SERVER` overrides both (useful for testing).

![Connection settings, shown only when the address is not locked](img/connection-settings.png)

**Windows with Intune.** Package the installer as a Win32 app (or use the `.exe` directly with `/S` for a silent per-user install), and deploy `config.json` with a PowerShell script or a configuration profile that writes the file to `%ProgramData%`. **macOS with an MDM**: deploy the `.dmg`'s app and a script that writes the file to `/Library/Application Support/Halden/EBA Filler/`.

Sessions are stored in the operating system's credential vault (Windows Credential Manager, macOS Keychain), never in project files.

## 10. Backups, restore and updates

Everything the server knows is in one SQLite file. Back it up nightly with the online backup command, which is consistent while the server runs.

```bash
# Docker: nightly at 02:15, keeps 30 days in /var/backups/halden
15 2 * * *  cd /opt/halden/deploy && ./backup.sh >> /var/log/halden-backup.log 2>&1

# VM
sudo halden-admin backup /var/lib/halden/backups/premium-$(date +%F).sqlite3
```

Copy the backups off the machine (object storage or your backup system); they contain user names, e-mails and the audit log, but no report data.

**Restore.**

```bash
B=premium-20261004-021500.sqlite3
docker compose stop halden
# backups of the last 7 days are also inside the volume; from off-machine, copy one in first:
docker compose cp /var/backups/halden/$B halden:/data/backups/$B
docker compose run --rm --no-deps --user root halden \
    sh -c "cp /data/backups/$B /data/premium.sqlite3 && chown halden /data/premium.sqlite3 && chmod 600 /data/premium.sqlite3"
docker compose start halden
docker compose exec halden halden-admin audit-verify --org org_…   # chain should be ok
```

**Update to a new version.**

```bash
./backup.sh                       # always first
git pull                          # or unpack the new release over /opt/halden
docker compose up -d --build
curl https://halden.yourbank.eu/v1/health
```

The database schema upgrades itself on start (new tables and columns are added; nothing is removed). Desktop apps and server should run the same minor version; a newer server keeps accepting older apps within the same major version.

## 11. Audit export, access review and exit

**SIEM.** Schedule an incremental export; each run appends only new events (with their hash chain) to a file your log forwarder picks up:

```bash
# /etc/cron.d/halden-audit  (Docker: docker compose exec -T halden halden-admin …)
*/15 * * * * root halden-admin audit-export --org org_… --format jsonl \
    --out /var/log/halden/audit.jsonl --state /var/lib/halden/audit.seq
```

**Access review.** `access-review --org … --csv review.csv` lists every account with roles, source, last sign-in and flags (dormant 90+ days, never signed in, maker and checker). Administrators record the review in the app (*Organisation settings → Access review*); the audit event holds a fingerprint of the reviewed list.

**Exit.** `org-export --org … --out export.json` writes all organisation data (users without password hashes, projects, approvals, runs, the audit log, SSO settings without the secret). `org-delete --org … --confirm "<exact organisation name>"` then removes every record, revokes the licenses, compacts the database and prints a deletion receipt. Earlier backups still contain the data until your backup retention removes them.

## 12. Security checklist

Work through this list before the first real users sign in.

- [ ] The server is only reachable over HTTPS; port 8765 is not exposed to the network
- [ ] DNS name and certificate are valid; HSTS is on (default Caddyfile)
- [ ] `.env` and `/etc/halden/premium.env` are readable by root only (chmod 600)
- [ ] The SSO client secret is passed as `env:HALDEN_OIDC_SECRET`, not stored in the database, and its expiry date is in the calendar
- [ ] At least two admins exist, each with a password (break-glass when SSO is down)
- [ ] `--no-password` is set if policy requires SSO-only sign-in for non-admins
- [ ] Nightly backups run and are copied off the machine; one restore has been tested
- [ ] `audit-verify` runs weekly (cron) and alerts when the chain breaks
- [ ] Audit events are shipped to the SIEM (`audit-export --state`, section 11)
- [ ] A session policy is set (session length, inactivity sign-out) and an access review is recorded at least yearly
- [ ] SCIM tokens are labelled per provider; unused tokens are revoked
- [ ] The test identity provider (`dev-idp`) is not running anywhere reachable
- [ ] Groq is only enabled if the bank accepts that user-approved evidence leaves the network; otherwise Ollama or no AI
- [ ] OS and Docker images are patched monthly (`docker compose pull && docker compose up -d --build`)

Built in: PBKDF2 password hashing (240,000 iterations), lockout after 5 failed attempts, SHA-256-only storage of tokens and keys, configurable sessions with inactivity sign-out, ID-token signature verification against the provider JWKS, browser requests to the API refused, no request bodies or secrets in logs, PKCE and loopback-only redirects for SSO, a hash-chained audit log.

## 13. Command reference and troubleshooting

| Command | Purpose |
| --- | --- |
| `serve [--host] [--port] [--public-url]` | Run the server (defaults from `HALDEN_HOST`, `HALDEN_PORT`, `HALDEN_PUBLIC_URL`) |
| `bootstrap --org-name --admin-email [--days] [--no-approval]` | First organisation, license and admin |
| `sso-set`, `sso-show`, `sso-remove` | Single sign-on per organisation |
| `backup <file>` | Consistent online copy of the database |
| `dev-idp [--port]` | Test identity provider, development only |
| `configure-groq` | Store a Groq key in the OS vault (desktop servers only; containers use `GROQ_API_KEY`) |
| Organisation, license and user commands | See section 6 |

All commands accept `--db <file>` (default `$HALDEN_DB`). Health endpoint: `GET /v1/health` returns `{"ok": true, "version": "0.9.0"}`.

| Symptom | Likely cause and fix |
| --- | --- |
| `docker compose up` stops with “set HALDEN\_DOMAIN” | `.env` missing or incomplete; copy `.env.example` |
| Caddy cannot get a certificate | DNS not pointing at the server, or port 80 closed; check `docker compose logs caddy` |
| App: “Can't reach your organisation's Halden server” | Wrong address in `config.json`, proxy or firewall in between, or the server is down (`/v1/health`) |
| App: “Remote premium servers require HTTPS” | The configured address starts with `http://` and is not localhost |
| SSO page: “Single sign-on is not set up for this e-mail domain” | Domain missing in `--domains` |
| SSO: “The identity provider refused the request” | Wrong client secret or redirect URI; secret expired |
| SSO: “client secret missing” | `env:HALDEN_OIDC_SECRET` used but the variable is empty; set it and restart |
| “Your organisation has no active premium license” | License expired or revoked; `issue` a new one linked with `--org` |
| User locked out | 5 failed attempts: wait 15 minutes or `user-reset` |
| AI: “the administrator must configure the AI provider” | Set `GROQ_API_KEY` or the Ollama variables and restart |
