This commit is contained in:
Jonas Bengtsson
2026-08-30 11:30:09 +02:00
parent 47714fa7e3
commit c139b30f6f
28 changed files with 2100 additions and 34 deletions
+17
View File
@@ -38,3 +38,20 @@ LOKI_TIMEOUT_SECONDS=5
API_PORT=8443
DB_POOL_MIN_SIZE=2
DB_POOL_MAX_SIZE=10
# --- Web UI ---
WEBUI_PORT=8080
# Initial admin, created only when the users table is empty on first
# start. Leave ADMIN_PASSWORD empty to get the default admin/admin (the
# UI then forces a password change on first login).
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
# --- Web UI (access control) ---
# Port for the web UI (bound to 127.0.0.1).
WEBUI_PORT=8080
# Initial admin, created only when the users table is empty.
# Leave ADMIN_PASSWORD empty to use the default admin/admin (the UI then
# forces a password change on first login).
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
+118 -17
View File
@@ -19,9 +19,41 @@ flowchart LR
| ------------ | -------------------------------------------------------------- |
| `timescaledb`| Time-series database storing telemetry as hypertables. |
| `mqtt-client`| Subscribes to MQTTS topics, writes payloads to the database. |
| `api` | FastAPI service serving telemetry over HTTPS with mTLS. |
| `api` | FastAPI service serving telemetry over HTTPS (mTLS or session).|
| `webui` | Web UI for users, groups, and topic access rules. |
| `loki` | Optional log aggregation (enabled via the `telemetry` profile).|
## Running
Start the core stack:
```bash
docker compose up -d
```
Start with Loki enabled:
```bash
docker compose --profile telemetry up -d
```
Or use the helper scripts (`scripts/`), which load `.env`, check the
certificate files, and print the service URLs:
```bash
# PowerShell
.\scripts\start.ps1 [-Profiles telemetry]
.\scripts\stop.ps1 [-Profiles telemetry]
.\scripts\prune.ps1 [-Volumes] [-All] # -Volumes deletes ALL data!
# bash
./scripts/start.sh [telemetry]
./scripts/stop.sh [telemetry]
./scripts/prune.sh [--volumes] [--all]
```
The web UI listens on http://localhost:8080 (bound to 127.0.0.1).
## Prerequisites
- Docker and Docker Compose v2.
@@ -56,6 +88,9 @@ Key variables:
| `LOKI_URL` | Loki push URL; leave empty to disable. |
| `LOKI_FLUSH_SECONDS`| Seconds between batched Loki pushes (default `5`). |
| `API_PORT` | Host port for the HTTPS API (default `8443`). |
| `WEBUI_PORT` | Host port for the web UI (default `8080`). |
| `ADMIN_USERNAME` | Initial admin username (default `admin`; created only when no users exist). |
| `ADMIN_PASSWORD` | Initial admin password. Empty = default `admin`; the UI then forces a change on first login. |
## Certificates
@@ -123,24 +158,15 @@ psk_hint mqtt-ingester
psk_file /mosquitto/psk_file # line: ingester:0123456789abcdef
```
## Running
Start the core stack:
```bash
docker compose up -d
```
Start with Loki enabled:
```bash
docker compose --profile telemetry up -d
```
## API
The API is served over HTTPS with mutual TLS. Clients must present a
certificate signed by the configured CA.
The API is served over HTTPS and accepts two authentication methods:
- **Mutual TLS**: clients present a certificate signed by the configured CA.
Certificate holders are treated as services with full read access.
- **Session cookie**: a login in the web UI (http://localhost:8080) issues a
`mqtt_session` cookie that also authenticates API requests from the same
browser, subject to that user's access rules.
```bash
curl --cacert certs/ca.crt \
@@ -154,6 +180,7 @@ Endpoints:
| Method | Path | Description |
| ------ | -------------------- | ---------------------------------------- |
| GET | `/health` | Health check. |
| GET | `/whoami` | Shows the authenticated principal and effective access rules. |
| GET | `/telemetry` | List telemetry (filters: `topic`, `device_id`, `limit`). |
| GET | `/telemetry/latest` | Latest record (filters: `topic`, `device_id`). |
@@ -164,6 +191,64 @@ curl --cacert certs/ca.crt --cert certs/client.crt --key certs/client.key \
"https://localhost:8443/telemetry?topic=sensors/temp&limit=10"
```
## Web UI and Access Control
The web UI (http://localhost:8080) manages **users**, **groups**, and
**topic access rules** for the API. Users and groups are stored in
TimescaleDB (`db/init/002_access_control.sql`); the API and web UI share
the same session store, so one login works for both.
### Users and roles
- Every user has a username, an argon2-hashed password, and a role:
`user` or `admin`. Admins manage users/groups/rules and implicitly have
access to all data; regular users see only what their rules allow.
- Users can be disabled (which also kills their sessions) and deleted.
- On first start — and only when the users table is empty — an initial
admin is created from `ADMIN_USERNAME`/`ADMIN_PASSWORD`, defaulting to
`admin`/`admin`. The default password is flagged for a forced change
at first login. Restarts never reset or recreate the admin.
- Login is throttled: 5 failed attempts per source IP within 15 minutes
triggers a 5-minute lockout.
### Access rules
A rule is an **allow** or **deny** entry with a glob **pattern** matched
against the full tag name (e.g. `11001.KF1.BF1_Value`):
| Pattern | Matches |
| ------------------- | -------------------------------------------- |
| `*` | everything |
| `11001.*` | everything prefixed `11001.` |
| `11001.*_Value` | `11001.`-prefixed and `_Value`-suffixed tags |
| `11001.KF1.*` | everything under the `KF1` function |
Semantics:
- `*` is the only wildcard and matches any character sequence
(including `.`). Everything else — `.`, `_`, digits — is literal.
This is deliberately **not** regex: patterns stay writable for plant
engineers, translate directly to SQL `LIKE` (so filtering is pushed
down into the database and indexes still work), and cannot
accidentally match sibling systems the way `^11001.*` would.
- A user's effective rules are the **union** of personal rules and the
rules of every group they belong to.
- **Deny wins over allow**: a tag is readable only if at least one
allow rule matches it and no deny rule does. This is what lets a
personal deny override a group allow, and vice versa.
- Users with no allow rules see nothing (deny-by-default).
Rules are enforced in SQL (`topic LIKE ANY(...)` + `NOT (...)`), so
`limit` behaves correctly and there is no filtering in application code.
Example: a group grants `11001.*`, but a user's personal rule denies
`11001.*_Raw` — the user sees the `Value` tags but not the `Raw` ones.
### Sessions
Sessions are 12-hour random tokens stored as SHA-256 hashes. The cookie
is HttpOnly, SameSite=Lax. All mutating web UI forms require a CSRF
token bound to the session.
## Logging
The MQTT client logs in JSON format to:
@@ -198,8 +283,24 @@ Telemetry is stored in the `telemetry` hypertable:
| `device_id`| `TEXT` | Extracted from payload if present.|
| `payload` | `JSONB` | Full message payload. |
Access control (see `db/init/002_access_control.sql`):
| Table | Purpose |
| -------------- | ---------------------------------------------------------- |
| `users` | Accounts: username, argon2 password hash, role, disabled flag. |
| `groups` | Named collections of access rules. |
| `user_groups` | Group membership. |
| `access_rules`| Allow/deny glob patterns owned by a user OR a group. |
| `sessions` | Web sessions (SHA-256 token hashes, 12h expiry); shared with the API. |
| `login_attempts` | Per-IP login throttling state. |
## Security Notes
- Change `POSTGRES_PASSWORD` before deploying.
- Set `ADMIN_PASSWORD` in `.env` or change the default `admin`/`admin`
immediately after first login (the UI forces it).
- Use a real CA and rotate certificates regularly.
- Restrict the API port with a firewall or reverse proxy.
- The web UI is bound to `127.0.0.1`. If you expose it remotely, put it
behind a TLS-terminating reverse proxy; the cookie has no `Secure` flag
because the UI itself serves plain HTTP.
+10 -2
View File
@@ -2,10 +2,18 @@ FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
# Build context is the repo root (see docker-compose.yml) so shared/ is
# copyable; shared/ holds the access-control model used by api + webui.
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY shared /app/shared
COPY api /app/api
WORKDIR /app/api
# shared/ is installed at /app/shared; make it importable from /app/api.
ENV PYTHONPATH=/app
RUN useradd --system --no-create-home api
USER api
+54 -11
View File
@@ -16,6 +16,8 @@ from psycopg import sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from shared.access import COOKIE_NAME, Identity, hash_token, load_identity, topic_conditions
_SELECT_SQL: Final = "SELECT time, topic, device_id, payload FROM telemetry"
# Connections use row_factory=dict_row, so each row is a dict.
@@ -63,17 +65,33 @@ def get_pool() -> ConnectionPool[_DictConnection]:
return pool
@app.middleware("http")
async def require_client_cert(request: Request, call_next):
def _request_identity(request: Request) -> Optional[Identity]:
"""Resolve the request principal: mTLS cert, session cookie, or None."""
# uvicorn populates the client cert into the transport when mTLS is enabled.
cert = getattr(request.scope.get("transport"), "get_extra_info", lambda _: None)(
"peercert"
)
if cert is None:
# Return a response directly: HTTPException raised from middleware is
# not handled by the (inner) exception handlers and would surface
# as a 500.
return JSONResponse(status_code=401, content={"detail": "Client certificate required"})
if cert is not None:
return Identity.service(cert)
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
conn = get_pool().getconn()
try:
return load_identity(conn, hash_token(token))
finally:
get_pool().putconn(conn)
@app.middleware("http")
async def require_client_cert(request: Request, call_next):
# Return a response directly: HTTPException raised from middleware is
# not handled by the (inner) exception handlers and would surface
# as a 500.
identity = _request_identity(request)
if identity is None:
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
request.state.identity = identity
return await call_next(request)
@@ -82,15 +100,35 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/whoami")
def whoami(request: Request) -> dict[str, Any]:
"""Show the authenticated principal and effective access rules.
Useful to verify which auth path a request took (client certificate
vs session cookie) and which topics the effective rules allow.
"""
identity: Identity = request.state.identity
return {
"username": identity.username,
"role": identity.role,
"rules": {"allow": list(identity.rules.allow), "deny": list(identity.rules.deny)},
}
def _telemetry_query(
topic: Optional[str], device_id: Optional[str], limit: int
identity: Identity,
topic: Optional[str],
device_id: Optional[str],
limit: int,
) -> tuple[sql.Composed, list[Any]]:
"""Build the SELECT for the requested filters as composed SQL.
The statement is composed with psycopg.sql, and all values are bound
as parameters, so nothing is interpolated into the query string.
Access rules are folded into the WHERE clause (deny wins over allow),
so filtering happens in the database and LIMIT stays correct.
"""
conditions: list[sql.Composable] = []
conditions, param_groups = topic_conditions(identity)
params: list[Any] = []
if topic:
conditions.append(sql.SQL("topic = %s"))
@@ -98,6 +136,7 @@ def _telemetry_query(
if device_id:
conditions.append(sql.SQL("device_id = %s"))
params.append(device_id)
params.extend(p for group in param_groups for p in group)
query = sql.SQL(_SELECT_SQL)
if conditions:
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
@@ -108,11 +147,13 @@ def _telemetry_query(
@app.get("/telemetry")
def list_telemetry(
request: Request,
topic: Optional[str] = Query(default=None),
device_id: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=1000),
) -> list[dict[str, Any]]:
query, params = _telemetry_query(topic, device_id, limit)
identity: Identity = request.state.identity
query, params = _telemetry_query(identity, topic, device_id, limit)
with get_pool().connection() as conn, conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
@@ -120,10 +161,12 @@ def list_telemetry(
@app.get("/telemetry/latest")
def latest_telemetry(
request: Request,
topic: Optional[str] = Query(default=None),
device_id: Optional[str] = Query(default=None),
) -> Optional[dict[str, Any]]:
query, params = _telemetry_query(topic, device_id, 1)
identity: Identity = request.state.identity
query, params = _telemetry_query(identity, topic, device_id, 1)
with get_pool().connection() as conn, conn.cursor() as cur:
cur.execute(query, params)
row = cur.fetchone()
+65
View File
@@ -0,0 +1,65 @@
-- Access control for the API and web UI: users, groups, and glob-based
-- topic access rules (allow/deny; see shared/access.py for semantics).
-- NOTE: user/group administration happens in the web UI (or SQL). Rule
-- patterns are globs matched against the full topic/tag name, where
-- '*' matches any character sequence (including '.') and everything
-- else is literal. Example: '11001.*_Value' grants everything under
-- '11001.' ending in '_Value'; user rules can override group rules via
-- deny, which always wins over allow.
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('user', 'admin')),
disabled_at TIMESTAMPTZ,
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS groups (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE IF NOT EXISTS access_rules (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
group_id BIGINT REFERENCES groups(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('allow', 'deny')),
pattern TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK ((user_id IS NULL) <> (group_id IS NULL)) -- exactly one owner
);
CREATE INDEX IF NOT EXISTS idx_access_rules_user ON access_rules (user_id);
CREATE INDEX IF NOT EXISTS idx_access_rules_group ON access_rules (group_id);
-- Sessions issued by the web UI; the API validates the same table, so a
-- web login is also an API login. Only a SHA-256 of the token is stored.
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions (expires_at);
-- Login attempt throttling for the web UI.
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
);
+26 -2
View File
@@ -19,7 +19,9 @@ services:
retries: 5
mqtt-client:
build: ./mqtt-client
build:
context: .
dockerfile: mqtt-client/Dockerfile
container_name: mqtt-client
restart: unless-stopped
depends_on:
@@ -57,7 +59,9 @@ services:
- mqtt-logs:/var/log/mqtt-client
api:
build: ./api
build:
context: .
dockerfile: api/Dockerfile
container_name: mqtt-api
restart: unless-stopped
depends_on:
@@ -81,6 +85,26 @@ services:
volumes:
- ./certs:/certs:ro
webui:
build:
context: .
dockerfile: webui/Dockerfile
container_name: mqtt-webui
restart: unless-stopped
depends_on:
timescaledb:
condition: service_healthy
environment:
DB_HOST: timescaledb
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB:-telemetry}
DB_USER: ${POSTGRES_USER:-ingester}
DB_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ports:
- "127.0.0.1:${WEBUI_PORT:-8080}:8080"
loki:
image: grafana/loki:3.4.2
container_name: mqtt-loki
+3 -2
View File
@@ -4,10 +4,11 @@ FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
# Build context is the repo root (see docker-compose.yml).
COPY mqtt-client/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY mqtt-client/ /app/
RUN mkdir -p /var/log/mqtt-client \
&& useradd --system --no-create-home mqtt \
+51
View File
@@ -0,0 +1,51 @@
<#
.SYNOPSIS
Removes stack data: volumes and orphans, optionally build artifacts.
.DESCRIPTION
DANGER: this deletes the TimescaleDB data volume (all telemetry data),
the MQTT log volume, and the Loki volume. Users/groups/sessions stored
in Postgres are deleted with it.
-Volumes remove the named volumes (data loss!)
-All additionally remove images and build cache
#>
[CmdletBinding()]
param(
[switch]$Volumes,
[switch]$All
)
$ErrorActionPreference = "Stop"
$StackRoot = Split-Path -Parent $PSScriptRoot
Set-Location $StackRoot
if ($Volumes) {
Write-Warning "This deletes ALL stored data (telemetry, users, groups, sessions, logs)."
$confirm = Read-Host "Type 'delete' to continue"
if ($confirm -ne "delete") {
Write-Host "Aborted."
exit 0
}
}
Write-Host "Removing stack containers..." -ForegroundColor Cyan
docker compose down --remove-orphans
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($Volumes) {
Write-Host "Removing volumes..." -ForegroundColor Cyan
docker compose down --volumes
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
if ($All) {
Write-Host "Removing images and build cache..." -ForegroundColor Cyan
docker compose down --rmi all
docker image prune -f
docker builder prune -f
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
Write-Host "Done." -ForegroundColor Green
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Removes stack data: volumes and orphans, optionally build artifacts.
#
# DANGER: deletes the TimescaleDB data volume (all telemetry data), the
# MQTT log volume, and the Loki volume. Users/groups/sessions stored in
# Postgres are deleted with it.
#
# Usage:
# ./scripts/prune.sh # containers only
# ./scripts/prune.sh --volumes # also delete all data (destructive)
# ./scripts/prune.sh --all # also remove images and build cache
set -euo pipefail
StackRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$StackRoot"
Volumes=0
All=0
for arg in "$@"; do
case "$arg" in
--volumes|-v) Volumes=1 ;;
--all|-a) All=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
if [ "$Volumes" -eq 1 ]; then
echo "This deletes ALL stored data (telemetry, users, groups, sessions, logs)."
read -r -p "Type 'delete' to continue: " confirm
[ "$confirm" = "delete" ] || { echo "Aborted."; exit 0; }
fi
echo "Removing stack containers..."
docker compose down --remove-orphans
if [ "$Volumes" -eq 1 ]; then
echo "Removing volumes..."
docker compose down --volumes
fi
if [ "$All" -eq 1 ]; then
echo "Removing images and build cache..."
docker compose down --rmi all
docker image prune -f
docker builder prune -f
fi
echo "Done."
+84
View File
@@ -0,0 +1,84 @@
<#
.SYNOPSIS
Starts the mqtt-ingester stack on local Docker.
.DESCRIPTION
Loads .env (values already set in the environment take precedence),
verifies that the certificate files exist, then runs docker compose up
with the requested profiles.
.PARAMETER Profiles
Comma-separated compose profiles to enable (e.g. "telemetry").
.EXAMPLE
.\scripts\start.ps1
.\scripts\start.ps1 -Profiles telemetry
#>
[CmdletBinding()]
param(
[string]$Profiles = ""
)
$ErrorActionPreference = "Stop"
# Stack root = parent of the scripts directory.
$StackRoot = Split-Path -Parent $PSScriptRoot
Set-Location $StackRoot
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
Write-Error "docker not found in PATH"
}
# Load .env if present; existing environment variables win.
if (Test-Path ".env") {
Get-Content ".env" | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith("#")) {
$parts = $line -split "=", 2
if ($parts.Length -eq 2) {
$name = $parts[0].Trim()
if (-not (Test-Path "Env:$name")) {
Set-Item -Path "Env:$name" -Value $parts[1].Trim()
}
}
else {
Write-Warning "Ignoring malformed .env line: $line"
}
}
}
}
# Required certificates (see README).
$requiredCerts = @("ca.crt", "client.crt", "client.key", "server.crt", "server.key")
$missing = @()
foreach ($cert in $requiredCerts) {
if (-not (Test-Path (Join-Path "certs" $cert))) {
$missing += $cert
}
}
if ($missing.Count -gt 0) {
Write-Warning "Missing certificates in certs/: $($missing -join ', ')"
Write-Warning "The API will fail to start. See the 'Certificates' section of README.md."
}
# MQTT_BROKER_HOST must be defined somewhere.
if (-not $env:MQTT_BROKER_HOST) {
Write-Warning "MQTT_BROKER_HOST is not set; mqtt-client will exit with an error."
}
$composeArgs = @("compose", "up", "-d", "--build")
if ($Profiles) {
$composeArgs += @("--profile", $Profiles)
}
Write-Host "Starting stack..." -ForegroundColor Cyan
docker @composeArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host ""
Write-Host "Services:" -ForegroundColor Cyan
docker compose ps
Write-Host ""
Write-Host "Web UI: http://localhost:8080 (default login admin/admin)" -ForegroundColor Green
Write-Host "API: https://localhost:8443 (mTLS or session cookie)" -ForegroundColor Green
Write-Host "Logs: docker compose logs -f <service>" -ForegroundColor Gray
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Starts the mqtt-ingester stack on local Docker.
#
# Loads .env if present (existing environment variables take precedence),
# warns about missing certificates, then runs docker compose up with the
# requested profiles.
#
# Usage:
# ./scripts/start.sh [profiles...]
# ./scripts/start.sh telemetry
set -euo pipefail
StackRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$StackRoot"
Profiles="${*:-}"
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found in PATH" >&2
exit 1
fi
# Load .env if present; existing environment variables win.
if [ -f .env ]; then
while IFS='=' read -r name value; do
# Skip comments and blank lines.
case "$name" in ''|\#*) continue ;; esac
if [ -z "${!name:-}" ]; then
export "$name=$value"
fi
done < <(grep -v -e '^[[:space:]]*$' -e '^[[:space:]]*#' .env)
fi
# Required certificates (see README).
missing=()
for cert in ca.crt client.crt client.key server.crt server.key; do
[ -f "certs/$cert" ] || missing+=("$cert")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "WARNING: missing certificates in certs/: ${missing[*]}" >&2
echo "WARNING: the API will fail to start. See README.md 'Certificates'." >&2
fi
if [ -z "${MQTT_BROKER_HOST:-}" ]; then
echo "WARNING: MQTT_BROKER_HOST is not set; mqtt-client will exit with an error." >&2
fi
ComposeArgs=(compose up -d --build)
if [ -n "$Profiles" ]; then
ComposeArgs+=(--profile "$Profiles")
fi
echo "Starting stack..."
docker "${ComposeArgs[@]}"
echo
echo "Services:"
docker compose ps
echo
echo "Web UI: http://localhost:8080 (default login admin/admin)"
echo "API: https://localhost:8443 (mTLS or session cookie)"
echo "Logs: docker compose logs -f <service>"
+31
View File
@@ -0,0 +1,31 @@
<#
.SYNOPSIS
Stops the mqtt-ingester stack.
.DESCRIPTION
Stops and removes containers and the default network. Volumes
(timescale-data, mqtt-logs, loki-data) are preserved; use prune.ps1
to remove them.
.PARAMETER Profiles
Comma-separated compose profiles (must match the ones used at start
so the same services are addressed).
#>
[CmdletBinding()]
param(
[string]$Profiles = ""
)
$ErrorActionPreference = "Stop"
$StackRoot = Split-Path -Parent $PSScriptRoot
Set-Location $StackRoot
$composeArgs = @("compose", "down", "--remove-orphans")
if ($Profiles) {
$composeArgs += @("--profile", $Profiles)
}
Write-Host "Stopping stack..." -ForegroundColor Cyan
docker @composeArgs
exit $LASTEXITCODE
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Stops the mqtt-ingester stack.
#
# Stops and removes containers and the default network. Volumes
# (timescale-data, mqtt-logs, loki-data) are preserved; use prune.sh to
# remove them.
#
# Usage:
# ./scripts/stop.sh [profiles...]
set -euo pipefail
StackRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$StackRoot"
Profiles="${*:-}"
ComposeArgs=(compose down --remove-orphans)
if [ -n "$Profiles" ]; then
ComposeArgs+=(--profile "$Profiles")
fi
echo "Stopping stack..."
docker "${ComposeArgs[@]}"
+1
View File
@@ -0,0 +1 @@
"""Code shared by the api and webui services."""
+218
View File
@@ -0,0 +1,218 @@
"""Shared access-control model used by both the API and the web UI.
Both services authenticate against the same tables (users, groups,
access_rules, sessions — see db/init/002_access_control.sql), so the
meaning of a rule must be defined in exactly one place: here.
Semantics:
- A pattern is a glob matched against the FULL topic / tag name
(e.g. ``11001.KF1.BF1_Value``). ``*`` is the only wildcard and
matches any character sequence, including ``.``. Everything else
(``.``, ``_``, digits, ...) is literal — deliberately NOT regex:
the audience is plant engineers, and globs translate to SQL LIKE
(with ``_`` / ``%`` / backslash escaped) so authorization can be
pushed down into telemetry queries.
- A user's effective rules are the union of its personal rules and
the rules of every group it belongs to.
- deny wins over allow: a topic is readable only if at least one
allow rule matches it AND no deny rule does. This is what lets a
user rule override a group grant (via a user deny) and vice versa.
- Admin users and certificate-authenticated services bypass rule
evaluation and see everything.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any, Mapping, Optional
import psycopg
from psycopg import sql
from psycopg.rows import tuple_row
#: Cookie carrying the session token. Shared by the web UI (which sets
#: it) and the API (which accepts it), so a web login is also an API login.
COOKIE_NAME: str = "mqtt_session"
# -- Pattern translation -------------------------------------------------
def escape_like(value: str) -> str:
"""Escape LIKE special characters so ``value`` matches literally."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def glob_to_like(pattern: str) -> str:
"""Translate an access-control glob to a SQL LIKE pattern.
``*`` becomes ``%``; ``_``, ``%`` and ``\\`` in the pattern are
literal and must be escaped — ``11001.*_Value`` must not let ``_``
match an arbitrary character.
"""
if pattern == "*":
return "%"
return escape_like(pattern).replace("*", "%")
@lru_cache(maxsize=1024)
def _compile_glob(pattern: str) -> re.Pattern[str]:
parts = pattern.split("*")
return re.compile("^" + ".*".join(re.escape(part) for part in parts) + "$", re.DOTALL)
def _glob_match(pattern: str, topic: str) -> bool:
return _compile_glob(pattern).match(topic) is not None
# -- Rule model ------------------------------------------------------------
@dataclass(frozen=True)
class Rules:
"""Allow/deny glob patterns; combined user+group rules are a union."""
allow: tuple[str, ...] = ()
deny: tuple[str, ...] = ()
@property
def allow_all(self) -> bool:
return "*" in self.allow
@property
def deny_all(self) -> bool:
return "*" in self.deny
def allow_likes(self) -> list[str]:
"""LIKE patterns for the allow side (``*`` handled separately)."""
return [glob_to_like(p) for p in self.allow if p != "*"]
def deny_likes(self) -> list[str]:
return [glob_to_like(p) for p in self.deny if p != "*"]
def topic_allowed(self, topic: str) -> bool:
"""In-Python check for a single topic (deny wins over allow)."""
if self.deny_all:
return False
for pattern in self.deny:
if _glob_match(pattern, topic):
return False
if self.allow_all:
return True
return any(_glob_match(p, topic) for p in self.allow)
@dataclass(frozen=True)
class Identity:
"""The authenticated principal for one request."""
user_id: Optional[int]
username: str
role: str # 'admin' | 'user' | 'service'
rules: Rules = field(default_factory=Rules)
must_change_password: bool = False
csrf_token: str = ""
@classmethod
def service(cls, peercert: Mapping[str, Any]) -> "Identity":
"""Identity for an mTLS-authenticated client certificate."""
common_name = ""
for rdn in peercert.get("subject") or ():
for attr, value in rdn:
if attr == "commonName":
common_name = value
return cls(
user_id=None,
username=common_name or "client",
role="service",
rules=Rules(allow=("*",)),
)
@property
def is_admin(self) -> bool:
return self.role == "admin"
@property
def full_access(self) -> bool:
return self.role in ("admin", "service")
def topic_allowed(self, topic: str) -> bool:
return self.full_access or self.rules.topic_allowed(topic)
def topic_conditions(identity: Identity) -> tuple[list[sql.Composable], list[list[str]]]:
"""WHERE fragments + params enforcing ``identity`` on telemetry.topic.
Conditions are composed for psycopg.sql and ANDed into the query, so
filtering happens in the database and LIMIT stays correct. Users with
no allow rules match nothing (deny-by-default).
"""
conditions: list[sql.Composable] = []
params: list[list[str]] = []
if identity.full_access:
return conditions, params
rules = identity.rules
if rules.deny_all or not rules.allow:
conditions.append(sql.SQL("false"))
return conditions, params
if not rules.allow_all:
conditions.append(sql.SQL("topic LIKE ANY(%s)"))
params.append(rules.allow_likes())
deny_likes = rules.deny_likes()
if deny_likes:
conditions.append(sql.SQL("NOT (topic LIKE ANY(%s))"))
params.append(deny_likes)
return conditions, params
# -- Session lookup ---------------------------------------------------------
# Row factory is pinned to tuples so this works regardless of the calling
# service's pool row_factory.
_SESSION_USER_SQL = """
SELECT u.id, u.username, u.role, u.must_change_password, s.csrf_token
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = %s
AND s.expires_at > now()
AND u.disabled_at IS NULL
"""
_USER_RULES_SQL = """
SELECT kind, pattern FROM access_rules WHERE user_id = %s
UNION ALL
SELECT ar.kind, ar.pattern
FROM access_rules ar
JOIN user_groups ug ON ug.group_id = ar.group_id
WHERE ug.user_id = %s
"""
def hash_token(token: str) -> str:
"""Sessions store only a SHA-256 of the random token."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def load_identity(conn: psycopg.Connection[Any], token_hash: str) -> Optional[Identity]:
"""Resolve a session token hash to an Identity (None if invalid)."""
with conn.cursor(row_factory=tuple_row) as cur:
user_row = cur.execute(_SESSION_USER_SQL, (token_hash,)).fetchone()
if user_row is None:
return None
user_id, username, role, must_change, csrf_token = user_row
rule_rows = cur.execute(_USER_RULES_SQL, (user_id, user_id)).fetchall()
allow: list[str] = []
deny: list[str] = []
for kind, pattern in rule_rows:
(allow if kind == "allow" else deny).append(pattern)
return Identity(
user_id=user_id,
username=username,
role=role,
rules=Rules(allow=tuple(allow), deny=tuple(deny)),
must_change_password=must_change,
csrf_token=csrf_token,
)
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim
WORKDIR /app
# Build context is the repo root (see docker-compose.yml) so shared/ is
# copyable; shared/ holds the access-control model used by api + webui.
COPY webui/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY shared /app/shared
COPY webui /app/webui
WORKDIR /app/webui
# shared/ is installed at /app/shared; make it importable from /app/webui.
ENV PYTHONPATH=/app
RUN useradd --system --no-create-home webui
USER webui
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
+174
View File
@@ -0,0 +1,174 @@
"""Authentication for the web UI: password hashing, sessions, throttling.
- Passwords are hashed with argon2id (passlib).
- Session cookies carry a random token; only its SHA-256 is stored.
- Sessions are persisted in Postgres, so a login in the web UI is also
valid against the API (shared/access.py resolves the same table).
- Login is throttled per source IP (5 attempts per 15 minutes, then a
5-minute lockout) to blunt credential stuffing against the exposed
login form.
- On startup, an admin account is created only if the users table is
empty: username/password from ADMIN_USERNAME/ADMIN_PASSWORD if set,
otherwise the documented default admin/admin. The default account is
flagged must_change_password so the UI forces a change on first login.
"""
import hmac
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import psycopg
from passlib.context import CryptContext
from shared.access import COOKIE_NAME, hash_token
# Fixed session lifetime; a sliding window would need a write per request,
# so sessions simply expire after this span (users log in again).
SESSION_TTL = timedelta(hours=12)
LOCKOUT_ATTEMPTS = 5
LOCKOUT_WINDOW = timedelta(minutes=15)
LOCKOUT_DURATION = timedelta(minutes=5)
_pwd: Optional[CryptContext] = None
def _crypt() -> CryptContext:
"""Lazy argon2 init: passlib's runtime self-check is slow (~1s)."""
global _pwd
if _pwd is None:
_pwd = CryptContext(schemes=["argon2"])
return _pwd
def hash_password(password: str) -> str:
return _crypt().hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _crypt().verify(password, password_hash)
except Exception: # noqa: BLE001 - malformed hash must not 500 the login
return False
def _interval(td: timedelta) -> str:
return f"{int(td.total_seconds())} seconds"
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def create_session(conn: psycopg.Connection[Any], user_id: int) -> tuple[str, str]:
"""Create a session; returns (cookie_token, csrf_token)."""
cookie_token = secrets.token_urlsafe(32)
csrf_token = secrets.token_urlsafe(32)
with conn.cursor() as cur:
cur.execute(
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)"
" VALUES (%s, %s, %s, now() + %s::interval)",
(hash_token(cookie_token), user_id, csrf_token, _interval(SESSION_TTL)),
)
return cookie_token, csrf_token
def delete_session(conn: psycopg.Connection[Any], token: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM sessions WHERE token_hash = %s", (hash_token(token),))
def bootstrap_admin(conn: psycopg.Connection[Any]) -> None:
"""Create the initial admin from ENV or default admin/admin.
Runs only when the users table is completely empty — restarting the
stack must never reset or resurrect an administrator.
"""
with conn.cursor() as cur:
count = cur.execute("SELECT count(*) FROM users").fetchone()[0]
if count and count > 0:
return
username = os.getenv("ADMIN_USERNAME", "admin")
password = os.getenv("ADMIN_PASSWORD", "admin")
from_env = bool(os.getenv("ADMIN_PASSWORD"))
# A default (non-ENV) password must always be changed at first
# login; an ENV-provided one is treated as provisioned.
must_change = not from_env or username == "admin"
cur.execute(
"INSERT INTO users (username, password_hash, role, must_change_password)"
" VALUES (%s, %s, 'admin', %s)",
(username, hash_password(password), must_change),
)
print(
f"Bootstrapped initial admin '{username}'"
+ (" (default credentials; change on first login)" if must_change else "")
)
def _attempt_key(ip: str) -> str:
# login_attempts.ip is TEXT PRIMARY KEY; keep long IPv6 strings sane.
return ip[:64] if ip else "unknown"
def check_throttle(conn: psycopg.Connection[Any], ip: str) -> tuple[bool, int]:
"""Return (allowed, attempts). Locks out after LOCKOUT_ATTEMPTS tries."""
key = _attempt_key(ip)
with conn.cursor() as cur:
row = cur.execute(
"SELECT attempts, last_seen FROM login_attempts WHERE ip = %s", (key,)
).fetchone()
if row is None:
return True, 0
attempts, last_seen = row
if attempts >= LOCKOUT_ATTEMPTS:
if last_seen < _utcnow() - LOCKOUT_DURATION:
# Lockout expired: start a fresh window.
cur.execute("DELETE FROM login_attempts WHERE ip = %s", (key,))
return True, 0
return False, attempts
return True, attempts
def record_failed_login(conn: psycopg.Connection[Any], ip: str) -> None:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO login_attempts (ip, attempts, last_seen)
VALUES (%s, 1, now())
ON CONFLICT (ip) DO UPDATE
SET attempts = CASE
WHEN login_attempts.last_seen < now() - %s::interval
THEN 1
ELSE login_attempts.attempts + 1
END,
last_seen = now()
""",
(_attempt_key(ip), _interval(LOCKOUT_WINDOW)),
)
def clear_failed_logins(conn: psycopg.Connection[Any], ip: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM login_attempts WHERE ip = %s", (_attempt_key(ip),))
def set_cookie(response: Any, token: str) -> None:
"""Attach the session cookie (HttpOnly; the webui is same-origin)."""
response.set_cookie(
COOKIE_NAME,
token,
httponly=True,
samesite="lax",
path="/",
max_age=int(SESSION_TTL.total_seconds()),
)
def clear_cookie(response: Any) -> None:
response.delete_cookie(COOKIE_NAME, path="/")
def constant_time_eq(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode(), b.encode())
+572
View File
@@ -0,0 +1,572 @@
"""Web UI for access control: users, groups, topic access rules.
Serves the login page, the admin pages (users, groups), and a telemetry
browser so the effect of access rules can be verified directly. Sessions
are shared with the API via the mqtt_session cookie, so a web login also
authenticates API requests from the same browser.
User/group management is admin-only. Access-rule semantics (globs, deny
wins over allow, user rules override group rules) live in shared/access.py.
"""
import hmac
import os
from contextlib import asynccontextmanager
from typing import Any, Optional
import psycopg
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from psycopg import sql
from psycopg.rows import dict_row
import auth
from shared.access import (
COOKIE_NAME,
Identity,
escape_like,
hash_token,
load_identity,
topic_conditions,
)
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
_DictConnection = psycopg.Connection[dict[str, Any]]
pool: Optional[_DictConnection] = None
def _connect_kwargs() -> dict[str, Any]:
return {
"host": os.getenv("DB_HOST", "timescaledb"),
"port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "telemetry"),
"user": os.getenv("DB_USER", "ingester"),
"password": os.getenv("DB_PASSWORD", "changeme"),
"row_factory": dict_row,
}
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
# Single long-lived autocommit connection; the web UI's load is tiny
# compared to the API's, so a pool would be overkill.
conn = psycopg.connect(**_connect_kwargs())
pool = conn
try:
# First run: create the initial admin (ENV or default admin/admin).
auth.bootstrap_admin(conn)
yield
finally:
pool = None
conn.close()
app = FastAPI(title="MQTT Ingester Web UI", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
def get_conn() -> _DictConnection:
if pool is None:
raise RuntimeError("Database connection not initialized")
return pool
def current_identity(request: Request) -> Optional[Identity]:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
return load_identity(get_conn(), hash_token(token))
def require_login(request: Request) -> Identity:
identity = current_identity(request)
if identity is None:
raise HTTPException(status_code=303, headers={"Location": "/login"})
# A default/admin-set password must be changed before anything else.
if identity.must_change_password and request.url.path not in (
"/password",
"/logout",
):
raise HTTPException(status_code=303, headers={"Location": "/password"})
return identity
def require_admin(request: Request) -> Identity:
identity = require_login(request)
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return identity
async def require_csrf(request: Request, identity: Identity) -> None:
"""Validate the CSRF token for a mutating (form) request."""
form = await request.form()
submitted = form.get("csrf_token", "")
if not isinstance(submitted, str) or not hmac.compare_digest(
submitted, identity.csrf_token
):
raise HTTPException(status_code=400, detail="Invalid CSRF token")
def _telemetry_rows(identity: Identity, topic: Optional[str]) -> list[dict[str, Any]]:
"""Recent telemetry filtered by the identity's access rules in SQL."""
conditions, param_groups = topic_conditions(identity)
params: list[Any] = []
if topic:
conditions.append(sql.SQL("topic LIKE %s"))
params.append(escape_like(topic))
params.extend(p for group in param_groups for p in group)
query = sql.SQL("SELECT time, topic, payload FROM telemetry")
if conditions:
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
query += sql.SQL(" ORDER BY time DESC LIMIT 100")
with get_conn().cursor() as cur:
cur.execute(query, params)
return list(cur.fetchall())
# -- Login / logout ---------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
if current_identity(request) is None:
return RedirectResponse("/login", status_code=303)
return RedirectResponse("/data", status_code=303)
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None):
if current_identity(request) is not None:
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse(request, "login.html", {"error": error})
@app.post("/login")
async def login(request: Request, username: str = Form(...), password: str = Form(...)):
conn = get_conn()
ip = request.client.host if request.client else "unknown"
allowed, attempts = auth.check_throttle(conn, ip)
if not allowed:
return templates.TemplateResponse(
request,
"login.html",
{"error": "Too many failed attempts; try again in a few minutes."},
status_code=429,
)
with conn.cursor() as cur:
row = cur.execute(
"SELECT id, password_hash FROM users"
" WHERE username = %s AND disabled_at IS NULL",
(username,),
).fetchone()
if row is None or not auth.verify_password(password, row[1]):
auth.record_failed_login(conn, ip)
return templates.TemplateResponse(
request, "login.html", {"error": "Invalid username or password"}, status_code=401
)
auth.clear_failed_logins(conn, ip)
cookie_token, _csrf = auth.create_session(conn, row[0])
resp = RedirectResponse("/data", status_code=303)
auth.set_cookie(resp, cookie_token)
return resp
@app.post("/logout")
async def logout(request: Request):
identity = current_identity(request)
if identity is not None:
await require_csrf(request, identity)
token = request.cookies.get(COOKIE_NAME, "")
if token:
auth.delete_session(get_conn(), token)
resp = RedirectResponse("/login", status_code=303)
auth.clear_cookie(resp)
return resp
# -- Account (self-service) --------------------------------------------------
@app.get("/password", response_class=HTMLResponse)
async def password_page(request: Request, error: Optional[str] = None):
identity = require_login(request)
return templates.TemplateResponse(
request, "password.html", {"identity": identity, "error": error}
)
@app.post("/password")
async def password_change(
request: Request,
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
):
identity = require_login(request)
await require_csrf(request, identity)
conn = get_conn()
def fail(message: str, status: int):
return templates.TemplateResponse(
request,
"password.html",
{"identity": identity, "error": message},
status_code=status,
)
with conn.cursor() as cur:
row = cur.execute(
"SELECT password_hash FROM users WHERE id = %s", (identity.user_id,)
).fetchone()
if row is None or not auth.verify_password(current_password, row[0]):
return fail("Current password is incorrect", 401)
if new_password != confirm_password:
return fail("New passwords do not match", 400)
if len(new_password) < 8:
return fail("New password must be at least 8 characters", 400)
with conn.cursor() as cur:
cur.execute(
"UPDATE users SET password_hash = %s, must_change_password = FALSE"
" WHERE id = %s",
(auth.hash_password(new_password), identity.user_id),
)
return RedirectResponse("/data", status_code=303)
# -- Users (admin) -----------------------------------------------------------
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request):
identity = require_admin(request)
with get_conn().cursor() as cur:
users = cur.execute(
"""
SELECT u.id, u.username, u.role, u.disabled_at, u.must_change_password,
coalesce(
(SELECT array_agg(g.name ORDER BY g.name)
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.id), '{}') AS groups
FROM users u ORDER BY u.username
"""
).fetchall()
groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
return templates.TemplateResponse(
request, "users.html", {"identity": identity, "users": users, "groups": groups}
)
@app.post("/users")
async def create_user(
request: Request,
username: str = Form(...),
password: str = Form(...),
role: str = Form("user"),
group_ids: list[str] = Form(default=[]),
):
identity = require_admin(request)
await require_csrf(request, identity)
conn = get_conn()
username = username.strip()
if not username:
raise HTTPException(status_code=400, detail="Username is required")
if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="Invalid role")
if len(password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
with conn.cursor() as cur:
try:
cur.execute(
"INSERT INTO users (username, password_hash, role) VALUES (%s, %s, %s)",
(username, auth.hash_password(password), role),
)
except psycopg.errors.UniqueViolation:
raise HTTPException(status_code=409, detail="Username already exists")
if group_ids:
user_id = cur.execute(
"SELECT id FROM users WHERE username = %s", (username,)
).fetchone()[0]
cur.executemany(
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
[(user_id, int(gid)) for gid in group_ids],
)
return RedirectResponse("/users", status_code=303)
@app.get("/users/{user_id}", response_class=HTMLResponse)
async def user_detail(request: Request, user_id: int):
identity = require_admin(request)
with get_conn().cursor() as cur:
user = cur.execute(
"SELECT id, username, role, disabled_at, must_change_password"
" FROM users WHERE id = %s",
(user_id,),
).fetchone()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
rules = cur.execute(
"SELECT id, kind, pattern FROM access_rules WHERE user_id = %s"
" ORDER BY kind, pattern",
(user_id,),
).fetchall()
member_of = cur.execute(
"SELECT g.id, g.name FROM groups g"
" JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = %s",
(user_id,),
).fetchall()
all_groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
return templates.TemplateResponse(
request,
"user_detail.html",
{
"identity": identity,
"user": user,
"rules": rules,
"member_of": member_of,
"all_groups": all_groups,
},
)
@app.post("/users/{user_id}/password")
async def set_user_password(request: Request, user_id: int, password: str = Form(...)):
identity = require_admin(request)
await require_csrf(request, identity)
if len(password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
with get_conn().cursor() as cur:
cur.execute(
"UPDATE users SET password_hash = %s, must_change_password = TRUE"
" WHERE id = %s",
(auth.hash_password(password), user_id),
)
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
return RedirectResponse(f"/users/{user_id}", status_code=303)
@app.post("/users/{user_id}/groups")
async def set_user_groups(
request: Request, user_id: int, group_ids: list[str] = Form(default=[])
):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
if group_ids:
cur.executemany(
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
[(user_id, int(gid)) for gid in group_ids],
)
return RedirectResponse(f"/users/{user_id}", status_code=303)
@app.post("/users/{user_id}/disable")
async def disable_user(request: Request, user_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute(
"UPDATE users SET disabled_at = now() WHERE id = %s AND disabled_at IS NULL",
(user_id,),
)
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/enable")
async def enable_user(request: Request, user_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("UPDATE users SET disabled_at = NULL WHERE id = %s", (user_id,))
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/delete")
async def delete_user(request: Request, user_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
if user_id == identity.user_id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
with get_conn().cursor() as cur:
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/rules")
async def add_user_rule(
request: Request, user_id: int, kind: str = Form(...), pattern: str = Form(...)
):
identity = require_admin(request)
await require_csrf(request, identity)
if kind not in ("allow", "deny"):
raise HTTPException(status_code=400, detail="Invalid kind")
pattern = pattern.strip()
if not pattern:
raise HTTPException(status_code=400, detail="Pattern is required")
with get_conn().cursor() as cur:
cur.execute(
"INSERT INTO access_rules (user_id, kind, pattern) VALUES (%s, %s, %s)",
(user_id, kind, pattern),
)
return RedirectResponse(f"/users/{user_id}", status_code=303)
@app.post("/users/{user_id}/rules/{rule_id}/delete")
async def delete_user_rule(request: Request, user_id: int, rule_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute(
"DELETE FROM access_rules WHERE id = %s AND user_id = %s", (rule_id, user_id)
)
return RedirectResponse(f"/users/{user_id}", status_code=303)
# -- Groups (admin) -----------------------------------------------------------
@app.get("/groups", response_class=HTMLResponse)
async def groups_page(request: Request):
identity = require_admin(request)
with get_conn().cursor() as cur:
groups = cur.execute(
"""
SELECT g.id, g.name,
(SELECT count(*) FROM user_groups ug WHERE ug.group_id = g.id)
AS member_count,
(SELECT count(*) FROM access_rules ar WHERE ar.group_id = g.id)
AS rule_count
FROM groups g ORDER BY g.name
"""
).fetchall()
return templates.TemplateResponse(
request, "groups.html", {"identity": identity, "groups": groups}
)
@app.post("/groups")
async def create_group(request: Request, name: str = Form(...)):
identity = require_admin(request)
await require_csrf(request, identity)
name = name.strip()
if not name:
raise HTTPException(status_code=400, detail="Group name is required")
with get_conn().cursor() as cur:
try:
cur.execute("INSERT INTO groups (name) VALUES (%s)", (name,))
except psycopg.errors.UniqueViolation:
raise HTTPException(status_code=409, detail="Group name already exists")
return RedirectResponse("/groups", status_code=303)
@app.get("/groups/{group_id}", response_class=HTMLResponse)
async def group_detail(request: Request, group_id: int):
identity = require_admin(request)
with get_conn().cursor() as cur:
group = cur.execute(
"SELECT id, name FROM groups WHERE id = %s", (group_id,)
).fetchone()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
rules = cur.execute(
"SELECT id, kind, pattern FROM access_rules WHERE group_id = %s"
" ORDER BY kind, pattern",
(group_id,),
).fetchall()
members = cur.execute(
"SELECT u.id, u.username FROM users u"
" JOIN user_groups ug ON ug.user_id = u.id WHERE ug.group_id = %s",
(group_id,),
).fetchall()
all_users = cur.execute(
"SELECT id, username FROM users ORDER BY username"
).fetchall()
return templates.TemplateResponse(
request,
"group_detail.html",
{
"identity": identity,
"group": group,
"rules": rules,
"members": members,
"all_users": all_users,
},
)
@app.post("/groups/{group_id}/rules")
async def add_group_rule(
request: Request, group_id: int, kind: str = Form(...), pattern: str = Form(...)
):
identity = require_admin(request)
await require_csrf(request, identity)
if kind not in ("allow", "deny"):
raise HTTPException(status_code=400, detail="Invalid kind")
pattern = pattern.strip()
if not pattern:
raise HTTPException(status_code=400, detail="Pattern is required")
with get_conn().cursor() as cur:
cur.execute(
"INSERT INTO access_rules (group_id, kind, pattern) VALUES (%s, %s, %s)",
(group_id, kind, pattern),
)
return RedirectResponse(f"/groups/{group_id}", status_code=303)
@app.post("/groups/{group_id}/rules/{rule_id}/delete")
async def delete_group_rule(request: Request, group_id: int, rule_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute(
"DELETE FROM access_rules WHERE id = %s AND group_id = %s",
(rule_id, group_id),
)
return RedirectResponse(f"/groups/{group_id}", status_code=303)
@app.post("/groups/{group_id}/members")
async def set_group_members(
request: Request, group_id: int, user_ids: list[str] = Form(default=[])
):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("DELETE FROM user_groups WHERE group_id = %s", (group_id,))
if user_ids:
cur.executemany(
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
[(int(uid), group_id) for uid in user_ids],
)
return RedirectResponse(f"/groups/{group_id}", status_code=303)
@app.post("/groups/{group_id}/delete")
async def delete_group(request: Request, group_id: int):
identity = require_admin(request)
await require_csrf(request, identity)
with get_conn().cursor() as cur:
cur.execute("DELETE FROM groups WHERE id = %s", (group_id,))
return RedirectResponse("/groups", status_code=303)
# -- Data browser -------------------------------------------------------------
@app.get("/data", response_class=HTMLResponse)
async def data_page(request: Request, topic: Optional[str] = None):
identity = require_login(request)
rows = _telemetry_rows(identity, topic)
return templates.TemplateResponse(
request,
"data.html",
{"identity": identity, "rows": rows, "topic": topic or ""},
)
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
psycopg[binary,pool]==3.2.3
jinja2==3.1.5
passlib[argon2]==1.7.4
python-multipart==0.0.20
+198
View File
@@ -0,0 +1,198 @@
:root {
--bg: #f4f5f7;
--card: #ffffff;
--text: #1f2430;
--muted: #6b7280;
--accent: #2563eb;
--accent-dark: #1e40af;
--danger: #dc2626;
--ok: #16a34a;
--warn: #d97706;
--border: #e2e5ea;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 15px;
}
header {
background: #101828;
color: #fff;
}
nav {
display: flex;
align-items: center;
gap: 1rem;
max-width: 960px;
margin: 0 auto;
padding: 0.6rem 1rem;
}
nav a { color: #cbd5e1; text-decoration: none; padding: 0.25rem 0.5rem; border-radius: 4px; }
nav a:hover { color: #fff; }
nav a.active { color: #fff; background: rgba(255, 255, 255, 0.12); }
nav a.brand { font-weight: 700; color: #fff; margin-right: 0.5rem; }
nav .spacer { flex: 1; }
nav .user { color: #94a3b8; font-size: 0.9em; }
form.inline { display: inline; }
main {
max-width: 960px;
margin: 1.5rem auto;
padding: 0 1rem;
}
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
.banner {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
padding: 0.5rem 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.error {
background: #fee2e2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.5rem 0.75rem;
border-radius: 6px;
}
.hint { color: var(--muted); font-size: 0.88em; }
table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
th { background: #f9fafb; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
td.actions { display: flex; gap: 0.5rem; }
td code, .inline-form code { font-size: 0.9em; }
.tag {
display: inline-block;
font-size: 0.75em;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: #e5e7eb;
color: #374151;
}
.tag.allow, .tag.ok { background: #dcfce7; color: #166534; }
.tag.deny, .tag.disabled { background: #fee2e2; color: #991b1b; }
.tag.warn { background: #fef3c7; color: #92400e; }
form.stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
max-width: 480px;
}
form.stack label, form.stack fieldset legend { font-size: 0.85em; color: var(--muted); }
form.stack input[type="text"], form.stack input[type="password"], form.stack input[type="search"],
form.inline-form input[type="text"], form.filter input[type="search"] {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.95em;
margin-top: 0.2rem;
}
form.stack select, form.inline-form select {
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.95em;
}
form.inline-form {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
flex-wrap: wrap;
}
form.inline-form input[type="text"] { width: auto; flex: 1; min-width: 200px; margin-top: 0; }
button {
background: var(--accent);
color: #fff;
border: none;
padding: 0.45rem 1rem;
border-radius: 6px;
font-size: 0.9em;
cursor: pointer;
}
button:hover { background: var(--accent-dark); }
button.link {
background: none;
color: var(--accent);
padding: 0.1rem 0.3rem;
font-size: 0.85em;
text-decoration: underline;
}
button.link:hover { background: none; color: var(--accent-dark); }
button.link.danger { color: var(--danger); }
button.link.danger:hover { color: #991b1b; }
form.filter {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
form.filter input[type="search"] { flex: 1; }
label.check { display: flex; gap: 0.5rem; align-items: center; font-size: 0.95em; }
fieldset { border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
section { margin-top: 2rem; }
/* Login page */
body.login-body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #101828;
}
.login-card {
background: var(--card);
padding: 2rem;
border-radius: 8px;
width: 340px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-card h1 { margin: 0; font-size: 1.2rem; }
.login-card form { display: flex; flex-direction: column; gap: 0.75rem; }
.login-card label { font-size: 0.85em; color: var(--muted); }
.login-card input {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.95em;
margin-top: 0.2rem;
}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MQTT Ingester{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<nav>
<a class="brand" href="/data">MQTT Ingester</a>
<a href="/data" {% if nav == 'data' %}class="active"{% endif %}>Data</a>
{% if identity and identity.is_admin %}
<a href="/users" {% if nav == 'users' %}class="active"{% endif %}>Users</a>
<a href="/groups" {% if nav == 'groups' %}class="active"{% endif %}>Groups</a>
{% endif %}
<span class="spacer"></span>
{% if identity %}
<span class="user">{{ identity.username }}{% if identity.is_admin %} (admin){% endif %}</span>
<a href="/password">Password</a>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<button type="submit" class="link">Log out</button>
</form>
{% endif %}
</nav>
</header>
<main>
{% if identity and identity.must_change_password %}
<div class="banner">Default password in use — please <a href="/password">change your password</a>.</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Data — MQTT Ingester{% endblock %}
{% block content %}
<h1>Recent telemetry</h1>
<form method="get" action="/data" class="filter">
<input type="search" name="topic" value="{{ topic }}" placeholder="Filter by topic, e.g. 11001.*">
<button type="submit">Filter</button>
</form>
<p class="hint">Showing the 100 most recent measurements you have access to. Patterns follow the access rules: <code>*</code> matches any sequence.</p>
{% if rows %}
<table>
<thead><tr><th>Time</th><th>Topic</th><th>Payload</th></tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.time }}</td>
<td>{{ row.topic }}</td>
<td><code>{{ row.payload | tojson }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No telemetry visible (nothing ingested yet, or your access rules match no topics).</p>
{% endif %}
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}{{ group.name }} — MQTT Ingester{% endblock %}
{% block content %}
<h1>{{ group.name }}</h1>
<section>
<h2>Access rules</h2>
<p class="hint">Glob patterns matched against the full tag name. <code>*</code> matches any sequence (including <code>.</code>). Deny wins over allow. Group rules combine with users' personal rules.</p>
<table>
<thead><tr><th>Kind</th><th>Pattern</th><th></th></tr></thead>
<tbody>
{% for r in rules %}
<tr>
<td><span class="tag {{ r.kind }}">{{ r.kind }}</span></td>
<td><code>{{ r.pattern }}</code></td>
<td>
<form method="post" action="/groups/{{ group.id }}/rules/{{ r.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<button type="submit" class="link danger">remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<form method="post" action="/groups/{{ group.id }}/rules" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<select name="kind">
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
<input type="text" name="pattern" placeholder="e.g. 11001.*" required>
<button type="submit">Add rule</button>
</form>
</section>
<section>
<h2>Members</h2>
<form method="post" action="/groups/{{ group.id }}/members" class="stack">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<fieldset>
{% for u in all_users %}
<label class="check">
<input type="checkbox" name="user_ids" value="{{ u.id }}"
{% if u.id in members | map(attribute='id') | list %}checked{% endif %}>
{{ u.username }}
</label>
{% endfor %}
</fieldset>
<button type="submit">Save members</button>
</form>
</section>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Groups — MQTT Ingester{% endblock %}
{% block content %}
<h1>Groups</h1>
<h2>Create group</h2>
<form method="post" action="/groups" class="stack">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<label>Name <input type="text" name="name" required></label>
<button type="submit">Create</button>
</form>
<h2>Existing groups</h2>
{% if groups %}
<table>
<thead><tr><th>Name</th><th>Members</th><th>Rules</th><th></th></tr></thead>
<tbody>
{% for g in groups %}
<tr>
<td><a href="/groups/{{ g.id }}">{{ g.name }}</a></td>
<td>{{ g.member_count }}</td>
<td>{{ g.rule_count }}</td>
<td>
<form method="post" action="/groups/{{ g.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<button type="submit" class="link danger">delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No groups yet.</p>
{% endif %}
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log in — MQTT Ingester</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<main class="login-card">
<h1>MQTT Ingester</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="/login">
<label>Username
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Log in</button>
</form>
</main>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Password — MQTT Ingester{% endblock %}
{% block content %}
<h1>Change password</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
{% if identity.must_change_password %}
<p class="banner">You are using the default or admin-set password. Set a new one to continue — you will be redirected here until you do.</p>
{% endif %}
<form method="post" action="/password" class="stack">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<label>Current password
<input type="password" name="current_password" required>
</label>
<label>New password
<input type="password" name="new_password" required minlength="8" autocomplete="new-password">
</label>
<label>Confirm new password
<input type="password" name="confirm_password" required minlength="8" autocomplete="new-password">
</label>
<button type="submit">Change password</button>
</form>
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}{{ user.username }} — MQTT Ingester{% endblock %}
{% block content %}
<h1>{{ user.username }} <span class="tag">{{ user.role }}</span></h1>
{% if user.disabled_at %}<p class="banner">User is disabled.</p>{% endif %}
<section>
<h2>Groups</h2>
<form method="post" action="/users/{{ user.id }}/groups" class="stack">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<fieldset>
{% for g in all_groups %}
<label class="check">
<input type="checkbox" name="group_ids" value="{{ g.id }}"
{% if g.id in member_of | map(attribute='id') | list %}checked{% endif %}>
{{ g.name }}
</label>
{% else %}
<p class="hint">No groups exist yet — create one under Groups.</p>
{% endfor %}
</fieldset>
<button type="submit">Save groups</button>
</form>
</section>
<section>
<h2>Personal access rules</h2>
<p class="hint">Glob patterns matched against the full tag name. <code>*</code> matches any sequence (including <code>.</code>). Deny wins over allow. Personal rules combine with group rules; use a personal deny to override a group allow, and vice versa.</p>
<table>
<thead><tr><th>Kind</th><th>Pattern</th><th></th></tr></thead>
<tbody>
{% for r in rules %}
<tr>
<td><span class="tag {{ r.kind }}">{{ r.kind }}</span></td>
<td><code>{{ r.pattern }}</code></td>
<td>
<form method="post" action="/users/{{ user.id }}/rules/{{ r.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<button type="submit" class="link danger">remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<form method="post" action="/users/{{ user.id }}/rules" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<select name="kind">
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
<input type="text" name="pattern" placeholder="e.g. 11001.*_Value" required>
<button type="submit">Add rule</button>
</form>
</section>
<section>
<h2>Set new password</h2>
<form method="post" action="/users/{{ user.id }}/password" class="stack">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<label>Password <input type="password" name="password" required minlength="8"></label>
<button type="submit">Set password</button>
</form>
<p class="hint">The user must change it at next login.</p>
</section>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Users — MQTT Ingester{% endblock %}
{% block content %}
<h1>Users</h1>
<h2>Create user</h2>
<form method="post" action="/users" class="stack">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<label>Username <input type="text" name="username" required></label>
<label>Password <input type="password" name="password" required minlength="8"></label>
<label>Role
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</label>
<fieldset>
<legend>Groups</legend>
{% for g in groups %}
<label class="check"><input type="checkbox" name="group_ids" value="{{ g.id }}"> {{ g.name }}</label>
{% endfor %}
</fieldset>
<button type="submit">Create</button>
</form>
<h2>Existing users</h2>
<table>
<thead><tr><th>Username</th><th>Role</th><th>Groups</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td><a href="/users/{{ u.id }}">{{ u.username }}</a></td>
<td>{{ u.role }}</td>
<td>{{ u.groups | join(', ') if u.groups else '—' }}</td>
<td>
{% if u.disabled_at %}<span class="tag disabled">disabled</span>
{% elif u.must_change_password %}<span class="tag warn">must change pw</span>
{% else %}<span class="tag ok">active</span>{% endif %}
</td>
<td class="actions">
<form method="post" action="/users/{{ u.id }}/{% if u.disabled_at %}enable{% else %}disable{% endif %}">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<button type="submit" class="link">{{ 'enable' if u.disabled_at else 'disable' }}</button>
</form>
<form method="post" action="/users/{{ u.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
<button type="submit" class="link danger">delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}