dccd 3.2.0__tar.gz → 3.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dccd-3.2.0 → dccd-3.3.0}/CHANGELOG.md +41 -0
- {dccd-3.2.0 → dccd-3.3.0}/PKG-INFO +1 -1
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/config.py +20 -1
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/api/app.py +173 -17
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/base.html +46 -3
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/live.html +1 -1
- dccd-3.3.0/dccd/interfaces/ui/templates/login.html +47 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/logs.html +1 -1
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_api.py +180 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/PKG-INFO +1 -1
- {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/SOURCES.txt +1 -0
- {dccd-3.2.0 → dccd-3.3.0}/pyproject.toml +1 -1
- {dccd-3.2.0 → dccd-3.3.0}/CLAUDE.md +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/CONTRIBUTING.md +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/LICENSE.txt +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/MANIFEST.in +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/README.md +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/events.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/jobs.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/monitor.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/operations.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/registry.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/scheduler.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/application/service_factory.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/capability.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/dataset.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/errors.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/records.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/symbol.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/timeutils.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/transforms.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/types.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/api/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/cli/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/cli/main.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/static/favicon.svg +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/static/logo.svg +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/config.html +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/dashboard.html +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/data.html +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/historical.html +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/storage.html +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/base.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/binance.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/bitfinex.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/bitmex.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/bybit.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/coinbase.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/kraken.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/okx.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/registry.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/coverage_sqlite.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/parquet.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/purge.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/remote.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/runs_sqlite.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_application.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_backfill_lookback.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_client.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_coverage.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_domain.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_domain_extended.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_network.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_purge.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_remote_sync.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_restart.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_restore.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_sources.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_storage.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_storage_extended.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_transport.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/__init__.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/http.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/paginate.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/ratelimit.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/ws.py +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/dependency_links.txt +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/entry_points.txt +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/requires.txt +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/top_level.txt +0 -0
- {dccd-3.2.0 → dccd-3.3.0}/setup.cfg +0 -0
|
@@ -16,6 +16,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
### Removed
|
|
18
18
|
|
|
19
|
+
## [3.3.0] - 2026-06-10
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Threat-model section in `how-to/expose-remote` (trust boundaries: localhost / tailnet
|
|
24
|
+
/ public; what the token+cookie session protect and don't; residual risks; a
|
|
25
|
+
recommended-postures table) — completing **Epic B** (view the UI remotely). (#110)
|
|
26
|
+
- API hardening for remote exposure (all opt-in, off by default): `ui_rate_limit`
|
|
27
|
+
(token-bucket per client on `/api/*`, over budget → `429` + `Retry-After`),
|
|
28
|
+
`ui_readonly` (block mutating methods → `403`, view-only share), and
|
|
29
|
+
`ui_trusted_proxy` (trust `X-Forwarded-For` as the rate-limit key only behind a
|
|
30
|
+
vetted proxy). Regression tests prove CORS is never wildcard and every mutating
|
|
31
|
+
route is `401` without a token. Verified live over Tailscale. (#108)
|
|
32
|
+
- Browser login + session: when `ui_auth_token` is set, the web UI now serves a
|
|
33
|
+
`/login` page and an `HttpOnly`, `SameSite=Lax` session cookie (marked `Secure`
|
|
34
|
+
behind an HTTPS proxy), with a Logout control. Page routes are gated (an
|
|
35
|
+
unauthenticated load is redirected to `/login`, no longer served), and the API
|
|
36
|
+
accepts the cookie alongside `Bearer`/`?token=`. Verified live over Tailscale. (#107)
|
|
37
|
+
- How-to guide `how-to/expose-remote` for reaching the UI from a laptop/phone behind
|
|
38
|
+
TLS (Caddy/nginx/Cloudflare Tunnel) or a private Tailscale overlay — never the API
|
|
39
|
+
plaintext off-box. Verified on a real server (Tailscale path reached live; Caddy
|
|
40
|
+
installs + reverse-proxies). (#106)
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Responsive layout for the web UI on narrow (mobile) viewports: wide/dense tables
|
|
45
|
+
scroll inside their own box (a `MutationObserver` wraps tables built after fetch),
|
|
46
|
+
bigger tap targets, and tighter nav/chrome under 640px — desktop layout unchanged.
|
|
47
|
+
`ui_smoke.py` gains a 390px mobile pass asserting no page-wide horizontal overflow.
|
|
48
|
+
Verified: 27/27 smoke steps, Δ=0px overflow on every page. (#109)
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- The web UI no longer injects the raw `ui_auth_token` into served pages (it was
|
|
53
|
+
templated into `base.html`); a remotely reachable UI could leak the token to anyone
|
|
54
|
+
who loaded a page. The browser now holds only an opaque session cookie. (#107)
|
|
55
|
+
|
|
56
|
+
### Deprecated
|
|
57
|
+
|
|
58
|
+
### Removed
|
|
59
|
+
|
|
19
60
|
## [3.2.0] - 2026-06-10
|
|
20
61
|
|
|
21
62
|
### Added
|
|
@@ -37,19 +37,38 @@ SUPPORTED_EXCHANGES: frozenset[str] = frozenset(
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class SettingsConfig(BaseModel):
|
|
40
|
-
"""Global settings: data path, timezone, and web-UI bind/auth.
|
|
40
|
+
"""Global settings: data path, timezone, and web-UI bind/auth.
|
|
41
|
+
|
|
42
|
+
Hardening knobs (all off by default, i.e. localhost-safe):
|
|
43
|
+
|
|
44
|
+
- ``ui_readonly`` — block mutating HTTP methods on ``/api/*`` (read-only share).
|
|
45
|
+
- ``ui_rate_limit`` — requests/sec per client on ``/api/*`` (``0`` disables).
|
|
46
|
+
- ``ui_trusted_proxy`` — trust ``X-Forwarded-For`` as the rate-limit client key.
|
|
47
|
+
Enable **only** behind a reverse proxy that overwrites the header, else a direct
|
|
48
|
+
client can forge it and bypass the limit.
|
|
49
|
+
"""
|
|
41
50
|
data_path: str = "./data/crypto"
|
|
42
51
|
timezone: str = "local"
|
|
43
52
|
ui_host: str = "127.0.0.1"
|
|
44
53
|
ui_port: int = 8080
|
|
45
54
|
ui_auth_token: str | None = None
|
|
46
55
|
ui_allow_origins: list[str] = Field(default_factory=list)
|
|
56
|
+
ui_readonly: bool = False
|
|
57
|
+
ui_rate_limit: int = 0
|
|
58
|
+
ui_trusted_proxy: bool = False
|
|
47
59
|
|
|
48
60
|
@field_validator("data_path")
|
|
49
61
|
@classmethod
|
|
50
62
|
def _expand(cls, v: str) -> str:
|
|
51
63
|
return str(pathlib.Path(v).expanduser())
|
|
52
64
|
|
|
65
|
+
@field_validator("ui_rate_limit")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _non_negative(cls, v: int) -> int:
|
|
68
|
+
if v < 0:
|
|
69
|
+
raise ValueError("ui_rate_limit must be >= 0")
|
|
70
|
+
return v
|
|
71
|
+
|
|
53
72
|
@field_validator("timezone")
|
|
54
73
|
@classmethod
|
|
55
74
|
def _validate_tz(cls, v: str) -> str:
|
|
@@ -24,8 +24,11 @@ import asyncio
|
|
|
24
24
|
import contextlib
|
|
25
25
|
import logging
|
|
26
26
|
import pathlib
|
|
27
|
+
import secrets
|
|
28
|
+
import time
|
|
27
29
|
from collections.abc import Coroutine
|
|
28
30
|
from typing import Any, cast
|
|
31
|
+
from urllib.parse import parse_qs
|
|
29
32
|
|
|
30
33
|
from fastapi import FastAPI, HTTPException, Request
|
|
31
34
|
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -56,6 +59,14 @@ _UI_DIR = pathlib.Path(__file__).parent.parent / "ui"
|
|
|
56
59
|
_TEMPLATES_DIR = _UI_DIR / "templates"
|
|
57
60
|
_STATIC_DIR = _UI_DIR / "static"
|
|
58
61
|
|
|
62
|
+
# Browser session cookie (set on /login when ui_auth_token is configured). The
|
|
63
|
+
# cookie is opaque and HttpOnly; the raw token is never sent to the browser.
|
|
64
|
+
_SESSION_COOKIE = "dccd_session"
|
|
65
|
+
_SESSION_TTL_SECONDS = 7 * 24 * 3600
|
|
66
|
+
_SESSION_TTL_NS = _SESSION_TTL_SECONDS * 1_000_000_000
|
|
67
|
+
# Paths reachable without a session (so the login flow itself is not gated).
|
|
68
|
+
_OPEN_PREFIXES = ("/login", "/logout", "/static", "/health")
|
|
69
|
+
|
|
59
70
|
__all__ = ["create_app"]
|
|
60
71
|
|
|
61
72
|
logger = logging.getLogger(__name__)
|
|
@@ -219,6 +230,73 @@ def create_app(
|
|
|
219
230
|
|
|
220
231
|
app = FastAPI(title="dccd v3", version="3.0.0", lifespan=lifespan)
|
|
221
232
|
|
|
233
|
+
# Browser sessions: sid -> creation time (ns). Opaque, in-process; reset on
|
|
234
|
+
# restart (acceptable for a single-node daemon). Populated by POST /login.
|
|
235
|
+
app.state.sessions = {}
|
|
236
|
+
|
|
237
|
+
# --- session / auth helpers (closures over app.state) ---
|
|
238
|
+
|
|
239
|
+
def _configured_token() -> str | None:
|
|
240
|
+
cfg = getattr(app.state, "config", None)
|
|
241
|
+
return getattr(getattr(cfg, "settings", None), "ui_auth_token", None)
|
|
242
|
+
|
|
243
|
+
def _prune_sessions() -> None:
|
|
244
|
+
cutoff = time.time_ns() - _SESSION_TTL_NS
|
|
245
|
+
for sid in [s for s, ts in app.state.sessions.items() if ts < cutoff]:
|
|
246
|
+
app.state.sessions.pop(sid, None)
|
|
247
|
+
|
|
248
|
+
def _new_session() -> str:
|
|
249
|
+
sid = secrets.token_urlsafe(32)
|
|
250
|
+
app.state.sessions[sid] = time.time_ns()
|
|
251
|
+
return sid
|
|
252
|
+
|
|
253
|
+
def _valid_session(request: Request) -> bool:
|
|
254
|
+
sid = request.cookies.get(_SESSION_COOKIE)
|
|
255
|
+
if not sid:
|
|
256
|
+
return False
|
|
257
|
+
_prune_sessions()
|
|
258
|
+
return sid in app.state.sessions
|
|
259
|
+
|
|
260
|
+
def _request_is_https(request: Request) -> bool:
|
|
261
|
+
"""True over real HTTPS or behind a proxy that sets X-Forwarded-Proto."""
|
|
262
|
+
if request.url.scheme == "https":
|
|
263
|
+
return True
|
|
264
|
+
fwd = request.headers.get("x-forwarded-proto", "")
|
|
265
|
+
return fwd.split(",", 1)[0].strip() == "https"
|
|
266
|
+
|
|
267
|
+
def _safe_next(nxt: str | None) -> str:
|
|
268
|
+
"""Local-path-only redirect target (blocks //evil.com and absolute URLs)."""
|
|
269
|
+
if nxt and nxt.startswith("/") and not nxt.startswith("//") and "\\" not in nxt:
|
|
270
|
+
return nxt
|
|
271
|
+
return "/"
|
|
272
|
+
|
|
273
|
+
# --- hardening helpers (rate limit + read-only) ---
|
|
274
|
+
|
|
275
|
+
# key -> (tokens, last monotonic). Non-blocking token bucket: over budget ->
|
|
276
|
+
# 429 immediately (unlike transport.ratelimit which sleeps for outbound calls).
|
|
277
|
+
app.state.rate_buckets = {}
|
|
278
|
+
|
|
279
|
+
def _client_key(request: Request, settings: Any) -> str:
|
|
280
|
+
"""Rate-limit key. Trust X-Forwarded-For only behind a vetted proxy, else
|
|
281
|
+
a direct client could forge it and mint unlimited keys."""
|
|
282
|
+
if getattr(settings, "ui_trusted_proxy", False):
|
|
283
|
+
xff = request.headers.get("x-forwarded-for", "")
|
|
284
|
+
if xff:
|
|
285
|
+
return xff.split(",", 1)[0].strip()
|
|
286
|
+
client = request.client
|
|
287
|
+
return client.host if client else "unknown"
|
|
288
|
+
|
|
289
|
+
def _rate_allow(key: str, limit: int) -> bool:
|
|
290
|
+
buckets = app.state.rate_buckets
|
|
291
|
+
now = time.monotonic()
|
|
292
|
+
tokens, last = buckets.get(key, (float(limit), now))
|
|
293
|
+
tokens = min(float(limit), tokens + (now - last) * limit)
|
|
294
|
+
if tokens < 1.0:
|
|
295
|
+
buckets[key] = (tokens, now)
|
|
296
|
+
return False
|
|
297
|
+
buckets[key] = (tokens - 1.0, now)
|
|
298
|
+
return True
|
|
299
|
+
|
|
222
300
|
# CORS: no wildcard. The UI is served same-origin, so it needs no CORS at
|
|
223
301
|
# all; allowing every origin let any website's JS drive the local API
|
|
224
302
|
# (reachable on 127.0.0.1 from the user's browser). Cross-origin access is
|
|
@@ -234,24 +312,51 @@ def create_app(
|
|
|
234
312
|
|
|
235
313
|
@app.middleware("http")
|
|
236
314
|
async def _auth_guard(request: Request, call_next):
|
|
237
|
-
"""
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
315
|
+
"""Rate-limit, authenticate and (optionally) read-only-gate requests.
|
|
316
|
+
|
|
317
|
+
Order matters: rate limit ``/api/*`` first (cheap rejection, before any
|
|
318
|
+
auth work), then authenticate, then enforce read-only — so an
|
|
319
|
+
unauthenticated mutating call still gets ``401`` (auth wins) rather than
|
|
320
|
+
``403``. ``/api/*`` accepts a Bearer header, a ``?token=`` query (non-browser
|
|
321
|
+
SSE) or the session cookie; page routes accept only the cookie and otherwise
|
|
322
|
+
redirect to ``/login``. With no token configured everything authenticates,
|
|
323
|
+
but rate-limit and read-only still apply if enabled.
|
|
241
324
|
"""
|
|
242
|
-
|
|
243
|
-
|
|
325
|
+
settings = getattr(getattr(app.state, "config", None), "settings", None)
|
|
326
|
+
path = request.url.path
|
|
327
|
+
method = request.method
|
|
328
|
+
is_api = path.startswith("/api/")
|
|
329
|
+
|
|
330
|
+
# 1) Rate limit /api/* (independent of auth), opt-in via ui_rate_limit.
|
|
331
|
+
limit = int(getattr(settings, "ui_rate_limit", 0) or 0)
|
|
332
|
+
if is_api and method != "OPTIONS" and limit > 0:
|
|
333
|
+
if not _rate_allow(_client_key(request, settings), limit):
|
|
334
|
+
return JSONResponse(
|
|
335
|
+
{"detail": "rate limited"}, status_code=429,
|
|
336
|
+
headers={"Retry-After": "1"},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# 2) Authenticate (when a token is configured).
|
|
340
|
+
token = _configured_token()
|
|
341
|
+
if token and method != "OPTIONS":
|
|
342
|
+
if is_api:
|
|
343
|
+
bearer = request.headers.get("Authorization") == f"Bearer {token}"
|
|
344
|
+
query = request.query_params.get("token") == token
|
|
345
|
+
if not (bearer or query or _valid_session(request)):
|
|
346
|
+
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
|
347
|
+
elif templates is not None and not path.startswith(_OPEN_PREFIXES):
|
|
348
|
+
# Page route: only the session cookie authenticates a browser.
|
|
349
|
+
if not _valid_session(request):
|
|
350
|
+
return RedirectResponse(f"/login?next={path}", status_code=303)
|
|
351
|
+
|
|
352
|
+
# 3) Read-only gate (after auth): block mutating /api/* when ui_readonly.
|
|
244
353
|
if (
|
|
245
|
-
|
|
246
|
-
and
|
|
247
|
-
and
|
|
354
|
+
is_api
|
|
355
|
+
and getattr(settings, "ui_readonly", False)
|
|
356
|
+
and method in ("POST", "PUT", "PATCH", "DELETE")
|
|
248
357
|
):
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
bearer = request.headers.get("Authorization") == f"Bearer {token}"
|
|
252
|
-
query = request.query_params.get("token") == token
|
|
253
|
-
if not (bearer or query):
|
|
254
|
-
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
|
358
|
+
return JSONResponse({"detail": "read-only"}, status_code=403)
|
|
359
|
+
|
|
255
360
|
return await call_next(request)
|
|
256
361
|
|
|
257
362
|
if _STATIC_DIR.exists():
|
|
@@ -718,13 +823,64 @@ def create_app(
|
|
|
718
823
|
ver = "dev"
|
|
719
824
|
cfg = getattr(request.app.state, "config", None)
|
|
720
825
|
settings = getattr(cfg, "settings", None)
|
|
721
|
-
token = getattr(settings, "ui_auth_token", None)
|
|
722
826
|
tz = getattr(settings, "timezone", None) or "local"
|
|
827
|
+
# `authed` drives the Logout affordance; only meaningful when a token
|
|
828
|
+
# is configured. The raw token is never injected into the page.
|
|
829
|
+
authed = bool(_configured_token()) and _valid_session(request)
|
|
723
830
|
return {
|
|
724
831
|
"active": request.url.path, "version": ver, "page": page,
|
|
725
|
-
"
|
|
832
|
+
"authed": authed, "timezone": tz,
|
|
726
833
|
}
|
|
727
834
|
|
|
835
|
+
@app.get("/login")
|
|
836
|
+
async def ui_login(request: Request):
|
|
837
|
+
# No token configured, or already authenticated → nothing to log into.
|
|
838
|
+
if not _configured_token() or _valid_session(request):
|
|
839
|
+
return RedirectResponse("/", status_code=303)
|
|
840
|
+
try:
|
|
841
|
+
ver = _pkg_version("dccd")
|
|
842
|
+
except Exception:
|
|
843
|
+
ver = "dev"
|
|
844
|
+
return templates.TemplateResponse(
|
|
845
|
+
request, "login.html",
|
|
846
|
+
{"version": ver, "next": _safe_next(request.query_params.get("next")),
|
|
847
|
+
"error": False},
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
@app.post("/login")
|
|
851
|
+
async def ui_login_post(request: Request):
|
|
852
|
+
token = _configured_token()
|
|
853
|
+
# Parse the urlencoded form by hand to avoid a python-multipart dep.
|
|
854
|
+
fields = parse_qs((await request.body()).decode("utf-8", "replace"))
|
|
855
|
+
submitted = (fields.get("token") or [""])[0]
|
|
856
|
+
nxt = _safe_next((fields.get("next") or ["/"])[0])
|
|
857
|
+
if not token or not secrets.compare_digest(submitted, token):
|
|
858
|
+
try:
|
|
859
|
+
ver = _pkg_version("dccd")
|
|
860
|
+
except Exception:
|
|
861
|
+
ver = "dev"
|
|
862
|
+
return templates.TemplateResponse(
|
|
863
|
+
request, "login.html",
|
|
864
|
+
{"version": ver, "next": nxt, "error": True},
|
|
865
|
+
status_code=401,
|
|
866
|
+
)
|
|
867
|
+
sid = _new_session()
|
|
868
|
+
resp = RedirectResponse(nxt, status_code=303)
|
|
869
|
+
resp.set_cookie(
|
|
870
|
+
_SESSION_COOKIE, sid, httponly=True, samesite="lax",
|
|
871
|
+
secure=_request_is_https(request), max_age=_SESSION_TTL_SECONDS, path="/",
|
|
872
|
+
)
|
|
873
|
+
return resp
|
|
874
|
+
|
|
875
|
+
@app.post("/logout")
|
|
876
|
+
async def ui_logout(request: Request):
|
|
877
|
+
sid = request.cookies.get(_SESSION_COOKIE)
|
|
878
|
+
if sid:
|
|
879
|
+
app.state.sessions.pop(sid, None)
|
|
880
|
+
resp = RedirectResponse("/login", status_code=303)
|
|
881
|
+
resp.delete_cookie(_SESSION_COOKIE, path="/")
|
|
882
|
+
return resp
|
|
883
|
+
|
|
728
884
|
# Starlette >= 0.29 / 1.x signature: TemplateResponse(request, name, context)
|
|
729
885
|
@app.get("/")
|
|
730
886
|
async def ui_dashboard(request: Request):
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
nav a:hover { background:var(--border); color:var(--fg); }
|
|
28
28
|
nav a.active { color:var(--accent); background:rgba(74,158,255,.12);
|
|
29
29
|
font-weight:600; }
|
|
30
|
+
nav .nav-logout { margin:0; margin-left:auto; }
|
|
30
31
|
nav .nav-menu { position:relative; }
|
|
31
32
|
nav .nav-menu-btn { color:var(--muted); background:transparent; border:0;
|
|
32
33
|
padding:.4rem .8rem; border-radius:6px; cursor:pointer; font:inherit; }
|
|
@@ -128,6 +129,24 @@
|
|
|
128
129
|
to { opacity:1; transform:none; } }
|
|
129
130
|
.j-key { color:#4a9eff; } .j-str { color:#3fb950; }
|
|
130
131
|
.j-num { color:#d2a8ff; } .j-bool { color:#f0883e; } .j-null { color:#8b98a5; }
|
|
132
|
+
/* Wide/dense tables scroll horizontally inside their own box instead of
|
|
133
|
+
pushing the whole page wide (wrapper added by wrapTables(), below). */
|
|
134
|
+
.table-scroll { overflow-x:auto; -webkit-overflow-scrolling:touch; max-width:100%; }
|
|
135
|
+
/* Mobile / narrow viewport: bigger tap targets, tighter chrome, no page-wide
|
|
136
|
+
horizontal scroll. Desktop layout above the breakpoint is untouched. */
|
|
137
|
+
@media (max-width: 640px) {
|
|
138
|
+
nav { padding:.4rem .6rem; gap:.15rem; }
|
|
139
|
+
nav a, nav .nav-menu-btn, nav .nav-logout button { padding:.55rem .7rem; }
|
|
140
|
+
nav .brand-group .ver { display:none; }
|
|
141
|
+
nav .nav-menu-items { max-width:calc(100vw - 1.2rem); }
|
|
142
|
+
main { padding:.8rem .7rem; }
|
|
143
|
+
h1 { font-size:1.15rem; }
|
|
144
|
+
h2 { font-size:1rem; }
|
|
145
|
+
button, .tab { min-height:40px; }
|
|
146
|
+
details.ex > summary { padding:.7rem .8rem; }
|
|
147
|
+
.inline-form { gap:.5rem; }
|
|
148
|
+
.modal { min-width:0; width:calc(100vw - 2rem); }
|
|
149
|
+
}
|
|
131
150
|
</style>
|
|
132
151
|
</head>
|
|
133
152
|
<body>
|
|
@@ -159,6 +178,11 @@
|
|
|
159
178
|
{% endfor %}
|
|
160
179
|
</div>
|
|
161
180
|
</div>
|
|
181
|
+
{% if authed %}
|
|
182
|
+
<form method="post" action="/logout" class="nav-logout">
|
|
183
|
+
<button type="submit" class="nav-menu-btn" title="Sign out">Logout</button>
|
|
184
|
+
</form>
|
|
185
|
+
{% endif %}
|
|
162
186
|
</nav>
|
|
163
187
|
<main>
|
|
164
188
|
{% block content %}{% endblock %}
|
|
@@ -305,10 +329,29 @@
|
|
|
305
329
|
root.appendChild(el);
|
|
306
330
|
setTimeout(function () { el.remove(); }, kind === 'err' ? 6000 : 3500);
|
|
307
331
|
}
|
|
308
|
-
|
|
332
|
+
// Keep wide tables inside a horizontally-scrollable box so a narrow viewport
|
|
333
|
+
// never scrolls the whole page sideways. Idempotent; a MutationObserver re-runs
|
|
334
|
+
// it for tables that pages build after fetching data.
|
|
335
|
+
function wrapTables(root) {
|
|
336
|
+
(root || document).querySelectorAll('main table').forEach(function (t) {
|
|
337
|
+
if (!t.parentElement.classList.contains('table-scroll')) {
|
|
338
|
+
var w = document.createElement('div');
|
|
339
|
+
w.className = 'table-scroll';
|
|
340
|
+
t.parentNode.insertBefore(w, t);
|
|
341
|
+
w.appendChild(t);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
346
|
+
wrapTables();
|
|
347
|
+
var main = document.querySelector('main') || document.body;
|
|
348
|
+
new MutationObserver(function () { wrapTables(); })
|
|
349
|
+
.observe(main, { childList: true, subtree: true });
|
|
350
|
+
});
|
|
351
|
+
// Auth is carried by the HttpOnly `dccd_session` cookie (set at /login); the
|
|
352
|
+
// raw token is never exposed to JS. `same-origin` ensures the cookie is sent.
|
|
309
353
|
async function api(method, url, body) {
|
|
310
|
-
const opts = { method, headers: {} };
|
|
311
|
-
if (DCCD_TOKEN) opts.headers['Authorization'] = 'Bearer ' + DCCD_TOKEN;
|
|
354
|
+
const opts = { method, headers: {}, credentials: 'same-origin' };
|
|
312
355
|
if (body !== undefined) {
|
|
313
356
|
opts.headers['Content-Type'] = 'application/json';
|
|
314
357
|
opts.body = JSON.stringify(body);
|
|
@@ -278,7 +278,7 @@ async function createNew() {
|
|
|
278
278
|
let es;
|
|
279
279
|
function connectSSE() {
|
|
280
280
|
if (es) es.close();
|
|
281
|
-
es = new EventSource('/api/events'
|
|
281
|
+
es = new EventSource('/api/events');
|
|
282
282
|
const st = document.getElementById('sse-status');
|
|
283
283
|
es.onopen = () => { st.innerHTML = '<span class="ok">● connected</span>'; };
|
|
284
284
|
es.onmessage = (e) => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Sign in — dccd UI</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg:#0f1419; --fg:#e6e6e6; --muted:#8b98a5; --accent:#4a9eff;
|
|
10
|
+
--border:#2a3038; --card:#161b22; --ok:#3fb950; --err:#f85149; }
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
body { margin:0; font:14px/1.5 system-ui, sans-serif; background:var(--bg);
|
|
13
|
+
color:var(--fg); display:flex; min-height:100vh; align-items:center;
|
|
14
|
+
justify-content:center; }
|
|
15
|
+
.card { background:var(--card); border:1px solid var(--border); border-radius:10px;
|
|
16
|
+
padding:2rem; width:min(92vw, 360px); }
|
|
17
|
+
.brand { display:flex; align-items:center; gap:.5rem; margin-bottom:1.25rem; }
|
|
18
|
+
.brand img { height:32px; width:auto; }
|
|
19
|
+
.brand .name { font-size:1.2rem; font-weight:700; }
|
|
20
|
+
.brand .ver { color:var(--muted); font-size:.78rem; }
|
|
21
|
+
label { display:block; font-size:.85rem; color:var(--muted); margin-bottom:.35rem; }
|
|
22
|
+
input[type=password] { width:100%; padding:.6rem .7rem; border-radius:8px;
|
|
23
|
+
border:1px solid var(--border); background:var(--bg); color:var(--fg);
|
|
24
|
+
font-size:1rem; }
|
|
25
|
+
button { margin-top:1rem; width:100%; padding:.65rem; border:0; border-radius:8px;
|
|
26
|
+
background:var(--accent); color:#06121f; font-weight:700; font-size:1rem;
|
|
27
|
+
cursor:pointer; }
|
|
28
|
+
button:hover { filter:brightness(1.08); }
|
|
29
|
+
.err { margin-top:.9rem; color:var(--err); font-size:.85rem; }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<form class="card" method="post" action="/login">
|
|
34
|
+
<div class="brand">
|
|
35
|
+
<img src="/static/logo.svg" alt="dccd">
|
|
36
|
+
<span class="name">dccd</span>
|
|
37
|
+
<span class="ver">v{{ version }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<input type="hidden" name="next" value="{{ next }}">
|
|
40
|
+
<label for="token">Access token</label>
|
|
41
|
+
<input id="token" name="token" type="password" autocomplete="current-password"
|
|
42
|
+
autofocus required>
|
|
43
|
+
<button type="submit">Sign in</button>
|
|
44
|
+
{% if error %}<div class="err">Invalid token — try again.</div>{% endif %}
|
|
45
|
+
</form>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
@@ -50,7 +50,7 @@ function clearLive() { logEl.textContent = 'Waiting for events…\n'; eventCount
|
|
|
50
50
|
|
|
51
51
|
function connect() {
|
|
52
52
|
if (es) es.close();
|
|
53
|
-
es = new EventSource('/api/events'
|
|
53
|
+
es = new EventSource('/api/events');
|
|
54
54
|
es.onopen = () => { statusEl.innerHTML = '<span class="ok">● connected</span>'; };
|
|
55
55
|
es.onmessage = (e) => {
|
|
56
56
|
if (e.data.startsWith(':')) return;
|
|
@@ -66,6 +66,186 @@ class TestAuth:
|
|
|
66
66
|
def test_no_token_means_open(self, client):
|
|
67
67
|
assert client.get("/api/inventory").status_code == 200
|
|
68
68
|
|
|
69
|
+
def test_api_accepts_query_token(self, auth_client):
|
|
70
|
+
# SSE/EventSource path: ?token= still authorises (non-browser clients).
|
|
71
|
+
r = auth_client.get("/api/inventory?token=s3cret")
|
|
72
|
+
assert r.status_code == 200
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestAuthSession:
|
|
76
|
+
"""Browser login/session: page routes gated, token never templated."""
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def cfg(self, tmp_data_path):
|
|
80
|
+
cfg = AppConfig()
|
|
81
|
+
cfg.settings.data_path = tmp_data_path
|
|
82
|
+
cfg.storage.local_path = tmp_data_path
|
|
83
|
+
cfg.settings.ui_auth_token = "s3cret"
|
|
84
|
+
return cfg
|
|
85
|
+
|
|
86
|
+
@pytest.fixture
|
|
87
|
+
def auth_client(self, cfg):
|
|
88
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
89
|
+
yield c
|
|
90
|
+
|
|
91
|
+
def test_page_redirects_to_login_when_unauthenticated(self, auth_client):
|
|
92
|
+
r = auth_client.get("/", follow_redirects=False)
|
|
93
|
+
assert r.status_code == 303
|
|
94
|
+
assert r.headers["location"] == "/login?next=/"
|
|
95
|
+
|
|
96
|
+
def test_login_page_renders(self, auth_client):
|
|
97
|
+
r = auth_client.get("/login")
|
|
98
|
+
assert r.status_code == 200
|
|
99
|
+
assert "token" in r.text.lower()
|
|
100
|
+
|
|
101
|
+
def test_login_wrong_token_401(self, auth_client):
|
|
102
|
+
r = auth_client.post(
|
|
103
|
+
"/login", data={"token": "nope", "next": "/"}, follow_redirects=False
|
|
104
|
+
)
|
|
105
|
+
assert r.status_code == 401
|
|
106
|
+
|
|
107
|
+
def test_login_sets_httponly_lax_cookie_and_grants_access(self, cfg):
|
|
108
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
109
|
+
r = c.post(
|
|
110
|
+
"/login", data={"token": "s3cret", "next": "/"}, follow_redirects=False
|
|
111
|
+
)
|
|
112
|
+
assert r.status_code == 303
|
|
113
|
+
set_cookie = r.headers["set-cookie"].lower()
|
|
114
|
+
assert "dccd_session=" in set_cookie
|
|
115
|
+
assert "httponly" in set_cookie
|
|
116
|
+
assert "samesite=lax" in set_cookie
|
|
117
|
+
assert "samesite=none" not in set_cookie # CSRF: never cross-site
|
|
118
|
+
# The session cookie now drives both pages and the API.
|
|
119
|
+
assert c.get("/", follow_redirects=False).status_code == 200
|
|
120
|
+
assert c.get("/api/inventory").status_code == 200
|
|
121
|
+
|
|
122
|
+
def test_api_rejects_without_cookie_or_bearer(self, auth_client):
|
|
123
|
+
# Fresh client (no cookie) cannot reach the API.
|
|
124
|
+
assert auth_client.get("/api/inventory").status_code == 401
|
|
125
|
+
|
|
126
|
+
def test_logout_clears_session(self, cfg):
|
|
127
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
128
|
+
c.post("/login", data={"token": "s3cret", "next": "/"})
|
|
129
|
+
assert c.get("/", follow_redirects=False).status_code == 200
|
|
130
|
+
c.post("/logout", follow_redirects=False)
|
|
131
|
+
assert c.get("/", follow_redirects=False).status_code == 303
|
|
132
|
+
|
|
133
|
+
def test_token_never_in_page(self, cfg):
|
|
134
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
135
|
+
c.post("/login", data={"token": "s3cret", "next": "/"})
|
|
136
|
+
body = c.get("/").text
|
|
137
|
+
assert "s3cret" not in body # regression: token must not leak into HTML
|
|
138
|
+
|
|
139
|
+
def test_open_redirect_blocked(self, cfg):
|
|
140
|
+
for bad in ("//evil.com", "https://evil", "/\\evil"):
|
|
141
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
142
|
+
r = c.post(
|
|
143
|
+
"/login", data={"token": "s3cret", "next": bad},
|
|
144
|
+
follow_redirects=False,
|
|
145
|
+
)
|
|
146
|
+
assert r.headers["location"] == "/", bad
|
|
147
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
148
|
+
r = c.post(
|
|
149
|
+
"/login", data={"token": "s3cret", "next": "/data"},
|
|
150
|
+
follow_redirects=False,
|
|
151
|
+
)
|
|
152
|
+
assert r.headers["location"] == "/data"
|
|
153
|
+
|
|
154
|
+
def test_no_token_pages_open(self, client):
|
|
155
|
+
# Default localhost (no token): pages render without a login.
|
|
156
|
+
assert client.get("/", follow_redirects=False).status_code == 200
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestHardening:
|
|
160
|
+
"""Rate limit, CORS-never-wildcard, read-only mode, mutating-routes-need-auth."""
|
|
161
|
+
|
|
162
|
+
def _cfg(self, tmp_data_path, **over):
|
|
163
|
+
cfg = AppConfig()
|
|
164
|
+
cfg.settings.data_path = tmp_data_path
|
|
165
|
+
cfg.storage.local_path = tmp_data_path
|
|
166
|
+
for k, v in over.items():
|
|
167
|
+
setattr(cfg.settings, k, v)
|
|
168
|
+
return cfg
|
|
169
|
+
|
|
170
|
+
# --- CORS: never wildcard ---
|
|
171
|
+
|
|
172
|
+
def test_cors_no_origin_when_unconfigured(self, tmp_data_path):
|
|
173
|
+
cfg = self._cfg(tmp_data_path) # ui_allow_origins=[]
|
|
174
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
175
|
+
r = c.get("/api/inventory", headers={"Origin": "http://evil.test"})
|
|
176
|
+
assert "access-control-allow-origin" not in {k.lower() for k in r.headers}
|
|
177
|
+
|
|
178
|
+
def test_cors_echoes_allowed_origin_never_star(self, tmp_data_path):
|
|
179
|
+
cfg = self._cfg(tmp_data_path, ui_allow_origins=["http://good.test"])
|
|
180
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
181
|
+
ok = c.get("/api/inventory", headers={"Origin": "http://good.test"})
|
|
182
|
+
assert ok.headers.get("access-control-allow-origin") == "http://good.test"
|
|
183
|
+
evil = c.get("/api/inventory", headers={"Origin": "http://evil.test"})
|
|
184
|
+
assert evil.headers.get("access-control-allow-origin") != "*"
|
|
185
|
+
|
|
186
|
+
# --- Rate limit ---
|
|
187
|
+
|
|
188
|
+
def test_rate_limit_trips_429(self, tmp_data_path):
|
|
189
|
+
cfg = self._cfg(tmp_data_path, ui_rate_limit=2)
|
|
190
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
191
|
+
responses = [c.get("/api/inventory") for _ in range(8)]
|
|
192
|
+
codes = [r.status_code for r in responses]
|
|
193
|
+
assert 429 in codes
|
|
194
|
+
# every 429 must carry Retry-After
|
|
195
|
+
assert all("retry-after" in {k.lower() for k in r.headers}
|
|
196
|
+
for r in responses if r.status_code == 429)
|
|
197
|
+
|
|
198
|
+
def test_rate_limit_off_never_429(self, tmp_data_path):
|
|
199
|
+
cfg = self._cfg(tmp_data_path, ui_rate_limit=0)
|
|
200
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
201
|
+
assert all(c.get("/api/inventory").status_code == 200 for _ in range(12))
|
|
202
|
+
|
|
203
|
+
def test_xff_not_trusted_by_default(self, tmp_data_path):
|
|
204
|
+
# Forged X-Forwarded-For must not mint fresh buckets when proxy not trusted.
|
|
205
|
+
cfg = self._cfg(tmp_data_path, ui_rate_limit=2)
|
|
206
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
207
|
+
codes = [c.get("/api/inventory", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
|
|
208
|
+
for i in range(8)]
|
|
209
|
+
assert 429 in codes # all share the real peer key despite forged XFF
|
|
210
|
+
|
|
211
|
+
def test_xff_trusted_gives_distinct_buckets(self, tmp_data_path):
|
|
212
|
+
cfg = self._cfg(tmp_data_path, ui_rate_limit=2, ui_trusted_proxy=True)
|
|
213
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
214
|
+
codes = [c.get("/api/inventory", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
|
|
215
|
+
for i in range(8)]
|
|
216
|
+
assert codes.count(200) == 8 # each distinct client has its own bucket
|
|
217
|
+
|
|
218
|
+
# --- Read-only ---
|
|
219
|
+
|
|
220
|
+
def test_readonly_blocks_mutating(self, tmp_data_path):
|
|
221
|
+
cfg = self._cfg(tmp_data_path, ui_readonly=True)
|
|
222
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
223
|
+
assert c.post("/api/jobs/create", json={}).status_code == 403
|
|
224
|
+
assert c.get("/api/jobs").status_code == 200
|
|
225
|
+
|
|
226
|
+
def test_readonly_401_wins_for_unauth_mutating(self, tmp_data_path):
|
|
227
|
+
# token + readonly: an unauthenticated mutating call is 401, not 403.
|
|
228
|
+
cfg = self._cfg(tmp_data_path, ui_readonly=True, ui_auth_token="s3cret")
|
|
229
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
230
|
+
assert c.post("/api/jobs/create", json={}).status_code == 401
|
|
231
|
+
# authenticated mutating call is then blocked by read-only (403).
|
|
232
|
+
r = c.post("/api/jobs/create", json={},
|
|
233
|
+
headers={"Authorization": "Bearer s3cret"})
|
|
234
|
+
assert r.status_code == 403
|
|
235
|
+
|
|
236
|
+
# --- Mutating routes require auth ---
|
|
237
|
+
|
|
238
|
+
@pytest.mark.parametrize("method,path", [
|
|
239
|
+
("post", "/api/jobs/create"), ("post", "/api/jobs/delete"),
|
|
240
|
+
("post", "/api/jobs/update"), ("post", "/api/jobs/run"),
|
|
241
|
+
("post", "/api/jobs/run-all"), ("post", "/api/streams/start"),
|
|
242
|
+
("post", "/api/streams/stop"), ("delete", "/api/backfill/x"),
|
|
243
|
+
])
|
|
244
|
+
def test_mutating_routes_require_token(self, tmp_data_path, method, path):
|
|
245
|
+
cfg = self._cfg(tmp_data_path, ui_auth_token="s3cret")
|
|
246
|
+
with TestClient(create_app(config=cfg)) as c:
|
|
247
|
+
assert getattr(c, method)(path).status_code == 401
|
|
248
|
+
|
|
69
249
|
|
|
70
250
|
class _OkRemote:
|
|
71
251
|
"""In-test remote that reports success without invoking rclone."""
|
|
@@ -44,6 +44,7 @@ dccd/interfaces/ui/templates/dashboard.html
|
|
|
44
44
|
dccd/interfaces/ui/templates/data.html
|
|
45
45
|
dccd/interfaces/ui/templates/historical.html
|
|
46
46
|
dccd/interfaces/ui/templates/live.html
|
|
47
|
+
dccd/interfaces/ui/templates/login.html
|
|
47
48
|
dccd/interfaces/ui/templates/logs.html
|
|
48
49
|
dccd/interfaces/ui/templates/storage.html
|
|
49
50
|
dccd/sources/__init__.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|