portal: add access request approvals

This commit is contained in:
Brad Stein 2026-01-01 22:14:15 -03:00
parent 5a9a3b4f8b
commit c22c27b8aa
3 changed files with 640 additions and 8 deletions

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import re
import time
from functools import wraps
from pathlib import Path
@ -70,6 +71,13 @@ ACCOUNT_ALLOWED_GROUPS = [
if g.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()]
ACCESS_REQUEST_ENABLED = os.getenv("ACCESS_REQUEST_ENABLED", "true").lower() in ("1", "true", "yes")
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)))
MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
MAILU_SYNC_URL = os.getenv(
"MAILU_SYNC_URL",
@ -80,6 +88,8 @@ JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
_KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None
_KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0}
_KEYCLOAK_GROUP_ID_CACHE: dict[str, str] = {}
_ACCESS_REQUEST_RATE: dict[str, list[float]] = {}
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
@ -167,6 +177,20 @@ def require_auth(fn):
return wrapper
def _require_portal_admin() -> tuple[bool, Any]:
if not KEYCLOAK_ENABLED:
return False, (jsonify({"error": "keycloak not enabled"}), 503)
username = getattr(g, "keycloak_username", "") or ""
groups = set(getattr(g, "keycloak_groups", []) or [])
if username and username in PORTAL_ADMIN_USERS:
return True, None
if PORTAL_ADMIN_GROUPS and groups.intersection(PORTAL_ADMIN_GROUPS):
return True, None
return False, (jsonify({"error": "forbidden"}), 403)
@app.route("/api/auth/config", methods=["GET"])
def auth_config() -> Any:
if not KEYCLOAK_ENABLED:
@ -334,6 +358,263 @@ def account_overview() -> Any:
)
def _rate_limit_allow(ip: str) -> bool:
if ACCESS_REQUEST_RATE_LIMIT <= 0:
return True
now = time.time()
window_start = now - ACCESS_REQUEST_RATE_WINDOW_SEC
bucket = _ACCESS_REQUEST_RATE.setdefault(ip, [])
bucket[:] = [t for t in bucket if t >= window_start]
if len(bucket) >= ACCESS_REQUEST_RATE_LIMIT:
return False
bucket.append(now)
return True
def _kc_admin_create_user(payload: dict[str, Any]) -> str:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.post(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload)
resp.raise_for_status()
location = resp.headers.get("Location") or ""
if location:
return location.rstrip("/").split("/")[-1]
raise RuntimeError("failed to determine created user id")
def _kc_admin_delete_user(user_id: str) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.delete(url, headers=_kc_admin_headers())
resp.raise_for_status()
def _kc_admin_get_group_id(group_name: str) -> str | None:
cached = _KEYCLOAK_GROUP_ID_CACHE.get(group_name)
if cached:
return cached
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups"
params = {"search": group_name}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=_kc_admin_headers())
resp.raise_for_status()
items = resp.json()
if not isinstance(items, list):
return None
for item in items:
if not isinstance(item, dict):
continue
if item.get("name") == group_name and item.get("id"):
gid = str(item["id"])
_KEYCLOAK_GROUP_ID_CACHE[group_name] = gid
return gid
return None
def _kc_admin_add_user_to_group(user_id: str, group_id: str) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}"
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(url, headers=_kc_admin_headers())
resp.raise_for_status()
def _kc_admin_execute_actions_email(user_id: str, actions: list[str]) -> None:
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/execute-actions-email"
params = {"client_id": KEYCLOAK_CLIENT_ID, "redirect_uri": request.host_url.rstrip("/") + "/"}
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.put(
url,
params=params,
headers={**_kc_admin_headers(), "Content-Type": "application/json"},
json=actions,
)
resp.raise_for_status()
def _extract_request_payload() -> tuple[str, str, str]:
payload = request.get_json(silent=True) or {}
username = (payload.get("username") or "").strip()
email = (payload.get("email") or "").strip()
note = (payload.get("note") or "").strip()
return username, email, note
@app.route("/api/access/request", methods=["POST"])
def request_access() -> Any:
if not ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
ip = request.remote_addr or "unknown"
if not _rate_limit_allow(ip):
return jsonify({"error": "rate limited"}), 429
username, email, note = _extract_request_payload()
if not username or not email:
return jsonify({"error": "username and email are required"}), 400
if len(username) < 3 or len(username) > 32:
return jsonify({"error": "username must be 3-32 characters"}), 400
if not re.fullmatch(r"[a-zA-Z0-9._-]+", username):
return jsonify({"error": "username contains invalid characters"}), 400
if _kc_admin_find_user(username):
return jsonify({"error": "username already exists"}), 409
attrs: dict[str, Any] = {
"access_request_status": ["pending"],
"access_request_note": [note] if note else [""],
"access_request_created_at": [str(int(time.time()))],
}
user_payload = {
"username": username,
"enabled": False,
"email": email,
"emailVerified": False,
"attributes": attrs,
}
try:
user_id = _kc_admin_create_user(user_payload)
except Exception:
return jsonify({"error": "failed to submit request"}), 502
return jsonify({"ok": True, "id": user_id})
def _kc_admin_list_pending_requests(limit: int = 100) -> list[dict[str, Any]]:
def is_pending(user: dict[str, Any]) -> bool:
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
return False
status = attrs.get("access_request_status")
if isinstance(status, list) and status:
return str(status[0]) == "pending"
if isinstance(status, str):
return status == "pending"
return False
url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users"
# Prefer server-side search (q=) if supported by this Keycloak version.
# Always filter client-side for correctness.
candidates: list[dict[str, Any]] = []
for params in (
{"max": str(limit), "enabled": "false", "q": "access_request_status:pending"},
{"max": str(limit), "enabled": "false"},
{"max": str(limit)},
):
try:
with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client:
resp = client.get(url, params=params, headers=_kc_admin_headers())
resp.raise_for_status()
users = resp.json()
if not isinstance(users, list):
continue
candidates = [u for u in users if isinstance(u, dict)]
break
except httpx.HTTPStatusError:
continue
pending = [u for u in candidates if is_pending(u)]
return pending[:limit]
@app.route("/api/admin/access/requests", methods=["GET"])
@require_auth
def admin_list_requests() -> Any:
ok, resp = _require_portal_admin()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
try:
items = _kc_admin_list_pending_requests()
except Exception:
return jsonify({"error": "failed to load requests"}), 502
output: list[dict[str, Any]] = []
for user in items:
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
output.append(
{
"id": user.get("id") or "",
"username": user.get("username") or "",
"email": user.get("email") or "",
"created_at": (attrs.get("access_request_created_at") or [""])[0],
"note": (attrs.get("access_request_note") or [""])[0],
}
)
return jsonify({"requests": output})
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
@require_auth
def admin_approve_request(username: str) -> Any:
ok, resp = _require_portal_admin()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
user = _kc_admin_find_user(username)
if not user:
return jsonify({"error": "user not found"}), 404
user_id = user.get("id") or ""
if not user_id:
return jsonify({"error": "user id missing"}), 502
full = _kc_admin_get_user(user_id)
full["enabled"] = True
try:
_kc_admin_update_user(user_id, full)
except Exception:
return jsonify({"error": "failed to enable user"}), 502
group_id = _kc_admin_get_group_id("dev")
if group_id:
try:
_kc_admin_add_user_to_group(user_id, group_id)
except Exception:
pass
try:
_kc_admin_execute_actions_email(user_id, ["UPDATE_PASSWORD"])
except Exception:
pass
return jsonify({"ok": True})
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
@require_auth
def admin_deny_request(username: str) -> Any:
ok, resp = _require_portal_admin()
if not ok:
return resp
if not _kc_admin_ready():
return jsonify({"error": "server not configured"}), 503
user = _kc_admin_find_user(username)
if not user:
return jsonify({"ok": True})
user_id = user.get("id") or ""
if not user_id:
return jsonify({"error": "user id missing"}), 502
try:
_kc_admin_delete_user(user_id)
except Exception:
return jsonify({"error": "failed to delete user"}), 502
return jsonify({"ok": True})
@app.route("/api/account/mailu/rotate", methods=["POST"])
@require_auth
def account_mailu_rotate() -> Any:

View File

@ -15,8 +15,9 @@
</div>
</section>
<section v-if="auth.ready && auth.authenticated" class="grid two">
<div class="card module">
<section v-if="auth.ready && auth.authenticated">
<div class="grid two">
<div class="card module">
<div class="module-head">
<h2>Mail</h2>
<span class="pill mono">{{ mailu.status }}</span>
@ -59,7 +60,7 @@
</div>
</div>
<div class="card module">
<div class="card module">
<div class="module-head">
<h2>Jellyfin</h2>
<span class="pill mono">{{ jellyfin.status }}</span>
@ -94,6 +95,43 @@
<div class="mono">{{ jellyfin.error }}</div>
</div>
</div>
</div>
<div v-if="admin.enabled" class="card module admin">
<div class="module-head">
<h2>Admin Approvals</h2>
<span class="pill mono">{{ admin.loading ? "loading..." : `${admin.requests.length} pending` }}</span>
</div>
<p class="muted">Approve or deny pending access requests.</p>
<div v-if="admin.error" class="error-box">
<div class="mono">{{ admin.error }}</div>
</div>
<div v-if="!admin.loading && admin.requests.length === 0" class="muted">No pending requests.</div>
<div v-else class="requests">
<div class="req-head mono">
<span>User</span>
<span>Email</span>
<span>Note</span>
<span></span>
</div>
<div v-for="req in admin.requests" :key="req.username" class="req-row">
<div class="mono">{{ req.username }}</div>
<div class="mono">{{ req.email }}</div>
<div class="note">{{ req.note }}</div>
<div class="req-actions">
<button class="primary" type="button" :disabled="admin.acting[req.username]" @click="approve(req.username)">
approve
</button>
<button class="pill mono" type="button" :disabled="admin.acting[req.username]" @click="deny(req.username)">
deny
</button>
</div>
</div>
</div>
</div>
</section>
<section v-else class="card module">
@ -128,6 +166,14 @@ const jellyfin = reactive({
error: "",
});
const admin = reactive({
enabled: false,
loading: false,
requests: [],
error: "",
acting: {},
});
const doLogin = () => login("/account");
onMounted(async () => {
@ -138,6 +184,7 @@ onMounted(async () => {
}
await refreshOverview();
await refreshAdminRequests();
});
async function refreshOverview() {
@ -157,6 +204,29 @@ async function refreshOverview() {
}
}
async function refreshAdminRequests() {
admin.error = "";
admin.loading = true;
try {
const resp = await authFetch("/api/admin/access/requests", { headers: { Accept: "application/json" } });
if (resp.status === 403) {
admin.enabled = false;
admin.requests = [];
return;
}
if (!resp.ok) throw new Error(`status ${resp.status}`);
const data = await resp.json();
admin.enabled = true;
admin.requests = Array.isArray(data.requests) ? data.requests : [];
} catch (err) {
admin.enabled = false;
admin.requests = [];
admin.error = err.message || "Failed to load access requests.";
} finally {
admin.loading = false;
}
}
async function rotateMailu() {
mailu.error = "";
mailu.newPassword = "";
@ -191,6 +261,40 @@ async function rotateJellyfin() {
}
}
async function approve(username) {
admin.error = "";
admin.acting[username] = true;
try {
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/approve`, { method: "POST" });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `status ${resp.status}`);
}
await refreshAdminRequests();
} catch (err) {
admin.error = err.message || "Approve failed";
} finally {
admin.acting[username] = false;
}
}
async function deny(username) {
admin.error = "";
admin.acting[username] = true;
try {
const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/deny`, { method: "POST" });
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `status ${resp.status}`);
}
await refreshAdminRequests();
} catch (err) {
admin.error = err.message || "Deny failed";
} finally {
admin.acting[username] = false;
}
}
async function copy(text) {
if (!text) return;
try {
@ -357,4 +461,68 @@ button.primary {
grid-template-columns: 1fr;
}
}
.admin {
margin-top: 12px;
}
.requests {
margin-top: 12px;
display: grid;
gap: 10px;
}
.req-head {
display: grid;
grid-template-columns: 160px 220px 1fr 140px;
gap: 12px;
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.req-row {
display: grid;
grid-template-columns: 160px 220px 1fr 140px;
gap: 12px;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.18);
border-radius: 14px;
padding: 10px 12px;
}
.note {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.req-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 900px) {
.req-head {
display: none;
}
.req-row {
grid-template-columns: 1fr;
gap: 8px;
align-items: start;
}
.note {
white-space: normal;
}
.req-actions {
justify-content: flex-start;
}
}
</style>

View File

@ -4,20 +4,120 @@
<div>
<p class="eyebrow">Atlas</p>
<h1>Request Access</h1>
<p class="lede">Self-serve signups are not enabled yet. Request access and an admin can approve your account.</p>
<p class="lede">
Self-serve signups are not enabled yet. Request access and an admin can approve your account.
</p>
</div>
</section>
<section class="card module">
<h2>Not live yet</h2>
<div class="module-head">
<h2>Request form</h2>
<span class="pill mono" :class="submitted ? 'pill-ok' : 'pill-warn'">
{{ submitted ? "submitted" : "pending" }}
</span>
</div>
<p class="muted">
This flow is being wired into Keycloak approvals. For now, email <span class="mono">brad@bstein.dev</span> with the
username you want and a short note about what you need access to.
This creates a pending request in Atlas. If approved, you'll receive an email with next steps.
</p>
<form class="form" @submit.prevent="submit" v-if="!submitted">
<label class="field">
<span class="label mono">Desired Username</span>
<input
v-model="form.username"
class="input mono"
type="text"
autocomplete="username"
placeholder="e.g. alice"
:disabled="submitting"
required
/>
</label>
<label class="field">
<span class="label mono">Email</span>
<input
v-model="form.email"
class="input mono"
type="email"
autocomplete="email"
placeholder="you@example.com"
:disabled="submitting"
required
/>
</label>
<label class="field">
<span class="label mono">Note (optional)</span>
<textarea
v-model="form.note"
class="textarea"
rows="4"
placeholder="What do you want access to?"
:disabled="submitting"
/>
</label>
<div class="actions">
<button class="primary" type="submit" :disabled="submitting || !form.username.trim() || !form.email.trim()">
{{ submitting ? "Submitting..." : "Submit request" }}
</button>
<span class="hint mono">Requests are rate-limited.</span>
</div>
</form>
<div v-else class="success-box">
<div class="mono">Request submitted.</div>
<div class="muted">If approved, you'll get an email to finish setup.</div>
</div>
<div v-if="error" class="error-box">
<div class="mono">{{ error }}</div>
</div>
</section>
</div>
</template>
<script setup>
import { reactive, ref } from "vue";
const form = reactive({
username: "",
email: "",
note: "",
});
const submitting = ref(false);
const submitted = ref(false);
const error = ref("");
async function submit() {
if (submitting.value) return;
error.value = "";
submitting.value = true;
try {
const resp = await fetch("/api/access/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: form.username.trim(),
email: form.email.trim(),
note: form.note.trim(),
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
submitted.value = true;
} catch (err) {
error.value = err.message || "Failed to submit request";
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.page {
max-width: 960px;
@ -56,6 +156,13 @@ h1 {
padding: 18px;
}
.module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.muted {
color: var(--text-muted);
margin: 10px 0 0;
@ -64,5 +171,81 @@ h1 {
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
</style>
.form {
margin-top: 14px;
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.label {
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.input,
.textarea {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.22);
color: var(--text);
padding: 10px 12px;
outline: none;
}
.textarea {
resize: vertical;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.hint {
color: var(--text-muted);
font-size: 12px;
}
.error-box {
margin-top: 14px;
border: 1px solid rgba(255, 120, 120, 0.35);
background: rgba(255, 64, 64, 0.12);
border-radius: 14px;
padding: 12px;
}
.success-box {
margin-top: 14px;
border: 1px solid rgba(120, 255, 160, 0.25);
background: rgba(48, 255, 160, 0.1);
border-radius: 14px;
padding: 12px;
}
.pill {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
}
.pill-ok {
border-color: rgba(120, 255, 160, 0.3);
}
.pill-warn {
border-color: rgba(255, 220, 120, 0.25);
}
</style>