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,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 %}
|
||||
Reference in New Issue
Block a user