This commit is contained in:
Jonas Bengtsson
2026-09-05 14:22:29 +02:00
parent f9d10364f8
commit 2117f96ae7
20 changed files with 1721 additions and 112 deletions
+3
View File
@@ -62,3 +62,6 @@ PGADMIN_DEFAULT_EMAIL=jonas@bengtsson.ws
PGADMIN_DEFAULT_PASSWORD=change_this_bad_password
PGADMIN_DISABLE_POSTFIX=true
PGADMIN_LISTEN_ADDRESS=0.0.0.0
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=some_random_password
+7
View File
@@ -39,6 +39,13 @@ INJECT_DEVICE_KEY=device
# table has rows (and never on a migrated database).
BOOTSTRAP_APP_TOKEN=
# First admin for the management UI. On the FIRST deployment the users
# table is empty; on the first login attempt the api seeds this account
# (role admin, scrypt-hashed password). Set a strong password, create real
# users in the UI, then unset this. Ignored once users has rows.
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=some_random_password
# Management UI (ui container). Host port for the browser; API_UPSTREAM is
# where nginx proxies /api to (service name:port inside the compose network).
UI_HOST_PORT=18090
+30 -2
View File
@@ -106,7 +106,19 @@ npm run dev # serves the SPA on :5173, proxying /api to localhost:18080
## API reference
Authentication: pass one of the configured app tokens either as the `X-App-Token` header or as the `app_token` query parameter. Missing/invalid tokens get `401`.
Authentication is layered:
- **App tokens** (`X-App-Token` header or `app_token` query param) — for machine clients (scripts, integrations). Unrestricted access; SHA-256 digests live in `app_tokens`.
- **User sessions** — for humans via the management UI. `POST /auth/login` (username/password) returns an opaque Bearer token; only its SHA-256 digest is stored in `sessions` (30-day server-side expiry). Roles gate writes:
| Capability | `user` | `superuser` | `admin` | app token |
| --- | --- | --- | --- | --- |
| Read messages/devices/attributes/users | ✓ | ✓ | ✓ | ✓ |
| Write devices, settings, attribute timelines, configs, tokens | — 403 | ✓ | ✓ | ✓ |
| Add users | — 403 | ✓ — only role `user` | ✓ any role | ✓ |
| Modify/delete users | — 403 | ✓ — only `user`-role accounts | ✓ (never the last admin) | ✓ |
The first admin is seeded from `BOOTSTRAP_ADMIN_USER`/`BOOTSTRAP_ADMIN_PASSWORD` on the first login attempt against an empty `users` table (same bootstrap pattern as `BOOTSTRAP_APP_TOKEN`). Sessions resolve the user's CURRENT role on every request, so role changes apply immediately. Login is throttled: 5 failed attempts per username → 15-minute lockout (429). Missing/invalid credentials get `401`; valid credentials but insufficient role get `403`.
| Endpoint | Auth | Description |
| -------- | ---- | ----------- |
@@ -123,10 +135,17 @@ Authentication: pass one of the configured app tokens either as the `X-App-Token
| `GET /devices/{imei}/attributes/history` | token | The full timeline per key, oldest version first: `{key: [{value, effective_from, note}, ...]}` |
| `PUT/POST /devices/{imei}/attributes` | token | **Append one version** to a key's timeline. Body: `{"key": ..., "value": ..., "effective_from": ISO8601?, "note"?}`; `effective_from` defaults to now, backdating allowed (a sensor swap discovered after the fact becomes a backdated row). Re-using an exact existing `(key, effective_from)` edits that version in place |
| `DELETE /devices/{imei}/attributes/{key}` | token | Delete **only the latest** version of a key (typo/undo); earlier history stays immutable and the previous value is back in effect. Unknown key → 404 |
| `POST /devices/{imei}/rename` | superuser+ | **Backfill-rename** the device name on stored messages (both the `device` column and the payload key): `{"device": "New", "from"?: ISO8601, "to"?: ISO8601, "dry_run"?}`. Bounds are inclusive on `received_at`; an absent/null bound means **no framing on that end**. `dry_run: true` returns the affected count without writing. No undo — the IMEI is the identity, names are labels |
| `GET /tokens` | token | App names with a token (secrets are unrecoverable, shown as `null`) |
| `PUT /tokens/{app}` | token | **Add or rotate ONE app token** (single-row upsert — never touches the other apps' rows). Body: `{"token": "secret"}`. Takes effect immediately |
| `DELETE /tokens/{app}` | token | Remove one app token; unknown name → 404. Deleting the last token locks the API until a new one is added |
| `GET /docs`, `GET /redoc` | none | OpenAPI docs (public) |
| `POST /auth/login` | none | `{"username", "password"}` → `{"token", "username", "role"}`. Opaque Bearer token for the session; 5 failed attempts lock the username for 15 min (429) |
| `GET /auth/me` | session or app token | `{"username", "role"}` (both `null` for app tokens) |
| `POST /auth/logout` | session or app token | Revokes the Bearer session |
| `GET /users` | any | All users as `[{username, role}]` (passwords are unrecoverable) |
| `PUT /users/{username}` | superuser+ | Create/reset a user: `{"password", "role"?}` (role default `user`). Superusers may only assign role `user` (403 otherwise); admins any role |
| `DELETE /users/{username}` | superuser+ | Delete a user (their sessions are revoked via cascade). Superusers only `user`-role accounts; admins any except the last admin |
When the database is unreachable, authenticated endpoints return **503** (the API has no purpose without the db).
@@ -214,4 +233,13 @@ Example response:
}
]
}
```
```
## TODO
- [x] Add possability to rename ingested device names in message payloads, e g based on the imei. Must be possible to perform between datetimes. If either field(from or to) is unset, no framing is performed in that end. (→ `POST /devices/{imei}/rename`, see the endpoint table)
- [x] Separate payload relative settings from auth settings. Sub-sections in menu? (→ sidebar grouped into **Data** / **Access** sections)
- [x] Hide features from users not able to use them based on authorization. (→ auth-management links hidden for read-only `user` role; route guard blocks direct URL access; the API enforces 403 server-side regardless)
- [ ] Fix dashboard view, message expanding outside table. Make it look like the table in messages view.
- [ ] Make all tables able to sort on header.
- [ ] Remove clickable device links in tables for users that is not allowed to see the device view, or does not have enought authorization to be able to change anything. CLicking as a user ends up on a page with only some text.
- [ ] Change datetime localization. How is it handled now, from browser or static? I'm in sweden and not following swedish rules on layout.
+638 -27
View File
@@ -12,10 +12,17 @@ resolves each device's TIME-VARYING attributes (offsets etc., from the
imei_attributes timeline) as of the message's device time and returns
them as a sibling "attributes" field.
Authentication: app tokens stored as SHA-256 digests in the app_tokens
table, passed either as the `X-App-Token` header or as an `app_token` query
parameter. Digests are compared in constant time; invalid tokens yield 401.
When the database is unavailable, authenticated endpoints return 503.
Authentication is layered:
- app tokens (SHA-256 digests in app_tokens, X-App-Token header) — machine
clients with unrestricted access
- interactive users (scrypt password hashes in the users table) logging
in via POST /auth/login to get an opaque Bearer session token; only its
SHA-256 digest is stored in sessions. Roles: admin (manages everything
incl. users), superuser (configures devices/configs/tokens and manages
day-to-day 'user' accounts), user (read-only viewer). App tokens keep
unrestricted access; session users carry their role per request and
writes are gated by require_level(). When the database is unavailable,
authenticated endpoints return 503.
"""
from __future__ import annotations
@@ -24,8 +31,10 @@ import hashlib
import hmac
import logging
import os
import secrets
import threading
from datetime import datetime, timezone
import time
from datetime import datetime, timedelta, timezone
from typing import Any
import psycopg
@@ -37,6 +46,40 @@ from listener.process import normalize_imei, validate_imei_doc, validate_imei_ma
logger = logging.getLogger("api")
# --- roles ---------------------------------------------------------------
# Privilege levels: user (read-only) < superuser (configure + manage 'user'
# accounts) < admin (everything). App tokens are unrestricted.
ROLE_LEVELS = {"user": 0, "superuser": 1, "admin": 2}
# Login brute-force protection (in-process, per username).
LOCKOUT_ATTEMPTS = 5
LOCKOUT_MINUTES = 15
# Session lifetime.
SESSION_TTL_DAYS = 30
def hash_password(password: str, salt: bytes | None = None) -> str:
"""scrypt password hash, stored as 'scrypt$<salthex>$<keyhex>'.
Only hashes are persisted; plaintext passwords never are."""
salt = salt if salt is not None else secrets.token_bytes(16)
key = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32
)
return f"scrypt${salt.hex()}${key.hex()}"
def verify_password(password: str, stored: str) -> bool:
"""Constant-time check of a password against a stored scrypt hash."""
try:
algo, salt_hex, _key_hex = stored.split("$")
if algo != "scrypt":
return False
candidate = hash_password(password, salt=bytes.fromhex(salt_hex))
return hmac.compare_digest(candidate, stored)
except (ValueError, TypeError):
return False
def hash_token(token: str) -> str:
"""SHA-256 hex digest of an app token. Only digests are stored in the
@@ -395,6 +438,352 @@ class ImeiAttributesStore:
conn.close()
class UserStore:
"""Interactive users backed by the users table (scrypt hashes).
The first admin is seeded lazily from BOOTSTRAP_ADMIN_USER/
BOOTSTRAP_ADMIN_PASSWORD when the table is empty (same bootstrap
pattern as app_tokens). Sessions join users for the CURRENT role on
every request, so role changes apply immediately.
Capability caps are enforced HERE (not just in the endpoints), because
the stores are the single choke point every request funnels through:
- admin: any operation on any account
- superuser: create accounts, but only with role 'user'; modify/delete
only accounts whose current role is 'user'
- user: no user management
"""
def __init__(
self,
dsn: str,
bootstrap_user: str | None = None,
bootstrap_password: str | None = None,
) -> None:
self._dsn = dsn
self._bootstrap_user = bootstrap_user
self._bootstrap_password = bootstrap_password
self._bootstrap_lock = threading.Lock()
self._bootstrapped = False
def _ensure_bootstrapped(self) -> None:
"""Seed the first admin once, if configured and the table is empty.
Lazy like BOOTSTRAP_APP_TOKEN: a db outage at startup must not
block startup; a failed attempt retries on the next request."""
if self._bootstrapped:
return
with self._bootstrap_lock:
if self._bootstrapped:
return
conn = psycopg.connect(self._dsn, autocommit=True)
try:
with conn.cursor() as cur:
if self._bootstrap_user and self._bootstrap_password:
cur.execute(
"""
INSERT INTO users (username, password_hash, role)
SELECT %s, %s, 'admin'
WHERE NOT EXISTS (SELECT 1 FROM users)
ON CONFLICT DO NOTHING
""",
(self._bootstrap_user, hash_password(self._bootstrap_password)),
)
if cur.rowcount:
logger.info(
"seeded bootstrap admin %r from BOOTSTRAP_ADMIN_USER",
self._bootstrap_user,
)
else:
cur.execute("SELECT count(*) FROM users")
if cur.fetchone()[0] == 0:
logger.warning(
"users is empty and BOOTSTRAP_ADMIN_USER/"
"BOOTSTRAP_ADMIN_PASSWORD are not set; the UI "
"cannot log in until a user is added"
)
finally:
conn.close()
self._bootstrapped = True
def verify(self, username: str, password: str) -> str | None:
"""Return the role on success, else None. Unknown usernames burn
the same scrypt time as wrong passwords (timing equalization)."""
self._ensure_bootstrapped()
dummy = hash_password("timing-equalizer")
conn = psycopg.connect(self._dsn)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT role, password_hash FROM users WHERE username = %s",
(username,),
)
row = cur.fetchone()
if row is None:
verify_password(password, dummy) # equalize timing
return None
role, stored = row
if not verify_password(password, stored):
return None
return role
finally:
conn.close()
def list(self) -> list[dict[str, Any]]:
"""All users as [{username, role}] (passwords are unrecoverable)."""
self._ensure_bootstrapped()
conn = psycopg.connect(self._dsn)
try:
with conn.cursor() as cur:
cur.execute("SELECT username, role FROM users ORDER BY username")
return [
{"username": row[0], "role": row[1]} for row in cur.fetchall()
]
finally:
conn.close()
def get_role(self, username: str) -> str | None:
conn = psycopg.connect(self._dsn)
try:
with conn.cursor() as cur:
cur.execute("SELECT role FROM users WHERE username = %s", (username,))
row = cur.fetchone()
return row[0] if row is not None else None
finally:
conn.close()
def upsert(
self, username: str, password: str, role: str, actor_role: str
) -> None:
"""Create or fully update one user (password + role).
Caps (server-side, actor_role = the requesting user's role):
- superuser may only assign role 'user'; any other target role
raises PermissionError (they must never be able to escalate).
- admin may assign any role.
"""
if actor_role not in ROLE_LEVELS:
raise PermissionError(f"unknown actor role {actor_role!r}")
if ROLE_LEVELS[actor_role] < ROLE_LEVELS["admin"] and role != "user":
raise PermissionError(
f"{actor_role} may only create or assign the 'user' role"
)
conn = psycopg.connect(self._dsn, autocommit=True)
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO users (username, password_hash, role)
VALUES (%s, %s, %s)
ON CONFLICT (username) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role, updated_at = now()
""",
(username, hash_password(password), role),
)
finally:
conn.close()
def delete(self, username: str, actor_role: str) -> bool:
"""Delete one user (their sessions cascade).
Caps: superuser may only delete accounts whose current role is
'user'; admin any account except the LAST admin (refused, False).
Returns False when the username is unknown."""
conn = psycopg.connect(self._dsn, autocommit=True)
try:
with conn.cursor() as cur:
cur.execute("SELECT role FROM users WHERE username = %s", (username,))
row = cur.fetchone()
if row is None:
return False
target_role = row[0]
if actor_role == "superuser" and target_role != "user":
raise PermissionError(
"superuser may only delete accounts with role 'user'"
)
if target_role == "admin":
cur.execute("SELECT count(*) FROM users WHERE role = 'admin'")
if cur.fetchone()[0] <= 1:
return False # never the last admin
cur.execute("DELETE FROM users WHERE username = %s", (username,))
return True
finally:
conn.close()
class SessionStore:
"""Login sessions backed by the sessions table: login creates an
opaque bearer token, only its SHA-256 digest is stored. resolve()
joins users so the CURRENT role is returned - role changes apply on
the very next request."""
def __init__(self, dsn: str, ttl_days: int = SESSION_TTL_DAYS) -> None:
self._dsn = dsn
self._ttl = timedelta(days=ttl_days)
def login(self, username: str) -> str:
"""Create a session and return the plaintext bearer token."""
token = secrets.token_urlsafe(32)
# interval as a string: psycopg cannot cast an integer parameter
# to interval directly (make_interval needs named args instead).
ttl = f"{int(self._ttl.total_seconds())} seconds"
conn = psycopg.connect(self._dsn, autocommit=True)
try:
with conn.cursor() as cur:
# opportunistic cleanup of expired sessions
cur.execute("DELETE FROM sessions WHERE expires_at < now()")
cur.execute(
"""
INSERT INTO sessions (token_hash, username, expires_at)
VALUES (%s, %s, now() + %s::interval)
""",
(hash_token(token), username, ttl),
)
finally:
conn.close()
return token
def resolve(self, token: str) -> tuple[str, str] | None:
"""(username, CURRENT role) for a valid, unexpired session."""
conn = psycopg.connect(self._dsn)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT u.username, u.role
FROM sessions s JOIN users u ON u.username = s.username
WHERE s.token_hash = %s AND s.expires_at > now()
""",
(hash_token(token),),
)
row = cur.fetchone()
return (row[0], row[1]) if row is not None else None
finally:
conn.close()
def logout(self, token: str) -> bool:
conn = psycopg.connect(self._dsn, autocommit=True)
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM sessions WHERE token_hash = %s", (hash_token(token),)
)
return cur.rowcount > 0
finally:
conn.close()
class DeviceRenameStore:
"""Backfill-renames the device name on INGESTED messages. The IMEI is
the identity; names are labels — a rename just rewrites the label on
matched rows (both the device column and the payload key). No audit
trail: the caller (superuser/admin) decides; preview via dry_run."""
def __init__(self, dsn: str) -> None:
self._dsn = dsn
def rename(
self,
imei: str,
new_name: str,
from_time: datetime | None,
to_time: datetime | None,
payload_key: str = "device",
dry_run: bool = False,
) -> dict[str, Any]:
"""Rename matching rows' device column AND payload key. NULL bounds
mean unbounded (no framing on that end). Returns the row count."""
match_pred = """
SELECT count(*) FROM mqtt_messages
WHERE imei = %s
AND (%s::timestamptz IS NULL OR received_at >= %s::timestamptz)
AND (%s::timestamptz IS NULL OR received_at <= %s::timestamptz)
AND device IS DISTINCT FROM %s
"""
if dry_run:
# preview: count the matches with the exact predicate the
# update would use, but write nothing
conn = psycopg.connect(self._dsn)
try:
with conn.cursor() as cur:
cur.execute(
match_pred,
(imei, from_time, from_time, to_time, to_time, new_name),
)
return {"updated": cur.fetchone()[0], "dry_run": True}
finally:
conn.close()
# autocommit: a single-statement write commits immediately (a plain
# connection would roll back on close).
conn = psycopg.connect(self._dsn, autocommit=True)
try:
with conn.cursor() as cur:
# jsonb_set's path must be an array; ARRAY[%s]::text[] builds
# it from the key name safely (no string concatenation).
cur.execute(
"""
WITH matched AS (
SELECT id FROM mqtt_messages
WHERE imei = %s
AND (%s::timestamptz IS NULL OR received_at >= %s::timestamptz)
AND (%s::timestamptz IS NULL OR received_at <= %s::timestamptz)
AND device IS DISTINCT FROM %s
), updated AS (
UPDATE mqtt_messages m
SET device = %s,
payload = jsonb_set(m.payload, ARRAY[%s]::text[], %s::jsonb)
FROM matched
WHERE m.id = matched.id
RETURNING m.id
)
SELECT count(*) FROM updated
""",
(
imei,
from_time, from_time,
to_time, to_time,
new_name,
new_name,
payload_key,
Jsonb(new_name),
),
)
return {"updated": cur.fetchone()[0], "dry_run": False}
finally:
conn.close()
def _validate_rename_body(
data: dict[str, Any], source: str
) -> tuple[str, datetime | None, datetime | None, bool]:
"""Validate a rename body {"device", "from"?, "to"?, "dry_run"?} and
return (new_name, from, to, dry_run). Absent/null bounds are unbounded
(no framing on that end). Raises ValueError otherwise."""
if not isinstance(data, dict):
raise ValueError(
f'{source}: expected JSON on the form {{"device": "New name", "from"?: ..., "to"?: ...}}'
)
new_name = data.get("device")
if not isinstance(new_name, str) or not new_name.strip():
raise ValueError(f'{source}: "device" must be a non-empty string')
parsed: list[datetime | None] = []
for field in ("from", "to"):
raw = data.get(field)
if raw is None:
parsed.append(None)
continue
if not isinstance(raw, str):
raise ValueError(f'{source}: "{field}" must be an ISO 8601 string or null')
try:
parsed.append(_as_utc(datetime.fromisoformat(raw)))
except ValueError:
raise ValueError(
f'{source}: "{field}" is not a valid ISO 8601 timestamp'
) from None
dry_run = bool(data.get("dry_run", False))
return new_name.strip(), parsed[0], parsed[1], dry_run
def _validate_token_body(data: dict[str, Any], source: str) -> dict[str, str]:
"""Validate a {"tokens": {app: secret}} request body and return the
inner tokens mapping. Raises ValueError with a descriptive message
@@ -491,12 +880,16 @@ def create_app(
token_store: TokenStore | None = None,
imei_store: ImeiMapStore | None = None,
attributes_store: ImeiAttributesStore | None = None,
user_store: UserStore | None = None,
session_store: SessionStore | None = None,
rename_store: DeviceRenameStore | None = None,
) -> FastAPI:
"""Build the API application.
The stores are injectable so tests can run without a database; in
production the defaults are built from the environment (DB_DSN,
BOOTSTRAP_APP_TOKEN, BOOTSTRAP_APP_NAME).
BOOTSTRAP_APP_TOKEN, BOOTSTRAP_APP_NAME, BOOTSTRAP_ADMIN_USER,
BOOTSTRAP_ADMIN_PASSWORD).
"""
app = FastAPI(
title="nodeledge MQTT data API",
@@ -517,24 +910,69 @@ def create_app(
imei_store = ImeiMapStore(dsn)
if attributes_store is None:
attributes_store = ImeiAttributesStore(dsn)
if user_store is None:
user_store = UserStore(
dsn,
bootstrap_user=os.environ.get("BOOTSTRAP_ADMIN_USER") or None,
bootstrap_password=os.environ.get("BOOTSTRAP_ADMIN_PASSWORD") or None,
)
if session_store is None:
session_store = SessionStore(dsn)
if rename_store is None:
rename_store = DeviceRenameStore(dsn)
# Brute-force lockout state (in-process, per username).
login_attempts: dict[str, dict[str, float]] = {}
def authenticate(request: Request) -> None:
"""401 without valid credentials. X-App-Token keeps unrestricted
machine access; Bearer sessions carry the user's CURRENT role."""
token = request.headers.get("X-App-Token")
if not token:
token = request.query_params.get("app_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing app token: pass the X-App-Token header",
)
try:
if not token_store.verify(token):
if token:
try:
if not token_store.verify(token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid app token",
)
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
return # app token: unrestricted (machine clients)
bearer = request.headers.get("Authorization", "")
if bearer.startswith("Bearer "):
try:
session = session_store.resolve(bearer[7:].strip())
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
if session is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid app token",
detail="invalid or expired session",
)
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
request.state.username = session[0]
request.state.role = session[1]
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing credentials: pass X-App-Token or a Bearer session token",
)
def require_level(minimum: str):
"""Dependency factory gating writes by role level. App tokens
(no role set on request.state) stay unrestricted; logged-in roles
below the minimum get 403."""
def check(request: Request) -> None:
role = getattr(request.state, "role", None)
if role is None:
return # app token: unrestricted per design decision
if ROLE_LEVELS.get(role, -1) < ROLE_LEVELS[minimum]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{minimum} role required",
)
return check
app.state.config = config
app.state.authenticate = authenticate
@@ -562,8 +1000,8 @@ def create_app(
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
@app.put("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
@app.post("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
@app.put("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
@app.post("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def set_config(name: str, body: dict[str, Any]) -> dict[str, Any]:
try:
if name == "app_tokens":
@@ -619,7 +1057,7 @@ def create_app(
)
return _device_repr(normalized, doc)
@app.put("/devices/{imei}", tags=["devices"], dependencies=[Depends(authenticate)])
@app.put("/devices/{imei}", tags=["devices"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def set_device(imei: str, body: dict[str, Any]) -> dict[str, Any]:
normalized = normalize_imei(imei)
if normalized is None:
@@ -649,7 +1087,7 @@ def create_app(
logger.info("device %s upserted: %d setting(s)", normalized, len(doc))
return _device_repr(normalized, doc)
@app.delete("/devices/{imei}", tags=["devices"], dependencies=[Depends(authenticate)])
@app.delete("/devices/{imei}", tags=["devices"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def delete_device(imei: str) -> dict[str, Any]:
normalized = normalize_imei(imei)
if normalized is None:
@@ -728,8 +1166,8 @@ def create_app(
result["at"] = resolved_at.isoformat()
return result
@app.put("/devices/{imei}/attributes", tags=["devices"], dependencies=[Depends(authenticate)])
@app.post("/devices/{imei}/attributes", tags=["devices"], dependencies=[Depends(authenticate)])
@app.put("/devices/{imei}/attributes", tags=["devices"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
@app.post("/devices/{imei}/attributes", tags=["devices"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def add_device_attribute(imei: str, body: dict[str, Any]) -> dict[str, Any]:
normalized = normalize_imei(imei)
if normalized is None:
@@ -764,7 +1202,7 @@ def create_app(
@app.delete(
"/devices/{imei}/attributes/{key}",
tags=["devices"],
dependencies=[Depends(authenticate)],
dependencies=[Depends(authenticate), Depends(require_level("superuser"))],
)
def delete_device_attribute(imei: str, key: str) -> dict[str, Any]:
normalized = normalize_imei(imei)
@@ -791,6 +1229,50 @@ def create_app(
logger.info("attribute %r latest version deleted for %s", name, normalized)
return {"status": "deleted", "imei": normalized, "key": name}
# --- Backfill rename of stored messages: the IMEI is the identity,
# names are labels — a rewrite of the label on matched rows (device
# column AND payload key), optionally framed on received_at. No audit
# trail, no undo: the caller decides after the dry_run preview.
@app.post(
"/devices/{imei}/rename",
tags=["devices"],
dependencies=[Depends(authenticate), Depends(require_level("superuser"))],
)
def rename_device_messages(imei: str, body: dict[str, Any]) -> dict[str, Any]:
normalized = normalize_imei(imei)
if normalized is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{imei!r} is not a 15-digit IMEI",
)
try:
new_name, from_time, to_time, dry_run = _validate_rename_body(
body, "request body"
)
if from_time is not None and to_time is not None and from_time > to_time:
raise ValueError("'from' must be earlier than or equal to 'to'")
result = rename_store.rename(
normalized, new_name, from_time, to_time, dry_run=dry_run
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
logger.info(
"rename for %s -> %r (from=%s to=%s dry_run=%s): %s",
normalized,
new_name,
from_time,
to_time,
dry_run,
result,
)
return {"imei": normalized, "device": new_name, **result}
# --- Token resources: add/rotate/remove ONE app token without
# touching the other apps' rows.
@@ -803,7 +1285,7 @@ def create_app(
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
@app.put("/tokens/{app_name}", tags=["tokens"], dependencies=[Depends(authenticate)])
@app.put("/tokens/{app_name}", tags=["tokens"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def set_token(app_name: str, body: dict[str, Any]) -> dict[str, Any]:
name = app_name.strip()
try:
@@ -822,7 +1304,7 @@ def create_app(
logger.info("token %r upserted", name)
return {"status": "updated", "app": name}
@app.delete("/tokens/{app_name}", tags=["tokens"], dependencies=[Depends(authenticate)])
@app.delete("/tokens/{app_name}", tags=["tokens"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def delete_token(app_name: str) -> dict[str, Any]:
name = app_name.strip()
if not name:
@@ -842,6 +1324,135 @@ def create_app(
logger.info("token %r deleted", name)
return {"status": "deleted", "app": name}
# --- Interactive authentication: username/password login issuing an
# opaque Bearer session token (only its SHA-256 digest is stored).
# POST /auth/login is anonymous; lockout is per username.
@app.post("/auth/login", tags=["auth"])
def login(body: dict[str, Any]) -> dict[str, Any]:
username = body.get("username") if isinstance(body, dict) else None
password = body.get("password") if isinstance(body, dict) else None
if not isinstance(username, str) or not username.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="username is required",
)
if not isinstance(password, str) or not password:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="password is required",
)
name = username.strip()
now = time.monotonic()
state = login_attempts.get(name, {"fails": 0.0, "locked_until": 0.0})
if state["locked_until"] > now:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="too many failed attempts; try again later",
)
try:
role = user_store.verify(name, password)
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
if role is None:
state["fails"] += 1
if state["fails"] >= LOCKOUT_ATTEMPTS:
state["locked_until"] = now + LOCKOUT_MINUTES * 60
state["fails"] = 0.0
logger.warning(
"login for %r locked out for %d minutes", name, LOCKOUT_MINUTES
)
login_attempts[name] = state
# Same response for unknown user and wrong password.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
login_attempts.pop(name, None)
token = session_store.login(name)
logger.info("user %r logged in (role %s)", name, role)
return {"token": token, "username": name, "role": role}
@app.get("/auth/me", tags=["auth"], dependencies=[Depends(authenticate)])
def whoami(request: Request) -> dict[str, Any]:
return {
"username": getattr(request.state, "username", None),
"role": getattr(request.state, "role", None),
}
@app.post("/auth/logout", tags=["auth"], dependencies=[Depends(authenticate)])
def logout(request: Request) -> dict[str, Any]:
bearer = request.headers.get("Authorization", "")
if bearer.startswith("Bearer "):
try:
session_store.logout(bearer[7:].strip())
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
return {"status": "logged out"}
# --- User management. Add: superuser+ (a superuser may only assign
# role 'user' - no escalation). Modify/delete: superuser may only
# touch accounts whose current role is 'user'; admin anything except
# the last admin. Caps enforced in UserStore via the ACTOR's role.
@app.get("/users", tags=["users"], dependencies=[Depends(authenticate)])
def list_users() -> list[dict[str, Any]]:
try:
return user_store.list()
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
@app.put("/users/{username}", tags=["users"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def upsert_user(username: str, body: dict[str, Any], request: Request) -> dict[str, Any]:
name = username.strip()
role = "user"
if isinstance(body, dict) and body.get("role") in ROLE_LEVELS:
role = body["role"]
password = body.get("password") if isinstance(body, dict) else None
if not name or not isinstance(password, str) or not password:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="password (and optionally role) required",
)
# The actor's role comes from the session (set by authenticate);
# an app token has no role and is unrestricted.
actor_role = getattr(request.state, "role", None)
if actor_role not in ROLE_LEVELS:
actor_role = "admin"
try:
user_store.upsert(name, password, role, actor_role=actor_role)
except PermissionError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
logger.info("user %r upserted (role %s)", name, role)
return {"status": "updated", "username": name, "role": role}
@app.delete("/users/{username}", tags=["users"], dependencies=[Depends(authenticate), Depends(require_level("superuser"))])
def delete_user(username: str, request: Request) -> dict[str, Any]:
actor_role = getattr(request.state, "role", None)
if actor_role not in ROLE_LEVELS:
actor_role = "admin" # app token: unrestricted
try:
deleted = user_store.delete(username.strip(), actor_role=actor_role)
except PermissionError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
except psycopg.Error as exc:
raise _db_unavailable(exc) from exc
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"unknown user {username!r}, or the last admin cannot be deleted",
)
logger.info("user %r deleted", username.strip())
return {"status": "deleted", "username": username.strip()}
@app.get("/health", tags=["meta"])
def health() -> dict[str, Any]:
return {"status": "ok"}
BIN
View File
Binary file not shown.
+9 -1
View File
@@ -81,6 +81,11 @@ services:
# this variable. Ignored once app_tokens has rows.
BOOTSTRAP_APP_TOKEN: ${BOOTSTRAP_APP_TOKEN:-}
BOOTSTRAP_APP_NAME: ${BOOTSTRAP_APP_NAME:-bootstrap}
# First admin for the management UI: on the first login attempt the
# api seeds this user (scrypt hash) into the empty users table. Set a
# strong password, then unset after creating real users.
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
API_MAX_LIMIT: ${API_MAX_LIMIT:-10000}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
TZ: ${TZ:-Europe/Stockholm}
@@ -105,6 +110,8 @@ services:
PGADMIN_DISABLE_POSTFIX: ${PGADMIN_DISABLE_POSTFIX}
PGADMIN_LISTEN_ADDRESS: ${PGADMIN_LISTEN_ADDRESS}
TZ: ${TZ:-Europe/Stockholm}
volumes:
- pgadmin_data:/var/lib/pgadmin
ports:
- ${PGADMIN_HOST_PORT:-18081}:80
healthcheck:
@@ -155,4 +162,5 @@ services:
volumes:
db_data:
db_data:
pgadmin_data:
+71 -39
View File
@@ -1,36 +1,76 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Cpu, KeyRound, LayoutDashboard, MessageSquareText, Moon, Sun } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import {
Cpu,
KeyRound,
LayoutDashboard,
LogOut,
MessageSquareText,
Moon,
Sun,
UserRound,
} from 'lucide-vue-next'
import { api } from './api'
import { router } from './router'
import { applyTheme, initialTheme, type Theme } from './theme'
import { submitToken, ui } from './store'
import { canEdit, clearSession, ui } from './store'
const token = ref('')
const theme = ref<Theme>(initialTheme())
function onSubmit() {
submitToken(token.value)
token.value = ''
}
// the login screen renders full-page, without the sidebar
const isLogin = computed(() => router.currentRoute.value.path === '/login')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
applyTheme(theme.value)
}
// Apply on mount (theme.ts also runs before the app mounts, see main.ts)
// so the sidebar ghost button label matches the active theme.
async function logout() {
try {
await api('/api/auth/logout', { method: 'POST' })
} catch {
// already invalid; the local session is cleared regardless
}
clearSession()
router.push('/login')
}
onMounted(() => applyTheme(theme.value))
</script>
<template>
<div class="shell">
<div v-if="isLogin" class="shell">
<main style="max-width: none">
<router-view />
</main>
</div>
<div v-else class="shell">
<aside class="sidebar">
<div class="brand">.:: vivab ::.</div>
<div class="nav-section">Data</div>
<router-link to="/"><LayoutDashboard :size="17" /> <span>Dashboard</span></router-link>
<router-link to="/devices"><Cpu :size="17" /> <span>Devices</span></router-link>
<router-link to="/tokens"><KeyRound :size="17" /> <span>Tokens</span></router-link>
<router-link to="/messages"><MessageSquareText :size="17" /> <span>Messages</span></router-link>
<router-link to="/messages">
<MessageSquareText :size="17" /> <span>Messages</span>
</router-link>
<div v-if="canEdit()" class="nav-section">Access</div>
<router-link v-if="canEdit()" to="/tokens">
<KeyRound :size="17" /> <span>Tokens</span>
</router-link>
<router-link v-if="canEdit()" to="/users">
<UserRound :size="17" /> <span>Users</span>
</router-link>
<div class="bottom">
<div v-if="ui.session" class="session-box">
<div class="muted" style="padding: 0 0.25rem 0.5rem">
{{ ui.session.username }} · {{ ui.session.role }}
</div>
<button class="ghost" @click="logout">
<LogOut :size="16" />
<span>Log out</span>
</button>
</div>
<button class="ghost" @click="toggleTheme">
<Sun v-if="theme === 'dark'" :size="16" />
<Moon v-else :size="16" />
@@ -42,31 +82,23 @@ onMounted(() => applyTheme(theme.value))
<main>
<router-view />
</main>
</div>
<div v-if="ui.toasts.length" class="toasts">
<div
v-for="t in ui.toasts"
:key="t.id"
class="toast"
:class="t.kind"
@click="ui.toasts.splice(ui.toasts.indexOf(t), 1)"
>
{{ t.text }}
</div>
</div>
<div v-if="ui.showTokenPrompt" class="modal-backdrop">
<form class="modal" @submit.prevent="onSubmit">
<h1>App token</h1>
<p class="muted">
Paste an app token (X-App-Token). It is stored in this browser and sent with
every request. A real login flow will replace this later.
</p>
<input v-model="token" type="password" placeholder="app token" autofocus />
<div class="row" style="margin-top: 1rem">
<button class="primary" type="submit">Save</button>
</div>
</form>
<div v-if="ui.toasts.length" class="toasts">
<div
v-for="t in ui.toasts"
:key="t.id"
class="toast"
:class="t.kind"
@click="ui.toasts.splice(ui.toasts.indexOf(t), 1)"
>
{{ t.text }}
</div>
</div>
</template>
</template>
<style scoped>
.session-box {
margin-bottom: 0.5rem;
}
</style>
+13 -11
View File
@@ -50,16 +50,17 @@ export class ApiError extends Error {
}
}
// The auth token is pasted into the app by the admin (real login comes
// later); kept in localStorage and sent as X-App-Token on every request.
const TOKEN_KEY = 'nodeledge-ui-token'
// The session (bearer token + role) lives in localStorage; read directly
// here to avoid a circular import with store.ts.
const SESSION_KEY = 'nodeledge-ui-session'
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
function sessionToken(): string {
try {
const raw = localStorage.getItem(SESSION_KEY)
return raw ? (JSON.parse(raw) as { token: string }).token : ''
} catch {
return ''
}
}
type Listener = () => void
@@ -71,17 +72,18 @@ export function onUnauthorized(listener: Listener): () => void {
}
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const token = sessionToken()
const res = await fetch(path, {
...init,
headers: {
'X-App-Token': getToken(),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
if (res.status === 401) {
for (const listener of unauthorizedListeners) listener()
throw new ApiError(401, 'invalid or missing app token')
throw new ApiError(401, 'not logged in')
}
if (!res.ok) {
const body: unknown = await res.json().catch(() => null)
+21
View File
@@ -1,19 +1,40 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { loadSession, ui } from './store'
import Dashboard from './views/Dashboard.vue'
import Devices from './views/Devices.vue'
import DeviceDetail from './views/DeviceDetail.vue'
import Tokens from './views/Tokens.vue'
import Users from './views/Users.vue'
import Messages from './views/Messages.vue'
import Login from './views/Login.vue'
// Hash routing: SPA routes can never collide with the API paths that the
// nginx /api proxy serves.
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/login', component: Login },
{ path: '/', component: Dashboard },
{ path: '/devices', component: Devices },
{ path: '/devices/:imei', component: DeviceDetail, props: true },
{ path: '/tokens', component: Tokens },
{ path: '/users', component: Users },
{ path: '/messages', component: Messages },
],
})
// Route guard: everything except /login requires a session. An expired or
// revoked session is caught by the api() wrapper (401 -> clearSession),
// and the next navigation bounces to /login. Role-gated routes redirect
// unauthorized roles back to the dashboard (the API enforces 403 anyway;
// this keeps the URL honest).
router.beforeEach((to) => {
loadSession()
if (to.path !== '/login' && !ui.session) return '/login'
if (ui.session && to.path.startsWith('/users') && ui.session.role === 'user') {
return '/'
}
if (ui.session && to.path.startsWith('/tokens') && ui.session.role === 'user') {
return '/'
}
})
+46 -12
View File
@@ -1,7 +1,10 @@
// Shared UI state: toasts + the token prompt (login UI comes later).
// Shared UI state: toasts + the login session. Sessions are opaque
// bearer tokens issued by POST /auth/login (users table, roles: admin /
// superuser / user); the token lives in localStorage under this key and
// is sent as Authorization: Bearer by the api() wrapper.
import { reactive } from 'vue'
import { ApiError, onUnauthorized, setToken } from './api'
import { ApiError, onUnauthorized } from './api'
export interface Toast {
id: number
@@ -9,9 +12,17 @@ export interface Toast {
text: string
}
export interface Session {
token: string
username: string
role: 'admin' | 'superuser' | 'user'
}
const SESSION_KEY = 'nodeledge-ui-session'
const state = reactive({
toasts: [] as Toast[],
showTokenPrompt: false,
session: null as Session | null,
nextToastId: 1,
})
@@ -32,13 +43,36 @@ export function showError(error: unknown): void {
export const ui = state
// Any 401 from any screen re-opens the token prompt.
onUnauthorized(() => {
state.showTokenPrompt = true
})
export function setSession(session: Session): void {
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
state.session = session
}
export function submitToken(token: string): void {
setToken(token)
state.showTokenPrompt = false
toast('success', 'token saved')
}
export function loadSession(): void {
try {
const raw = localStorage.getItem(SESSION_KEY)
state.session = raw ? (JSON.parse(raw) as Session) : null
} catch {
state.session = null
}
}
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY)
state.session = null
}
export function canEdit(): boolean {
// superuser and admin may write; app-token sessions are not a thing in
// the UI anymore (login is mandatory), so the role drives everything.
return state.session !== null && state.session.role !== 'user'
}
export function isAdmin(): boolean {
return state.session?.role === 'admin'
}
// Any 401 from any screen invalidates the session (expired/deleted user).
onUnauthorized(() => {
clearSession()
})
+20
View File
@@ -80,6 +80,22 @@ body {
letter-spacing: 0.01em;
}
/* non-clickable section labels (Data / Access) */
.sidebar .nav-section {
color: #64748b;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
padding: 1rem 0.75rem 0.3rem;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.sidebar .nav-section:first-of-type {
padding-top: 0.25rem;
border-top: none;
}
.sidebar a {
display: flex;
align-items: center;
@@ -505,6 +521,10 @@ pre.json {
padding: 0.5rem 0.75rem;
}
.sidebar .nav-section {
display: none;
}
.sidebar a span {
display: none;
}
+87 -4
View File
@@ -4,7 +4,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import type { AttributeHistory, AttributeVersion, Device, ResolvedAttributes } from '../api'
import { formatTime, localInputToUtcIso } from '../format'
import { showError, toast } from '../store'
import { canEdit, showError, toast } from '../store'
const props = defineProps<{ imei: string }>()
@@ -151,6 +151,54 @@ async function deleteLatest(key: string) {
}
}
// --- backfill rename of stored messages ---------------------------------
const renName = ref('')
const renFrom = ref('')
const renTo = ref('')
const renPreview = ref<number | null>(null)
async function previewRename() {
const body: Record<string, unknown> = { device: renName.value.trim(), dry_run: true }
const f = localInputToUtcIso(renFrom.value)
const t = localInputToUtcIso(renTo.value)
if (f) body.from = f
if (t) body.to = t
try {
const res = await api<{ updated: number }>(`/api/devices/${props.imei}/rename`, {
method: 'POST',
body: JSON.stringify(body),
})
renPreview.value = res.updated
} catch (e) {
showError(e)
}
}
async function applyRename() {
if (
!confirm(
`Rename ALL matching stored messages to "${renName.value.trim()}"? ` +
`This rewrites the device column and the payload's device key — there is no undo.`,
)
)
return
const body: Record<string, unknown> = { device: renName.value.trim() }
const f = localInputToUtcIso(renFrom.value)
const t = localInputToUtcIso(renTo.value)
if (f) body.from = f
if (t) body.to = t
try {
const res = await api<{ updated: number }>(`/api/devices/${props.imei}/rename`, {
method: 'POST',
body: JSON.stringify(body),
})
toast('success', `${res.updated} message(s) renamed`)
renPreview.value = null
} catch (e) {
showError(e)
}
}
onMounted(async () => {
await loadDevice()
await loadAttributes()
@@ -164,7 +212,7 @@ onMounted(async () => {
</h1>
<h2>Settings</h2>
<form class="card" @submit.prevent="saveSettings">
<form v-if="canEdit()" class="card" @submit.prevent="saveSettings">
<div class="row" style="margin-bottom: 0.75rem">
<label style="width: 8rem">Device name</label>
<input v-model="name" class="grow" />
@@ -204,7 +252,7 @@ onMounted(async () => {
</form>
<h2>Attribute timeline (time-varying values)</h2>
<form class="card" @submit.prevent="appendAttribute">
<form v-if="canEdit()" class="card" @submit.prevent="appendAttribute">
<div class="row" style="margin-bottom: 0.5rem">
<input v-model="attrKey" class="mono" placeholder="key (e.g. temperature_offset)" style="max-width: 16rem" />
<input v-model="attrValue" class="mono grow" placeholder="value (number, text or JSON)" />
@@ -243,7 +291,7 @@ onMounted(async () => {
<span class="when" style="margin-left: 0.5rem">{{ formatTime(v.effective_from) }}</span>
</span>
<button
v-if="isLatest(key, v)"
v-if="canEdit() && isLatest(key, v)"
class="danger"
style="padding: 0.25rem 0.6rem"
@click="deleteLatest(key)"
@@ -256,4 +304,39 @@ onMounted(async () => {
</li>
</ul>
</div>
<h2 v-if="canEdit()">Rename stored messages</h2>
<form v-if="canEdit()" class="card" @submit.prevent="previewRename">
<div class="row" style="margin-bottom: 0.5rem">
<input v-model="renName" placeholder="New device name" class="grow" />
</div>
<div class="row" style="margin-bottom: 0.5rem">
<label>from</label>
<input v-model="renFrom" type="datetime-local" style="max-width: 14rem" />
<span class="muted">empty = all history</span>
<label>to</label>
<input v-model="renTo" type="datetime-local" style="max-width: 14rem" />
<span class="muted">empty = up to now</span>
</div>
<div class="row">
<button type="submit">Preview</button>
<span v-if="renPreview !== null" class="badge" :class="{ ok: renPreview > 0 }">
{{ renPreview }} message(s) match
</span>
<span class="grow"></span>
<button
class="danger"
type="button"
:disabled="renPreview === null || renPreview === 0"
@click="applyRename"
>
Apply rename (no undo)
</button>
</div>
<p class="muted">
Rewrites the device name on already-stored messages for this IMEI (both the
column and the payload key) — use it when a device was misnamed at ingest.
The IMEI is never touched. To rename FUTURE messages, just edit the name
in the settings form above.
</p>
</form>
</template>
+3 -3
View File
@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue'
import { Cpu, Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import type { Device } from '../api'
import { showError, toast } from '../store'
import { canEdit, showError, toast } from '../store'
const devices = ref<Device[]>([])
const loaded = ref(false)
@@ -56,7 +56,7 @@ async function removeDevice(d: Device) {
<template>
<h1>Devices</h1>
<form class="card row" @submit.prevent="addDevice">
<form v-if="canEdit()" class="card row" @submit.prevent="addDevice">
<input v-model="newImei" class="grow mono" placeholder="IMEI (15 digits)" style="max-width: 14rem" />
<input v-model="newName" class="grow" placeholder="Device name" />
<button class="primary" type="submit">
@@ -81,7 +81,7 @@ async function removeDevice(d: Device) {
<router-link :to="`/devices/${d.imei}`">{{ d.device }}</router-link>
</td>
<td style="text-align: right">
<button class="danger" @click="removeDevice(d)">
<button v-if="canEdit()" class="danger" @click="removeDevice(d)">
<Trash2 :size="15" />
Delete
</button>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
import { LogIn } from 'lucide-vue-next'
import { api } from '../api'
import { setSession, showError, toast } from '../store'
const username = ref('')
const password = ref('')
async function submit() {
try {
const res = await api<{ token: string; username: string; role: 'admin' | 'superuser' | 'user' }>(
'/api/auth/login',
{
method: 'POST',
body: JSON.stringify({ username: username.value, password: password.value }),
},
)
setSession({ token: res.token, username: res.username, role: res.role })
password.value = ''
toast('success', `logged in as ${res.username} (${res.role})`)
window.location.hash = '#/'
} catch (e) {
showError(e)
}
}
</script>
<template>
<div class="login-wrap">
<form class="card login-card" @submit.prevent="submit">
<div class="row" style="justify-content: center; margin-bottom: 0.75rem">
<LogIn :size="26" />
</div>
<h1 style="text-align: center; margin-bottom: 1rem">Sign in</h1>
<div class="row" style="margin-bottom: 0.6rem">
<label style="width: 5rem">Username</label>
<input v-model="username" autofocus autocomplete="username" />
</div>
<div class="row" style="margin-bottom: 1rem">
<label style="width: 5rem">Password</label>
<input v-model="password" type="password" autocomplete="current-password" />
</div>
<button class="primary" type="submit" style="width: 100%; justify-content: center">
Log in
</button>
<p class="muted" style="text-align: center; margin-bottom: 0">
Sessions expire after 30 days; the app token header is for machine clients.
</p>
</form>
</div>
</template>
<style scoped>
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: min(90vw, 22rem);
}
</style>
+3 -3
View File
@@ -2,7 +2,7 @@
import { onMounted, ref } from 'vue'
import { KeyRound, Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import { showError, toast } from '../store'
import { canEdit, showError, toast } from '../store'
const names = ref<string[]>([])
const loaded = ref(false)
@@ -63,7 +63,7 @@ async function removeToken(name: string) {
rotates its secret; the change takes effect immediately.
</p>
<form class="card row" @submit.prevent="saveToken">
<form v-if="canEdit()" class="card row" @submit.prevent="saveToken">
<input v-model="newName" placeholder="app name" style="max-width: 14rem" />
<input v-model="newSecret" placeholder="secret" type="password" class="grow" />
<button class="primary" type="submit">
@@ -84,7 +84,7 @@ async function removeToken(name: string) {
<tr v-for="n in names" :key="n">
<td class="mono">{{ n }}</td>
<td style="text-align: right">
<button class="danger" @click="removeToken(n)">
<button v-if="canEdit()" class="danger" @click="removeToken(n)">
<Trash2 :size="15" />
Delete
</button>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Plus, Trash2, UserRound } from 'lucide-vue-next'
import { api } from '../api'
import { canEdit, isAdmin, showError, toast, ui } from '../store'
const users = ref<{ username: string; role: string }[]>([])
const loaded = ref(false)
const newName = ref('')
const newPassword = ref('')
// superusers may only create 'user' accounts (server-enforced); admins any.
const newRole = ref('user')
async function load() {
try {
users.value = await api<{ username: string; role: string }[]>('/api/users')
} catch (e) {
showError(e)
} finally {
loaded.value = true
}
}
onMounted(load)
async function saveUser() {
if (!newName.value.trim() || !newPassword.value) {
toast('error', 'username and password are required')
return
}
try {
await api(`/api/users/${encodeURIComponent(newName.value.trim())}`, {
method: 'PUT',
body: JSON.stringify({ password: newPassword.value, role: newRole.value }),
})
newName.value = ''
newPassword.value = ''
await load()
toast('success', 'user saved')
} catch (e) {
showError(e)
}
}
function mayDelete(target: { username: string; role: string }): boolean {
if (!canEdit()) return false
if (isAdmin()) return target.username !== ui.session?.username
// superuser: only accounts with role 'user'
return target.role === 'user'
}
async function removeUser(u: { username: string; role: string }) {
const last =
u.role === 'admin' && users.value.filter((x) => x.role === 'admin').length <= 1
const warning = last
? `This is the LAST admin. Delete ${u.username} anyway?`
: `Delete user ${u.username}? Their sessions are revoked too.`
if (!confirm(warning)) return
try {
await api(`/api/users/${encodeURIComponent(u.username)}`, { method: 'DELETE' })
await load()
} catch (e) {
showError(e)
}
}
</script>
<template>
<h1>Users</h1>
<p class="muted">
Passwords are stored as scrypt hashes and cannot be read back. Re-adding a user
resets their password (and role). Roles:
<strong>admin</strong> manages everything including users,
<strong>superuser</strong> configures devices/tokens and manages day-to-day
<strong>user</strong> accounts, <strong>user</strong> is a read-only viewer.
</p>
<form v-if="canEdit()" class="card row" @submit.prevent="saveUser">
<input v-model="newName" placeholder="username" style="max-width: 12rem" />
<input v-model="newPassword" placeholder="password" type="password" class="grow" />
<select v-if="isAdmin()" v-model="newRole" style="max-width: 9rem">
<option value="user">user</option>
<option value="superuser">superuser</option>
<option value="admin">admin</option>
</select>
<button class="primary" type="submit">
<Plus :size="15" />
Add / update
</button>
</form>
<div class="card">
<table v-if="users.length">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td class="mono">{{ u.username }}</td>
<td>
<span class="badge" :class="{ ok: u.role === 'admin' }">{{ u.role }}</span>
</td>
<td style="text-align: right">
<button v-if="mayDelete(u)" class="danger" @click="removeUser(u)">
<Trash2 :size="15" />
Delete
</button>
</td>
</tr>
</tbody>
</table>
<div v-else-if="loaded" class="empty">
<UserRound :size="28" />
<span>No users yet.</span>
</div>
<template v-else>
<div class="skeleton line" />
</template>
</div>
</template>
+35 -1
View File
@@ -105,4 +105,38 @@ CREATE TABLE IF NOT EXISTS imei_attributes (
COMMENT ON TABLE imei_attributes IS
'Time-varying device attributes (e.g. offsets) as an append-only timeline; the value valid at T is the row with the greatest effective_from <= T.';
COMMENT ON COLUMN imei_attributes.effective_from IS
'Time from which the value applies; backdating allowed. Resolved against the device timestamp (payload_time, received_at fallback) in GET /messages.';
'Time from which the value applies; backdating allowed. Resolved against the device timestamp (payload_time, received_at fallback) in GET /messages.';
-- Interactive users for the management UI: username/password login with a
-- role. 'admin' (level 2) manages everything incl. users; 'superuser'
-- (level 1) configures devices/configs/tokens and manages day-to-day
-- 'user' accounts; 'user' (level 0) is a read-only viewer. Passwords are
-- stored as scrypt hashes, never plaintext. The first admin is seeded
-- lazily from BOOTSTRAP_ADMIN_USER/BOOTSTRAP_ADMIN_PASSWORD when the table
-- is empty (same bootstrap pattern as app_tokens).
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY CHECK (username <> ''),
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('admin', 'superuser', 'user')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Login sessions: the browser keeps the opaque bearer token; only its
-- SHA-256 digest is persisted (same pattern as app_tokens). Sessions
-- expire server-side; the role is NOT cached here (joined fresh from
-- users on every request), so role changes apply immediately. Deleting a
-- user cascades to their sessions.
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY CHECK (token_hash ~ '^[0-9a-f]{64}$'),
username TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions (expires_at);
COMMENT ON TABLE users IS
'Interactive UI users (scrypt password hashes); admin = full access, superuser = configure + manage user accounts, user = read-only. First admin seeded from BOOTSTRAP_ADMIN_* when empty.';
COMMENT ON TABLE sessions IS
'Login sessions (SHA-256 digests of opaque bearer tokens); role resolved fresh from users on each request.';
BIN
View File
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
........................................................................ [ 52%]
.................................................................. [100%]
============================== warnings summary ===============================
.venv\Lib\site-packages\fastapi\routing.py:233: 24 warnings
tools/test_listener.py: 3312 warnings
C:\Users\jonas\Documents\Git\vivab\nodeledge-mqtt-listener\.venv\Lib\site-packages\fastapi\routing.py:233: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
138 passed, 3336 warnings in 34.17s
+537 -9
View File
@@ -19,12 +19,18 @@ os.environ.setdefault(
)
from api.app import ( # noqa: E402
DeviceRenameStore,
ImeiAttributesStore,
ImeiMapStore,
SessionStore,
TokenStore,
UserStore,
_validate_attribute_body,
_validate_rename_body,
_validate_token_body,
hash_password,
hash_token,
verify_password,
)
from listener.process import ( # noqa: E402
InjectionResult,
@@ -355,6 +361,145 @@ class TestTokenHelpers:
_validate_token_body({"tokens": {"app": ""}}, "test")
class TestValidateRenameBody:
"""_validate_rename_body is the schema check for rename requests."""
def test_accepts_full_body(self) -> None:
name, frm, to, dry = _validate_rename_body(
{"device": "New name", "from": "2026-08-01T00:00:00Z", "to": "2026-08-31T00:00:00Z", "dry_run": True},
"test",
)
assert name == "New name"
assert frm == datetime(2026, 8, 1, tzinfo=timezone.utc)
assert to == datetime(2026, 8, 31, tzinfo=timezone.utc)
assert dry is True
def test_absent_bounds_are_unbounded(self) -> None:
# the core framing rule: absent/null bound = no framing on that end
name, frm, to, dry = _validate_rename_body({"device": "X"}, "test")
assert frm is None
assert to is None
assert dry is False
name, frm, to, dry = _validate_rename_body(
{"device": "X", "from": None, "to": None}, "test"
)
assert frm is None and to is None
def test_one_sided_frames(self) -> None:
_, frm, to, _ = _validate_rename_body(
{"device": "X", "from": "2026-08-01T00:00:00Z"}, "test"
)
assert frm is not None and to is None
_, frm, to, _ = _validate_rename_body(
{"device": "X", "to": "2026-08-31T00:00:00Z"}, "test"
)
assert frm is None and to is not None
def test_rejects_bad_bodies(self) -> None:
for body in ({}, {"device": ""}, {"device": 42}, {"device": "X", "from": 5}, {"device": "X", "from": "garbage"}, [1]):
with pytest.raises(ValueError):
_validate_rename_body(body, "test")
class TestRenameEndpoint:
"""POST /devices/{imei}/rename — backfill rename, superuser-gated,
optional inclusive received_at frame, dry_run preview."""
HDRS = {"X-App-Token": "secret-abc"}
@pytest.fixture()
def client_and_store(self, api):
from fastapi.testclient import TestClient
app, *_ = api
renames = api[6]
return TestClient(app), renames
def test_requires_auth(self, client_and_store) -> None:
client, _ = client_and_store
r = client.post("/devices/350123456789012/rename", json={"device": "X"})
assert r.status_code == 401
def test_viewer_role_403(self, api) -> None:
from fastapi.testclient import TestClient
app, *_ = api
sessions = api[5]
client = TestClient(app)
token = sessions.login("viewer1")
r = client.post(
"/devices/350123456789012/rename",
json={"device": "X"},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 403
def test_dry_run_and_apply_pass_frame_through(self, client_and_store) -> None:
client, renames = client_and_store
r = client.post(
"/devices/350123456789012/rename",
json={"device": "New name", "from": "2026-08-01T00:00:00Z", "dry_run": True},
headers=self.HDRS,
)
assert r.status_code == 200
body = r.json()
assert body["imei"] == "350123456789012"
assert body["device"] == "New name"
assert body["dry_run"] is True
call = renames.calls[-1]
assert call["from"] == datetime(2026, 8, 1, tzinfo=timezone.utc)
assert call["to"] is None # unbounded end: no framing
r = client.post(
"/devices/350123456789012/rename",
json={"device": "New name", "from": "2026-08-01T00:00:00Z", "to": "2026-08-31T00:00:00Z"},
headers=self.HDRS,
)
assert r.json() == {
"imei": "350123456789012",
"device": "New name",
"updated": 3,
"dry_run": False,
}
def test_superuser_allowed(self, api) -> None:
from fastapi.testclient import TestClient
app, *_ = api
sessions = api[5]
renames = api[6]
client = TestClient(app)
token = sessions.login("super1")
r = client.post(
"/devices/350123456789012/rename",
json={"device": "New name"},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
assert renames.calls[-1]["device"] == "New name"
def test_reversed_frame_422(self, client_and_store) -> None:
client, _ = client_and_store
r = client.post(
"/devices/350123456789012/rename",
json={"device": "X", "from": "2026-09-01T00:00:00Z", "to": "2026-08-01T00:00:00Z"},
headers=self.HDRS,
)
assert r.status_code == 422
assert "earlier than or equal" in r.json()["detail"]
def test_bad_bodies_422(self, client_and_store) -> None:
client, _ = client_and_store
for body in ({}, {"device": ""}, {"device": 42}, {"device": "X", "from": "garbage"}, [1]):
r = client.post("/devices/350123456789012/rename", json=body, headers=self.HDRS)
assert r.status_code == 422, body
def test_non_imei_url_422(self, client_and_store) -> None:
client, _ = client_and_store
r = client.post("/devices/123/rename", json={"device": "X"}, headers=self.HDRS)
assert r.status_code == 422
class TestValidateAttributeBody:
"""_validate_attribute_body is the schema check for attribute appends."""
@@ -550,19 +695,155 @@ def app_module(monkeypatch):
yield app_module
class FakeUserStore(UserStore):
"""In-memory user store: no database, scrypt hashing still real."""
def __init__(
self,
users: dict[str, tuple[str, str]] | None = None,
) -> None:
# users: {username: (password, role)}
super().__init__("postgresql://unused")
self._users = {
name: (hash_password(secret), role)
for name, (secret, role) in (users or {}).items()
}
self.upserted: tuple[str, str, str, str] | None = None
self.deleted: str | None = None
def verify(self, username: str, password: str) -> str | None:
entry = self._users.get(username)
if entry is None:
verify_password(password, hash_password("timing-equalizer"))
return None
stored, role = entry
return role if verify_password(password, stored) else None
def list(self) -> list[dict[str, Any]]:
return [
{"username": name, "role": entry[1]}
for name, entry in sorted(self._users.items())
]
def get_role(self, username: str) -> str | None:
entry = self._users.get(username)
return entry[1] if entry else None
def upsert(self, username: str, password: str, role: str, actor_role: str) -> None:
if actor_role not in ("user", "superuser", "admin"):
raise PermissionError(f"unknown actor role {actor_role!r}")
if actor_role != "admin" and role != "user":
raise PermissionError(
f"{actor_role} may only create or assign the 'user' role"
)
self.upserted = (username, password, role, actor_role)
self._users[username] = (hash_password(password), role)
def delete(self, username: str, actor_role: str) -> bool:
entry = self._users.get(username)
if entry is None:
return False
if actor_role == "superuser" and entry[1] != "user":
raise PermissionError(
"superuser may only delete accounts with role 'user'"
)
if entry[1] == "admin" and sum(
1 for e in self._users.values() if e[1] == "admin"
) <= 1:
return False # last admin
self.deleted = username
del self._users[username]
return True
class FakeSessionStore(SessionStore):
"""In-memory session store: token -> username; the role is looked up
fresh from the user store on resolve (mirrors the real JOIN)."""
def __init__(self, user_store: FakeUserStore) -> None:
super().__init__("postgresql://unused")
self._user_store = user_store
self._owners: dict[str, str] = {}
self._counter = 0
def login(self, username: str) -> str:
self._counter += 1
token = f"session-token-{self._counter}"
self._owners[token] = username
return token
def resolve(self, token: str) -> tuple[str, str] | None:
username = self._owners.get(token)
if username is None:
return None
role = self._user_store.get_role(username)
# a deleted user's sessions are invalid (real store: ON DELETE CASCADE)
return (username, role) if role is not None else None
def logout(self, token: str) -> bool:
if token in self._owners:
del self._owners[token]
return True
return False
class FakeRenameStore(DeviceRenameStore):
"""In-memory rename store: records calls; the real SQL is exercised
in the e2e against a throwaway postgres."""
def __init__(self) -> None:
super().__init__("postgresql://unused")
self.calls: list[dict[str, Any]] = []
def rename(
self,
imei: str,
new_name: str,
from_time: datetime | None,
to_time: datetime | None,
payload_key: str = "device",
dry_run: bool = False,
) -> dict[str, Any]:
self.calls.append(
{
"imei": imei,
"device": new_name,
"from": from_time,
"to": to_time,
"payload_key": payload_key,
"dry_run": dry_run,
}
)
return {"updated": 3, "dry_run": dry_run}
@pytest.fixture()
def api(app_module):
"""ASGI app wired to in-memory fakes; yields
(app, token_store, imei_store, attributes_store)."""
(app, token_store, imei_store, attributes_store, user_store, session_store)."""
tokens = FakeTokenStore({"test-app": "secret-abc"})
imeis = FakeImeiMapStore(
{"350123456789012": {"device": "Greenhouse sensor A"}}
)
attributes = FakeImeiAttributesStore()
app = app_module.create_app(
token_store=tokens, imei_store=imeis, attributes_store=attributes
users = FakeUserStore(
{
"admin1": ("admin-pass", "admin"),
"super1": ("super-pass", "superuser"),
"viewer1": ("viewer-pass", "user"),
}
)
return app, tokens, imeis, attributes
sessions = FakeSessionStore(users)
renames = FakeRenameStore()
app = app_module.create_app(
token_store=tokens,
imei_store=imeis,
attributes_store=attributes,
user_store=users,
session_store=sessions,
rename_store=renames,
)
return app, tokens, imeis, attributes, users, sessions, renames
class TestApiAuth:
@@ -652,7 +933,7 @@ class TestConfigEndpoints:
def client_and_stores(self, api):
from fastapi.testclient import TestClient
app, tokens, imeis, _attributes = api
app, tokens, imeis, _attributes, _users, _sessions, _renames = api
return TestClient(app), tokens, imeis
def test_get_imei_map(self, client_and_stores) -> None:
@@ -757,7 +1038,7 @@ class TestDeviceEndpoints:
def client_and_store(self, api):
from fastapi.testclient import TestClient
app, _, imeis, _attributes = api
app, _, imeis, _attributes, _users, _sessions, _renames = api
return TestClient(app), imeis
def test_list_devices(self, client_and_store) -> None:
@@ -867,7 +1148,7 @@ class TestTokenEndpoints:
def client_and_store(self, api):
from fastapi.testclient import TestClient
app, tokens, _, _attributes = api
app, tokens, _imeis, _attributes, _users, _sessions, _renames = api
return TestClient(app), tokens
def test_list_tokens_masks_secrets(self, client_and_store) -> None:
@@ -926,7 +1207,7 @@ class TestDeviceAttributeEndpoints:
def client_and_store(self, api):
from fastapi.testclient import TestClient
app, _tokens, _imeis, attributes = api
app, _tokens, _imeis, attributes, _users, _sessions, _renames = api
return TestClient(app), attributes
def test_endpoints_require_token(self, client_and_store) -> None:
@@ -1087,4 +1368,251 @@ class TestDeviceAttributeEndpoints:
r = client.delete(
"/devices/350123456789012/attributes/does-not-exist", headers=self.HDRS
)
assert r.status_code == 404
assert r.status_code == 404
class TestPasswordHashing:
def test_hash_and_verify_roundtrip(self) -> None:
stored = hash_password("secret-pass")
assert stored.startswith("scrypt$")
assert verify_password("secret-pass", stored)
assert not verify_password("wrong-pass", stored)
def test_hashes_are_salted(self) -> None:
# same password, different salts -> different stored hashes
assert hash_password("same") != hash_password("same")
def test_verify_rejects_garbage(self) -> None:
assert not verify_password("x", "not-a-hash")
assert not verify_password("x", "bcrypt$abc$def")
assert not verify_password("x", "")
class TestAuthEndpoints:
"""POST /auth/login, GET /auth/me, POST /auth/logout — session flow."""
HDRS = {"X-App-Token": "secret-abc"}
@pytest.fixture()
def client_and_stores(self, api):
from fastapi.testclient import TestClient
app, _tokens, _imeis, _attributes, users, sessions, _renames = api
return TestClient(app), users, sessions
def test_login_returns_token_and_role(self, client_and_stores) -> None:
client, _, _ = client_and_stores
r = client.post(
"/auth/login", json={"username": "admin1", "password": "admin-pass"}
)
assert r.status_code == 200
body = r.json()
assert body["token"]
assert body["username"] == "admin1"
assert body["role"] == "admin"
def test_login_wrong_password_401(self, client_and_stores) -> None:
client, _, _ = client_and_stores
r = client.post(
"/auth/login", json={"username": "admin1", "password": "nope"}
)
assert r.status_code == 401
assert r.json()["detail"] == "invalid username or password"
def test_login_unknown_user_same_401(self, client_and_stores) -> None:
# same body as wrong password: no username enumeration
client, _, _ = client_and_stores
r = client.post(
"/auth/login", json={"username": "ghost", "password": "whatever"}
)
assert r.status_code == 401
assert r.json()["detail"] == "invalid username or password"
def test_login_requires_both_fields(self, client_and_stores) -> None:
client, _, _ = client_and_stores
assert client.post("/auth/login", json={}).status_code == 422
assert (
client.post("/auth/login", json={"username": "a"}).status_code == 422
)
def test_lockout_after_failed_attempts(self, client_and_stores) -> None:
client, _, _ = client_and_stores
for _ in range(5):
r = client.post(
"/auth/login", json={"username": "admin1", "password": "bad"}
)
assert r.status_code == 401
# 6th attempt is locked out even with the CORRECT password
r = client.post(
"/auth/login", json={"username": "admin1", "password": "admin-pass"}
)
assert r.status_code == 429
def test_me_requires_auth(self, client_and_stores) -> None:
client, _, _ = client_and_stores
assert client.get("/auth/me").status_code == 401
def test_me_with_session(self, client_and_stores) -> None:
client, _, sessions = client_and_stores
token = sessions.login("viewer1")
r = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert r.json() == {"username": "viewer1", "role": "user"}
def test_me_with_app_token_has_no_role(self, client_and_stores) -> None:
client, _, _ = client_and_stores
r = client.get("/auth/me", headers=self.HDRS)
assert r.json() == {"username": None, "role": None}
def test_logout_invalidates_session(self, client_and_stores) -> None:
client, _, sessions = client_and_stores
token = sessions.login("admin1")
r = client.post("/auth/logout", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
r = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 401
def test_invalid_bearer_401(self, client_and_stores) -> None:
client, _, _ = client_and_stores
r = client.get("/auth/me", headers={"Authorization": "Bearer bogus"})
assert r.status_code == 401
class TestAuthorizationMatrix:
"""Role gating across the API: anonymous / app token / user /
superuser / admin, plus the superuser caps."""
APP = {"X-App-Token": "secret-abc"}
@pytest.fixture()
def client_and_stores(self, api):
from fastapi.testclient import TestClient
app, _tokens, _imeis, _attributes, users, sessions, _renames = api
return TestClient(app), users, sessions
def bearer(self, sessions: FakeSessionStore, username: str) -> dict[str, str]:
return {"Authorization": f"Bearer {sessions.login(username)}"}
# --- read endpoints: everyone authenticated may read -------------------
def test_reads_open_to_all_authenticated(self, client_and_stores) -> None:
client, _, sessions = client_and_stores
for headers in (
self.APP,
self.bearer(sessions, "viewer1"),
self.bearer(sessions, "super1"),
self.bearer(sessions, "admin1"),
):
assert client.get("/devices", headers=headers).status_code == 200
assert (
client.get(
"/devices/350123456789012/attributes", headers=headers
).status_code
== 200
)
assert client.get("/users", headers=headers).status_code == 200
assert client.get("/tokens", headers=headers).status_code == 200
def test_anonymous_gets_401_everywhere(self, client_and_stores) -> None:
client, _, _ = client_and_stores
for path in ("/devices", "/users", "/tokens", "/messages?limit=1"):
assert client.get(path).status_code == 401
assert client.put(
"/devices/111111111111111", json={"device": "X"}
).status_code == 401
# --- write endpoints: gated at superuser ------------------------------
WRITE_CASES = [
("/configs/imei_map", "PUT", {"350123456789012": "Renamed"}),
("/devices/350123456789012", "PUT", {"device": "Renamed"}),
("/devices/350123456789012", "DELETE", None),
(
"/devices/350123456789012/attributes",
"PUT",
{"key": "k", "value": 1},
),
("/devices/350123456789012/attributes/k", "DELETE", None),
("/tokens/someapp", "PUT", {"token": "secret"}),
("/tokens/someapp", "DELETE", None),
("/users/newuser", "PUT", {"password": "pw"}),
]
def test_viewer_role_cannot_write(self, client_and_stores) -> None:
client, _, sessions = client_and_stores
headers = self.bearer(sessions, "viewer1")
for path, method, body in self.WRITE_CASES:
r = client.request(method, path, json=body, headers=headers)
assert r.status_code == 403, (method, path, r.status_code)
def test_superuser_and_admin_can_write(self, client_and_stores) -> None:
client, users, sessions = client_and_stores
for username in ("super1", "admin1"):
headers = self.bearer(sessions, username)
# device writes are safe (idempotent upsert); use a scratch IMEI
r = client.put(
"/devices/111111111111111",
json={"device": f"by-{username}"},
headers=headers,
)
assert r.status_code == 200, (username, r.status_code)
def test_app_token_still_writes(self, client_and_stores) -> None:
client, _, _ = client_and_stores
r = client.put(
"/devices/111111111111111",
json={"device": "by-app-token"},
headers=self.APP,
)
assert r.status_code == 200
# --- superuser caps on user management ---------------------------------
def test_superuser_creates_only_user_role(self, client_and_stores) -> None:
client, _, sessions = client_and_stores
headers = self.bearer(sessions, "super1")
# creating a plain user is fine
r = client.put(
"/users/newviewer", json={"password": "pw", "role": "user"}, headers=headers
)
assert r.status_code == 200
# attempting to create a superuser/admin is refused (no escalation)
for role in ("superuser", "admin"):
r = client.put(
"/users/escalate", json={"password": "pw", "role": role}, headers=headers
)
assert r.status_code == 403, (role, r.status_code)
def test_superuser_deletes_only_user_accounts(self, client_and_stores) -> None:
client, users, sessions = client_and_stores
headers = self.bearer(sessions, "super1")
# target with role 'user': allowed
assert client.delete("/users/viewer1", headers=headers).status_code == 200
# target with role 'superuser'/'admin': refused
assert client.delete("/users/super1", headers=headers).status_code == 403
assert client.delete("/users/admin1", headers=headers).status_code == 403
def test_admin_manages_any_account(self, client_and_stores) -> None:
client, users, sessions = client_and_stores
headers = self.bearer(sessions, "admin1")
r = client.put(
"/users/newsuper",
json={"password": "pw", "role": "superuser"},
headers=headers,
)
assert r.status_code == 200
assert client.delete("/users/newsuper", headers=headers).status_code == 200
def test_last_admin_cannot_be_deleted(self, client_and_stores) -> None:
client, users, sessions = client_and_stores
headers = self.bearer(sessions, "admin1")
# ensure only ONE admin exists (admin1); delete another admin first
# if the fixture had one - it doesn't, admin1 is the only admin.
assert client.delete("/users/admin1", headers=headers).status_code == 404
def test_viewer_cannot_manage_users(self, client_and_stores) -> None:
client, _, sessions = client_and_stores
headers = self.bearer(sessions, "viewer1")
r = client.put(
"/users/sneaky", json={"password": "pw", "role": "user"}, headers=headers
)
assert r.status_code == 403
assert client.delete("/users/admin1", headers=headers).status_code == 403