diff --git a/.env.dev b/.env.dev index b96e8c8..2af2f9d 100644 --- a/.env.dev +++ b/.env.dev @@ -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 diff --git a/.env.example b/.env.example index 0a16439..df5f18a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d4a26d1..2c653f1 100644 --- a/README.md +++ b/README.md @@ -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: } ] } -``` \ No newline at end of file +``` + +## 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. diff --git a/api/app.py b/api/app.py index 6e0525a..5a5957a 100644 --- a/api/app.py +++ b/api/app.py @@ -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$$'. + 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"} diff --git a/auth-build.log b/auth-build.log new file mode 100644 index 0000000..3bad718 Binary files /dev/null and b/auth-build.log differ diff --git a/docker-compose.yml b/docker-compose.yml index efe8232..9424b1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: \ No newline at end of file + db_data: + pgadmin_data: \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1b13e61..b7d9b3b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,36 +1,76 @@ \ No newline at end of file + + + \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4c183e4..f2decd1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 6621258..6aaac9d 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -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 '/' + } }) \ No newline at end of file diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 48168ae..b1b376c 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -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') -} \ No newline at end of file +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() +}) \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css index a8a3fba..1bdbc4e 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/views/DeviceDetail.vue b/frontend/src/views/DeviceDetail.vue index 807b5e3..3537f07 100644 --- a/frontend/src/views/DeviceDetail.vue +++ b/frontend/src/views/DeviceDetail.vue @@ -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(null) + +async function previewRename() { + const body: Record = { 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 = { 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 () => {

Settings

-
+
@@ -204,7 +252,7 @@ onMounted(async () => {

Attribute timeline (time-varying values)

-
+
@@ -243,7 +291,7 @@ onMounted(async () => { {{ formatTime(v.effective_from) }}
+

Rename stored messages

+ +
+ +
+
+ + + empty = all history + + + empty = up to now +
+
+ + + {{ renPreview }} message(s) match + + + +
+

+ 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. +

+
\ No newline at end of file diff --git a/frontend/src/views/Devices.vue b/frontend/src/views/Devices.vue index b3aaf01..563c87f 100644 --- a/frontend/src/views/Devices.vue +++ b/frontend/src/views/Devices.vue @@ -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([]) const loaded = ref(false) @@ -56,7 +56,7 @@ async function removeDevice(d: Device) {