portal: provision Keycloak + Mailu on approve

This commit is contained in:
Brad Stein 2026-01-02 11:12:43 -03:00
parent 712676a054
commit d986cbd922
8 changed files with 512 additions and 75 deletions

View File

@ -35,7 +35,23 @@ def ensure_schema() -> None:
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
decided_at TIMESTAMPTZ,
decided_by TEXT
decided_by TEXT,
initial_password TEXT,
initial_password_revealed_at TIMESTAMPTZ
)
"""
)
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password_revealed_at TIMESTAMPTZ")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS access_request_tasks (
request_code TEXT NOT NULL REFERENCES access_requests(request_code) ON DELETE CASCADE,
task TEXT NOT NULL,
status TEXT NOT NULL,
detail TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (request_code, task)
)
"""
)
@ -55,6 +71,12 @@ def ensure_schema() -> None:
ON access_requests (status, created_at)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS access_request_tasks_request_code
ON access_request_tasks (request_code)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS access_request_onboarding_steps_request_code

View File

@ -130,6 +130,16 @@ class KeycloakAdminClient:
return location.rstrip("/").split("/")[-1]
raise RuntimeError("failed to determine created user id")
def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None:
url = (
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
f"/users/{quote(user_id, safe='')}/reset-password"
)
payload = {"type": "password", "value": password, "temporary": bool(temporary)}
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
def set_user_attribute(self, username: str, key: str, value: str) -> None:
user = self.find_user(username)
if not user:

View File

@ -0,0 +1,210 @@
from __future__ import annotations
from dataclasses import dataclass
import time
import httpx
from . import settings
from .db import connect
from .keycloak import admin_client
from .utils import random_password
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
"keycloak_user",
"keycloak_password",
"keycloak_groups",
"mailu_app_password",
"mailu_sync",
)
@dataclass(frozen=True)
class ProvisionResult:
ok: bool
status: str
def _upsert_task(conn, request_code: str, task: str, status: str, detail: str | None = None) -> None:
conn.execute(
"""
INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (request_code, task)
DO UPDATE SET status = EXCLUDED.status, detail = EXCLUDED.detail, updated_at = NOW()
""",
(request_code, task, status, detail),
)
def _task_statuses(conn, request_code: str) -> dict[str, str]:
rows = conn.execute(
"SELECT task, status FROM access_request_tasks WHERE request_code = %s",
(request_code,),
).fetchall()
output: dict[str, str] = {}
for row in rows:
task = row.get("task") if isinstance(row, dict) else None
status = row.get("status") if isinstance(row, dict) else None
if isinstance(task, str) and isinstance(status, str):
output[task] = status
return output
def _all_tasks_ok(conn, request_code: str, tasks: list[str]) -> bool:
statuses = _task_statuses(conn, request_code)
for task in tasks:
if statuses.get(task) != "ok":
return False
return True
def provision_tasks_complete(conn, request_code: str) -> bool:
return _all_tasks_ok(conn, request_code, list(REQUIRED_PROVISION_TASKS))
def provision_access_request(request_code: str) -> ProvisionResult:
if not request_code:
return ProvisionResult(ok=False, status="unknown")
if not admin_client().ready():
return ProvisionResult(ok=False, status="accounts_building")
required_tasks = list(REQUIRED_PROVISION_TASKS)
with connect() as conn:
row = conn.execute(
"""
SELECT username, contact_email, status, initial_password, initial_password_revealed_at
FROM access_requests
WHERE request_code = %s
""",
(request_code,),
).fetchone()
if not row:
return ProvisionResult(ok=False, status="unknown")
username = str(row.get("username") or "")
contact_email = str(row.get("contact_email") or "")
status = str(row.get("status") or "")
initial_password = row.get("initial_password")
revealed_at = row.get("initial_password_revealed_at")
if status not in {"accounts_building", "awaiting_onboarding", "ready"}:
return ProvisionResult(ok=False, status=status or "unknown")
user_id = ""
# Task: ensure Keycloak user exists
try:
user = admin_client().find_user(username)
if not user:
email = contact_email.strip() or f"{username}@{settings.MAILU_DOMAIN}"
payload = {
"username": username,
"enabled": True,
"email": email,
"emailVerified": False,
}
created_id = admin_client().create_user(payload)
user = admin_client().get_user(created_id)
user_id = str((user or {}).get("id") or "")
if not user_id:
raise RuntimeError("user id missing")
_upsert_task(conn, request_code, "keycloak_user", "ok", None)
except Exception:
_upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user")
# Task: set initial temporary password and store it for "show once" onboarding
try:
if user_id:
password_value = ""
if isinstance(initial_password, str) and initial_password:
password_value = initial_password
elif initial_password is None and revealed_at is None:
password_value = random_password(20)
conn.execute(
"""
UPDATE access_requests
SET initial_password = %s, initial_password_revealed_at = NULL
WHERE request_code = %s AND initial_password IS NULL
""",
(password_value, request_code),
)
initial_password = password_value
elif isinstance(initial_password, str) and initial_password and revealed_at is None:
password_value = initial_password
if password_value:
admin_client().reset_password(user_id, password_value, temporary=True)
_upsert_task(conn, request_code, "keycloak_password", "ok", None)
else:
raise RuntimeError("missing user id")
except Exception:
_upsert_task(conn, request_code, "keycloak_password", "error", "failed to set password")
# Task: group membership (default dev)
try:
if user_id:
groups = settings.DEFAULT_USER_GROUPS or ["dev"]
for group_name in groups:
gid = admin_client().get_group_id(group_name)
if not gid:
raise RuntimeError("group missing")
admin_client().add_user_to_group(user_id, gid)
_upsert_task(conn, request_code, "keycloak_groups", "ok", None)
else:
raise RuntimeError("missing user id")
except Exception:
_upsert_task(conn, request_code, "keycloak_groups", "error", "failed to add groups")
# Task: ensure mailu_app_password attribute exists
try:
if user_id:
full = admin_client().get_user(user_id)
attrs = full.get("attributes") or {}
existing = None
if isinstance(attrs, dict):
raw = attrs.get(MAILU_APP_PASSWORD_ATTR)
if isinstance(raw, list) and raw and isinstance(raw[0], str):
existing = raw[0]
elif isinstance(raw, str) and raw:
existing = raw
if not existing:
admin_client().set_user_attribute(username, MAILU_APP_PASSWORD_ATTR, random_password())
_upsert_task(conn, request_code, "mailu_app_password", "ok", None)
else:
raise RuntimeError("missing user id")
except Exception:
_upsert_task(conn, request_code, "mailu_app_password", "error", "failed to set mail password")
# Task: trigger Mailu sync if configured
try:
if not settings.MAILU_SYNC_URL:
_upsert_task(conn, request_code, "mailu_sync", "ok", "sync disabled")
else:
with httpx.Client(timeout=30) as client:
resp = client.post(
settings.MAILU_SYNC_URL,
json={"ts": int(time.time()), "wait": True, "reason": "portal_access_approve"},
)
if resp.status_code != 200:
raise RuntimeError("mailu sync failed")
_upsert_task(conn, request_code, "mailu_sync", "ok", None)
except Exception:
_upsert_task(conn, request_code, "mailu_sync", "error", "failed to sync mailu")
# If everything is OK, advance to awaiting_onboarding.
if _all_tasks_ok(conn, request_code, required_tasks):
conn.execute(
"""
UPDATE access_requests
SET status = 'awaiting_onboarding'
WHERE request_code = %s AND status = 'accounts_building'
""",
(request_code,),
)
return ProvisionResult(ok=True, status="awaiting_onboarding")
return ProvisionResult(ok=False, status="accounts_building")

View File

@ -12,6 +12,7 @@ import psycopg
from ..db import connect, configured
from ..keycloak import admin_client, require_auth
from ..rate_limit import rate_limit_allow
from ..provisioning import provision_tasks_complete
from .. import settings
@ -66,13 +67,36 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
return completed
def _automation_ready(username: str) -> bool:
def _automation_ready(conn, request_code: str, username: str) -> bool:
if not username:
return False
if not admin_client().ready():
return False
# Prefer task-based readiness when we have task rows for the request.
task_row = conn.execute(
"SELECT 1 FROM access_request_tasks WHERE request_code = %s LIMIT 1",
(request_code,),
).fetchone()
if task_row:
return provision_tasks_complete(conn, request_code)
# Fallback for legacy requests: confirm user exists and has a mail app password.
try:
return bool(admin_client().find_user(username))
user = admin_client().find_user(username)
if not user:
return False
user_id = user.get("id") if isinstance(user, dict) else None
if not user_id:
return False
full = admin_client().get_user(str(user_id))
attrs = full.get("attributes") or {}
if not isinstance(attrs, dict):
return False
raw_pw = attrs.get("mailu_app_password")
if isinstance(raw_pw, list):
return bool(raw_pw and isinstance(raw_pw[0], str) and raw_pw[0])
return bool(isinstance(raw_pw, str) and raw_pw)
except Exception:
return False
@ -80,7 +104,7 @@ def _automation_ready(username: str) -> bool:
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
status = _normalize_status(status)
if status == "accounts_building" and _automation_ready(username):
if status == "accounts_building" and _automation_ready(conn, request_code, username):
conn.execute(
"UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'",
(request_code,),
@ -213,7 +237,7 @@ def register(app) -> None:
try:
with connect() as conn:
row = conn.execute(
"SELECT status, username FROM access_requests WHERE request_code = %s",
"SELECT status, username, initial_password, initial_password_revealed_at FROM access_requests WHERE request_code = %s",
(code,),
).fetchone()
if not row:
@ -224,6 +248,15 @@ def register(app) -> None:
"status": status,
"username": row.get("username") or "",
}
if status in {"awaiting_onboarding", "ready"}:
password = row.get("initial_password")
revealed_at = row.get("initial_password_revealed_at")
if isinstance(password, str) and password and revealed_at is None:
response["initial_password"] = password
conn.execute(
"UPDATE access_requests SET initial_password_revealed_at = NOW() WHERE request_code = %s AND initial_password_revealed_at IS NULL",
(code,),
)
if status in {"awaiting_onboarding", "ready"}:
response["onboarding_url"] = f"/onboarding?code={code}"
if status in {"awaiting_onboarding", "ready"}:

View File

@ -6,6 +6,7 @@ from flask import jsonify, g
from ..db import connect, configured
from ..keycloak import require_auth, require_portal_admin
from ..provisioning import provision_access_request
def register(app) -> None:
@ -72,6 +73,13 @@ def register(app) -> None:
if not row:
return jsonify({"ok": True, "request_code": ""})
# Provision the account best-effort (Keycloak user + Mailu password + sync).
try:
provision_access_request(row["request_code"])
except Exception:
# Keep the request in accounts_building; status checks will surface it.
pass
return jsonify({"ok": True, "request_code": row["request_code"]})
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])

View File

@ -58,6 +58,8 @@ PORTAL_DATABASE_URL = os.getenv("PORTAL_DATABASE_URL", "").strip()
PORTAL_ADMIN_USERS = [u.strip() for u in os.getenv("PORTAL_ADMIN_USERS", "bstein").split(",") if u.strip()]
PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admin").split(",") if g.strip()]
DEFAULT_USER_GROUPS = [g.strip() for g in os.getenv("DEFAULT_USER_GROUPS", "dev").split(",") if g.strip()]
ACCESS_REQUEST_ENABLED = _env_bool("ACCESS_REQUEST_ENABLED", "true")
ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5"))
ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60)))

View File

@ -10,119 +10,169 @@
</div>
</section>
<section v-for="category in categories" :key="category.title" class="card category">
<div class="section-head">
<div>
<h2>{{ category.title }}</h2>
<p class="muted">{{ category.description }}</p>
<section class="section-grid">
<section v-for="section in sections" :key="section.title" class="card category">
<div class="section-head">
<div>
<h2>{{ section.title }}</h2>
<p class="muted">{{ section.description }}</p>
</div>
</div>
</div>
<div class="tiles">
<a
v-for="app in category.apps"
:key="app.name"
class="tile"
:href="app.url"
:target="app.target"
rel="noreferrer"
>
<div class="tile-title">{{ app.name }}</div>
<div class="tile-desc">{{ app.description }}</div>
</a>
</div>
<div v-for="group in section.groups" :key="group.title" class="group">
<div class="group-title">{{ group.title }}</div>
<div class="tiles">
<a
v-for="app in group.apps"
:key="app.name"
class="tile"
:href="app.url"
:target="app.target"
rel="noreferrer"
>
<div class="tile-title">{{ app.name }}</div>
<div class="tile-desc">{{ app.description }}</div>
</a>
</div>
</div>
</section>
</section>
</div>
</template>
<script setup>
const categories = [
const sections = [
{
title: "Cloud",
description: "Files, photos, mail, calendars, and documents — the primary hub for Atlas users.",
apps: [
groups: [
{
name: "Nextcloud",
url: "https://cloud.bstein.dev",
target: "_blank",
description: "Your personal cloud storage and productivity suite.",
title: "Nextcloud",
apps: [
{
name: "Cloud",
url: "https://cloud.bstein.dev",
target: "_blank",
description: "Your personal cloud storage and productivity suite.",
},
],
},
],
},
{
title: "Security",
description: "Modern password security and secrets tooling.",
apps: [
description: "Password security, secrets, and account hygiene.",
groups: [
{
name: "Vaultwarden",
url: "https://vault.bstein.dev",
target: "_blank",
description: "Password manager (Bitwarden-compatible).",
title: "Personal",
apps: [
{
name: "Vaultwarden",
url: "https://vault.bstein.dev",
target: "_blank",
description: "Password manager (Bitwarden-compatible).",
},
],
},
{
name: "Vault",
url: "https://secret.bstein.dev",
target: "_blank",
description: "Secrets management for infrastructure and apps.",
title: "Infrastructure",
apps: [
{
name: "Vault",
url: "https://secret.bstein.dev",
target: "_blank",
description: "Secrets management for infrastructure and apps.",
},
],
},
],
},
{
title: "Communications",
description: "Chat and meetings.",
apps: [
description: "Discord-like chat, calls, and rooms — plus Atlas AI chat bots.",
groups: [
{
name: "AI Chat",
url: "/ai/chat",
target: "_self",
description: "Chat with Atlas AI (GPU-accelerated).",
},
{
name: "Meet",
url: "https://meet.bstein.dev",
target: "_blank",
description: "Video meetings (Jitsi).",
title: "Chat",
apps: [
{
name: "Element",
url: "https://live.bstein.dev",
target: "_blank",
description: "Matrix chat rooms with voice/video powered by local infra.",
},
{
name: "AI Chat",
url: "/ai/chat",
target: "_self",
description: "Chat with Atlas AI (GPU-accelerated).",
},
],
},
],
},
{
title: "Streaming",
description: "Family media streaming and upload workflows.",
apps: [
groups: [
{
name: "Jellyfin",
url: "https://stream.bstein.dev",
target: "_blank",
description: "Stream videos to desktop, mobile, and TV.",
},
{
name: "Pegasus",
url: "https://pegasus.bstein.dev",
target: "_blank",
description: "Mobile-friendly upload/publish into Jellyfin.",
title: "Media",
apps: [
{
name: "Jellyfin",
url: "https://stream.bstein.dev",
target: "_blank",
description: "Stream videos to desktop, mobile, and TV.",
},
{
name: "Pegasus",
url: "https://pegasus.bstein.dev",
target: "_blank",
description: "Mobile-friendly upload/publish into Jellyfin.",
},
],
},
],
},
{
title: "Dev",
description: "Source control, CI, registry, GitOps, and observability.",
apps: [
{ name: "Gitea", url: "https://scm.bstein.dev", target: "_blank", description: "Git hosting and collaboration." },
{ name: "Jenkins", url: "https://ci.bstein.dev", target: "_blank", description: "CI pipelines and automation." },
{ name: "Harbor", url: "https://registry.bstein.dev", target: "_blank", description: "Artifact registry." },
{ name: "GitOps", url: "https://cd.bstein.dev", target: "_blank", description: "GitOps UI for Flux." },
{ name: "Grafana", url: "https://metrics.bstein.dev", target: "_blank", description: "Dashboards and monitoring." },
groups: [
{
title: "Source & CI",
apps: [
{ name: "Gitea", url: "https://scm.bstein.dev", target: "_blank", description: "Git hosting and collaboration." },
{ name: "Jenkins", url: "https://ci.bstein.dev", target: "_blank", description: "CI pipelines and automation." },
],
},
{
title: "Registry & Deploy",
apps: [
{ name: "Harbor", url: "https://registry.bstein.dev", target: "_blank", description: "Artifact registry." },
{ name: "GitOps", url: "https://cd.bstein.dev", target: "_blank", description: "GitOps UI for Flux." },
],
},
{
title: "Observability",
apps: [
{ name: "Grafana", url: "https://metrics.bstein.dev", target: "_blank", description: "Dashboards and monitoring." },
],
},
],
},
{
title: "Crypto",
description: "Local infrastructure for crypto workloads.",
apps: [
groups: [
{
name: "Monero Node",
url: "https://monero.bstein.dev",
target: "_blank",
description: "Faster sync using the Atlas Monero node.",
title: "Monero",
apps: [
{
name: "Monero Node",
url: "https://monero.bstein.dev",
target: "_blank",
description: "Faster sync using the Atlas Monero node.",
},
],
},
],
},
@ -136,6 +186,17 @@ const categories = [
padding: 32px 22px 72px;
}
.section-grid {
display: grid;
gap: 14px;
}
@media (min-width: 980px) {
.section-grid {
grid-template-columns: 1fr 1fr;
}
}
.hero {
display: flex;
align-items: flex-start;
@ -146,7 +207,6 @@ const categories = [
.category {
padding: 18px;
margin-top: 12px;
}
.section-head {
@ -157,6 +217,16 @@ const categories = [
margin-bottom: 14px;
}
.group + .group {
margin-top: 14px;
}
.group-title {
font-weight: 700;
color: var(--text-strong);
margin-bottom: 10px;
}
.muted {
margin: 6px 0 0;
color: var(--text-muted);

View File

@ -54,6 +54,26 @@
Atlas can't reliably verify these steps automatically yet. Mark them complete once you're done.
</p>
<div v-if="initialPassword" class="initial-password">
<h3>Temporary password</h3>
<p class="muted">
Use this password to log in for the first time. Keycloak will prompt you to change it.
</p>
<div class="request-code-row">
<span class="label mono">Password</span>
<button class="copy mono" type="button" @click="copyInitialPassword">
{{ initialPassword }}
<span v-if="copied" class="copied">copied</span>
</button>
</div>
<p class="muted">
Log in at
<a href="https://sso.bstein.dev" target="_blank" rel="noreferrer">sso.bstein.dev</a>
or go directly to
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.
</p>
</div>
<div v-if="!auth.authenticated" class="login-callout">
<p class="muted">Log in to check off onboarding steps.</p>
<button class="primary" type="button" @click="loginToContinue" :disabled="loading">Log in</button>
@ -155,6 +175,8 @@ const status = ref("");
const loading = ref(false);
const error = ref("");
const onboarding = ref({ required_steps: [], completed_steps: [] });
const initialPassword = ref("");
const copied = ref(false);
function statusLabel(value) {
const key = (value || "").trim();
@ -196,6 +218,9 @@ async function check() {
status.value = data.status || "unknown";
requestUsername.value = data.username || "";
onboarding.value = data.onboarding || { required_steps: [], completed_steps: [] };
if (data.initial_password) {
initialPassword.value = data.initial_password;
}
} catch (err) {
error.value = err.message || "Failed to check status";
} finally {
@ -203,6 +228,31 @@ async function check() {
}
}
async function copyInitialPassword() {
if (!initialPassword.value) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(initialPassword.value);
} else {
const textarea = document.createElement("textarea");
textarea.value = initialPassword.value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand("copy");
document.body.removeChild(textarea);
}
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
} catch (err) {
error.value = err?.message || "Failed to copy password";
}
}
async function loginToContinue() {
await login(`/onboarding?code=${encodeURIComponent(requestCode.value.trim())}`);
}
@ -395,6 +445,38 @@ button.primary {
background: rgba(120, 180, 255, 0.06);
}
.initial-password {
margin-top: 14px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(255, 220, 120, 0.25);
background: rgba(255, 220, 120, 0.06);
}
.request-code-row {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.copy {
display: inline-flex;
align-items: center;
gap: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.22);
color: var(--text-primary);
padding: 10px 12px;
cursor: pointer;
}
.copied {
font-size: 12px;
color: rgba(120, 255, 160, 0.9);
}
.steps ol {
margin: 0;
padding-left: 18px;