Filipe: "egal welche rolle oder person hinzugefuegt wird soll immer nur
das sehen wass ich erlaube. mehr nicht. soll nichts so sein dass wenn
mann eine rolle oder jemanden hinzufuegt dass er dan alles sieht."
DER BEFUND -- GEZAEHLT, NICHT GESCHAETZT
NEUN von zwanzig Seiten standen auf `null`, und `null` hiess "jede
angemeldete Rolle": Start, Uebersicht, Aufgaben, Chat, Kalender,
Dateien, Calls, Start-Check, Wissen. Eine neue Rolle erbte sie alle,
ohne dass jemand etwas erlaubt haette.
UND EIN ZWEITES LOCH, das ich vorher nicht gemessen hatte: Die Schranke
las `const erlaubt = GESCHUETZT[pfad]` und prueffte `if (erlaubt && ...)`.
Eine Seite, die GAR NICHT in der Tabelle stand, ergab `undefined`, fiel
durch dieselbe Bedingung und war damit ebenfalls fuer jede angemeldete
Rolle offen. Am 11.09.2026 betraf das keine einzige Seite (nachgezaehlt:
22 Dateien = 20 Eintraege + 2 Zugangswaende) -- aber jede NEUE waere so
entstanden.
Die Rolle wird im Haus an 74 Stellen in 20 Modulen abgefragt, und die
Endzweige widersprechen einander: Aufgaben enden mit `default: 0=1`
(sieht nichts), Bereiche und Dateien fallen in "eigene plus betreute".
Drei Module, drei Antworten auf dieselbe Frage.
WARUM NICHT "DIE LISTE DURCHGEHEN"
Das waere die naheliegende Antwort und die falsche. Im Code stand seit
dem 10.09. genau dieser Satz -- "Wer eine Rolle hinzufuegt, muss diese
Liste durchgehen" -- und einen Tag spaeter standen immer noch neun
Seiten auf `null`. Ein Satz, der sich auf ein Gedaechtnis verlaesst, ist
keine Sicherung. Dazu: eine vergessene Erlaubnis MELDET SICH NIE. Die
Seite laedt ja. In der anderen Richtung ist es schlimmer -- eine Seite,
die zu viel zeigt, sieht aus wie eine Seite, die funktioniert.
WAS JETZT GILT
server/rechte.js traegt die Tafel, `darfSeite()` liest sie, und sie
kennt zwei Antworten: Seite in der Tafel UND Rolle darin -> ja. Alles
andere -> nein. Kein `null`, kein `undefined`, kein Zweig, der etwas
durchlaesst.
DIE TABELLE IST UMGEZOGEN, NICHT NEU GESCHRIEBEN. In ihren Kommentaren
steckt das Gedaechtnis des Hauses -- jede Zeile traegt ein Datum und
einen Satz von Filipe ("nimm die kategorie zahlen bei jedem weg", "die
manager sollen diese kategorien garnicht sehen", "ich will dass die
rechte Hand auch alle sieht"). Die wegzurefactoren waere der teuerste
Fehler dieses Umbaus gewesen.
VERHALTENSGLEICH FUER DIE SIEBEN VORHANDENEN ROLLEN. Die neun
`null`-Seiten tragen jetzt alle sieben Namen. Eine Umkehrung, die
nebenbei Rechte entzieht, waeren zwei Aenderungen in einer -- und man
wuesste hinterher nicht, welche etwas kaputtgemacht hat. Enger stellen
ist ein eigener Schritt.
pruef-rechtetafel.mjs (NEU, 19 Pruefungen, Port 4397) macht daraus eine
Garantie statt einer Absicht:
* Jede Rolle braucht einen Eintrag -- sonst rot. Man kann eine Rolle
nicht mehr hinzufuegen, ohne zu entscheiden, was sie sieht.
* Jede Seite braucht einen Eintrag -- verglichen gegen das
DATEISYSTEM, nicht gegen eine zweite Liste. Eine neue Seite ist
damit erst einmal fuer NIEMANDEN offen statt fuer alle.
* Eine Phantomrolle kommt auf keine der zwanzig Seiten.
* Gegenprobe: jede ECHTE Rolle kommt irgendwo hin (spicy 18, admin 20,
manager 17, scout 16, creator 15, hand 13, modi 11) -- sonst waere
die Zeile darueber auch gruen, wenn schlicht alles zu waere.
* Am Server: eine Seite ohne Eintrag wird abgewiesen statt
ausgeliefert, und alle 20 Seiten der DogFather-Rolle liefern wirklich.
* Zerstoerende Probe ganz am Ende (die Lektion vom 09.09.: in der
Mitte verbiegt sie alles danach).
ZWEI EIGENE FEHLER UNTERWEGS. `node --check` meldete "Syntax in
Ordnung", das Modul lud aber nicht -- beim Umzug war eine Konstante
verlorengegangen. Syntax ist kein Beweis. Und ein Kommentar behauptete
danach noch das Alte ("eine neue Seite ist mindestens nur fuer
Angemeldete"); der Rueckfall ist jetzt "fuer niemanden", und das steht
da auch so.
Gruen: pruef-rechtetafel 19 (neu), pruef-rollen 287, pruef-sicht 84,
pruef-crew-adresse 129, pruef-modi-verborgen 80, pruef-haus-trennung 62.
Stempel 202609111617.
Co-Authored-By: Claude Opus 5 <[email protected]>
281 lines
13 KiB
JavaScript
281 lines
13 KiB
JavaScript
/* =====================================================================
|
|
DIE RECHTETAFEL — die Pruefung, die aus einer Absicht eine Garantie
|
|
macht (11.09.2026)
|
|
|
|
Filipe:
|
|
|
|
"egal welche rolle oder person hinzugefuegt wird soll immer nur das
|
|
sehen wass ich erlaube. mehr nicht. soll nichts so sein dass wenn
|
|
mann eine rolle oder jemanden hinzufuegt dass er dan alles sieht."
|
|
|
|
---------------------------------------------------------------------
|
|
WARUM DIESE DATEI UND NICHT EIN KOMMENTAR
|
|
|
|
In `rechte.js` stand bis heute ein Satz, der genau das Richtige
|
|
verlangte: *"Wer eine Rolle hinzufuegt, muss diese Liste durchgehen."*
|
|
Er stand seit dem 10.09. da, und am Tag darauf standen immer noch neun
|
|
Seiten auf `null`. Ein Satz, der sich auf ein Gedaechtnis verlaesst,
|
|
ist keine Sicherung.
|
|
|
|
Diese Datei verlaesst sich auf nichts. Sie wird rot, wenn
|
|
|
|
* eine Rolle keinen Eintrag hat,
|
|
* eine Seite keinen Eintrag hat,
|
|
* eine unbekannte Rolle irgendwo hineinkaeme,
|
|
* oder eine unbekannte Seite sich fuer irgendwen oeffnet.
|
|
|
|
Man KANN es damit nicht mehr vergessen -- der Prueflauf laesst es
|
|
nicht zu.
|
|
|
|
---------------------------------------------------------------------
|
|
DREI EBENEN, UND JEDE BEANTWORTET ETWAS ANDERES
|
|
|
|
1. DIE TAFEL SELBST, ohne Server. Vollstaendig, schnell, und sie
|
|
vergleicht gegen das DATEISYSTEM statt gegen eine zweite Liste --
|
|
eine zweite Liste waere die Stelle, an der beide auseinanderlaufen.
|
|
|
|
2. DER SERVER. Dass die Tafel richtig ist, heisst nicht, dass sie
|
|
auch gefragt wird. Genau dieser Unterschied hat am 10.09. drei
|
|
Stunden gekostet: Kachel und Schnittstelle stimmten, die Seite
|
|
warf trotzdem zurueck. Hier wird deshalb wirklich angeklopft.
|
|
|
|
3. DIE GEGENPROBE. Eine Pruefung, die immer bestaetigt, bestaetigt
|
|
nichts. Am Ende bekommt sie absichtlich eine kaputte Tafel und
|
|
MUSS sie als kaputt melden.
|
|
===================================================================== */
|
|
import { mkdtempSync, rmSync, readdirSync, existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { portMussFreiSein } from "./helfer-port.mjs";
|
|
|
|
const PORT = await portMussFreiSein(4397, "pruef-rechtetafel");
|
|
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-rechte-"));
|
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
|
process.env.PORT = String(PORT);
|
|
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
|
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
|
|
|
const express = (await import("express")).default;
|
|
const ec = express.response.cookie;
|
|
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
|
import { notbremse } from "./helfer-notbremse.mjs";
|
|
await import("./index.js");
|
|
notbremse(180_000, "pruef-rechtetafel");
|
|
await new Promise((r) => setTimeout(r, 700));
|
|
const BASIS = `http://127.0.0.1:${PORT}`;
|
|
|
|
/* EIGENE ZUHOERER. index.js haengt fuer den Betrieb Auffangnetze an
|
|
`uncaughtException` -- eine Pruefung, die dieselbe Datei in denselben
|
|
Prozess laedt, erbt sie und wuerde nach einem Absturz weiterhaengen,
|
|
statt rot zu werden. */
|
|
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
|
|
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
|
|
|
|
let fehler = 0, geprueft = 0;
|
|
const melde = (t) => console.log(t);
|
|
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
|
|
const rechte = await import("./rechte.js");
|
|
const w = await import("./workspace.js");
|
|
|
|
/* =======================================================================
|
|
1. DIE TAFEL IST VOLLSTAENDIG
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 1. Jede Rolle und jede Seite hat einen Eintrag ===");
|
|
|
|
/* DIE ROLLENLISTE DARF NICHT ZWEIMAL EXISTIEREN.
|
|
|
|
rechte.js kennt ALLE_ROLLEN, workspace.js kennt ROLLEN. Zwei Listen
|
|
derselben Sache laufen auseinander -- die Frage ist nur wann. Hier
|
|
werden sie gegeneinandergehalten, damit das Wann "beim naechsten
|
|
Prueflauf" ist und nicht "wenn jemand hineinfaellt". */
|
|
{
|
|
const ausWorkspace = [...(w.ROLLEN_NAME ? Object.keys(w.ROLLEN_NAME) : [])].sort();
|
|
const ausTafel = [...rechte.ALLE_ROLLEN].sort();
|
|
ok(ausWorkspace.length > 0, `workspace.js kennt ${ausWorkspace.length} Rollen`);
|
|
ok(JSON.stringify(ausWorkspace) === JSON.stringify(ausTafel),
|
|
`beide Listen sind identisch (${ausTafel.join(", ")})`);
|
|
}
|
|
|
|
/* JEDE DATEI AUF DER PLATTE BRAUCHT EINEN EINTRAG.
|
|
|
|
Verglichen wird gegen das Dateisystem, nicht gegen eine Liste im Code.
|
|
Eine neue Seite entsteht als DATEI -- wer sie anlegt und den Eintrag
|
|
vergisst, faellt genau hier auf. */
|
|
const SEITEN_ORDNER = new URL("../workspace/", import.meta.url).pathname
|
|
.replace(/^\/([A-Za-z]:)/, "$1");
|
|
{
|
|
const dateien = readdirSync(SEITEN_ORDNER)
|
|
.filter((f) => f.endsWith(".html"))
|
|
.map((f) => "/workspace/" + f).sort();
|
|
const inTafel = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]);
|
|
const fehlend = dateien.filter((p) => !inTafel.has(p));
|
|
const ueberzaehlig = rechte.alleSeiten().filter((p) => !dateien.includes(p));
|
|
|
|
ok(dateien.length >= 20, `${dateien.length} Seiten liegen im Ordner`);
|
|
ok(fehlend.length === 0,
|
|
`jede davon steht in der Tafel${fehlend.length ? " -- FEHLT: " + fehlend.join(", ") : ""}`);
|
|
ok(ueberzaehlig.length === 0,
|
|
`und jeder Eintrag hat eine Datei${ueberzaehlig.length ? " -- OHNE DATEI: " + ueberzaehlig.join(", ") : ""}`);
|
|
}
|
|
|
|
/* KEIN EINTRAG DARF UNSCHARF SEIN. `null` war der alte Fehler; eine
|
|
Rolle, die es gar nicht gibt, waere der naechste. */
|
|
{
|
|
const bekannt = new Set(rechte.ALLE_ROLLEN);
|
|
const kaputt = [];
|
|
for (const [pfad, rollen] of Object.entries(rechte.SEITEN)) {
|
|
if (!Array.isArray(rollen)) { kaputt.push(`${pfad}: kein Feld`); continue; }
|
|
for (const r of rollen) if (!bekannt.has(r)) kaputt.push(`${pfad}: unbekannte Rolle "${r}"`);
|
|
}
|
|
ok(kaputt.length === 0, `alle Eintraege sind Listen bekannter Rollen${kaputt.length ? " -- " + kaputt.join(" | ") : ""}`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
2. WAS NICHT IN DER TAFEL STEHT, IST VERBOTEN
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 2. Die Regel selbst ===");
|
|
|
|
const PHANTOM = "xx-gibt-es-nicht";
|
|
{
|
|
const alle = rechte.alleSeiten();
|
|
const durchgelassen = alle.filter((p) => rechte.darfSeite({ rolle: PHANTOM }, p));
|
|
ok(alle.length === 20, `${alle.length} Seiten in der Tafel`);
|
|
ok(durchgelassen.length === 0,
|
|
`eine unbekannte Rolle kommt auf KEINE davon${durchgelassen.length ? " -- durchgelassen: " + durchgelassen.join(", ") : ""}`);
|
|
|
|
/* DIE GEGENPROBE ZUR OBIGEN ZEILE. Ohne sie waere sie auch dann
|
|
gruen, wenn darfSeite() schlicht immer nein sagt -- und dann waere
|
|
das ganze Haus zu, ohne dass diese Datei es meldet. */
|
|
const echteWege = rechte.ALLE_ROLLEN
|
|
.map((r) => [r, alle.filter((p) => rechte.darfSeite({ rolle: r }, p)).length]);
|
|
const ohneSeite = echteWege.filter(([, n]) => n === 0).map(([r]) => r);
|
|
ok(ohneSeite.length === 0,
|
|
`und jede ECHTE Rolle kommt irgendwo hin (${echteWege.map(([r, n]) => r + ":" + n).join(" ")})`);
|
|
}
|
|
|
|
{
|
|
/* EINE SEITE, DIE ES NICHT GIBT. Das war das zweite Loch: `undefined`
|
|
fiel durch dieselbe Bedingung wie `null`. */
|
|
const erfunden = rechte.ALLE_ROLLEN
|
|
.filter((r) => rechte.darfSeite({ rolle: r }, "/workspace/gibt-es-nicht.html"));
|
|
ok(erfunden.length === 0,
|
|
`eine Seite ohne Eintrag oeffnet sich fuer niemanden -- auch nicht fuer DogFather`);
|
|
ok(rechte.darfSeite(null, "/workspace/start.html") === false,
|
|
"und ohne Anmeldung erst recht nicht");
|
|
}
|
|
|
|
/* =======================================================================
|
|
3. DIE TAFEL WIRD AUCH WIRKLICH GEFRAGT
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 3. Der Server haelt sich daran ===");
|
|
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
|
|
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const jetzt = new Date().toISOString();
|
|
const schluessel = randomBytes(32).toString("hex");
|
|
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
|
|
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
|
|
.run("code_kennung_schluessel", schluessel, jetzt);
|
|
function anlegen(name, rolle, code) {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
|
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
|
|
+ " VALUES (?,?,?,?,?,?,1,?)")
|
|
.run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
|
|
}
|
|
anlegen("Filipe", "admin", "CODE-DOGI-0001");
|
|
anlegen("Mara", "manager", "CODE-MANA-0001");
|
|
d.close();
|
|
|
|
async function anmelden(rolle, code) {
|
|
const a = await fetch(`${BASIS}/workspace/api/anmelden`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rolle, code }),
|
|
});
|
|
return a.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
|
|
}
|
|
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
|
|
const kMana = await anmelden("manager", "CODE-MANA-0001");
|
|
|
|
async function hole(pfad, keks) {
|
|
const a = await fetch(BASIS + pfad, { headers: { cookie: keks }, redirect: "manual" });
|
|
return { status: a.status, ziel: a.headers.get("location") || "" };
|
|
}
|
|
|
|
ok(kDogi.length > 0 && kMana.length > 0, "beide Anmeldungen haben eine Sitzung");
|
|
|
|
{
|
|
const a = await hole("/workspace/automation.html", kDogi);
|
|
ok(a.status === 200, `DogFather kommt auf automation.html (${a.status})`);
|
|
const b = await hole("/workspace/automation.html", kMana);
|
|
ok(b.status === 302 && b.ziel.endsWith("start.html"),
|
|
`eine Managerin nicht -- sie landet auf der Startseite (${b.status} ${b.ziel})`);
|
|
}
|
|
|
|
{
|
|
/* DER EIGENTLICHE NEUE FALL: eine Seite, die es in der Tafel nicht
|
|
gibt. Vorher fiel sie durch die Schranke und traf danach auf
|
|
express.static -- also 404, was von aussen aussieht wie "gibt es
|
|
nicht", obwohl sie in Wahrheit einfach nicht geschuetzt war. Jetzt
|
|
greift die Schranke davor. */
|
|
const a = await hole("/workspace/gibt-es-nicht.html", kDogi);
|
|
ok(a.status === 302 && a.ziel.endsWith("start.html"),
|
|
`eine Seite ohne Eintrag wird abgewiesen, nicht ausgeliefert (${a.status} ${a.ziel})`);
|
|
}
|
|
|
|
{
|
|
/* Und die Gegenprobe: Die geoeffneten Seiten oeffnen sich wirklich.
|
|
Sonst waere "alles abgewiesen" ebenfalls gruen. */
|
|
let auf = 0;
|
|
for (const p of rechte.seitenFuer("admin")) {
|
|
const a = await hole(p, kDogi);
|
|
if (a.status === 200) auf++;
|
|
}
|
|
ok(auf === rechte.seitenFuer("admin").length,
|
|
`alle ${auf} Seiten der DogFather-Rolle liefern wirklich aus`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
4. DIE GEGENPROBE: KANN DIESE PRUEFUNG UEBERHAUPT ROT WERDEN?
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 4. Gegenprobe -- eine kaputte Tafel MUSS auffallen ===");
|
|
|
|
/* Dieselbe Vergleichslogik wie in Abschnitt 1, aber mit absichtlich
|
|
beschaedigten Daten. Steht ganz am Ende, damit ihr Schaden nichts
|
|
verfaelscht, was davor gemessen wurde -- die Lektion vom 09.09., als
|
|
eine zerstoerende Probe in der Mitte stand und alles danach verbog. */
|
|
{
|
|
const dateien = readdirSync(SEITEN_ORDNER).filter((f) => f.endsWith(".html"))
|
|
.map((f) => "/workspace/" + f);
|
|
|
|
const luecke = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]);
|
|
luecke.delete("/workspace/start.html");
|
|
ok(dateien.some((p) => !luecke.has(p)),
|
|
"eine entfernte Seite wird als Luecke erkannt");
|
|
|
|
const falscheRolle = { "/workspace/x.html": ["gibt-es-nicht"] };
|
|
const bekannt = new Set(rechte.ALLE_ROLLEN);
|
|
const gefunden = Object.values(falscheRolle)[0].some((r) => !bekannt.has(r));
|
|
ok(gefunden, "eine unbekannte Rolle in einem Eintrag wird erkannt");
|
|
|
|
const alsNull = { "/workspace/x.html": null };
|
|
ok(!Array.isArray(Object.values(alsNull)[0]),
|
|
"und ein `null`-Eintrag wird als kein Feld erkannt");
|
|
}
|
|
|
|
/* Der Server haelt die Datenbankdatei offen -- unter Windows laesst
|
|
sie sich dann nicht loeschen. Das ist ein Aufraeumrest im
|
|
Temp-Ordner, kein Befund, und darf die Pruefung nicht rot machen. */
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
|
console.log("");
|
|
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
|
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
|
|
process.exit(fehler === 0 ? 0 : 1);
|