P2 cleanups: pin deps, prune job-run rows, unmask non-secret creds, enforce allowlist at login

- R11: pin requirements.txt to the versions running in the image, so a rebuild
  can't pull a breaking upstream release. Documented how to upgrade.
- R12: log rotation now also prunes job_runs rows older than 90 days (nulling
  the manual_imports FK reference first), so the table doesn't grow without
  bound. dedup/genre candidates are review state and left alone.
- U5: credential form shows non-secret fields (URLs, usernames, prefs) as plain
  text so they can be verified while typing; only real secrets stay masked.
  Dashboard IP card reworded from gluetun-specific to generic "Outbound IP".
- S9: enforce ALLOWED_EMAIL at the OIDC callback, before any user row or session
  is created, instead of only on later requests.

Verified: pinned build resolves; prune deletes only old rows and keeps the
manual_imports record; credentials page renders text+password inputs; a
disallowed email gets 403 with no user row, an allowed one succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-10 15:44:31 -06:00
parent a9e1263b01
commit 2641a1b874
8 changed files with 77 additions and 22 deletions
+22
View File
@@ -71,6 +71,28 @@ def init_db() -> None:
conn.close() conn.close()
def prune_old_job_runs(max_age_days: int = 90) -> int:
"""Delete job_runs rows older than max_age_days so the table doesn't grow
without bound (roughly 30 scheduled runs/day). Rows are tiny and useful for
history, so the retention is longer than the 30-day log-file rotation.
manual_imports.job_run_id references job_runs, so null those references
first to respect the foreign key (the import record itself is kept). dedup
and genre candidates are review state, not run history, so they are left
alone here. Returns the number of job_runs rows deleted."""
cutoff = time.time() - max_age_days * 86400
with engine.begin() as conn:
conn.execute(
text(
"UPDATE manual_imports SET job_run_id = NULL "
"WHERE job_run_id IN (SELECT id FROM job_runs WHERE started_at < :c)"
),
{"c": cutoff},
)
result = conn.execute(text("DELETE FROM job_runs WHERE started_at < :c"), {"c": cutoff})
return result.rowcount or 0
def mark_interrupted_runs() -> None: def mark_interrupted_runs() -> None:
"""A job_run still marked 'running' at startup was killed mid-flight: the """A job_run still marked 'running' at startup was killed mid-flight: the
subprocess does not survive a container restart. Mark those so the UI and subprocess does not survive a container restart. Mark those so the UI and
+8 -1
View File
@@ -1,6 +1,6 @@
import time import time
from fastapi import APIRouter, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import select from sqlalchemy import select
@@ -8,6 +8,7 @@ from app.db import SessionLocal
from app.models import User from app.models import User
from app.security.oidc import oauth from app.security.oidc import oauth
from app.services import diagnostics from app.services import diagnostics
from app.settings import settings
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -33,6 +34,12 @@ async def auth_callback(request: Request):
name = userinfo.get("name") name = userinfo.get("name")
now = time.time() now = time.time()
# Enforce the allowlist here, before creating any user row or session, so a
# disallowed identity never gets persisted or a cookie (require_auth also
# checks it on every request, but that's after the fact).
if settings.allowed_email and email != settings.allowed_email:
raise HTTPException(status_code=403, detail="This account is not authorized to use alembic.")
with SessionLocal() as db: with SessionLocal() as db:
user = db.execute(select(User).where(User.oidc_sub == sub)).scalar_one_or_none() user = db.execute(select(User).where(User.oidc_sub == sub)).scalar_one_or_none()
if user is None: if user is None:
+1
View File
@@ -30,6 +30,7 @@ async def credentials_index(request: Request, user: dict = Depends(require_auth)
"scope_fields": credential_service.SCOPE_FIELDS, "scope_fields": credential_service.SCOPE_FIELDS,
"scope_descriptions": credential_service.SCOPE_DESCRIPTIONS, "scope_descriptions": credential_service.SCOPE_DESCRIPTIONS,
"core_scopes": credential_service.CORE_SCOPES, "core_scopes": credential_service.CORE_SCOPES,
"secret_keys": credential_service.SECRET_KEYS,
"configured": configured, "configured": configured,
"enabled": enabled, "enabled": enabled,
"key_present": crypto.key_present(), "key_present": crypto.key_present(),
+14
View File
@@ -44,6 +44,20 @@ SCOPE_DESCRIPTIONS = {
CORE_SCOPES = {"spotify", "soulseek", "navidrome"} CORE_SCOPES = {"spotify", "soulseek", "navidrome"}
OPTIONAL_SCOPES = {"bandcamp", "azuracast", "qobuz", "telegram"} OPTIONAL_SCOPES = {"bandcamp", "azuracast", "qobuz", "telegram"}
# The genuinely-secret fields, masked in the UI. Everything else is an
# identifier, URL, or preference the user should be able to see while typing
# so they can verify it (e.g. the Navidrome base URL or their Soulseek
# username). Values are write-only either way -- never sent back to the browser.
SECRET_KEYS = {
"client_secret",
"password",
"admin_pass",
"cookies_txt",
"api_key",
"token",
"bot_token",
}
def _upsert(db: Session, scope: str, key: str, value: str) -> None: def _upsert(db: Session, scope: str, key: str, value: str) -> None:
row = db.execute( row = db.execute(
+6 -2
View File
@@ -85,7 +85,10 @@ async def _genre_run(triggered_by: str = "schedule"):
async def _log_rotation(triggered_by: str = "schedule"): async def _log_rotation(triggered_by: str = "schedule"):
"""Replaces `find /var/log/sldl -name '*.log' -mtime +30 -delete`.""" """Replaces `find /var/log/sldl -name '*.log' -mtime +30 -delete`, and also
prunes old job_runs rows so the database doesn't grow without bound."""
from app.db import prune_old_job_runs
cutoff = time.time() - 30 * 86400 cutoff = time.time() - 30 * 86400
removed = 0 removed = 0
if settings.logs_dir.exists(): if settings.logs_dir.exists():
@@ -93,7 +96,8 @@ async def _log_rotation(triggered_by: str = "schedule"):
if path.is_file() and path.stat().st_mtime < cutoff: if path.is_file() and path.stat().st_mtime < cutoff:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
removed += 1 removed += 1
return removed pruned_rows = prune_old_job_runs()
return {"logs_removed": removed, "job_run_rows_pruned": pruned_rows}
MAINTENANCE_JOBS: dict[str, tuple[dict, callable]] = { MAINTENANCE_JOBS: dict[str, tuple[dict, callable]] = {
+3 -1
View File
@@ -54,8 +54,10 @@
</label> </label>
{% if field in ("cookies_txt",) %} {% if field in ("cookies_txt",) %}
<textarea id="{{ scope }}_{{ field }}" name="{{ field }}" rows="6" placeholder="leave blank to keep current"></textarea> <textarea id="{{ scope }}_{{ field }}" name="{{ field }}" rows="6" placeholder="leave blank to keep current"></textarea>
{% else %} {% elif field in secret_keys %}
<input type="password" id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current" autocomplete="off"> <input type="password" id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current" autocomplete="off">
{% else %}
<input type="text" id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current" autocomplete="off" spellcheck="false">
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
+2 -2
View File
@@ -39,12 +39,12 @@
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-value mono" style="font-size:1.5rem;" <div class="stat-value mono" style="font-size:1.5rem;"
title="{% if public_ip.error and not public_ip.ip %}Couldn't check: {{ public_ip.error }}{% elif public_ip.error %}Showing last known good check -- most recent check failed: {{ public_ip.error }}{% else %}This should be gluetun's VPN IP, not your home connection's{% endif %}"> title="{% if public_ip.error and not public_ip.ip %}Couldn't check: {{ public_ip.error }}{% elif public_ip.error %}Showing last known good check -- most recent check failed: {{ public_ip.error }}{% else %}alembic's outbound IP. If you route Soulseek through a VPN, confirm this is the VPN's IP and not your home connection's.{% endif %}">
{% if public_ip.ip %}{{ public_ip.ip }}{% else %}<span class="muted">unknown</span>{% endif %} {% if public_ip.ip %}{{ public_ip.ip }}{% else %}<span class="muted">unknown</span>{% endif %}
</div> </div>
<div class="stat-label"> <div class="stat-label">
<span class="status-dot {{ 'status-dot-success' if public_ip.ip and not public_ip.error else ('status-dot-warning' if public_ip.ip else 'status-dot-muted') }}" style="margin-right:0.3rem;"></span> <span class="status-dot {{ 'status-dot-success' if public_ip.ip and not public_ip.error else ('status-dot-warning' if public_ip.ip else 'status-dot-muted') }}" style="margin-right:0.3rem;"></span>
Public IP <span class="muted">(via gluetun)</span> Outbound IP
</div> </div>
</div> </div>
</div> </div>
+21 -16
View File
@@ -1,19 +1,24 @@
# Pinned to the versions currently running in the image. Rebuilds are then
# reproducible: a breaking upstream release can't be pulled in on the next
# `docker build`. To upgrade, bump a pin here deliberately, rebuild, and test.
# Control-plane app # Control-plane app
fastapi fastapi==0.139.0
uvicorn[standard] uvicorn[standard]==0.51.0
jinja2 jinja2==3.1.6
python-multipart python-multipart==0.0.32
pydantic-settings pydantic==2.13.4
sqlalchemy pydantic-settings==2.14.2
apscheduler sqlalchemy==2.0.51
authlib apscheduler==3.11.3
itsdangerous authlib==1.7.2
cryptography itsdangerous==2.2.0
httpx cryptography==49.0.0
httpx==0.28.1
# Pipeline scripts (migrated from /opt/sldl) # Pipeline scripts (migrated from /opt/sldl)
beets beets==2.12.0
mutagen mutagen==1.48.1
numpy numpy==2.4.6
pyacoustid pyacoustid==1.3.1
requests requests==2.34.2