🚧 WIP
This commit is contained in:
+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"}
|
||||
|
||||
Reference in New Issue
Block a user