Vollständiges Zugangs-/Rollen-/Rechtesystem gebaut (Anforderungsdokument): D1-Datenbank, AES-verschlüsselte Codes nur für Owner einsehbar, granulare Permissions, Session-Auth mit Lockout, Zugänge-&-Teammitglieder-Verwaltungsseite, Bewerbungen mit Status/Notizen/Entwürfen

This commit is contained in:
2026-07-31 00:37:04 +02:00
parent 96087f460e
commit 6a1c0e355c
18 changed files with 2283 additions and 539 deletions
+94 -249
View File
@@ -1,285 +1,130 @@
/* =====================================================================
DOGFATHER UNIVERSE — Bewerbungs-Postfach Worker
Nimmt Bewerbungen von bewerben.html entgegen, speichert sie in KV,
und gibt sie nur nach korrektem Code (serverseitig geprüft) an
postfach.html zurück.
DOGFATHER UNIVERSE — Interner Bereich Worker
Zugriffs-Modell (wichtig für's Verständnis):
- Owner-Code (POSTFACH_CODE, Secret) = Dogi selbst. Sieht alle
Bewerbungen UND kann Helfer-Codes mit Rollen (z.B. "Rechte Hand",
"Linke Hand") anlegen/entziehen.
- Helfer-Codes (von Dogi vergeben) = sehen NUR die Bewerbungen.
Sie können KEINE neuen Codes anlegen, KEINE Rollen verwalten,
und haben KEINERLEI Möglichkeit, irgendetwas an der Website
selbst zu verändern oder zu löschen — dafür gibt es in diesem
Worker schlicht keine Funktion. Er kann ausschließlich
Bewerbungen entgegennehmen und (mit gültigem Code) anzeigen.
- Alle Codes werden nur als SHA-256-Hash in KV gespeichert, nie im
Klartext. Ein neu erzeugter Code wird genau einmal zurückgegeben
(beim Erstellen) — danach ist er nicht mehr abrufbar.
Setzt "Geschuetzter_Zugang_Rollen_Benutzerverwaltung.pdf" um:
Session-basierte Anmeldung, verschlüsselte (reversible) Code-Speicherung
NUR für den Owner einsehbar, vollständig konfigurierbare Rollen mit
granularen Rechten, individuelle Personen-Ausnahmen, Aktivitätsprotokoll,
Login-Lockout nach Fehlversuchen.
Endpoints:
POST /submit { type, data } -> Bewerbung speichern
POST /postfach { code } -> Bewerbungen ansehen
POST /admin/roles/list { ownerCode } -> Helfer-Codes auflisten (nur Owner)
POST /admin/roles/create { ownerCode, role, label } -> neuen Helfer-Code erzeugen (nur Owner)
POST /admin/roles/revoke { ownerCode, keyId } -> Helfer-Code entziehen (nur Owner)
GET /live-status -> öffentlich: {live, updatedAt}
POST /live-status/set { ownerCode, live } -> Live-Status umschalten (nur Owner)
GET /health -> einfacher Check ob der Worker läuft
Architektur:
src/lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs
src/lib/auth.js Login-Prüfung, Sessions, Rate-Limiting
src/lib/permissions.js Vollständiger Permission-Katalog
src/lib/audit.js Aktivitätsprotokoll schreiben
src/lib/http.js CORS/JSON-Helfer
src/routes/*.js Endpoint-Handler pro Bereich
Secrets/Bindings (siehe wrangler.toml + README.md):
KV Binding: BEWERBUNGEN (Cloudflare KV Namespace, wird auch für Rollen genutzt)
Secret: POSTFACH_CODE (mit `wrangler secret put POSTFACH_CODE` setzen)
Bindings (siehe wrangler.toml):
D1: DB (Datenbank für Users/Rollen/Sessions/Audit/Bewerbungen)
KV: BEWERBUNGEN (nur noch für den Live-Status-Schalter genutzt)
Secret: POSTFACH_CODE (Owner-Code, mit `wrangler secret put` gesetzt)
Secret: ENCRYPTION_KEY (AES-256-Schlüssel, base64, für Code-Verschlüsselung)
===================================================================== */
import { json, corsHeaders } from "./lib/http.js";
import { requireSession } from "./lib/auth.js";
import { login, logout } from "./routes/auth.js";
import {
listRoles, createRole, updateRole, deleteRole,
} from "./routes/roles.js";
import {
listUsers, prepareUser, finalizeUser, setUserCode, revealUserCode,
lockUser, unlockUser, deleteUser, setUserRole, setUserPermissions,
endUserSessions, updateUserMeta,
} from "./routes/users.js";
import {
submitApplication, listApplications, changeApplicationStatus,
takeOverApplication, assignApplication, archiveApplication,
restoreApplication, deleteApplication, markAnswered,
listNotes, addNote, deleteNote, listDrafts, saveDraft,
} from "./routes/applications.js";
import { listAuditLog } from "./routes/audit.js";
const ALLOWED_TYPES = ["creator", "scout", "kooperation"];
const ROLE_KEY_PREFIX = "role:";
const LIVE_STATUS_KEY = "meta:live";
function corsHeaders() {
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen
// (z.B. "https://dogfather-universe.example"), damit nur die eigene Seite
// an die API senden kann.
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders() },
});
}
async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function generateFriendlyCode() {
// z.B. "K3F9-QX2L-7HRT" — leicht abzutippen/vorzulesen, ausreichend Entropie
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne verwechselbare Zeichen (I,O,0,1)
const bytes = crypto.getRandomValues(new Uint8Array(12));
let raw = "";
for (const b of bytes) raw += alphabet[b % alphabet.length];
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`;
}
async function isOwnerCode(code, env) {
return !!env.POSTFACH_CODE && code === env.POSTFACH_CODE;
}
async function findHelperRole(code, env) {
const hash = await sha256Hex(code);
const raw = await env.BEWERBUNGEN.get(ROLE_KEY_PREFIX + hash);
return raw ? { keyId: ROLE_KEY_PREFIX + hash, ...JSON.parse(raw) } : null;
}
async function handleSubmit(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
const { type, data } = body || {};
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
return json({ ok: false, error: "Ungültige Bewerbungsdaten." }, 400);
}
const values = Object.values(data).map((v) => String(v || "").trim());
if (values.every((v) => v === "")) {
return json({ ok: false, error: "Leere Bewerbung." }, 400);
}
const id = crypto.randomUUID();
const timestamp = new Date().toISOString();
const key = `sub:${timestamp}:${id}`;
await env.BEWERBUNGEN.put(key, JSON.stringify({ id, type, data, timestamp }));
return json({ ok: true });
}
async function handlePostfach(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
const code = (body && body.code) || "";
let owner = await isOwnerCode(code, env);
let helperRole = null;
if (!owner) {
helperRole = await findHelperRole(code, env);
}
if (!owner && !helperRole) {
// bewusst generische Fehlermeldung, kein Hinweis ob Code existiert
return json({ ok: false, error: "Falscher Code." }, 401);
}
const list = await env.BEWERBUNGEN.list({ prefix: "sub:", limit: 500 });
const items = await Promise.all(
list.keys.map(async (k) => {
const raw = await env.BEWERBUNGEN.get(k.name);
return raw ? JSON.parse(raw) : null;
})
);
const bewerbungen = items
.filter(Boolean)
.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)); // neueste zuerst
return json({
ok: true,
isOwner: owner,
roleLabel: owner ? "Dogi (Owner)" : helperRole.label,
bewerbungen,
});
}
async function requireOwner(body, env) {
const ownerCode = (body && body.ownerCode) || "";
return isOwnerCode(ownerCode, env);
}
async function handleRolesList(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401);
}
const list = await env.BEWERBUNGEN.list({ prefix: ROLE_KEY_PREFIX, limit: 200 });
const roles = await Promise.all(
list.keys.map(async (k) => {
const raw = await env.BEWERBUNGEN.get(k.name);
return raw ? { keyId: k.name, ...JSON.parse(raw) } : null;
})
);
return json({ ok: true, roles: roles.filter(Boolean) });
}
async function handleRolesCreate(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401);
}
const role = String((body && body.role) || "").slice(0, 60).trim() || "Helfer";
const label = String((body && body.label) || "").slice(0, 80).trim() || role;
const newCode = generateFriendlyCode();
const hash = await sha256Hex(newCode);
const record = { role, label, createdAt: new Date().toISOString() };
await env.BEWERBUNGEN.put(ROLE_KEY_PREFIX + hash, JSON.stringify(record));
// Der Klartext-Code wird NUR jetzt einmalig zurückgegeben — danach nie wieder.
return json({ ok: true, code: newCode, ...record });
}
async function handleRolesRevoke(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401);
}
const keyId = String((body && body.keyId) || "");
if (!keyId.startsWith(ROLE_KEY_PREFIX)) {
return json({ ok: false, error: "Ungültige keyId." }, 400);
}
await env.BEWERBUNGEN.delete(keyId);
return json({ ok: true });
}
async function handleLiveStatusGet(env) {
const raw = await env.BEWERBUNGEN.get(LIVE_STATUS_KEY);
const data = raw ? JSON.parse(raw) : { live: false, updatedAt: null };
return json({ ok: true, ...data });
}
async function handleLiveStatusSet(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 401);
}
const live = !!(body && body.live);
const record = { live, updatedAt: new Date().toISOString() };
async function handleLiveStatusSet(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403);
const body = await request.json().catch(() => ({}));
const record = { live: !!body.live, updatedAt: new Date().toISOString() };
await env.BEWERBUNGEN.put(LIVE_STATUS_KEY, JSON.stringify(record));
return json({ ok: true, ...record });
}
// Routen, die eine gültige Session brauchen. `session` wird injiziert.
const AUTHED_ROUTES = {
"POST /auth/logout": (req, env) => logout(req, env),
"POST /live-status/set": (req, env, session) => handleLiveStatusSet(req, env, session),
"POST /admin/applications/list": (req, env, session) => listApplications(req, env, session),
"POST /admin/applications/status": (req, env, session) => changeApplicationStatus(req, env, session),
"POST /admin/applications/take-over": (req, env, session) => takeOverApplication(req, env, session),
"POST /admin/applications/assign": (req, env, session) => assignApplication(req, env, session),
"POST /admin/applications/archive": (req, env, session) => archiveApplication(req, env, session),
"POST /admin/applications/restore": (req, env, session) => restoreApplication(req, env, session),
"POST /admin/applications/delete": (req, env, session) => deleteApplication(req, env, session),
"POST /admin/applications/mark-answered": (req, env, session) => markAnswered(req, env, session),
"POST /admin/notes/list": (req, env, session) => listNotes(req, env, session),
"POST /admin/notes/add": (req, env, session) => addNote(req, env, session),
"POST /admin/notes/delete": (req, env, session) => deleteNote(req, env, session),
"POST /admin/drafts/list": (req, env, session) => listDrafts(req, env, session),
"POST /admin/drafts/save": (req, env, session) => saveDraft(req, env, session),
"POST /admin/roles/list": (req, env, session) => listRoles(env, session),
"POST /admin/roles/create": (req, env, session) => createRole(req, env, session),
"POST /admin/roles/update": (req, env, session) => updateRole(req, env, session),
"POST /admin/roles/delete": (req, env, session) => deleteRole(req, env, session),
"POST /admin/users/list": (req, env, session) => listUsers(env, session),
"POST /admin/users/prepare": (req, env, session) => prepareUser(req, env, session),
"POST /admin/users/finalize": (req, env, session) => finalizeUser(req, env, session),
"POST /admin/users/set-code": (req, env, session) => setUserCode(req, env, session),
"POST /admin/users/reveal-code": (req, env, session) => revealUserCode(req, env, session),
"POST /admin/users/lock": (req, env, session) => lockUser(req, env, session),
"POST /admin/users/unlock": (req, env, session) => unlockUser(req, env, session),
"POST /admin/users/delete": (req, env, session) => deleteUser(req, env, session),
"POST /admin/users/set-role": (req, env, session) => setUserRole(req, env, session),
"POST /admin/users/set-permissions": (req, env, session) => setUserPermissions(req, env, session),
"POST /admin/users/end-sessions": (req, env, session) => endUserSessions(req, env, session),
"POST /admin/users/update-meta": (req, env, session) => updateUserMeta(req, env, session),
"POST /admin/audit/list": (req, env, session) => listAuditLog(req, env, session),
};
export default {
async fetch(request, env) {
const url = new URL(request.url);
const routeKey = `${request.method} ${url.pathname}`;
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders() });
}
if (url.pathname === "/health") {
return json({ ok: true, service: "dogfather-universe-postfach" });
if (url.pathname === "/health" && request.method === "GET") {
return json({ ok: true, service: "dogfather-universe-interner-bereich" });
}
if (url.pathname === "/submit" && request.method === "POST") {
return handleSubmit(request, env);
return submitApplication(request, env);
}
if (url.pathname === "/postfach" && request.method === "POST") {
return handlePostfach(request, env);
}
if (url.pathname === "/admin/roles/list" && request.method === "POST") {
return handleRolesList(request, env);
}
if (url.pathname === "/admin/roles/create" && request.method === "POST") {
return handleRolesCreate(request, env);
}
if (url.pathname === "/admin/roles/revoke" && request.method === "POST") {
return handleRolesRevoke(request, env);
}
if (url.pathname === "/live-status" && request.method === "GET") {
return handleLiveStatusGet(env);
}
if (url.pathname === "/auth/login" && request.method === "POST") {
return login(request, env);
}
if (url.pathname === "/live-status/set" && request.method === "POST") {
return handleLiveStatusSet(request, env);
const handler = AUTHED_ROUTES[routeKey];
if (handler) {
const session = await requireSession(request, env);
if (!session) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401);
return handler(request, env, session);
}
return json({ ok: false, error: "Not found." }, 404);