Security values move into the Settings UI
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the envs, we need the security values to be in the UI."* Overrules the call I made yesterday, and rule 25 is on your side — I argued deployment-topology, but the operator has to be able to SEE what protects them, and reading a container's environment is not seeing. Six new settings in a **Security** group: trusted proxy hops (default 1), the per-account and per-address sign-in limits with their shared window, and the sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate limits are no longer hardcoded constants. **The hard part was keeping the throttle cheap.** It consults these BEFORE opening a database connection — deliberately, because a refused attempt is meant to cost nothing, and the hop count is needed to know who is even asking. A query per attempt would undo both. So there is a small cache seeded from the registry defaults (the app works with no database at all, which is what the DB-free unit lane relies on), loaded at boot, and refreshed on every settings save — the same live-update contract `session_ttl_days` already had. `SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so a saved number applies to the next attempt instead of the next deploy. **Bounds are rejected, not clamped.** A hop count of 99 would trust anything a caller sent; a sign-in limit of 0 would lock every account out permanently. Both now fail validation with a message naming the range, and the number input carries min/max so the browser objects first. Silently storing a different number than the one typed is how somebody ends up believing a protection is set to something it is not. `MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself rather than the app from a caller, and there is no operator judgment to apply. Two integration tests, because the whole point is the round trip: a dangerous value refused, a legitimate one reaching the cache the throttle reads and persisting; and every Security row reaching the admin payload with bounds and a description that explains itself.
This commit is contained in:
@@ -27,7 +27,7 @@ from thoughtsync.db import dispose_engine, session_scope
|
||||
from thoughtsync.models.note import Note
|
||||
from thoughtsync.models.note_item import NoteItem
|
||||
from thoughtsync.models.user import User
|
||||
from thoughtsync.settings import get_setting, set_settings
|
||||
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
|
||||
from thoughtsync.notes.helpers import derive_display_title
|
||||
from thoughtsync.models.note_link_preview import NoteLinkPreview
|
||||
from thoughtsync.sync import _apply_note_items
|
||||
@@ -337,3 +337,80 @@ async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
|
||||
)
|
||||
assert third.status_code == 201
|
||||
assert (await third.get_json())["is_admin"] is False
|
||||
|
||||
|
||||
async def test_security_settings_are_live_and_bounded(app_client, db):
|
||||
"""The security values are settings now, not constants — so saving one has to take
|
||||
effect without a restart, and a dangerous value has to be refused.
|
||||
|
||||
Real database because the whole point is the round trip: write through the admin
|
||||
API, re-read into the cache the throttle consults, observe the new number.
|
||||
"""
|
||||
# An admin to authenticate as. First account, so it is allowed and becomes admin.
|
||||
reset_live()
|
||||
created = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
# Defaults are what the registry says.
|
||||
async with session_scope() as fresh:
|
||||
await refresh_live(fresh)
|
||||
assert live("trusted_proxy_hops") == 1
|
||||
assert live("signin_limit_per_account") == 10
|
||||
|
||||
# A value that would disable the protection is REFUSED, not clamped — storing a
|
||||
# different number than the one typed is how somebody ends up believing a limit
|
||||
# is set to something it is not.
|
||||
bad = await app_client.patch("/api/settings", json={"signin_limit_per_account": 0})
|
||||
assert bad.status_code == 400
|
||||
assert "at least" in (await bad.get_json())["error"]
|
||||
|
||||
# …and so is a hop count that would trust anything a caller sent.
|
||||
bad_hops = await app_client.patch("/api/settings", json={"trusted_proxy_hops": 99})
|
||||
assert bad_hops.status_code == 400
|
||||
|
||||
# A legitimate change applies to the cache the throttle reads, immediately.
|
||||
ok = await app_client.patch(
|
||||
"/api/settings", json={"signin_limit_per_account": 3, "trusted_proxy_hops": 2}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert live("signin_limit_per_account") == 3
|
||||
assert live("trusted_proxy_hops") == 2
|
||||
|
||||
# And it is persisted, not just cached.
|
||||
async with session_scope() as fresh:
|
||||
assert await get_setting(fresh, "trusted_proxy_hops") == 2
|
||||
|
||||
reset_live()
|
||||
|
||||
|
||||
async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
||||
"""Every security value has to be visible and editable, which is the whole reason
|
||||
they moved out of the environment."""
|
||||
created = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin2@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
resp = await app_client.get("/api/settings")
|
||||
assert resp.status_code == 200
|
||||
rows = (await resp.get_json())["settings"]
|
||||
security = {r["key"]: r for r in rows if r["group"] == "Security"}
|
||||
|
||||
assert set(security) == {
|
||||
"trusted_proxy_hops",
|
||||
"signin_limit_per_account",
|
||||
"signin_limit_per_address",
|
||||
"signin_window_minutes",
|
||||
"register_limit_per_address",
|
||||
"register_window_minutes",
|
||||
}
|
||||
# The UI renders a number input from these, and it cannot offer a safe range it
|
||||
# was never told about.
|
||||
for row in security.values():
|
||||
assert row["type"] == "int"
|
||||
assert row["minimum"] is not None and row["maximum"] is not None
|
||||
assert row["description"], f"{row['key']} has no description to explain itself"
|
||||
|
||||
Reference in New Issue
Block a user