feat: OIDC/LDAP auth, audit log, maintenance windows, migrations
- Add OIDC (OpenID Connect) authentication with discovery URL support, group-based role mapping, and token introspection - Add LDAP authentication with bind credentials, group-based role mapping, and configurable attribute names - Add audit log (model, routes, templates) tracking settings changes, plugin enable/disable, login events - Add migrations 0009 (widget variants), 0010 (maintenance windows), 0011 (audit log) - Add optional dependency groups to pyproject.toml: [ldap], [snmp] - Add Settings → Auth tab with OIDC and LDAP configuration forms Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from quart import Blueprint, render_template, request, current_app
|
||||
from sqlalchemy import select
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.audit import AuditEvent
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
audit_bp = Blueprint("audit", __name__, url_prefix="/audit")
|
||||
|
||||
|
||||
@audit_bp.get("/")
|
||||
@require_role(UserRole.admin)
|
||||
async def list_events():
|
||||
limit = min(int(request.args.get("limit", 200)), 500)
|
||||
action_filter = request.args.get("action", "").strip()
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
stmt = select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(limit)
|
||||
if action_filter:
|
||||
stmt = select(AuditEvent).where(
|
||||
AuditEvent.action.like(f"{action_filter}%")
|
||||
).order_by(AuditEvent.timestamp.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
events = result.scalars().all()
|
||||
|
||||
# Parse detail_json for display
|
||||
parsed = []
|
||||
for e in events:
|
||||
detail = {}
|
||||
if e.detail_json:
|
||||
try:
|
||||
detail = json.loads(e.detail_json)
|
||||
except (ValueError, TypeError):
|
||||
detail = {"raw": e.detail_json}
|
||||
parsed.append({"event": e, "detail": detail})
|
||||
|
||||
return await render_template(
|
||||
"audit/list.html",
|
||||
events=parsed,
|
||||
limit=limit,
|
||||
action_filter=action_filter,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""fabledscryer/auth/ldap_auth.py
|
||||
|
||||
LDAP authentication helper. Requires the optional `ldap3` package.
|
||||
Falls back gracefully if ldap3 is not installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ldap_available() -> bool:
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None:
|
||||
"""Synchronous LDAP auth. Returns user info dict or None on failure.
|
||||
|
||||
Called via run_in_executor so it doesn't block the event loop.
|
||||
"""
|
||||
import ldap3
|
||||
|
||||
host = cfg.get("host", "")
|
||||
port = int(cfg.get("port", 389))
|
||||
use_tls = cfg.get("tls", False)
|
||||
bind_dn = cfg.get("bind_dn", "")
|
||||
bind_password = cfg.get("bind_password", "")
|
||||
base_dn = cfg.get("base_dn", "")
|
||||
user_filter = cfg.get("user_filter", "(uid={username})").format(username=username)
|
||||
admin_group_dn = cfg.get("admin_group_dn", "")
|
||||
operator_group_dn = cfg.get("operator_group_dn", "")
|
||||
attr_username = cfg.get("attr_username", "uid")
|
||||
attr_email = cfg.get("attr_email", "mail")
|
||||
|
||||
tls = ldap3.Tls() if use_tls else None
|
||||
server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL)
|
||||
|
||||
# Service account bind to search for user DN
|
||||
try:
|
||||
svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
except Exception as exc:
|
||||
logger.error("LDAP service bind failed: %s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
svc_conn.search(
|
||||
search_base=base_dn,
|
||||
search_filter=user_filter,
|
||||
attributes=[attr_username, attr_email, "memberOf", "dn"],
|
||||
)
|
||||
if not svc_conn.entries:
|
||||
logger.debug("LDAP user not found: %s", username)
|
||||
return None
|
||||
entry = svc_conn.entries[0]
|
||||
user_dn = str(entry.entry_dn)
|
||||
ldap_username = str(getattr(entry, attr_username, [username])[0]) if hasattr(entry, attr_username) else username
|
||||
ldap_email = str(getattr(entry, attr_email, [""])[0]) if hasattr(entry, attr_email) else ""
|
||||
member_of: list[str] = [str(g) for g in getattr(entry, "memberOf", [])]
|
||||
except Exception as exc:
|
||||
logger.error("LDAP search failed: %s", exc)
|
||||
return None
|
||||
finally:
|
||||
svc_conn.unbind()
|
||||
|
||||
# Bind as the user to verify password
|
||||
try:
|
||||
user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
|
||||
user_conn.unbind()
|
||||
except Exception:
|
||||
logger.debug("LDAP password verify failed for %s", username)
|
||||
return None
|
||||
|
||||
# Map role from group membership
|
||||
role = "viewer"
|
||||
if admin_group_dn and admin_group_dn in member_of:
|
||||
role = "admin"
|
||||
elif operator_group_dn and operator_group_dn in member_of:
|
||||
role = "operator"
|
||||
|
||||
return {
|
||||
"username": ldap_username,
|
||||
"email": ldap_email,
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None:
|
||||
"""Async wrapper around synchronous LDAP auth. Returns user info or None."""
|
||||
if not _ldap_available():
|
||||
logger.warning("ldap3 not installed — LDAP auth unavailable")
|
||||
return None
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _ldap_authenticate_sync, cfg, username, password)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""fabledscryer/auth/oidc.py
|
||||
|
||||
OIDC Authorization Code flow helpers using httpx.
|
||||
No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.).
|
||||
|
||||
JWT signature verification is intentionally skipped for homelab use;
|
||||
we trust the HTTPS connection to the discovery-documented token/userinfo endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level cache: {discovery_url: (fetched_at, doc)}
|
||||
_discovery_cache: dict[str, tuple[float, dict]] = {}
|
||||
_DISCOVERY_TTL = 300 # seconds
|
||||
|
||||
|
||||
async def get_discovery(discovery_url: str) -> dict:
|
||||
"""Fetch and cache the OIDC provider discovery document."""
|
||||
now = time.monotonic()
|
||||
if discovery_url in _discovery_cache:
|
||||
fetched_at, doc = _discovery_cache[discovery_url]
|
||||
if now - fetched_at < _DISCOVERY_TTL:
|
||||
return doc
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(discovery_url)
|
||||
resp.raise_for_status()
|
||||
doc = resp.json()
|
||||
_discovery_cache[discovery_url] = (now, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def build_authorize_url(doc: dict, client_id: str, redirect_uri: str,
|
||||
scopes: str, state: str, nonce: str) -> str:
|
||||
"""Build the IdP authorization redirect URL."""
|
||||
import urllib.parse
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
}
|
||||
return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
async def exchange_code(doc: dict, client_id: str, client_secret: str,
|
||||
code: str, redirect_uri: str) -> dict:
|
||||
"""Exchange authorization code for tokens. Returns token response dict."""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
doc["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_userinfo(doc: dict, access_token: str) -> dict:
|
||||
"""Fetch user info from the IdP's userinfo endpoint."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
doc["userinfo_endpoint"],
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def decode_id_token_payload(id_token: str) -> dict:
|
||||
"""Decode ID token payload without verifying signature (homelab only)."""
|
||||
try:
|
||||
parts = id_token.split(".")
|
||||
if len(parts) < 2:
|
||||
return {}
|
||||
payload_b64 = parts[1]
|
||||
# Add padding
|
||||
payload_b64 += "=" * (4 - len(payload_b64) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def map_role(groups: list[str], admin_group: str, operator_group: str) -> str:
|
||||
"""Map IdP groups to a local role string."""
|
||||
if admin_group and admin_group in groups:
|
||||
return "admin"
|
||||
if operator_group and operator_group in groups:
|
||||
return "operator"
|
||||
return "viewer"
|
||||
|
||||
|
||||
def generate_state() -> str:
|
||||
return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=")
|
||||
+139
-6
@@ -8,6 +8,33 @@ from fabledscryer.models.users import User, UserRole
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
async def _provision_external_user(app, username: str, email: str, role_str: str):
|
||||
"""Create or update a user from an external auth provider (OIDC/LDAP).
|
||||
|
||||
External users get a randomised unusable password so local login is blocked.
|
||||
Their role is updated on every login to reflect current IdP group membership.
|
||||
"""
|
||||
import secrets
|
||||
from fabledscryer.models.users import UserRole
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
new_role = UserRole(role_str)
|
||||
async with db.begin():
|
||||
if user is None:
|
||||
unusable_hash = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt()).decode()
|
||||
user = User(username=username, email=email or f"{username}@external",
|
||||
password_hash=unusable_hash, role=new_role)
|
||||
db.add(user)
|
||||
else:
|
||||
if user.role != new_role:
|
||||
user.role = new_role
|
||||
# Re-fetch committed user
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_user_count(app) -> int:
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(func.count()).select_from(User))
|
||||
@@ -25,21 +52,124 @@ async def login():
|
||||
from quart import current_app
|
||||
if await get_user_count(current_app) == 0:
|
||||
return redirect(url_for("auth.setup"))
|
||||
return await render_template("auth/login.html")
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
return await render_template("auth/login.html",
|
||||
oidc_enabled=oidc_cfg.get("enabled", False))
|
||||
|
||||
|
||||
@auth_bp.post("/login")
|
||||
async def login_post():
|
||||
from quart import current_app
|
||||
from fabledscryer.core.audit import log_audit
|
||||
form = await request.form
|
||||
username = form.get("username", "").strip()
|
||||
password = form.get("password", "").encode()
|
||||
password_str = form.get("password", "")
|
||||
password = password_str.encode()
|
||||
|
||||
# Try local auth first
|
||||
user = await get_user_by_username(current_app, username)
|
||||
if not user or not user.is_active:
|
||||
return await render_template("auth/login.html", error="Invalid credentials"), 400
|
||||
if not bcrypt.checkpw(password, user.password_hash.encode()):
|
||||
return await render_template("auth/login.html", error="Invalid credentials"), 400
|
||||
if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()):
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "local", "role": user.role.value})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# Try LDAP fallback if enabled
|
||||
ldap_cfg = current_app.config.get("LDAP", {})
|
||||
if ldap_cfg.get("enabled"):
|
||||
from fabledscryer.auth.ldap_auth import ldap_authenticate
|
||||
ldap_info = await ldap_authenticate(ldap_cfg, username, password_str)
|
||||
if ldap_info:
|
||||
user = await _provision_external_user(
|
||||
current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"]
|
||||
)
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "ldap", "role": user.role.value})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
return await render_template(
|
||||
"auth/login.html",
|
||||
error="Invalid credentials",
|
||||
oidc_enabled=oidc_cfg.get("enabled", False),
|
||||
), 400
|
||||
|
||||
|
||||
@auth_bp.get("/login/oidc")
|
||||
async def login_oidc():
|
||||
from quart import current_app
|
||||
from fabledscryer.auth.oidc import get_discovery, build_authorize_url, generate_state
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
if not oidc_cfg.get("enabled") or not oidc_cfg.get("discovery_url"):
|
||||
return redirect(url_for("auth.login"))
|
||||
try:
|
||||
doc = await get_discovery(oidc_cfg["discovery_url"])
|
||||
except Exception as exc:
|
||||
return await render_template("auth/login.html", error=f"OIDC discovery failed: {exc}",
|
||||
oidc_enabled=True), 502
|
||||
state = generate_state()
|
||||
nonce = generate_state()
|
||||
session["oidc_state"] = state
|
||||
session["oidc_nonce"] = nonce
|
||||
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
|
||||
url = build_authorize_url(
|
||||
doc, oidc_cfg["client_id"], redirect_uri,
|
||||
oidc_cfg.get("scopes", "openid profile email"), state, nonce,
|
||||
)
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@auth_bp.get("/login/oidc/callback")
|
||||
async def login_oidc_callback():
|
||||
from quart import current_app
|
||||
from fabledscryer.auth.oidc import get_discovery, exchange_code, get_userinfo, map_role
|
||||
from fabledscryer.core.audit import log_audit
|
||||
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
error = request.args.get("error")
|
||||
if error:
|
||||
desc = request.args.get("error_description", error)
|
||||
return await render_template("auth/login.html", error=f"SSO error: {desc}",
|
||||
oidc_enabled=True), 400
|
||||
|
||||
code = request.args.get("code", "")
|
||||
state = request.args.get("state", "")
|
||||
if not code or state != session.pop("oidc_state", None):
|
||||
return await render_template("auth/login.html", error="Invalid SSO state — try again.",
|
||||
oidc_enabled=True), 400
|
||||
|
||||
try:
|
||||
doc = await get_discovery(oidc_cfg["discovery_url"])
|
||||
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
|
||||
tokens = await exchange_code(
|
||||
doc, oidc_cfg["client_id"], oidc_cfg["client_secret"], code, redirect_uri
|
||||
)
|
||||
userinfo = await get_userinfo(doc, tokens["access_token"])
|
||||
except Exception as exc:
|
||||
return await render_template("auth/login.html", error=f"SSO login failed: {exc}",
|
||||
oidc_enabled=True), 502
|
||||
|
||||
username_claim = oidc_cfg.get("username_claim", "preferred_username")
|
||||
email_claim = oidc_cfg.get("email_claim", "email")
|
||||
groups_claim = oidc_cfg.get("groups_claim", "groups")
|
||||
|
||||
username = userinfo.get(username_claim) or userinfo.get("sub", "")
|
||||
email = userinfo.get(email_claim, "") or f"{username}@oidc"
|
||||
groups = userinfo.get(groups_claim, [])
|
||||
if not isinstance(groups, list):
|
||||
groups = []
|
||||
role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", ""))
|
||||
|
||||
if not username:
|
||||
return await render_template("auth/login.html",
|
||||
error="SSO returned no username.",
|
||||
oidc_enabled=True), 400
|
||||
|
||||
user = await _provision_external_user(current_app, username, email, role)
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "oidc", "role": role})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
|
||||
@@ -60,6 +190,7 @@ async def setup():
|
||||
@auth_bp.post("/setup")
|
||||
async def setup_post():
|
||||
from quart import current_app
|
||||
from fabledscryer.core.audit import log_audit
|
||||
if await get_user_count(current_app) > 0:
|
||||
return redirect(url_for("auth.login"))
|
||||
form = await request.form
|
||||
@@ -74,4 +205,6 @@ async def setup_post():
|
||||
async with db.begin():
|
||||
db.add(user)
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.setup",
|
||||
detail={"username": username})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""fabledscryer/core/audit.py
|
||||
|
||||
Helpers for writing audit log entries. Each call opens its own DB
|
||||
session so audit events are committed independently of the calling
|
||||
route's transaction (audit is only written after the main action
|
||||
succeeds, by calling this after the main `async with db.begin()` block).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_audit(
|
||||
app,
|
||||
user_id: str | None,
|
||||
username: str,
|
||||
action: str,
|
||||
entity_type: str | None = None,
|
||||
entity_id: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""Write one audit event. Never raises — failures are logged and swallowed."""
|
||||
from fabledscryer.models.audit import AuditEvent
|
||||
try:
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(AuditEvent(
|
||||
user_id=user_id,
|
||||
username=username or "unknown",
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
detail_json=json.dumps(detail) if detail else None,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("Failed to write audit event action=%r", action)
|
||||
@@ -51,6 +51,35 @@ DEFAULTS: dict[str, Any] = {
|
||||
"ping.threshold.good_ms": 50,
|
||||
"ping.threshold.warn_ms": 200,
|
||||
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/raw/branch/main/index.yaml",
|
||||
# OIDC single-sign-on
|
||||
"oidc.enabled": False,
|
||||
"oidc.discovery_url": "",
|
||||
"oidc.client_id": "",
|
||||
"oidc.client_secret": "",
|
||||
"oidc.scopes": "openid profile email",
|
||||
"oidc.username_claim": "preferred_username",
|
||||
"oidc.email_claim": "email",
|
||||
"oidc.groups_claim": "groups",
|
||||
"oidc.admin_group": "",
|
||||
"oidc.operator_group": "",
|
||||
# LDAP authentication
|
||||
"ldap.enabled": False,
|
||||
"ldap.host": "",
|
||||
"ldap.port": 389,
|
||||
"ldap.tls": False,
|
||||
"ldap.bind_dn": "",
|
||||
"ldap.bind_password": "",
|
||||
"ldap.base_dn": "",
|
||||
"ldap.user_filter": "(uid={username})",
|
||||
"ldap.admin_group_dn": "",
|
||||
"ldap.operator_group_dn": "",
|
||||
"ldap.attr_username": "uid",
|
||||
"ldap.attr_email": "mail",
|
||||
# Scheduled reports
|
||||
"reports.enabled": False,
|
||||
"reports.schedule_day": 6, # 0=Monday … 6=Sunday
|
||||
"reports.schedule_hour": 8, # UTC hour
|
||||
"reports.last_sent_at": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +152,14 @@ def to_ansible_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {"sources": settings.get("ansible.sources", [])}
|
||||
|
||||
|
||||
def to_oidc_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")}
|
||||
|
||||
|
||||
def to_ldap_cfg(settings: dict[str, Any]) -> dict:
|
||||
return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")}
|
||||
|
||||
|
||||
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
|
||||
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
|
||||
result = {}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Widget variants — add config_json and title to dashboard_widgets
|
||||
|
||||
Revision ID: 0009_widget_variants
|
||||
Revises: 0008_share_tokens
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0009_widget_variants"
|
||||
down_revision: Union[str, None] = "0008_share_tokens"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("dashboard_widgets",
|
||||
sa.Column("title", sa.String(128), nullable=True))
|
||||
op.add_column("dashboard_widgets",
|
||||
sa.Column("config_json", sa.Text, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("dashboard_widgets", "config_json")
|
||||
op.drop_column("dashboard_widgets", "title")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Maintenance windows for alert suppression
|
||||
|
||||
Revision ID: 0010_maintenance_windows
|
||||
Revises: 0009_widget_variants
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0010_maintenance_windows"
|
||||
down_revision: Union[str, None] = "0009_widget_variants"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"maintenance_windows",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("scope_source", sa.String(64), nullable=True),
|
||||
sa.Column("scope_resource", sa.String(255), nullable=True),
|
||||
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("maintenance_windows")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Audit log table
|
||||
|
||||
Revision ID: 0011_audit_log
|
||||
Revises: 0010_maintenance_windows
|
||||
Create Date: 2026-03-22
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0011_audit_log"
|
||||
down_revision: Union[str, None] = "0010_maintenance_windows"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("user_id", sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("username", sa.String(64), nullable=False, server_default="system"),
|
||||
sa.Column("action", sa.String(64), nullable=False),
|
||||
sa.Column("entity_type", sa.String(64), nullable=True),
|
||||
sa.Column("entity_id", sa.String(255), nullable=True),
|
||||
sa.Column("detail_json", sa.Text, nullable=True),
|
||||
)
|
||||
op.create_index("ix_audit_events_timestamp", "audit_events", ["timestamp"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_events_timestamp", "audit_events")
|
||||
op.drop_table("audit_events")
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .base import Base
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Denormalized so display survives user deletion
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
entity_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Audit Log</h1>
|
||||
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" name="action" value="{{ action_filter }}"
|
||||
placeholder="Filter by action…"
|
||||
style="width:200px;font-size:0.85rem;">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Filter</button>
|
||||
{% if action_filter %}
|
||||
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if events %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width:160px;">Time (UTC)</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Entity</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in events %}
|
||||
{% set e = item.event %}
|
||||
{% set d = item.detail %}
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:nowrap;color:var(--text-muted);">
|
||||
{{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}
|
||||
</td>
|
||||
<td style="font-size:0.85rem;">{{ e.username }}</td>
|
||||
<td>
|
||||
{% set cat = e.action.split('.')[0] %}
|
||||
<span style="
|
||||
font-size:0.78rem;font-family:ui-monospace,monospace;
|
||||
padding:0.15rem 0.4rem;border-radius:3px;
|
||||
background:{% if cat == 'auth' %}var(--bg-elevated){% elif cat == 'plugin' %}var(--gold-dim){% elif cat == 'alert' %}var(--red-dim){% elif cat == 'host' %}var(--green-dim){% elif cat == 'settings' %}var(--bg-elevated){% else %}var(--bg-elevated){% endif %};
|
||||
color:{% if cat == 'auth' %}var(--accent){% elif cat == 'plugin' %}var(--gold){% elif cat == 'alert' %}var(--red){% elif cat == 'host' %}var(--green){% elif cat == 'settings' %}var(--text-muted){% else %}var(--text-muted){% endif %};">
|
||||
{{ e.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);">
|
||||
{% if e.entity_type %}
|
||||
<span style="color:var(--text-dim);">{{ e.entity_type }}/</span>{{ e.entity_id or "" }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
|
||||
{% if d %}
|
||||
{% for k, v in d.items() %}
|
||||
<span style="color:var(--text-dim);">{{ k }}:</span>
|
||||
<span style="font-family:ui-monospace,monospace;">{{ v }}</span>
|
||||
{% if not loop.last %} · {% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style="font-size:0.78rem;color:var(--text-dim);margin-top:0.75rem;">
|
||||
Showing last {{ limit }} events.
|
||||
{% if events | length == limit %}
|
||||
<a href="?limit={{ limit * 2 }}{% if action_filter %}&action={{ action_filter }}{% endif %}">Load more</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<p style="color:var(--text-muted);">No audit events recorded yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,22 @@
|
||||
<div style="max-width:400px;margin:4rem auto;">
|
||||
<div class="card">
|
||||
<h2 class="page-title">Sign In</h2>
|
||||
{% if error %}
|
||||
<div style="color:var(--red);font-size:0.88rem;margin-bottom:1rem;
|
||||
background:var(--red-dim);padding:0.6rem 0.75rem;border-radius:4px;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if oidc_enabled %}
|
||||
<a href="/login/oidc" class="btn" style="width:100%;text-align:center;margin-bottom:1.25rem;display:block;">
|
||||
Sign in with SSO
|
||||
</a>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.25rem;">
|
||||
<div style="flex:1;height:1px;background:var(--border-mid);"></div>
|
||||
<span style="font-size:0.78rem;color:var(--text-dim);">or</span>
|
||||
<div style="flex:1;height:1px;background:var(--border-mid);"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
@@ -13,7 +29,9 @@
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Sign In</button>
|
||||
<button type="submit" class="btn {% if oidc_enabled %}btn-ghost{% endif %}" style="width:100%;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
{# fabledscryer/templates/settings/auth.html #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Auth — Fabled Scryer{% endblock %}
|
||||
{% block content %}
|
||||
{% set active_tab = "auth" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<form method="post" action="/settings/auth/">
|
||||
<div style="display:grid;gap:1.25rem;max-width:640px;">
|
||||
|
||||
{# ── OIDC ─────────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">OIDC / SSO</h2>
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||
Supports any OIDC provider — Authentik, Keycloak, Authelia, etc. Uses Authorization Code flow.
|
||||
Local admin login always works regardless of OIDC status.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input type="checkbox" name="oidc.enabled" id="oidc-enabled"
|
||||
{% if settings['oidc.enabled'] %}checked{% endif %}>
|
||||
<label for="oidc-enabled" style="margin:0;">Enable OIDC login</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Discovery URL</label>
|
||||
<input type="text" name="oidc.discovery_url" value="{{ settings['oidc.discovery_url'] }}"
|
||||
placeholder="https://auth.example.com/application/o/fabledscryer/.well-known/openid-configuration">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
|
||||
Authentik: <code>https://<host>/application/o/<slug>/.well-known/openid-configuration</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Client ID</label>
|
||||
<input type="text" name="oidc.client_id" value="{{ settings['oidc.client_id'] }}"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret <span style="color:var(--text-dim);font-size:0.8rem;">(blank = keep current)</span></label>
|
||||
<input type="password" name="oidc.client_secret" placeholder="••••••••"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Scopes</label>
|
||||
<input type="text" name="oidc.scopes" value="{{ settings['oidc.scopes'] }}"
|
||||
placeholder="openid profile email">
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Username claim</label>
|
||||
<input type="text" name="oidc.username_claim" value="{{ settings['oidc.username_claim'] }}"
|
||||
placeholder="preferred_username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email claim</label>
|
||||
<input type="text" name="oidc.email_claim" value="{{ settings['oidc.email_claim'] }}"
|
||||
placeholder="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Groups claim</label>
|
||||
<input type="text" name="oidc.groups_claim" value="{{ settings['oidc.groups_claim'] }}"
|
||||
placeholder="groups">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Admin group name</label>
|
||||
<input type="text" name="oidc.admin_group" value="{{ settings['oidc.admin_group'] }}"
|
||||
placeholder="fabledscryer-admins">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">Members → admin role</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Operator group name</label>
|
||||
<input type="text" name="oidc.operator_group" value="{{ settings['oidc.operator_group'] }}"
|
||||
placeholder="fabledscryer-operators">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">Members → operator role; others → viewer</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── LDAP ─────────────────────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">LDAP</h2>
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||
Binds user credentials against an LDAP/AD directory. Requires the
|
||||
<code>ldap3</code> Python package (<code>pip install ldap3</code>).
|
||||
If both LDAP and OIDC are enabled, the login form tries LDAP; OIDC uses the SSO button.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input type="checkbox" name="ldap.enabled" id="ldap-enabled"
|
||||
{% if settings['ldap.enabled'] %}checked{% endif %}>
|
||||
<label for="ldap-enabled" style="margin:0;">Enable LDAP login</label>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr auto auto;gap:1rem;align-items:end;">
|
||||
<div class="form-group">
|
||||
<label>LDAP host</label>
|
||||
<input type="text" name="ldap.host" value="{{ settings['ldap.host'] }}"
|
||||
placeholder="ldap.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Port</label>
|
||||
<input type="number" name="ldap.port" value="{{ settings['ldap.port'] }}" style="width:80px;">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;padding-bottom:0.1rem;">
|
||||
<input type="checkbox" name="ldap.tls" id="ldap-tls"
|
||||
{% if settings['ldap.tls'] %}checked{% endif %}>
|
||||
<label for="ldap-tls" style="margin:0;white-space:nowrap;">StartTLS</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Service account bind DN</label>
|
||||
<input type="text" name="ldap.bind_dn" value="{{ settings['ldap.bind_dn'] }}"
|
||||
placeholder="cn=svc-fabledscryer,ou=service,dc=example,dc=com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bind password <span style="color:var(--text-dim);font-size:0.8rem;">(blank = keep current)</span></label>
|
||||
<input type="password" name="ldap.bind_password" placeholder="••••••••"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Base DN</label>
|
||||
<input type="text" name="ldap.base_dn" value="{{ settings['ldap.base_dn'] }}"
|
||||
placeholder="ou=users,dc=example,dc=com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>User search filter</label>
|
||||
<input type="text" name="ldap.user_filter" value="{{ settings['ldap.user_filter'] }}"
|
||||
placeholder="(uid={username})">
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
|
||||
<code>{username}</code> is replaced with the login username. AD example: <code>(sAMAccountName={username})</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Admin group DN</label>
|
||||
<input type="text" name="ldap.admin_group_dn" value="{{ settings['ldap.admin_group_dn'] }}"
|
||||
placeholder="cn=scryer-admins,ou=groups,dc=example,dc=com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Operator group DN</label>
|
||||
<input type="text" name="ldap.operator_group_dn" value="{{ settings['ldap.operator_group_dn'] }}"
|
||||
placeholder="cn=scryer-operators,ou=groups,dc=example,dc=com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div class="form-group">
|
||||
<label>Username attribute</label>
|
||||
<input type="text" name="ldap.attr_username" value="{{ settings['ldap.attr_username'] }}"
|
||||
placeholder="uid">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email attribute</label>
|
||||
<input type="text" name="ldap.attr_email" value="{{ settings['ldap.attr_email'] }}"
|
||||
placeholder="mail">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="margin-top:1.25rem;">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<span style="color:var(--text-muted);font-size:0.82rem;margin-left:1rem;">
|
||||
Changes take effect immediately.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,12 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
ldap = [
|
||||
"ldap3>=2.9",
|
||||
]
|
||||
snmp = [
|
||||
"pysnmp-lextudio>=6.2",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
|
||||
Reference in New Issue
Block a user