🚧 WIP
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
Binary file not shown.
+9
-1
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.';
|
||||
Binary file not shown.
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user