from __future__ import annotations import base64 import hashlib import html import json import mimetypes import urllib.parse from http import HTTPStatus from http.cookies import SimpleCookie from pathlib import Path from string import Template from urllib.parse import parse_qs, urlparse from . import pantheon from .config import Config from .authentik import trigger_ldap_sync from .db import Database, sql_literal from .ipa import approve_user, email_matches, provision_user, set_password, verify_password as verify_ipa_password from .mailer import send_password_reset_email, send_verification_email from .oidc import authorization_url, exchange_code, groups_from_claims, logout_url, userinfo from .security import hash_password, sign, token_urlsafe, unsign, verify_password ROOT = Path(__file__).resolve().parent TEMPLATES = ROOT / "templates" STATIC = ROOT / "static" #: The only routes a role="home" deployment answers. Everything else belongs to the deployment #: that holds the database and the OIDC client, and is reached through config.auth_base_url. #: Served by a home deployment itself. Everything else is forwarded to the deployment that owns #: identity. The auth routes are here so a home site can hold its own Authentik session: it needs #: to know who you are to show "My Account" and "Admin", even though it owns no user table. HOME_ROUTES = { ("GET", "/"), ("GET", "/trusted-domains"), ("GET", "/auth/start"), ("GET", "/auth/callback"), ("POST", "/logout"), } def esc(value: object) -> str: return html.escape("" if value is None else str(value), quote=True) class WebApp: def __init__(self, config: Config): self.config = config # A home deployment has no database credentials at all, so there is nothing to point at. self.db = None if config.role == "home" else Database(config.database_url) #: Whether this deployment can run the OIDC flow itself. A home site without these falls #: back to handing sign-in off to auth_base_url. self.oidc_ready = bool( config.oidc_issuer and config.oidc_client_id and config.oidc_client_secret ) def dispatch(self, handler) -> None: parsed = urlparse(handler.path) path = parsed.path method = handler.command if path.startswith("/static/"): return self.static(handler, path) routes = { ("GET", "/"): self.register_form, ("POST", "/register"): self.register_submit, ("GET", "/verify"): self.verify_email, ("GET", "/trusted-domains"): self.trusted_domains_page, ("GET", "/login"): self.login_form, ("POST", "/login"): self.login_submit, ("POST", "/logout"): self.logout, ("GET", "/auth/start"): self.auth_start, ("GET", "/auth/callback"): self.auth_callback, ("GET", "/profile"): self.profile, ("POST", "/profile"): self.profile_update, ("POST", "/profile/password"): self.profile_password_update, ("GET", "/password/request"): self.password_request_form, ("POST", "/password/request"): self.password_request_submit, ("GET", "/password/reset"): self.password_reset_form, ("POST", "/password/reset"): self.password_reset_submit, ("GET", "/admin"): self.admin, ("POST", "/admin/approve"): self.admin_approve, ("POST", "/admin/reject"): self.admin_reject, ("POST", "/admin/delete-unverified"): self.admin_delete_unverified, ("POST", "/admin/purge-unverified"): self.admin_purge_unverified, } if self.config.role == "home" and (method, path) not in HOME_ROUTES: # Not a 404: the page exists, it just lives on the deployment that owns identity. Only # GET is forwarded โ€” a 303 would turn a POST into a GET and drop the form body, so any # form that submits off-box has to target auth_base_url directly. if method != "GET": return self.render(handler, "Not found", "

That page does not exist.

", HTTPStatus.NOT_FOUND) query = f"?{parsed.query}" if parsed.query else "" return self.redirect(handler, f"{self.config.auth_base_url}{path}{query}") view = routes.get((method, path)) if view is None: return self.render(handler, "Not found", "

That page does not exist.

", HTTPStatus.NOT_FOUND) return view(handler) def form_data(self, handler) -> dict[str, str]: length = int(handler.headers.get("Content-Length", "0")) raw = handler.rfile.read(length).decode() return {key: values[0] for key, values in parse_qs(raw).items()} def current_user(self, handler) -> dict[str, str | None] | None: cookie = SimpleCookie(handler.headers.get("Cookie")) morsel = cookie.get("snreg_session") if not morsel: return None value = unsign(morsel.value, self.config.secret) if not value: return None if self.db is None: # No user table here, so the claims we care about travel in the cookie itself. It is # signed, so the contents are tamper-evident; nothing secret goes in. try: return json.loads(base64.urlsafe_b64decode(value.encode()).decode()) except Exception: return None return self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(value)}") def require_user(self, handler) -> dict[str, str | None] | None: user = self.current_user(handler) if user: return user self.redirect(handler, f"/auth/start?next={urllib.parse.quote(urlparse(handler.path).path)}") return None def require_admin(self, handler) -> dict[str, str | None] | None: user = self.require_user(handler) if not user: return None if user.get("is_admin") == "t": return user self.render(handler, "Forbidden", "

You do not have access to the admin dashboard.

", HTTPStatus.FORBIDDEN) return None def static(self, handler, path: str) -> None: rel = path.removeprefix("/static/").lstrip("/") target = (STATIC / rel).resolve() if not str(target).startswith(str(STATIC.resolve())) or not target.exists(): handler.send_error(404) return content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream" data = target.read_bytes() # Nothing here is fingerprinted, so a cached copy is a stale copy: no-cache makes the # browser ask every time, and the ETag lets it keep the bytes it already has. tag = '"%s"' % hashlib.sha1(data).hexdigest()[:16] if handler.headers.get("If-None-Match") == tag: handler.send_response(HTTPStatus.NOT_MODIFIED.value) handler.send_header("ETag", tag) handler.send_header("Cache-Control", "no-cache") handler.end_headers() return handler.send_response(200) handler.send_header("Content-Type", content_type) handler.send_header("Content-Length", str(len(data))) handler.send_header("ETag", tag) handler.send_header("Cache-Control", "no-cache") handler.end_headers() handler.wfile.write(data) def render( self, handler, title: str, content: str, status: HTTPStatus = HTTPStatus.OK, wide: bool = False, head: str = "", body_class: str = "", shell_class: str = "", ) -> None: template = Template((TEMPLATES / "base.html").read_text()) user = self.current_user(handler) html_out = template.substitute( title=esc(title), content=content, shell_class=shell_class or ("shell shell-wide" if wide else "shell"), nav=self.nav(user), head=head, body_class=body_class, ).encode() handler.send_response(status.value) handler.send_header("Content-Type", "text/html; charset=utf-8") handler.send_header("Content-Length", str(len(html_out))) handler.end_headers() handler.wfile.write(html_out) def nav(self, user: dict[str, str | None] | None) -> str: if not user: # Sign in here when this deployment can run the flow itself; otherwise hand off to the # deployment that owns identity. if self.oidc_ready: start, nxt = "/auth/start", "/" if self.config.role == "home" else "/profile" else: start, nxt = self.off_box("/auth/start"), "/profile" return f'Sign in' # off_box leaves these relative on a full deployment and absolute on a home one, so the # same markup serves both. admin = ( f'Admin' if user.get("is_admin") == "t" else "" ) account = f'My Account' return f'Home{account}{admin}
' def redirect(self, handler, location: str) -> None: handler.send_response(303) handler.send_header("Location", location) handler.end_headers() def set_session(self, handler, user_id: str) -> None: cookie = f"snreg_session={sign(user_id, self.config.secret)}; Path=/; HttpOnly; SameSite=Lax" if self.config.cookie_secure: cookie += "; Secure" handler.send_header("Set-Cookie", cookie) def set_home_session(self, handler, claims: dict) -> None: """Session for a deployment with no user table, built straight from the OIDC claims.""" groups = groups_from_claims(claims) email = str(claims.get("email") or "").lower() payload = { "username": str(claims.get("preferred_username") or email.split("@", 1)[0] or ""), "full_name": str(claims.get("name") or ""), "email": email, # Same rule the full deployment applies in upsert_oidc_user. "is_admin": "t" if email in self.config.app_admin_emails or groups & self.config.admin_groups else "f", } encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() self.set_session(handler, encoded) def off_box(self, path: str) -> str: """Absolute URL for a page this deployment does not serve; unchanged when it does. Only worth using where a relative link would not survive the forward in ``dispatch`` โ€” form actions above all, since a POST cannot be redirected without losing its body. """ return path if self.config.role != "home" else f"{self.config.auth_base_url}{path}" def trusted_domain(self, email: str) -> bool: domain = email.rsplit("@", 1)[-1].lower() return domain in self.config.trusted_domains def trusted_domains_page(self, handler) -> None: """Placeholder โ€” explains the shortcut the registration fine print points at.""" domains = sorted(self.config.trusted_domains) if domains: listing = ( "

Right now these are trusted:

" ) else: listing = "

No domains are trusted yet, so every request is read by a human.

" self.render( handler, "Trusted domains", "

Trusted domains

" "

Some email domains belong to people I already know — a workplace, or a " "shared project. An address at one of those is approved the moment you click the " "link in the verification email, with no wait for me to read the request.

" f"{listing}" "

Everything else still works exactly the same, it just waits for me. If you " "think your domain belongs on this list, ask.

" '

Back to the pantheon

', ) def create_password_token(self, username: str, email: str) -> str: token = token_urlsafe() self.db.execute( f""" INSERT INTO snreg_password_tokens (token, username, email, expires_at) VALUES ({sql_literal(token)}, {sql_literal(username)}, {sql_literal(email)}, now() + interval '2 hours'); """ ) return token def send_password_link(self, username: str, email: str) -> str: token = self.create_password_token(username, email) reset_url = f"{self.config.base_url}/password/reset?token={token}" send_password_reset_email(self.config, email, reset_url) return reset_url def auth_start(self, handler) -> None: if not self.config.oidc_client_id or not self.config.oidc_client_secret: return self.render(handler, "Sign in unavailable", "

Authentik OIDC is not configured for Charon yet.

", HTTPStatus.SERVICE_UNAVAILABLE) query = parse_qs(urlparse(handler.path).query) # A home deployment does not serve /profile, so land back on its own front page. default_next = "/" if self.config.role == "home" else "/profile" next_path = query.get("next", [default_next])[0] if not next_path.startswith("/") or next_path.startswith("//"): next_path = default_next state = sign(next_path, self.config.secret) self.redirect(handler, authorization_url(self.config, state, next_path)) def auth_callback(self, handler) -> None: query = parse_qs(urlparse(handler.path).query) code = query.get("code", [""])[0] state = query.get("state", [""])[0] next_path = unsign(state, self.config.secret, max_age=600) or "/profile" if not code: return self.render(handler, "Sign in failed", "

Authentik did not return an authorization code.

", HTTPStatus.BAD_REQUEST) try: token = exchange_code(self.config, code) claims = userinfo(self.config, token["access_token"]) user = None if self.db is None else self.upsert_oidc_user(claims) except Exception as exc: return self.render(handler, "Sign in failed", f"

{esc(exc)}

", HTTPStatus.BAD_GATEWAY) handler.send_response(303) handler.send_header("Location", next_path) if user is None: self.set_home_session(handler, claims) else: self.set_session(handler, user["id"] or "") handler.end_headers() def upsert_oidc_user(self, claims: dict) -> dict[str, str | None]: subject = str(claims.get("sub") or "") email = str(claims.get("email") or "").lower() username = str(claims.get("preferred_username") or email.split("@", 1)[0] or subject).lower() full_name = str(claims.get("name") or username) groups = groups_from_claims(claims) is_admin = bool(email in self.config.app_admin_emails or groups.intersection(self.config.admin_groups)) group_text = ",".join(sorted(groups)) existing = self.db.one( f""" SELECT * FROM snreg_users WHERE oidc_subject = {sql_literal(subject)} OR lower(email) = {sql_literal(email)} OR lower(username) = {sql_literal(username)} ORDER BY CASE WHEN oidc_subject = {sql_literal(subject)} THEN 0 ELSE 1 END LIMIT 1 """ ) if existing: self.db.execute( f""" UPDATE snreg_users SET oidc_subject = {sql_literal(subject)}, email = {sql_literal(email)}, full_name = {sql_literal(full_name)}, status = CASE WHEN status IN ('pending_email', 'pending_approval', 'rejected') THEN status ELSE 'active' END, is_admin = {sql_literal(is_admin)}, auth_groups = {sql_literal(group_text)}, updated_at = now() WHERE id = {sql_literal(existing['id'])}; """ ) return self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(existing['id'])}") or existing self.db.execute( f""" INSERT INTO snreg_users (username, email, full_name, password_hash, status, email_verified_at, trusted_domain, is_admin, oidc_subject, auth_groups) VALUES ({sql_literal(username)}, {sql_literal(email)}, {sql_literal(full_name)}, {sql_literal(hash_password(token_urlsafe()))}, 'active', now(), {sql_literal(self.trusted_domain(email))}, {sql_literal(is_admin)}, {sql_literal(subject)}, {sql_literal(group_text)}); """ ) return self.db.one(f"SELECT * FROM snreg_users WHERE oidc_subject = {sql_literal(subject)}") or {} def registration_flyout(self, handler) -> str: if self.current_user(handler): return "" return """
Create account

Create your Sticknife account

Use a verified email address to request access. You will set your password after verification.

Trusted email domains are auto-approved after verification. Other requests go to admin review.

""" def registration_card(self, handler) -> str: if self.current_user(handler): return ( '

You already have an account

' '

It opens everything below. ' f'Your account.

' ) return f"""

Ask for an account

""" def register_form(self, handler, message: str = "") -> None: content = Template((TEMPLATES / "landing.html").read_text()).substitute( message=message, registration_form=self.registration_card(handler), payload=pantheon.payload(), fallback=pantheon.fallback(), ) self.render( handler, "The Pantheon", # base.html appends " ยท Sticknife" content, head='', body_class="landing", shell_class="landing-shell", ) def register_submit(self, handler) -> None: data = self.form_data(handler) username = data.get("username", "").strip().lower() email = data.get("email", "").strip().lower() full_name = data.get("full_name", "").strip() if not username or not email or not full_name or "@" not in email: return self.register_form(handler, '
Fill every field and use a valid email address.
') placeholder_password_hash = hash_password(token_urlsafe()) trusted = self.trusted_domain(email) token = token_urlsafe() try: self.db.execute( f""" WITH inserted AS ( INSERT INTO snreg_users (username, email, full_name, password_hash, trusted_domain, is_admin) VALUES ({sql_literal(username)}, {sql_literal(email)}, {sql_literal(full_name)}, {sql_literal(placeholder_password_hash)}, {sql_literal(trusted)}, {sql_literal(email in self.config.app_admin_emails)}) RETURNING id ) INSERT INTO snreg_email_tokens (token, user_id, expires_at) SELECT {sql_literal(token)}, id, now() + interval '24 hours' FROM inserted; """ ) except Exception: return self.register_form(handler, '
That username or email is already registered.
') verify_url = f"{self.config.base_url}/verify?token={token}" try: send_verification_email(self.config, email, verify_url) message = '
Check your email for a verification link.
' except Exception as exc: message = f'
Registration saved, but email could not be sent: {esc(exc)}
' self.register_form(handler, message) def verify_email(self, handler) -> None: token = parse_qs(urlparse(handler.path).query).get("token", [""])[0] row = self.db.one( f""" SELECT u.* FROM snreg_email_tokens t JOIN snreg_users u ON u.id = t.user_id WHERE t.token = {sql_literal(token)} AND t.used_at IS NULL AND t.expires_at > now() """ ) if not row: return self.render(handler, "Verification failed", '

The verification link is invalid or expired.

') trusted = row.get("trusted_domain") == "t" next_status = "approved" if trusted else "pending_approval" self.db.execute( f""" UPDATE snreg_email_tokens SET used_at = now() WHERE token = {sql_literal(token)}; UPDATE snreg_users SET email_verified_at = now(), status = {sql_literal(next_status)}, updated_at = now() WHERE id = {sql_literal(row['id'])}; """ ) fresh = self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(row['id'])}") user = fresh or row result = provision_user(self.config, user, active=trusted) if not result.ok: failed_status = "ipa_error" if trusted else "pending_approval" self.db.execute( f""" UPDATE snreg_users SET status = {sql_literal(failed_status)}, ipa_message = {sql_literal(result.message)}, updated_at = now() WHERE id = {sql_literal(row['id'])}; INSERT INTO snreg_audit_log (target_user_id, action, detail) VALUES ({sql_literal(row['id'])}, 'email_verify_provision', {sql_literal(result.message)}); """ ) return self.render(handler, "Verified", '

Your email is verified, but your account could not be prepared automatically. An admin will review it.

') reset_url = "" if self.config.ipa_mode == "cli": reset_url = self.send_password_link(user["username"] or "", user["email"] or "") if trusted: sync_result = trigger_ldap_sync(self.config) self.db.execute( f""" INSERT INTO snreg_audit_log (target_user_id, action, detail) VALUES ({sql_literal(row['id'])}, 'auto_provision', {sql_literal(result.message)}); INSERT INTO snreg_audit_log (target_user_id, action, detail) VALUES ({sql_literal(row['id'])}, 'authentik_sync', {sql_literal(sync_result.message)}); DELETE FROM snreg_users WHERE id = {sql_literal(row['id'])}; """ ) if reset_url: return self.redirect(handler, reset_url) return self.render(handler, "Verified", '

Your email is verified. Your account is ready for Sticknife services.

Continue') self.db.execute( f""" UPDATE snreg_users SET status = 'pending_approval', ipa_provisioned_at = now(), ipa_message = {sql_literal(result.message)}, updated_at = now() WHERE id = {sql_literal(row['id'])}; INSERT INTO snreg_audit_log (target_user_id, action, detail) VALUES ({sql_literal(row['id'])}, 'pending_provision', {sql_literal(result.message)}); """ ) if reset_url: return self.redirect(handler, reset_url) return self.render(handler, "Verified", '

Your email is verified. Your account exists, but an admin must approve it before it can access Sticknife services.

') def login_form(self, handler, message: str = "") -> None: content = Template((TEMPLATES / "login.html").read_text()).substitute(message=message) self.render(handler, "Sign in", content) def login_submit(self, handler) -> None: data = self.form_data(handler) identifier = data.get("identifier", "").strip().lower() password = data.get("password", "") user = self.db.one( f"SELECT * FROM snreg_users WHERE lower(username) = {sql_literal(identifier)} OR lower(email) = {sql_literal(identifier)}" ) if not user or not verify_password(password, user.get("password_hash") or ""): return self.login_form(handler, '
Invalid username or password.
') handler.send_response(303) handler.send_header("Location", "/profile") self.set_session(handler, user["id"] or "") handler.end_headers() def logout(self, handler) -> None: try: location = logout_url(self.config) except Exception: location = "/" handler.send_response(303) handler.send_header("Location", location) handler.send_header("Set-Cookie", "snreg_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax") handler.end_headers() def profile(self, handler, message: str = "") -> None: user = self.require_user(handler) if not user: return content = Template((TEMPLATES / "profile.html").read_text()).substitute( message=message, username=esc(user["username"]), email=esc(user["email"]), full_name=esc(user["full_name"]), status=esc(user["status"]), ) self.render(handler, "Profile", content, wide=True) def profile_update(self, handler) -> None: user = self.require_user(handler) if not user: return data = self.form_data(handler) full_name = data.get("full_name", "").strip() email = data.get("email", "").strip().lower() if not full_name or "@" not in email: return self.profile(handler, '
Use a valid name and email.
') self.db.execute( f""" UPDATE snreg_users SET full_name = {sql_literal(full_name)}, email = {sql_literal(email)}, updated_at = now() WHERE id = {sql_literal(user['id'])}; INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) VALUES ({sql_literal(user['id'])}, {sql_literal(user['id'])}, 'profile_update', 'profile fields updated'); """ ) self.profile(handler, '
Profile updated.
') def profile_password_update(self, handler) -> None: user = self.require_user(handler) if not user: return data = self.form_data(handler) old_password = data.get("old_password", "") new_password = data.get("new_password", "") confirm_password = data.get("confirm_password", "") if len(new_password) < 12 or new_password != confirm_password: return self.profile(handler, '
Use matching new passwords of at least 12 characters.
') username = user["username"] or "" verified = verify_ipa_password(self.config, username, old_password) if not verified.ok: return self.profile(handler, '
Old password did not match.
') result = set_password(self.config, username, new_password) if not result.ok: return self.profile(handler, f'
Password could not be changed: {esc(result.message)}
') self.db.execute( f""" INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) VALUES ({sql_literal(user['id'])}, {sql_literal(user['id'])}, 'profile_password_change', 'password changed'); """ ) self.profile(handler, '
Password changed.
') def password_request_form(self, handler, message: str = "") -> None: content = Template((TEMPLATES / "password_request.html").read_text()).substitute(message=message) self.render(handler, "Password reset", content) def password_request_submit(self, handler) -> None: data = self.form_data(handler) username = data.get("username", "").strip().lower() email = data.get("email", "").strip().lower() detail = "password reset requested" if username and email and email_matches(self.config, username, email): try: self.send_password_link(username, email) detail = "password reset email sent" except Exception as exc: detail = f"password reset email failed: {exc}" self.db.execute( f""" INSERT INTO snreg_audit_log (action, detail) VALUES ('password_request', {sql_literal(username + ' ' + detail)}); """ ) message = '
If that account exists, a password reset link has been sent.
' self.password_request_form(handler, message) def password_reset_form(self, handler, message: str = "") -> None: token = parse_qs(urlparse(handler.path).query).get("token", [""])[0] row = self.db.one( f""" SELECT * FROM snreg_password_tokens WHERE token = {sql_literal(token)} AND used_at IS NULL AND expires_at > now() """ ) if not row: return self.render(handler, "Password reset", '

The password reset link is invalid or expired.

Request another link') content = Template((TEMPLATES / "password_reset.html").read_text()).substitute( message=message, username=esc(row["username"]), token=esc(token), ) self.render(handler, "Set password", content) def password_reset_submit(self, handler) -> None: data = self.form_data(handler) token = data.get("token", "") password = data.get("password", "") confirm_password = data.get("confirm_password", "") row = self.db.one( f""" SELECT * FROM snreg_password_tokens WHERE token = {sql_literal(token)} AND used_at IS NULL AND expires_at > now() """ ) if not row: return self.render(handler, "Password reset", '

The password reset link is invalid or expired.

Request another link') if len(password) < 12 or password != confirm_password: return self.password_reset_form(handler, '
Use matching passwords of at least 12 characters.
') result = set_password(self.config, row["username"] or "", password) if not result.ok: return self.password_reset_form(handler, f'
Password could not be set: {esc(result.message)}
') self.db.execute( f""" UPDATE snreg_password_tokens SET used_at = now() WHERE token = {sql_literal(token)}; INSERT INTO snreg_audit_log (action, detail) VALUES ('password_reset', {sql_literal(result.message)}); """ ) self.render(handler, "Password set", '

Your password is set. You can now sign in.

Sign in') def admin(self, handler) -> None: user = self.require_admin(handler) if not user: return pending = self.db.rows("SELECT * FROM snreg_users WHERE status = 'pending_approval' ORDER BY created_at ASC") unverified = self.db.rows("SELECT * FROM snreg_users WHERE status = 'pending_email' ORDER BY created_at ASC") recent = self.db.rows("SELECT * FROM snreg_users ORDER BY created_at DESC LIMIT 25") pending_rows = "".join(self.user_row(row, actions=True) for row in pending) or 'No pending approvals.' unverified_rows = "".join(self.user_row(row, actions=False, delete_unverified=True) for row in unverified) or 'No unverified registrations.' recent_rows = "".join(self.user_row(row, actions=False) for row in recent) purge_disabled = "disabled" if not unverified else "" content = Template((TEMPLATES / "admin.html").read_text()).substitute( pending_rows=pending_rows, unverified_rows=unverified_rows, purge_disabled=purge_disabled, recent_rows=recent_rows, ) self.render(handler, "Admin", content, wide=True) def user_row(self, row: dict[str, str | None], actions: bool, delete_unverified: bool = False) -> str: controls = "" if actions: controls = f"""
""" elif delete_unverified: controls = f"""
""" return f""" {esc(row['username'])} {esc(row['email'])} {esc(row['full_name'])} {esc(row['status'])} {controls} """ def admin_approve(self, handler) -> None: admin = self.require_admin(handler) if not admin: return user_id = self.form_data(handler).get("user_id", "") user = self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(user_id)}") if user: already_provisioned = bool(user.get("ipa_provisioned_at")) result = approve_user(self.config, user["username"] or "") if already_provisioned else provision_user(self.config, user, active=True) if result.ok and self.config.ipa_mode == "cli": if not already_provisioned: self.send_password_link(user["username"] or "", user["email"] or "") sync_result = trigger_ldap_sync(self.config) self.db.execute( f""" INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'admin_approve', {sql_literal(result.message)}); INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'authentik_sync', {sql_literal(sync_result.message)}); DELETE FROM snreg_users WHERE id = {sql_literal(user_id)}; """ ) else: status = "active" if result.ok else "ipa_error" self.db.execute( f""" UPDATE snreg_users SET status = {sql_literal(status)}, ipa_provisioned_at = CASE WHEN {sql_literal(result.ok)} THEN COALESCE(ipa_provisioned_at, now()) ELSE ipa_provisioned_at END, ipa_message = {sql_literal(result.message)}, updated_at = now() WHERE id = {sql_literal(user_id)}; INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'admin_approve', {sql_literal(result.message)}); """ ) self.redirect(handler, "/admin") def admin_reject(self, handler) -> None: admin = self.require_admin(handler) if not admin: return user_id = self.form_data(handler).get("user_id", "") self.db.execute( f""" UPDATE snreg_users SET status = 'rejected', updated_at = now() WHERE id = {sql_literal(user_id)}; INSERT INTO snreg_audit_log (actor_user_id, target_user_id, action, detail) VALUES ({sql_literal(admin['id'])}, {sql_literal(user_id)}, 'admin_reject', 'registration rejected'); """ ) self.redirect(handler, "/admin") def admin_delete_unverified(self, handler) -> None: admin = self.require_admin(handler) if not admin: return user_id = self.form_data(handler).get("user_id", "") self.db.execute( f""" WITH deleted AS ( DELETE FROM snreg_users WHERE id = {sql_literal(user_id)} AND status = 'pending_email' RETURNING id ) INSERT INTO snreg_audit_log (actor_user_id, action, detail) SELECT {sql_literal(admin['id'])}, 'admin_delete_unverified', 'deleted pending_email user id ' || id FROM deleted; """ ) self.redirect(handler, "/admin") def admin_purge_unverified(self, handler) -> None: admin = self.require_admin(handler) if not admin: return self.db.execute( f""" WITH deleted AS ( DELETE FROM snreg_users WHERE status = 'pending_email' RETURNING id ), count_deleted AS ( SELECT count(*) AS total FROM deleted ) INSERT INTO snreg_audit_log (actor_user_id, action, detail) SELECT {sql_literal(admin['id'])}, 'admin_purge_unverified', 'purged ' || total || ' pending_email users' FROM count_deleted; """ ) self.redirect(handler, "/admin")