Compare commits

...

34 Commits

Author SHA1 Message Date
bd8e2268db Dev build 2026-05-28 16:17 2026-05-28 16:17:22 +02:00
98f5d740f0 docs: log auth feature complete 2026-05-28 16:14:46 +02:00
7c12fdbdf3 release: bump build for authentication 2026-05-28 16:14:32 +02:00
57ce1aca92 docs: log session purge 2026-05-28 16:13:51 +02:00
98734b1c31 auth: purge expired sessions in worker tick 2026-05-28 16:13:32 +02:00
3e12196832 docs: log users UI 2026-05-28 16:12:11 +02:00
c93a611898 auth: add Users + Audit admin view in SPA 2026-05-28 16:12:11 +02:00
4c0f8bd06f docs: log SPA auth gate 2026-05-28 16:09:24 +02:00
2d8d58a9ef auth: gate SPA boot on /api/auth/me, add user badge and logout 2026-05-28 16:09:24 +02:00
939bf38b66 docs: log setup page 2026-05-28 16:07:40 +02:00
0709e07a05 auth: add first-run setup page 2026-05-28 16:07:40 +02:00
3b823ab18e docs: log login page 2026-05-28 16:06:45 +02:00
646fa747ab auth: add login page and shared auth.js 2026-05-28 16:06:33 +02:00
8b842f5d74 docs: log auth gating 2026-05-28 16:05:04 +02:00
17d91680d5 auth: gate existing routers behind require_user, wire auth + users routers 2026-05-28 16:05:04 +02:00
e993e8aa59 docs: log users router 2026-05-28 16:03:12 +02:00
b6a8a76f7b auth: add users + audit admin router 2026-05-28 16:02:56 +02:00
12609ba2a4 docs: log auth router 2026-05-28 16:01:03 +02:00
d1ed9cb4b5 auth: add /api/auth router (login, logout, me, setup) 2026-05-28 16:00:51 +02:00
7c9431c615 docs: log auth dependencies 2026-05-28 15:58:45 +02:00
a8cb96aa61 auth: add require_user / require_admin FastAPI dependencies 2026-05-28 15:58:45 +02:00
96879e75f0 auth: add cookie config (name, Secure flag, SameSite) 2026-05-28 15:56:26 +02:00
c75dc477af docs: log session lifecycle 2026-05-28 15:55:26 +02:00
0b7b58efe9 auth: add session lifecycle (create/lookup/refresh/revoke/purge) 2026-05-28 15:55:21 +02:00
f4dd7a507f docs: log audit helper 2026-05-28 15:52:18 +02:00
46e20de61b auth: add audit log helper 2026-05-28 15:52:06 +02:00
8a80ae71c4 docs: log auth migration 2026-05-28 15:50:50 +02:00
0a03ac60db auth: add 0003_auth_tables migration 2026-05-28 15:50:36 +02:00
448a5d7af4 docs: log auth hashing 2026-05-28 15:47:36 +02:00
e86985743a auth: add Argon2id hashing and password policy 2026-05-28 15:47:25 +02:00
51b0177f7f docs: log auth models 2026-05-28 15:45:30 +02:00
61ab979f5a auth: add User, UserSession, AuthAudit models 2026-05-28 15:45:19 +02:00
53e1094a10 docs: log auth scaffold 2026-05-28 15:43:05 +02:00
4bf2086fb8 auth: add dependencies and pytest scaffold 2026-05-28 15:42:42 +02:00
33 changed files with 4293 additions and 6 deletions

View File

@ -8,3 +8,6 @@ requests==2.32.3
cryptography==44.0.2 cryptography==44.0.2
msal==1.32.0 msal==1.32.0
openpyxl==3.1.5 openpyxl==3.1.5
argon2-cffi==23.1.0
pytest==8.3.3
httpx==0.27.2

View File

@ -1,3 +1,47 @@
// -------------------------------------------------------------------------
// Auth gate: runs before the main app IIFE bootstraps. If the user is not
// authenticated, we redirect to /login.html (or /setup.html when the backend
// indicates the initial setup is still required) and abort further init.
// -------------------------------------------------------------------------
(async function authGate() {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (r.status === 401) {
const setup = await fetch('/api/auth/setup-required').then(function (x) { return x.json(); }).catch(function () { return { setup_required: false }; });
window.location.replace(setup.setup_required ? '/setup.html' : '/login.html');
return;
}
if (!r.ok) return;
const me = await r.json();
window.__clearviewUser = me;
renderUserBadge(me);
if (me.role !== 'admin') {
const usersLink = document.querySelector('[data-route="users"]');
if (usersLink) usersLink.style.display = 'none';
}
} catch (e) {
window.location.replace('/login.html');
}
})();
function renderUserBadge(me) {
const slot = document.getElementById('userBadge');
if (!slot) return;
slot.innerHTML = '';
const wrap = document.createElement('span');
wrap.className = 'user-badge';
wrap.append(document.createTextNode(me.username + ' (' + me.role + ')'));
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Sign out';
btn.addEventListener('click', async function () {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
window.location.replace('/login.html');
});
wrap.append(btn);
slot.append(wrap);
}
(function () { (function () {
const state = { const state = {
selectedJobId: null, selectedJobId: null,
@ -15,6 +59,7 @@
'scan-mailbox': 'New Mailbox Scan', 'scan-mailbox': 'New Mailbox Scan',
'scan-entra': 'New Entra Group Scan', 'scan-entra': 'New Entra Group Scan',
'tenants': 'Tenants', 'tenants': 'Tenants',
'users': 'Users',
'settings': 'Settings', 'settings': 'Settings',
}; };
@ -113,7 +158,11 @@
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
async function requestJson(url, options) { async function requestJson(url, options) {
const response = await fetch(url, options); const response = await fetch(url, Object.assign({ credentials: 'same-origin' }, options || {}));
if (response.status === 401) {
window.location.replace('/login.html');
throw new Error('unauthenticated');
}
if (!response.ok) { if (!response.ok) {
let detail = response.statusText; let detail = response.statusText;
try { try {
@ -1551,6 +1600,181 @@
if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob); if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob);
if (els.allEntraForm) els.allEntraForm.addEventListener('submit', createAllEntraJob); if (els.allEntraForm) els.allEntraForm.addEventListener('submit', createAllEntraJob);
// -------------------------------------------------------------------------
// Users + Audit admin view
// -------------------------------------------------------------------------
async function renderUsersView() {
const root = document.getElementById('usersViewRoot');
if (!root) return;
root.innerHTML = '<section class="users-view"><h2>Users</h2>' +
'<div class="users-toolbar"><button id="newUserBtn" class="btn btn-primary">New user</button></div>' +
'<div id="usersTable"></div>' +
'<h3 style="margin-top:24px">Audit log</h3>' +
'<div id="auditTable"></div></section>';
document.getElementById('newUserBtn').addEventListener('click', function () { openUserModal(null); });
await reloadUsersTable();
await reloadAuditTable();
}
async function reloadUsersTable() {
const target = document.getElementById('usersTable');
if (!target) return;
let rows;
try {
rows = await requestJson('/api/users');
} catch (e) {
target.innerHTML = '<p class="auth-error">' + escHtml((e && e.message) || 'Load failed') + '</p>';
return;
}
target.innerHTML = '';
const tbl = document.createElement('table');
tbl.innerHTML = '<thead><tr><th>Username</th><th>Role</th><th>Active</th><th>Created</th><th></th></tr></thead>';
const tb = document.createElement('tbody');
rows.forEach(function (u) {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + escHtml(u.username) + '</td>' +
'<td>' + escHtml(u.role) + '</td>' +
'<td>' + (u.is_active ? 'yes' : 'no') + '</td>' +
'<td>' + escHtml(new Date(u.created_at).toLocaleString()) + '</td>';
const actions = document.createElement('td');
const edit = Object.assign(document.createElement('button'), { textContent: 'Edit', className: 'btn btn-outline btn-small' });
edit.addEventListener('click', function () { openUserModal(u); });
const reset = Object.assign(document.createElement('button'), { textContent: 'Reset pw', className: 'btn btn-outline btn-small' });
reset.addEventListener('click', function () { openResetModal(u); });
const del = Object.assign(document.createElement('button'), { textContent: 'Delete', className: 'btn btn-outline btn-small' });
del.addEventListener('click', async function () {
if (!confirm('Delete ' + u.username + '?')) return;
const r = await fetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) { alert('Delete failed'); return; }
await reloadUsersTable();
await reloadAuditTable();
});
actions.append(edit, ' ', reset, ' ', del);
tr.append(actions);
tb.append(tr);
});
tbl.append(tb);
target.append(tbl);
}
function openUserModal(existing) {
const isNew = !existing;
const usernameSafe = existing ? escHtml(existing.username) : '';
const html = '<div class="modal-back"><div class="modal">' +
'<h3>' + (isNew ? 'New user' : 'Edit ' + usernameSafe) + '</h3>' +
'<label>Username<input id="muUsername" ' + (isNew ? '' : 'disabled') + ' value="' + usernameSafe + '" /></label>' +
(isNew ? '<label>Password<input id="muPassword" type="password" /></label>' : '') +
'<label>Role<select id="muRole">' +
'<option value="user"' + (existing && existing.role === 'user' ? ' selected' : '') + '>user</option>' +
'<option value="admin"' + (existing && existing.role === 'admin' ? ' selected' : '') + '>admin</option>' +
'</select></label>' +
(isNew ? '' : '<label><input id="muActive" type="checkbox"' + (existing.is_active ? ' checked' : '') + ' /> Active</label>') +
'<p id="muError" class="auth-error" hidden></p>' +
'<div class="modal-actions">' +
'<button id="muCancel" class="btn btn-outline">Cancel</button>' +
'<button id="muSave" class="btn btn-primary">' + (isNew ? 'Create' : 'Save') + '</button>' +
'</div></div></div>';
const wrap = document.createElement('div');
wrap.innerHTML = html;
document.body.append(wrap);
const close = function () { wrap.remove(); };
document.getElementById('muCancel').addEventListener('click', close);
document.getElementById('muSave').addEventListener('click', async function () {
const err = document.getElementById('muError');
err.hidden = true;
try {
if (isNew) {
await requestJson('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('muUsername').value,
password: document.getElementById('muPassword').value,
role: document.getElementById('muRole').value,
}),
});
} else {
await requestJson('/api/users/' + encodeURIComponent(existing.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: document.getElementById('muRole').value,
is_active: document.getElementById('muActive').checked,
}),
});
}
close();
await reloadUsersTable();
await reloadAuditTable();
} catch (e) {
err.textContent = (e && e.message) || 'Save failed';
err.hidden = false;
}
});
}
function openResetModal(u) {
const wrap = document.createElement('div');
wrap.innerHTML = '<div class="modal-back"><div class="modal">' +
'<h3>Reset password for ' + escHtml(u.username) + '</h3>' +
'<label>New password<input id="rpPw" type="password" /></label>' +
'<p id="rpErr" class="auth-error" hidden></p>' +
'<div class="modal-actions">' +
'<button id="rpCancel" class="btn btn-outline">Cancel</button>' +
'<button id="rpSave" class="btn btn-primary">Reset</button>' +
'</div></div></div>';
document.body.append(wrap);
const close = function () { wrap.remove(); };
document.getElementById('rpCancel').addEventListener('click', close);
document.getElementById('rpSave').addEventListener('click', async function () {
const err = document.getElementById('rpErr');
err.hidden = true;
try {
await requestJson('/api/users/' + encodeURIComponent(u.id) + '/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: document.getElementById('rpPw').value }),
});
close();
await reloadAuditTable();
} catch (e) {
err.textContent = (e && e.message) || 'Reset failed';
err.hidden = false;
}
});
}
async function reloadAuditTable() {
const target = document.getElementById('auditTable');
if (!target) return;
let rows;
try {
rows = await requestJson('/api/audit?limit=100');
} catch (e) {
target.innerHTML = '<p class="auth-error">' + escHtml((e && e.message) || 'Load failed') + '</p>';
return;
}
target.innerHTML = '';
const tbl = document.createElement('table');
tbl.innerHTML = '<thead><tr><th>When</th><th>Event</th><th>User</th><th>IP</th><th>Detail</th></tr></thead>';
const tb = document.createElement('tbody');
rows.forEach(function (r) {
const tr = document.createElement('tr');
const detailText = r.detail ? JSON.stringify(r.detail) : '';
tr.innerHTML =
'<td>' + escHtml(new Date(r.ts).toLocaleString()) + '</td>' +
'<td>' + escHtml(r.event) + '</td>' +
'<td>' + escHtml(r.user_id == null ? '' : String(r.user_id)) + '</td>' +
'<td>' + escHtml(r.ip == null ? '' : String(r.ip)) + '</td>' +
'<td>' + escHtml(detailText) + '</td>';
tb.append(tr);
});
tbl.append(tb);
target.append(tbl);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Hash router // Hash router
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -1591,6 +1815,9 @@
els.contentTitle.textContent = ROUTE_TITLES[route]; els.contentTitle.textContent = ROUTE_TITLES[route];
} }
document.title = 'Clearview | ' + ROUTE_TITLES[route]; document.title = 'Clearview | ' + ROUTE_TITLES[route];
if (route === 'users') {
renderUsersView().catch(function (err) { console.error('Users view failed', err); });
}
// On user navigation, move focus to the new page's first heading so // On user navigation, move focus to the new page's first heading so
// screen-reader and keyboard users land in the freshly shown content. // screen-reader and keyboard users land in the freshly shown content.
if (moveFocus && activePage) { if (moveFocus && activePage) {

View File

@ -0,0 +1,22 @@
(function (global) {
async function postJson(url, body) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
let data = null;
try { data = await r.json(); } catch (_) {}
return { ok: r.ok, status: r.status, data };
}
async function getJson(url) {
const r = await fetch(url, { credentials: 'same-origin' });
let data = null;
try { data = await r.json(); } catch (_) {}
return { ok: r.ok, status: r.status, data };
}
global.ClearviewAuth = { postJson, getJson };
})(window);

View File

@ -32,6 +32,7 @@
<div class="nav-section">Entra</div> <div class="nav-section">Entra</div>
<a href="#/scan/entra" class="nav-link" data-route="scan-entra">New Entra Scan</a> <a href="#/scan/entra" class="nav-link" data-route="scan-entra">New Entra Scan</a>
<a href="#/users" class="nav-link" data-route="users">Users</a>
<div class="nav-spacer"></div> <div class="nav-spacer"></div>
<a href="#/tenants" class="nav-link" data-route="tenants">Tenants</a> <a href="#/tenants" class="nav-link" data-route="tenants">Tenants</a>
<a href="#/settings" class="nav-link" data-route="settings">Settings</a> <a href="#/settings" class="nav-link" data-route="settings">Settings</a>
@ -46,6 +47,7 @@
<div class="content-title" id="contentTitle">Dashboard</div> <div class="content-title" id="contentTitle">Dashboard</div>
<div class="content-actions"> <div class="content-actions">
<button id="refreshJobsBtn" class="btn btn-outline" type="button">Refresh</button> <button id="refreshJobsBtn" class="btn btn-outline" type="button">Refresh</button>
<div class="header-user" id="userBadge"></div>
</div> </div>
</header> </header>
@ -570,6 +572,10 @@
<!-- =================================================================== --> <!-- =================================================================== -->
<!-- Route: Settings (placeholder) --> <!-- Route: Settings (placeholder) -->
<!-- =================================================================== --> <!-- =================================================================== -->
<section class="route-page" data-route-page="users" hidden>
<div id="usersViewRoot"></div>
</section>
<section class="route-page" data-route-page="settings" hidden> <section class="route-page" data-route-page="settings" hidden>
<div class="panel"> <div class="panel">
<div class="panel-header split"> <div class="panel-header split">

View File

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Clearview — Sign in</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="auth-page">
<main class="auth-card">
<h1>Clearview</h1>
<p class="auth-sub">Sign in to continue</p>
<form id="loginForm">
<label>Username<input name="username" autocomplete="username" required autofocus /></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required /></label>
<label class="auth-remember"><input name="remember" type="checkbox" /> Remember me for 30 days</label>
<button type="submit">Sign in</button>
<p id="loginError" class="auth-error" hidden></p>
</form>
</main>
<script src="/auth.js"></script>
<script>
(async function () {
const setup = await ClearviewAuth.getJson('/api/auth/setup-required');
if (setup.ok && setup.data && setup.data.setup_required) {
window.location.replace('/setup.html');
return;
}
const form = document.getElementById('loginForm');
const err = document.getElementById('loginError');
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
err.hidden = true;
const fd = new FormData(form);
const res = await ClearviewAuth.postJson('/api/auth/login', {
username: fd.get('username'),
password: fd.get('password'),
remember: fd.get('remember') === 'on',
});
if (res.ok) {
window.location.replace('/');
} else {
err.textContent = (res.data && res.data.detail) || 'Sign-in failed';
err.hidden = false;
}
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Clearview — First-time setup</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="auth-page">
<main class="auth-card">
<h1>Welcome to Clearview</h1>
<p class="auth-sub">Create the first administrator account.</p>
<form id="setupForm">
<label>Username<input name="username" autocomplete="username" required autofocus /></label>
<label>Password (≥12 chars, letter + digit)<input name="password" type="password" autocomplete="new-password" required minlength="12" /></label>
<button type="submit">Create administrator</button>
<p id="setupError" class="auth-error" hidden></p>
</form>
</main>
<script src="/auth.js"></script>
<script>
(async function () {
const probe = await ClearviewAuth.getJson('/api/auth/setup-required');
if (!probe.ok || !probe.data || !probe.data.setup_required) {
window.location.replace('/login.html');
return;
}
const form = document.getElementById('setupForm');
const err = document.getElementById('setupError');
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
err.hidden = true;
const fd = new FormData(form);
const res = await ClearviewAuth.postJson('/api/auth/setup', {
username: fd.get('username'),
password: fd.get('password'),
});
if (res.ok) {
window.location.replace('/');
} else {
err.textContent = (res.data && res.data.detail) || 'Setup failed';
err.hidden = false;
}
});
})();
</script>
</body>
</html>

View File

@ -752,3 +752,28 @@ strong {
display: none; display: none;
} }
} }
/* === Auth (login / setup) pages and header badge ============================== */
.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0f1115; margin: 0; }
.auth-card { width: 360px; max-width: 92vw; padding: 28px; background: #1a1d24; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,.35); color: #e6e8ee; font-family: system-ui, sans-serif; }
.auth-card h1 { margin: 0 0 4px; font-size: 22px; }
.auth-sub { margin: 0 0 18px; opacity: .75; }
.auth-card form { display: flex; flex-direction: column; gap: 12px; }
.auth-card label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.auth-card input[type=text], .auth-card input[type=password], .auth-card input:not([type]) { padding: 8px 10px; background: #0e1116; border: 1px solid #2a2f3a; border-radius: 6px; color: inherit; }
.auth-card .auth-remember { flex-direction: row; align-items: center; gap: 8px; font-size: 13px; }
.auth-card button { padding: 10px; background: #3b82f6; border: 0; border-radius: 6px; color: #fff; font-weight: 600; cursor: pointer; }
.auth-card button:hover { background: #2563eb; }
.auth-error { background: #3a1f25; color: #fda4af; padding: 8px 10px; border-radius: 6px; font-size: 13px; }
.user-badge { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; border: 1px solid #2a2f3a; border-radius: 999px; font-size: 12px; }
.user-badge button { background: transparent; border: 0; color: #93c5fd; cursor: pointer; padding: 0; }
.user-badge button:hover { text-decoration: underline; }
/* Users / audit admin view */
.modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: #1a1d24; color: #e6e8ee; padding: 20px; border-radius: 10px; min-width: 320px; display: flex; flex-direction: column; gap: 10px; }
.modal label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
.users-view table { width: 100%; border-collapse: collapse; }
.users-view th, .users-view td { padding: 6px 8px; border-bottom: 1px solid #2a2f3a; text-align: left; }
.users-toolbar { margin: 12px 0; }

View File

@ -0,0 +1 @@
"""Authentication, session, and user-management subsystem."""

View File

@ -0,0 +1,20 @@
"""Single-entry helper for writing rows to the auth audit log."""
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from .models import AuthAudit
def record_event(
db: Session,
*,
event: str,
user_id: int | None = None,
ip: str | None = None,
detail: dict[str, Any] | None = None,
) -> None:
"""Add an AuthAudit row to the session. Caller commits."""
db.add(AuthAudit(event=event, user_id=user_id, ip=ip, detail=detail))

View File

@ -0,0 +1,52 @@
"""FastAPI dependencies that gate API endpoints behind a session."""
from __future__ import annotations
from typing import Annotated
from fastapi import Cookie, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..config import COOKIE_NAME # noqa: F401
from ..db import SessionLocal
from . import sessions as S
from .models import User, UserSession
AuthedUser = User
def get_db():
db: Session = SessionLocal()
try:
yield db
finally:
db.close()
def _load_session(db: Session, sid: str | None) -> tuple[User, UserSession]:
session = S.lookup_and_refresh(db, sid)
if session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user = db.get(User, session.user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
db.commit()
return user, session
def require_user(
db: Annotated[Session, Depends(get_db)],
clearview_session: Annotated[str | None, Cookie()] = None,
) -> User:
user, _ = _load_session(db, clearview_session)
return user
def require_admin(
db: Annotated[Session, Depends(get_db)],
clearview_session: Annotated[str | None, Cookie()] = None,
) -> User:
user, _ = _load_session(db, clearview_session)
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
return user

View File

@ -0,0 +1,87 @@
"""SQLAlchemy models for authentication, sessions, and audit log.
A dedicated ``Base`` is used so these tables can be created independently
of the existing scan/tenant models in tests; in production they coexist
in the same database under Alembic.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, TypeDecorator
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class UTCDateTime(TypeDecorator):
"""DateTime that always returns tz-aware UTC values.
SQLite (used in tests) does not preserve tzinfo on roundtrip even with
``DateTime(timezone=True)``. This decorator normalises stored and loaded
values to UTC-aware datetimes so app code can rely on tz arithmetic.
"""
impl = DateTime(timezone=True)
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def process_result_value(self, value, dialect):
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
class UserSession(Base):
__tablename__ = "user_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, index=True)
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
remember: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class AuthAudit(Base):
__tablename__ = "auth_audit"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False, index=True)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
event: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
detail: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)

View File

@ -0,0 +1,139 @@
"""Routes for login, logout, identity, and initial setup."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..config import COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE
from . import sessions as S
from .audit import record_event
from .dependencies import get_db, require_user
from .models import User, UserSession
from .schemas import LoginRequest, MeResponse, SetupRequest, SetupRequiredResponse
from .security import (
PasswordPolicyError,
hash_password,
validate_password,
verify_password,
)
router = APIRouter()
def _ip(request: Request) -> str | None:
return request.client.host if request.client else None
def _set_cookie(response: Response, sid: str, *, remember: bool) -> None:
max_age = 30 * 24 * 3600 if remember else 8 * 3600
response.set_cookie(
key=COOKIE_NAME,
value=sid,
max_age=max_age,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
)
def _clear_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, path="/")
def _users_count(db: Session) -> int:
return db.execute(select(func.count(User.id))).scalar_one()
@router.get("/api/auth/setup-required", response_model=SetupRequiredResponse)
def setup_required(db: Annotated[Session, Depends(get_db)]) -> SetupRequiredResponse:
return SetupRequiredResponse(setup_required=_users_count(db) == 0)
@router.post("/api/auth/setup", response_model=MeResponse)
def setup(
payload: SetupRequest,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> MeResponse:
if _users_count(db) > 0:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed")
try:
validate_password(payload.password)
except PasswordPolicyError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
user = User(
username=payload.username,
password_hash=hash_password(payload.password),
role="admin",
is_active=True,
)
db.add(user); db.flush()
sid, _ = S.create_session(
db, user_id=user.id, remember=False, ip=_ip(request), user_agent=request.headers.get("user-agent")
)
record_event(db, event="setup", user_id=user.id, ip=_ip(request), detail={"username": user.username})
db.commit()
_set_cookie(response, sid, remember=False)
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
@router.post("/api/auth/login", response_model=MeResponse)
def login(
payload: LoginRequest,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> MeResponse:
user = db.execute(select(User).where(User.username == payload.username)).scalar_one_or_none()
if user is None or not user.is_active or not verify_password(payload.password, user.password_hash):
record_event(
db,
event="login_fail",
user_id=user.id if user else None,
ip=_ip(request),
detail={"username": payload.username},
)
db.commit()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
sid, _ = S.create_session(
db, user_id=user.id, remember=payload.remember, ip=_ip(request), user_agent=request.headers.get("user-agent")
)
record_event(db, event="login_ok", user_id=user.id, ip=_ip(request), detail=None)
S.purge_expired(db)
db.commit()
_set_cookie(response, sid, remember=payload.remember)
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
@router.post("/api/auth/logout")
def logout(
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, bool]:
sid = request.cookies.get(COOKIE_NAME)
user_id: int | None = None
if sid:
existing = db.get(UserSession, sid)
if existing is not None:
user_id = existing.user_id
S.revoke(db, sid)
record_event(db, event="logout", user_id=user_id, ip=_ip(request), detail=None)
db.commit()
_clear_cookie(response)
return {"ok": True}
@router.get("/api/auth/me", response_model=MeResponse)
def me(user: Annotated[User, Depends(require_user)]) -> MeResponse:
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]

View File

@ -0,0 +1,59 @@
"""Pydantic schemas for the auth and users routers."""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=1024)
remember: bool = False
class SetupRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=1024)
class MeResponse(BaseModel):
username: str
role: Literal["admin", "user"]
class SetupRequiredResponse(BaseModel):
setup_required: bool
class UserItem(BaseModel):
id: int
username: str
role: Literal["admin", "user"]
is_active: bool
created_at: datetime
class CreateUserRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=1024)
role: Literal["admin", "user"] = "user"
class UpdateUserRequest(BaseModel):
role: Literal["admin", "user"] | None = None
is_active: bool | None = None
class ResetPasswordRequest(BaseModel):
password: str = Field(min_length=1, max_length=1024)
class AuditItem(BaseModel):
id: int
ts: datetime
user_id: int | None
event: str
ip: str | None
detail: dict | None

View File

@ -0,0 +1,44 @@
"""Password hashing, password-policy validation, and session-id generation."""
from __future__ import annotations
import uuid
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
class PasswordPolicyError(ValueError):
"""Raised when a candidate password does not meet the policy."""
_hasher = PasswordHasher()
MIN_LENGTH = 12
def validate_password(pw: str) -> None:
"""Enforce: length >= 12, at least one letter and one digit."""
if len(pw) < MIN_LENGTH:
raise PasswordPolicyError(f"Password must be at least {MIN_LENGTH} characters.")
if not any(c.isalpha() for c in pw):
raise PasswordPolicyError("Password must contain at least one letter.")
if not any(c.isdigit() for c in pw):
raise PasswordPolicyError("Password must contain at least one digit.")
def hash_password(pw: str) -> str:
return _hasher.hash(pw)
def verify_password(pw: str, encoded: str) -> bool:
try:
return _hasher.verify(encoded, pw)
except (VerifyMismatchError, InvalidHashError, VerificationError):
return False
except Exception:
return False
def new_session_id() -> str:
"""Opaque 128-bit session identifier rendered as 32 hex chars."""
return uuid.uuid4().hex

View File

@ -0,0 +1,72 @@
"""Session lifecycle: create, look up + refresh, revoke, purge expired."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from sqlalchemy.orm import Session
from .models import UserSession
from .security import new_session_id
SLIDING_TTL = timedelta(hours=8)
REMEMBER_TTL = timedelta(days=30)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def create_session(
db: Session,
*,
user_id: int,
remember: bool,
ip: str | None,
user_agent: str | None,
) -> tuple[str, datetime]:
ttl = REMEMBER_TTL if remember else SLIDING_TTL
expires = _utcnow() + ttl
sid = new_session_id()
db.add(
UserSession(
id=sid,
user_id=user_id,
expires_at=expires,
ip=ip,
user_agent=user_agent,
remember=remember,
)
)
db.flush()
return sid, expires
def lookup_and_refresh(db: Session, sid: str | None) -> UserSession | None:
if not sid:
return None
row = db.get(UserSession, sid)
if row is None:
return None
now = _utcnow()
expires = row.expires_at if row.expires_at.tzinfo else row.expires_at.replace(tzinfo=timezone.utc)
if expires <= now:
return None
row.last_seen_at = now
if not row.remember:
row.expires_at = now + SLIDING_TTL
return row
def revoke(db: Session, sid: str) -> None:
db.execute(delete(UserSession).where(UserSession.id == sid))
def revoke_all_for_user(db: Session, user_id: int) -> int:
res = db.execute(delete(UserSession).where(UserSession.user_id == user_id))
return res.rowcount or 0
def purge_expired(db: Session) -> int:
res = db.execute(delete(UserSession).where(UserSession.expires_at <= _utcnow()))
return res.rowcount or 0

View File

@ -0,0 +1,152 @@
"""Admin endpoints: user CRUD, password reset, audit log."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import sessions as S
from .audit import record_event
from .dependencies import get_db, require_admin
from .models import AuthAudit, User
from .schemas import (
AuditItem,
CreateUserRequest,
ResetPasswordRequest,
UpdateUserRequest,
UserItem,
)
from .security import PasswordPolicyError, hash_password, validate_password
router = APIRouter()
def _ip(request: Request) -> str | None:
return request.client.host if request.client else None
def _to_item(u: User) -> UserItem:
return UserItem(
id=u.id, username=u.username, role=u.role, is_active=u.is_active, created_at=u.created_at # type: ignore[arg-type]
)
@router.get("/api/users", response_model=list[UserItem])
def list_users(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(require_admin)],
) -> list[UserItem]:
rows = db.execute(select(User).order_by(User.created_at.asc())).scalars().all()
return [_to_item(u) for u in rows]
@router.post("/api/users", response_model=UserItem)
def create_user(
payload: CreateUserRequest,
request: Request,
db: Annotated[Session, Depends(get_db)],
actor: Annotated[User, Depends(require_admin)],
) -> UserItem:
try:
validate_password(payload.password)
except PasswordPolicyError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if db.execute(select(User).where(User.username == payload.username)).scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username already exists")
u = User(
username=payload.username,
password_hash=hash_password(payload.password),
role=payload.role,
is_active=True,
)
db.add(u); db.flush()
record_event(
db, event="user_create", user_id=actor.id, ip=_ip(request),
detail={"target": u.id, "username": u.username, "role": u.role},
)
db.commit(); db.refresh(u)
return _to_item(u)
@router.patch("/api/users/{user_id}", response_model=UserItem)
def update_user(
user_id: int,
payload: UpdateUserRequest,
request: Request,
db: Annotated[Session, Depends(get_db)],
actor: Annotated[User, Depends(require_admin)],
) -> UserItem:
u = db.get(User, user_id)
if u is None:
raise HTTPException(status_code=404, detail="User not found")
changed: dict = {}
if payload.role is not None and payload.role != u.role:
u.role = payload.role; changed["role"] = payload.role
if payload.is_active is not None and payload.is_active != u.is_active:
u.is_active = payload.is_active; changed["is_active"] = payload.is_active
if not payload.is_active:
S.revoke_all_for_user(db, u.id)
if changed:
record_event(db, event="user_update", user_id=actor.id, ip=_ip(request), detail={"target": u.id, **changed})
db.commit(); db.refresh(u)
return _to_item(u)
@router.delete("/api/users/{user_id}")
def delete_user(
user_id: int,
request: Request,
db: Annotated[Session, Depends(get_db)],
actor: Annotated[User, Depends(require_admin)],
) -> dict[str, bool]:
if user_id == actor.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
u = db.get(User, user_id)
if u is None:
raise HTTPException(status_code=404, detail="User not found")
db.delete(u)
record_event(db, event="user_delete", user_id=actor.id, ip=_ip(request), detail={"target": user_id})
db.commit()
return {"ok": True}
@router.post("/api/users/{user_id}/reset-password")
def reset_password(
user_id: int,
payload: ResetPasswordRequest,
request: Request,
db: Annotated[Session, Depends(get_db)],
actor: Annotated[User, Depends(require_admin)],
) -> dict[str, bool]:
u = db.get(User, user_id)
if u is None:
raise HTTPException(status_code=404, detail="User not found")
try:
validate_password(payload.password)
except PasswordPolicyError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
u.password_hash = hash_password(payload.password)
S.revoke_all_for_user(db, u.id)
record_event(db, event="password_reset", user_id=actor.id, ip=_ip(request), detail={"target": u.id})
db.commit()
return {"ok": True}
@router.get("/api/audit", response_model=list[AuditItem])
def list_audit(
db: Annotated[Session, Depends(get_db)],
_: Annotated[User, Depends(require_admin)],
limit: int = 200,
event: str | None = None,
) -> list[AuditItem]:
limit = max(1, min(limit, 1000))
q = select(AuthAudit).order_by(AuthAudit.ts.desc()).limit(limit)
if event:
q = q.where(AuthAudit.event == event)
rows = db.execute(q).scalars().all()
return [
AuditItem(id=r.id, ts=r.ts, user_id=r.user_id, event=r.event, ip=r.ip, detail=r.detail)
for r in rows
]

View File

@ -36,3 +36,8 @@ SCAN_HTTP_BACKOFF_SEC = _int_env("SCAN_HTTP_BACKOFF_SEC", 2)
SCAN_LIST_PAGE_SIZE = _int_env("SCAN_LIST_PAGE_SIZE", 200) SCAN_LIST_PAGE_SIZE = _int_env("SCAN_LIST_PAGE_SIZE", 200)
SCAN_MAX_ITEMS_PER_LIST = _int_env("SCAN_MAX_ITEMS_PER_LIST", 10000) SCAN_MAX_ITEMS_PER_LIST = _int_env("SCAN_MAX_ITEMS_PER_LIST", 10000)
# Auth cookie settings (override via env)
COOKIE_NAME = "clearview_session"
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false"
COOKIE_SAMESITE = "lax"

View File

@ -8,13 +8,16 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import Depends, FastAPI
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .api_jobs import router as jobs_router from .api_jobs import router as jobs_router
from .api_onboarding import router as onboarding_router from .api_onboarding import router as onboarding_router
from .api_tenants import router as tenants_router from .api_tenants import router as tenants_router
from .auth.dependencies import require_user
from .auth.router import router as auth_router
from .auth.users_router import router as users_router
from .db_migrate import run_migrations from .db_migrate import run_migrations
from .version import display_version from .version import display_version
from .worker import ScanWorker from .worker import ScanWorker
@ -47,9 +50,17 @@ def version() -> dict[str, str]:
return {"version": display_version()} return {"version": display_version()}
app.include_router(tenants_router) # Public auth endpoints (login / setup / setup-required) — no dependency.
app.include_router(jobs_router) app.include_router(auth_router)
app.include_router(onboarding_router)
# Admin endpoints — already enforce require_admin internally.
app.include_router(users_router)
# Existing routers gated by an authenticated session.
_protected = [Depends(require_user)]
app.include_router(tenants_router, dependencies=_protected)
app.include_router(jobs_router, dependencies=_protected)
app.include_router(onboarding_router, dependencies=_protected)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -0,0 +1,67 @@
"""Create users, user_sessions, auth_audit tables.
Revision ID: 0003_auth_tables
Revises: 0002_timestamptz
Create Date: 2026-05-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0003_auth_tables"
down_revision = "0002_timestamptz"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("username", sa.String(length=128), nullable=False, unique=True),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("role", sa.String(length=16), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_users_username", "users", ["username"], unique=True)
op.create_table(
"user_sessions",
sa.Column("id", sa.String(length=64), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("remember", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"])
op.create_index("ix_user_sessions_expires_at", "user_sessions", ["expires_at"])
op.create_table(
"auth_audit",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("ts", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("event", sa.String(length=32), nullable=False),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.Column("detail", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
op.create_index("ix_auth_audit_ts", "auth_audit", ["ts"])
op.create_index("ix_auth_audit_event", "auth_audit", ["event"])
def downgrade() -> None:
op.drop_index("ix_auth_audit_event", table_name="auth_audit")
op.drop_index("ix_auth_audit_ts", table_name="auth_audit")
op.drop_table("auth_audit")
op.drop_index("ix_user_sessions_expires_at", table_name="user_sessions")
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
op.drop_table("user_sessions")
op.drop_index("ix_users_username", table_name="users")
op.drop_table("users")

View File

@ -7,7 +7,7 @@ history, so operators can see exactly which image build is running.
from __future__ import annotations from __future__ import annotations
VERSION = "v0.1.0" VERSION = "v0.1.0"
BUILD = 2 BUILD = 3
def display_version() -> str: def display_version() -> str:

View File

@ -14,10 +14,13 @@ from .config import (
SCAN_TARGET_MAX_RETRIES, SCAN_TARGET_MAX_RETRIES,
SCAN_TARGET_TIMEOUT_SEC, SCAN_TARGET_TIMEOUT_SEC,
) )
from .auth.sessions import purge_expired
from .db import SessionLocal from .db import SessionLocal
from .models import PermissionDeviation, ScanJob, ScanTarget, TenantProfile from .models import PermissionDeviation, ScanJob, ScanTarget, TenantProfile
from .scanners import AuthConfig, ProbeResult, probe, scan from .scanners import AuthConfig, ProbeResult, probe, scan
_SESSION_PURGE_INTERVAL_SEC = 300
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -25,6 +28,7 @@ class ScanWorker:
def __init__(self) -> None: def __init__(self) -> None:
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._last_session_purge: float = 0.0
def start(self) -> None: def start(self) -> None:
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
@ -41,10 +45,25 @@ class ScanWorker:
def _run(self) -> None: def _run(self) -> None:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
self._maybe_purge_sessions()
did_work = self._process_next_job() did_work = self._process_next_job()
if not did_work: if not did_work:
self._stop_event.wait(SCAN_JOB_POLL_INTERVAL_SEC) self._stop_event.wait(SCAN_JOB_POLL_INTERVAL_SEC)
def _maybe_purge_sessions(self) -> None:
now = time.monotonic()
if now - self._last_session_purge < _SESSION_PURGE_INTERVAL_SEC:
return
self._last_session_purge = now
try:
with SessionLocal() as db:
removed = purge_expired(db)
db.commit()
if removed:
log.info("purged %d expired auth sessions", removed)
except Exception:
log.exception("auth session purge failed")
def _process_next_job(self) -> bool: def _process_next_job(self) -> bool:
with SessionLocal() as db: with SessionLocal() as db:
# Atomic claim: lock the chosen queued row and skip rows already # Atomic claim: lock the chosen queued row and skip rows already

View File

View File

@ -0,0 +1,53 @@
"""Pytest fixtures for Clearview tests.
Uses an in-memory SQLite database. Schema is created from the SQLAlchemy
metadata directly (the Alembic migrations target Postgres types like JSONB).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
SRC = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SRC))
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("COOKIE_SECURE", "false")
@pytest.fixture()
def db_engine():
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
@event.listens_for(engine, "connect")
def _fk_on(dbapi_conn, _):
cur = dbapi_conn.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
from clearview_app.auth.models import Base as AuthBase
AuthBase.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture()
def db_session(db_engine):
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
s = Session()
try:
yield s
finally:
s.close()

View File

@ -0,0 +1,76 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import sessionmaker
from clearview_app.auth.dependencies import get_db
from clearview_app.auth.router import router as auth_router
def make_app(db_engine):
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
def override_get_db():
s = Session()
try:
yield s
finally:
s.close()
app = FastAPI()
app.include_router(auth_router)
app.dependency_overrides[get_db] = override_get_db
return app
def test_setup_required_when_empty(db_engine):
c = TestClient(make_app(db_engine))
assert c.get("/api/auth/setup-required").json() == {"setup_required": True}
def test_setup_creates_first_admin_and_logs_in(db_engine):
c = TestClient(make_app(db_engine))
r = c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
assert r.status_code == 200
assert r.cookies.get("clearview_session")
me = c.get("/api/auth/me")
assert me.status_code == 200
assert me.json() == {"username": "root", "role": "admin"}
def test_setup_rejects_when_users_exist(db_engine):
app = make_app(db_engine)
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
r = TestClient(app).post("/api/auth/setup", json={"username": "x", "password": "CorrectHorse42"})
assert r.status_code == 409
def test_login_wrong_password_returns_401(db_engine):
app = make_app(db_engine)
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
r = TestClient(app).post("/api/auth/login", json={"username": "root", "password": "WrongPass00X", "remember": False})
assert r.status_code == 401
def test_login_success_sets_cookie(db_engine):
app = make_app(db_engine)
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
c2 = TestClient(app)
r = c2.post("/api/auth/login", json={"username": "root", "password": "CorrectHorse42", "remember": True})
assert r.status_code == 200
assert c2.cookies.get("clearview_session")
assert c2.get("/api/auth/me").json()["username"] == "root"
def test_logout_invalidates_session(db_engine):
app = make_app(db_engine)
c = TestClient(app)
c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
assert c.get("/api/auth/me").status_code == 200
c.post("/api/auth/logout")
assert c.get("/api/auth/me").status_code == 401
def test_password_policy_enforced_on_setup(db_engine):
c = TestClient(make_app(db_engine))
r = c.post("/api/auth/setup", json={"username": "root", "password": "short"})
assert r.status_code == 400

View File

@ -0,0 +1,91 @@
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import sessionmaker
from clearview_app.auth import sessions as S
from clearview_app.auth.dependencies import (
AuthedUser,
get_db,
require_admin,
require_user,
)
from clearview_app.auth.models import User
@pytest.fixture()
def app_and_client(db_engine):
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
def override_get_db():
s = Session()
try:
yield s
finally:
s.close()
app = FastAPI()
@app.get("/who")
def who(u: AuthedUser = Depends(require_user)):
return {"id": u.id, "role": u.role}
@app.get("/admin-only")
def admin_only(u: AuthedUser = Depends(require_admin)):
return {"ok": True}
app.dependency_overrides[get_db] = override_get_db
return app, Session
def _make_user(Session, role: str, username: str = "x"):
s = Session()
u = User(username=username, password_hash="h", role=role)
s.add(u); s.commit(); s.refresh(u); s.close()
return u
def _login(Session, user_id: int) -> str:
s = Session()
sid, _ = S.create_session(s, user_id=user_id, remember=False, ip=None, user_agent=None)
s.commit(); s.close()
return sid
def test_anon_gets_401(app_and_client):
app, _ = app_and_client
assert TestClient(app).get("/who").status_code == 401
def test_user_can_access_require_user(app_and_client):
app, Session = app_and_client
u = _make_user(Session, "user")
sid = _login(Session, u.id)
c = TestClient(app); c.cookies.set("clearview_session", sid)
r = c.get("/who")
assert r.status_code == 200 and r.json()["role"] == "user"
def test_user_blocked_from_admin(app_and_client):
app, Session = app_and_client
u = _make_user(Session, "user")
sid = _login(Session, u.id)
c = TestClient(app); c.cookies.set("clearview_session", sid)
assert c.get("/admin-only").status_code == 403
def test_admin_allowed(app_and_client):
app, Session = app_and_client
u = _make_user(Session, "admin")
sid = _login(Session, u.id)
c = TestClient(app); c.cookies.set("clearview_session", sid)
assert c.get("/admin-only").status_code == 200
def test_inactive_user_rejected(app_and_client):
app, Session = app_and_client
u = _make_user(Session, "admin")
s = Session(); s.get(User, u.id).is_active = False; s.commit(); s.close()
sid = _login(Session, u.id)
c = TestClient(app); c.cookies.set("clearview_session", sid)
assert c.get("/who").status_code == 401

View File

@ -0,0 +1,24 @@
"""Smoke check that existing routers refuse anonymous requests once gated."""
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import sessionmaker
from clearview_app.api_tenants import router as tenants_router
from clearview_app.auth.dependencies import get_db, require_user
def test_tenants_route_requires_auth(db_engine):
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
def override_get_db():
s = Session()
try:
yield s
finally:
s.close()
app = FastAPI()
app.include_router(tenants_router, dependencies=[Depends(require_user)])
app.dependency_overrides[get_db] = override_get_db
assert TestClient(app).get("/api/tenants").status_code == 401

View File

@ -0,0 +1,44 @@
from datetime import datetime, timedelta, timezone
from clearview_app.auth.models import AuthAudit, User, UserSession
def test_user_defaults(db_session):
u = User(username="alice", password_hash="x", role="admin")
db_session.add(u); db_session.commit(); db_session.refresh(u)
assert u.id is not None
assert u.is_active is True
assert isinstance(u.created_at, datetime)
def test_session_persists_with_expiry(db_session):
u = User(username="bob", password_hash="x", role="user")
db_session.add(u); db_session.commit(); db_session.refresh(u)
s = UserSession(
id="abc123",
user_id=u.id,
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
ip="1.2.3.4",
user_agent="ua",
remember=False,
)
db_session.add(s); db_session.commit()
assert s.created_at is not None
def test_audit_row(db_session):
a = AuthAudit(event="login_ok", ip="9.9.9.9", detail={"k": "v"})
db_session.add(a); db_session.commit()
assert a.id is not None
assert a.detail == {"k": "v"}
def test_record_event_persists(db_session):
from clearview_app.auth.audit import record_event
record_event(db_session, event="login_ok", user_id=None, ip="1.1.1.1", detail={"u": "x"})
db_session.commit()
rows = db_session.query(AuthAudit).all()
assert len(rows) == 1
assert rows[0].event == "login_ok"
assert rows[0].detail == {"u": "x"}

View File

@ -0,0 +1,37 @@
import pytest
from clearview_app.auth.security import (
PasswordPolicyError,
hash_password,
new_session_id,
validate_password,
verify_password,
)
def test_hash_and_verify_roundtrip():
h = hash_password("CorrectHorse42")
assert verify_password("CorrectHorse42", h) is True
assert verify_password("wrong", h) is False
def test_verify_returns_false_on_garbage_hash():
assert verify_password("anything", "not-a-real-hash") is False
@pytest.mark.parametrize("pw", ["short1A", "alllowercase", "ALLUPPERCASE", "12345678901234"])
def test_policy_rejects(pw):
with pytest.raises(PasswordPolicyError):
validate_password(pw)
@pytest.mark.parametrize("pw", ["CorrectHorse42", "abcdefghij12"])
def test_policy_accepts(pw):
validate_password(pw)
def test_new_session_id_unique_and_hex():
a = new_session_id()
b = new_session_id()
assert a != b
assert len(a) == 32 and all(c in "0123456789abcdef" for c in a)

View File

@ -0,0 +1,79 @@
from datetime import datetime, timedelta, timezone
import pytest
from clearview_app.auth import sessions as S
from clearview_app.auth.models import User, UserSession
@pytest.fixture()
def user(db_session):
u = User(username="alice", password_hash="x", role="admin")
db_session.add(u); db_session.commit(); db_session.refresh(u)
return u
def test_create_session_sliding(db_session, user):
sid, expires = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
db_session.commit()
assert len(sid) == 32
row = db_session.get(UserSession, sid)
assert row.remember is False
delta = row.expires_at - datetime.now(timezone.utc)
assert timedelta(hours=7, minutes=55) < delta < timedelta(hours=8, minutes=5)
def test_create_session_remember(db_session, user):
sid, _ = S.create_session(db_session, user_id=user.id, remember=True, ip=None, user_agent=None)
db_session.commit()
row = db_session.get(UserSession, sid)
delta = row.expires_at - datetime.now(timezone.utc)
assert delta > timedelta(days=29)
def test_lookup_refresh_sliding_extends(db_session, user):
sid, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
db_session.commit()
row = db_session.get(UserSession, sid)
row.expires_at = datetime.now(timezone.utc) + timedelta(minutes=5)
db_session.commit()
looked = S.lookup_and_refresh(db_session, sid)
db_session.commit()
assert looked is not None
assert looked.expires_at - datetime.now(timezone.utc) > timedelta(hours=7)
def test_lookup_refresh_remember_does_not_slide(db_session, user):
sid, _ = S.create_session(db_session, user_id=user.id, remember=True, ip=None, user_agent=None)
db_session.commit()
before = db_session.get(UserSession, sid).expires_at
S.lookup_and_refresh(db_session, sid)
db_session.commit()
after = db_session.get(UserSession, sid).expires_at
assert before == after
def test_expired_session_returns_none(db_session, user):
sid, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
row = db_session.get(UserSession, sid)
row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
assert S.lookup_and_refresh(db_session, sid) is None
def test_revoke(db_session, user):
sid, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
db_session.commit()
S.revoke(db_session, sid); db_session.commit()
assert db_session.get(UserSession, sid) is None
def test_purge_expired(db_session, user):
fresh, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
stale, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
db_session.get(UserSession, stale).expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
db_session.commit()
removed = S.purge_expired(db_session); db_session.commit()
assert removed == 1
assert db_session.get(UserSession, fresh) is not None
assert db_session.get(UserSession, stale) is None

View File

@ -0,0 +1,74 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import sessionmaker
from clearview_app.auth.dependencies import get_db
from clearview_app.auth.router import router as auth_router
from clearview_app.auth.users_router import router as users_router
def make_app(db_engine):
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
def override_get_db():
s = Session()
try:
yield s
finally:
s.close()
app = FastAPI()
app.include_router(auth_router)
app.include_router(users_router)
app.dependency_overrides[get_db] = override_get_db
return app
def _bootstrap_admin(app):
c = TestClient(app)
c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
return c
def test_non_admin_blocked(db_engine):
app = make_app(db_engine)
admin = _bootstrap_admin(app)
admin.post("/api/users", json={"username": "joe", "password": "JoePassword42", "role": "user"})
user = TestClient(app)
user.post("/api/auth/login", json={"username": "joe", "password": "JoePassword42", "remember": False})
assert user.get("/api/users").status_code == 403
def test_create_list_update_delete(db_engine):
app = make_app(db_engine)
c = _bootstrap_admin(app)
r = c.post("/api/users", json={"username": "alice", "password": "AlicePassword42", "role": "user"})
assert r.status_code == 200
uid = r.json()["id"]
assert {u["username"] for u in c.get("/api/users").json()} == {"root", "alice"}
c.patch(f"/api/users/{uid}", json={"role": "admin"})
assert next(u for u in c.get("/api/users").json() if u["id"] == uid)["role"] == "admin"
c.post(f"/api/users/{uid}/reset-password", json={"password": "NewAlicePass42"})
fresh = TestClient(app)
assert fresh.post("/api/auth/login", json={"username": "alice", "password": "NewAlicePass42", "remember": False}).status_code == 200
c.delete(f"/api/users/{uid}")
assert {u["username"] for u in c.get("/api/users").json()} == {"root"}
def test_cannot_delete_self(db_engine):
app = make_app(db_engine)
c = _bootstrap_admin(app)
me_id = c.get("/api/users").json()[0]["id"]
assert c.delete(f"/api/users/{me_id}").status_code == 400
def test_audit_returns_rows(db_engine):
app = make_app(db_engine)
c = _bootstrap_admin(app)
rows = c.get("/api/audit").json()
assert any(r["event"] == "setup" for r in rows)

View File

@ -2,6 +2,108 @@
This file documents changes on the develop branch of this project. This file documents changes on the develop branch of this project.
## 2026-05-28 — Authentication: feature complete
### Added
- End-to-end session-based authentication with admin/user roles, initial-setup flow, user management UI, and audit log.
### Changed
- All scanning, tenant, and onboarding endpoints now require an authenticated session.
- Build bumped to `v0.1.0.3`.
## 2026-05-28 — Authentication: session purge
### Added
- Worker periodically deletes expired rows from `user_sessions` (every 5 minutes).
## 2026-05-28 — Authentication: Users + Audit UI
### Added
- Users admin view with create/edit/delete and password reset.
- Audit log view (latest 100 events).
## 2026-05-28 — Authentication: SPA gate
### Added
- App boot calls `/api/auth/me`; 401 redirects to `/login.html` (or `/setup.html`).
- Header user-badge with sign-out button.
- Users nav link (hidden for non-admin).
### Changed
- `requestJson` wrapper now redirects on any 401 response.
## 2026-05-28 — Authentication: setup page
### Added
- `setup.html` for first-run admin creation, reachable only while the `users` table is empty.
## 2026-05-28 — Authentication: login page
### Added
- `login.html` with username / password / remember-me.
- Shared `auth.js` helpers (postJson, getJson).
- CSS for auth pages and the header user-badge.
## 2026-05-28 — Authentication: API gating
### Changed
- Tenants, Jobs, and Onboarding routers now require an authenticated session.
- Auth and Users routers wired into the FastAPI app.
## 2026-05-28 — Authentication: users + audit endpoints
### Added
- Admin endpoints: list/create/update/delete users, reset password, view audit log.
- Self-delete protection; deactivating or resetting password revokes existing sessions.
## 2026-05-28 — Authentication: auth router
### Added
- `/api/auth/setup-required`, `/api/auth/setup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`.
- HttpOnly session cookie with SameSite=Lax; Secure flag controlled by `COOKIE_SECURE` env.
## 2026-05-28 — Authentication: FastAPI dependencies
### Added
- `require_user` / `require_admin` cookie-based session loading.
- 401 for missing/expired/inactive; 403 for non-admin on admin routes.
## 2026-05-28 — Authentication: session lifecycle
### Added
- `auth.sessions` with 8h sliding / 30d remember TTLs, lookup-and-refresh, revoke, purge.
- `UTCDateTime` type decorator in `auth.models` to keep UTC-aware datetimes across SQLite roundtrips.
## 2026-05-28 — Authentication: audit helper
### Added
- `auth.audit.record_event()` for one-line writes to `auth_audit`.
## 2026-05-28 — Authentication: database migration
### Added
- Alembic migration `0003_auth_tables` creating `users`, `user_sessions`, `auth_audit`.
## 2026-05-28 — Authentication: hashing + password policy
### Added
- Argon2id password hashing (`hash_password`, `verify_password`).
- Server-side password policy (min 12, letter + digit).
- Opaque hex session-id generator.
## 2026-05-28 — Authentication: data models
### Added
- `User`, `UserSession`, `AuthAudit` SQLAlchemy models.
- Model-level tests using SQLite in-memory engine.
## 2026-05-28 — Authentication: scaffold
### Added
- `argon2-cffi`, `pytest`, `httpx` dependencies.
- New `clearview_app/auth/` package skeleton.
- `tests/` directory with SQLite-backed pytest fixtures.
## 2026-05-26 — UI/UX: dead CSS removal, a11y, distinct risk colours, richer dashboard ## 2026-05-26 — UI/UX: dead CSS removal, a11y, distinct risk colours, richer dashboard
### Added ### Added

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,177 @@
# Clearview — Authentication Design
**Date:** 2026-05-28
**Status:** Approved (brainstorming phase)
**Scope:** Add an authentication layer to the existing Clearview FastAPI + static-frontend application.
---
## 1. Goal
Restrict access to the Clearview UI and API to a small group of named administrators. No self-service registration, no public exposure of endpoints, no multi-tenant user isolation.
## 2. Requirements
| # | Requirement |
|---|---|
| R1 | All existing API routes (`/api/tenants/*`, `/api/jobs/*`, `/api/onboarding/*`) require an authenticated session. |
| R2 | Two roles: `admin` and `user`. Only `admin` can manage users and view the audit log. Both can use the scanner UI. |
| R3 | Server-side sessions using an opaque session ID stored in an HttpOnly cookie. |
| R4 | First admin is created via a one-time **initial-setup** page that is reachable only while the `users` table is empty. No env-var fallback. |
| R5 | Password hashing with **Argon2id** (default parameters via `argon2-cffi`). |
| R6 | Password policy: minimum 12 characters, at least one letter and one digit. Validated server-side. |
| R7 | Session TTL: **8 hours sliding** by default; **30 days fixed** when "remember me" is checked at login. |
| R8 | Audit log persisted in DB and viewable in the UI by admins. |
| R9 | Existing changelog convention applies: append entries to `changelog-develop.md` per change. |
Explicitly **out of scope** for v1: rate limiting / login lockout, password reset via email, MFA, SSO, fine-grained permissions beyond `admin` / `user`.
## 3. Architecture
### 3.1 Backend module layout
A new package `clearview_app/auth/` with focused modules:
| File | Purpose |
|---|---|
| `auth/__init__.py` | Package marker. |
| `auth/models.py` | SQLAlchemy models: `User`, `Session`, `AuthAudit`. |
| `auth/security.py` | Argon2id hashing, password policy validation, session-ID generation. |
| `auth/sessions.py` | Create / lookup / refresh / revoke session records; expiry handling. |
| `auth/audit.py` | Single `record(event, user_id, ip, detail)` helper. |
| `auth/dependencies.py` | FastAPI dependencies: `current_session`, `require_user`, `require_admin`. |
| `auth/router.py` | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `GET /api/auth/setup-required`, `POST /api/auth/setup`. |
| `auth/users_router.py` | Admin-only: `GET/POST/PATCH/DELETE /api/users`, `POST /api/users/{id}/reset-password`, `GET /api/audit`. |
`main.py` wires the new routers **before** the static-files mount and applies `Depends(require_user)` to the three existing routers (`tenants`, `jobs`, `onboarding`).
### 3.2 Database
Three new tables, added via a new Alembic migration in `clearview_app/migrations/versions/`.
```text
users
id int pk
username text unique not null
password_hash text not null -- Argon2id encoded string
role text not null -- 'admin' | 'user'
is_active bool not null default true
created_at timestamptz not null default now()
updated_at timestamptz not null default now()
sessions
id uuid pk -- opaque session id, stored in cookie
user_id int not null fk -> users.id on delete cascade
created_at timestamptz not null default now()
expires_at timestamptz not null
last_seen_at timestamptz not null default now()
ip text
user_agent text
remember bool not null default false
auth_audit
id bigserial pk
ts timestamptz not null default now()
user_id int null fk -> users.id on delete set null
event text not null -- see event list below
ip text
detail jsonb -- e.g. {"username": "alice"} on login_fail
```
Audit events: `login_ok`, `login_fail`, `logout`, `user_create`, `user_update`, `user_delete`, `password_reset`, `setup`.
### 3.3 Session handling
- Cookie name: `clearview_session`. Flags: `HttpOnly`, `SameSite=Lax`, `Secure` (set when request is HTTPS; configurable for local-dev HTTP).
- Cookie value: the `sessions.id` UUIDv4. No data is encoded in the cookie itself.
- On every authenticated request:
1. Read cookie → look up row in `sessions`.
2. If missing or `expires_at <= now()` → 401, delete cookie.
3. If `remember = false` → extend `expires_at = now() + 8h` (sliding).
4. If `remember = true` → leave `expires_at` untouched (fixed 30 days from creation).
5. Update `last_seen_at`.
- Logout: delete the session row and clear the cookie.
- Cleanup: a lightweight purge of expired sessions runs at login time and is also exposed as a periodic task in the existing worker (cheap query: `DELETE FROM sessions WHERE expires_at < now()`).
### 3.4 Initial-setup flow
- `GET /api/auth/setup-required` returns `{"setup_required": true}` iff `COUNT(*) FROM users = 0`.
- `POST /api/auth/setup` accepts `{username, password}`, succeeds **only** when the table is empty, creates the user with `role='admin'`, immediately establishes a session (sets cookie), and writes a `setup` audit row.
- Once any user exists, both endpoints return 409 / `setup_required=false`.
### 3.5 Password policy
- Server-side check before hashing or updating: `len(pw) >= 12 and any(c.isalpha() for c in pw) and any(c.isdigit() for c in pw)`.
- Hashing: `argon2.PasswordHasher()` with library defaults; the encoded string includes salt + parameters, so future tuning is non-breaking.
## 4. Frontend
The frontend is the existing static `site/` (`index.html`, `app.js`, `styles.css`). Changes:
- **New `login.html`** — standalone page, no app-shell. Form with username, password, "remember me" checkbox. Posts to `/api/auth/login`, then redirects to `/`.
- **New `setup.html`** — same shell as login, used when `/api/auth/setup-required` returns true. Posts to `/api/auth/setup`.
- **`app.js`**:
- On boot, call `GET /api/auth/me`. On 401, redirect to `/login` (or `/setup` if `setup-required`).
- Wrap the `fetch` helper so any 401 response triggers a redirect to `/login`.
- Header shows `username (role)` + a **Logout** button that calls `POST /api/auth/logout` then redirects to `/login`.
- **New "Users" sidebar tab** (admin-only): list / add / edit (username, role, active) / delete users, and a **Reset password** action that opens a modal.
- **New "Audit" sub-view under Users** (admin-only): paged table of `auth_audit` rows, newest first, with event-type filter.
- Non-admin users do not see the Users tab; the backend also enforces this independently (defence in depth).
## 5. Data flow — successful login
1. Browser `POST /api/auth/login` with `{username, password, remember}`.
2. Server fetches user by username. If not found or `is_active=false` → 401 + audit `login_fail` with the supplied username.
3. Verify password with Argon2id. On mismatch → 401 + audit `login_fail`.
4. Insert `sessions` row (`remember` flag determines TTL: 8h or 30d).
5. `Set-Cookie: clearview_session=<uuid>; HttpOnly; SameSite=Lax; Secure?; Path=/; Max-Age=<ttl>`.
6. Audit `login_ok`. Return `{username, role}`.
7. Browser then calls `GET /api/auth/me` and renders the app.
## 6. Error handling
| Situation | Response |
|---|---|
| Missing / invalid / expired session cookie on protected endpoint | `401 Unauthorized`, cookie cleared. |
| Authenticated `user` hitting an admin-only endpoint | `403 Forbidden`. |
| Setup endpoint called when users already exist | `409 Conflict`. |
| Password policy violation | `400 Bad Request` with a single human-readable message. |
| Argon2 verification raising any exception | Treated as failed login (no info leak). |
## 7. Testing
Pytest cases (added in the existing test layout, mirroring current patterns):
- Password hashing round-trip; policy validator edge cases.
- Login success, wrong password, unknown user, inactive user.
- Session expiry: sliding 8h refresh vs fixed 30d remember.
- Logout invalidates the session.
- `require_user` and `require_admin` reject correctly.
- Setup endpoint: succeeds when empty, 409 when not empty.
- User CRUD endpoints: admin allowed, `user` role gets 403.
- Audit rows are written for each event.
## 8. Migration & compatibility
- Single forward Alembic migration adds the three tables. No changes to existing tables.
- First deploy on an existing install: the `users` table is empty → users are redirected to `/setup` on first visit.
- No env vars are introduced for credentials. (An optional `CLEARVIEW_COOKIE_SECURE` toggle may be added so local-dev HTTP still works; default `true`.)
## 9. Work breakdown (units of work)
1. DB models + Alembic migration.
2. Auth core: hashing, policy, session create/lookup/refresh/revoke, audit helper.
3. Auth router: login / logout / me / setup-required / setup.
4. Users router + audit endpoint.
5. Apply `require_user` to existing routers; `require_admin` to user/audit routes.
6. Frontend: `login.html`, `setup.html`, fetch-wrapper 401 handling, header user-badge + logout.
7. Frontend: Users tab (CRUD + reset password) and Audit sub-view.
8. Tests for items 25.
9. Append entries to `changelog-develop.md` per change.
## 10. Open items deferred to v2
- Rate limiting / brute-force lockout.
- Email-based password reset.
- MFA / SSO via Microsoft Entra (would reuse existing tenant app-registration plumbing).
- Per-tenant data scoping for non-admin users.