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:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]] = {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user