🚧 WIP
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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>"
|
||||
@@ -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
|
||||
@@ -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[@]}"
|
||||
@@ -0,0 +1 @@
|
||||
"""Code shared by the api and webui services."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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 ""},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user