diff --git a/.gitignore b/.gitignore
index a43ce0f..4e89127 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,9 @@ src/api/schema.d.ts
/coverage/
.vitest/
+# Generated permission reports (may contain person names and instance-specific data)
+/reports/
+
# MkDocs output for docs/handbuch/ (built by .github/workflows/docs.yml, never committed)
site/
# …and the venv docs/README.md tells you to create here to build the section locally
diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md
index 522e13a..9bb49d2 100644
--- a/docs/handbuch/permissions.md
+++ b/docs/handbuch/permissions.md
@@ -2,15 +2,74 @@
title: Permissions
sources:
- src/permissions/**
+ - src/reports/permissions/**
+ - src/commands/report.ts
- src/resolve/resolver.ts
- src/resolve/refs.ts
- src/config/context.ts
-sources_hash: 3ce80118edd98c48
-reviewed: 2026-08-17
+sources_hash: eaea3f87de51a46f
+reviewed: 2026-08-25
---
# Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`)
+## Vollständiger Rechte-Report
+
+Der Permission-Report dokumentiert den vollständigen Live-Rechtebestand der
+ChurchTools-Instanz. Er ist ein reines Reporting-Werkzeug und steht deshalb
+unabhängig von deklarierbaren Grants und `ct adopt grants`.
+
+```bash
+ct report permissions --by-subject reports/permissions-by-subject.md
+ct report permissions --by-object reports/permissions-by-object.md
+
+# Beide Reports aus demselben Live-Datensatz:
+ct report permissions \
+ --by-subject reports/permissions-by-subject.md \
+ --by-object reports/permissions-by-object.md
+
+# Ohne Dateinamen entstehen permissions-by-subject.md und permissions-by-object.md:
+ct report permissions --by-subject --by-object
+
+# Ein Basispfad erzeugt zwei Dateien mit passenden Suffixen:
+ct report permissions --by-both reports/permission-report.md
+# → reports/permission-report_by-subject.md
+# → reports/permission-report_by-object.md
+```
+
+`subject` beantwortet „Was darf dieses Subjekt?“ und gruppiert Subjekte mit
+identischem vollständigem Rechte-Set unter einem stabilen Hash. Der Hash ist
+kein Subjekt-Identifier. Seine Identität besteht aus `authId`, Objektdimension,
+Objekt-ID, den zugehörigen Rechte-/Objektnamen und Grant/Revoke. Gleich benannte
+Objekte mit verschiedenen IDs bleiben dadurch verschieden; ebenso erzeugt eine
+Umbenennung bewusst einen neuen Report-Hash. Bekannte Status (`ST`) und
+Gruppentyp-Rollen (`GTRL`) erscheinen auch ohne eigene Zuweisung unter „Keine
+Berechtigungen“. So werden erwartete Status oder Rollen ohne direkte Rechte als
+Berechtigungslücke sichtbar, statt stillschweigend aus dem Report zu
+verschwinden. Eine leere Rechte-Menge erhält bewusst keinen Fingerprint.
+Personen (`PRS`) und konkrete Gruppenrollen (`GRRL`) werden dagegen nur
+aufgeführt, wenn mindestens eine direkte Permission existiert: Alle leeren
+Personen und Rollenpaarungen aufzulisten würde die relevanten Lücken zwischen
+tausenden bedeutungslosen Zeilen verstecken. Über Status oder Rollen geerbte
+effektive Personenrechte werden nicht zu einem synthetischen
+Personen-Rechte-Set verrechnet. Innerhalb einer Hash-Gruppe werden die Rechte
+nach `authId`, Rechtename, Grant/Revoke und Objekt sortiert und exakte
+Dubletten fallen weg. Der Report ist damit unabhängig von der Zeilenreihenfolge
+der API und zwischen zwei Läufen diff-stabil. `object` beantwortet „Wer darf
+auf dieses Objekt was?“ und sortiert vorhandene Subjekttypen fest nach Status,
+Gruppentyp, Gruppe und Person.
+
+Objektbezeichnungen werden verlustfrei übernommen und insbesondere nicht
+getrimmt. Das ist fachlich relevant, weil ChurchTools zwei Objekte mit
+unterschiedlichen IDs führen kann, deren Namen sich nur durch unsichtbaren
+Whitespace unterscheiden. Der Report soll diesen Live-Zustand zeigen und nicht
+stillschweigend bereinigen.
+
+Der Report liest auch direkte Personenrechte sowie Rechte auf Dimensionen, die
+`ct adopt grants --all-declarable` bewusst nicht verwalten kann, zum Beispiel
+Wiki-/Kalenderkategorien, Servicegruppen und OAuth-Objekte. Er verändert weder
+ChurchTools noch State oder Config.
+
Declare ChurchTools permission grants — group-role, group-type-role and
person-status rights — as code, and reconcile them idempotently with the same
`ct plan` / `ct apply` workflow used for structural resources (issue #13).
diff --git a/src/commands/report.ts b/src/commands/report.ts
new file mode 100644
index 0000000..17170b2
--- /dev/null
+++ b/src/commands/report.ts
@@ -0,0 +1,85 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, format, parse, resolve } from "node:path";
+import { Command } from "commander";
+import { authedSession } from "../api/session.js";
+import { prepareEnvHost } from "../env/context.js";
+import { collectLivePermissions } from "../reports/permissions/collect.js";
+import { renderByObject, renderBySubject } from "../reports/permissions/render.js";
+
+interface ReportOptions {
+ bySubject?: string | boolean;
+ byObject?: string | boolean;
+ byBoth?: string;
+ env?: string;
+}
+
+export interface PermissionReportTarget {
+ by: "subject" | "object";
+ output: string;
+}
+
+const DEFAULT_SUBJECT_OUTPUT = "permissions-by-subject.md";
+const DEFAULT_OBJECT_OUTPUT = "permissions-by-object.md";
+
+function selectedOutput(value: string | boolean | undefined, fallback: string): string | undefined {
+ if (value === undefined || value === false) return undefined;
+ return typeof value === "string" ? value : fallback;
+}
+
+function suffixedOutput(base: string, suffix: string): string {
+ const parts = parse(base);
+ return format({ dir: parts.dir, name: `${parts.name}${suffix}`, ext: parts.ext });
+}
+
+export function permissionReportTargets(opts: ReportOptions): PermissionReportTarget[] {
+ const subjectOutput = selectedOutput(opts.bySubject, DEFAULT_SUBJECT_OUTPUT);
+ const objectOutput = selectedOutput(opts.byObject, DEFAULT_OBJECT_OUTPUT);
+ const hasNamedOutputs = subjectOutput !== undefined || objectOutput !== undefined;
+
+ if (opts.byBoth && hasNamedOutputs)
+ throw new Error("--by-both cannot be combined with --by-subject or --by-object.");
+ if (subjectOutput && objectOutput && subjectOutput === objectOutput)
+ throw new Error("--by-subject and --by-object must not write to the same file.");
+
+ if (opts.byBoth)
+ return [
+ { by: "subject", output: suffixedOutput(opts.byBoth, "_by-subject") },
+ { by: "object", output: suffixedOutput(opts.byBoth, "_by-object") },
+ ];
+ const targets: PermissionReportTarget[] = [];
+ if (subjectOutput) targets.push({ by: "subject", output: subjectOutput });
+ if (objectOutput) targets.push({ by: "object", output: objectOutput });
+ if (targets.length === 0)
+ throw new Error("Select --by-subject [file], --by-object [file], or --by-both .");
+ return targets;
+}
+
+/**
+ * Reports are routinely written into a gitignored `reports/` directory that does not exist in a
+ * fresh clone. Create the directories up front so that fails immediately, rather than after the
+ * full read-only collection has already run.
+ */
+export async function ensureReportDirectories(targets: PermissionReportTarget[]): Promise {
+ for (const target of targets) await mkdir(dirname(resolve(target.output)), { recursive: true });
+}
+
+export function reportCommand(): Command {
+ const permissions = new Command("permissions")
+ .description("Report the complete live permission assignment set (read-only)")
+ .option("--by-subject [file]", `write the subject report (default: ${DEFAULT_SUBJECT_OUTPUT})`)
+ .option("--by-object [file]", `write the object report (default: ${DEFAULT_OBJECT_OUTPUT})`)
+ .option("--by-both ", "write both reports with _by-subject/_by-object suffixes")
+ .option("-e, --env ", "environment profile from ct.envs.json")
+ .action(async (opts: ReportOptions) => {
+ const targets = permissionReportTargets(opts);
+ await ensureReportDirectories(targets);
+ await prepareEnvHost(opts);
+ const { client } = await authedSession();
+ const dataset = await collectLivePermissions(client);
+ for (const target of targets) {
+ const text = target.by === "subject" ? renderBySubject(dataset) : renderByObject(dataset);
+ await writeFile(target.output, text, "utf8");
+ }
+ });
+ return new Command("report").description("Generate read-only reports").addCommand(permissions);
+}
diff --git a/src/index.ts b/src/index.ts
index ca1ecf2..3fa5b90 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,7 @@ import { adoptCommand } from "./commands/adopt.js";
import { stateCommand } from "./commands/state.js";
import { coverageCommand } from "./commands/coverage.js";
import { permissionsCommand } from "./commands/permissions.js";
+import { reportCommand } from "./commands/report.js";
import { refreshCommand } from "./commands/refresh.js";
import { planCommand } from "./commands/plan.js";
import { applyCommand } from "./commands/apply.js";
@@ -35,6 +36,7 @@ export function buildProgram(): Command {
program.addCommand(stateCommand());
program.addCommand(coverageCommand());
program.addCommand(permissionsCommand());
+ program.addCommand(reportCommand());
program.addCommand(refreshCommand());
program.addCommand(planCommand());
program.addCommand(applyCommand());
diff --git a/src/permissions/catalog-store.ts b/src/permissions/catalog-store.ts
index 4614432..7b789cf 100644
--- a/src/permissions/catalog-store.ts
+++ b/src/permissions/catalog-store.ts
@@ -21,6 +21,7 @@ import { readFile, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { CtClient } from "../api/ctClient.js";
import { useCatalog, type CatalogEntry } from "./catalog.js";
+import { fetchChurchAuthMasterData, permissionRightDefinitions } from "./masterdata.js";
/** Directory a consumer repo commits its per-instance captures into, beside the config/state files. */
export const CATALOG_DIR = ".ct";
@@ -94,18 +95,6 @@ function assertCatalogShape(parsed: Record, path: string): void
}
}
-/** The legacy master-data shape (`auth_table[module][right]`) — see `capturePermissionCatalog`. */
-interface RawRight {
- id: number;
- datenfeld?: string | null;
- bezeichnung?: string | null;
- isRevocable?: boolean | number;
-}
-interface MasterData {
- data?: { auth_table?: Record> };
- auth_table?: Record>;
-}
-
/**
* Capture the catalog from a live instance and return it in `catalog.json`'s exact schema.
*
@@ -117,25 +106,15 @@ interface MasterData {
export async function capturePermissionCatalog(
client: Pick,
): Promise> {
- const master = (await client.legacyPostForm("churchauth/ajax", { func: "getMasterData" })) as MasterData;
- const authTable = master?.data?.auth_table ?? master?.auth_table;
- if (!authTable || typeof authTable !== "object") {
- throw new Error(
- "Unexpected response from churchauth/ajax getMasterData: no data.auth_table. The legacy endpoint " +
- "or its shape may have changed — the bundled catalog is still in use.",
- );
- }
+ const master = await fetchChurchAuthMasterData(client);
const rights: Record = {};
- for (const [moduleName, moduleRights] of Object.entries(authTable)) {
- for (const [rightName, raw] of Object.entries(moduleRights)) {
- const field = raw.datenfeld;
- rights[`${moduleName}:${rightName}`] = {
- authId: raw.id,
- scopeField: field && String(field).length > 0 ? String(field) : null,
- revocable: Boolean(raw.isRevocable),
- desc: raw.bezeichnung ? String(raw.bezeichnung) : "",
- };
- }
+ for (const definition of permissionRightDefinitions(master)) {
+ rights[`${definition.module}:${definition.technicalName}`] = {
+ authId: definition.authId,
+ scopeField: definition.scopeField,
+ revocable: definition.revocable,
+ desc: definition.description,
+ };
}
const host = client.host.replace(/^https?:\/\//, "");
return {
diff --git a/src/permissions/masterdata.ts b/src/permissions/masterdata.ts
new file mode 100644
index 0000000..dd13796
--- /dev/null
+++ b/src/permissions/masterdata.ts
@@ -0,0 +1,101 @@
+import type { CtClient } from "../api/ctClient.js";
+import { warn } from "../ui.js";
+
+export interface RawChurchAuthRight {
+ id?: string | number;
+ datenfeld?: string | null;
+ bezeichnung?: string | null;
+ isRevocable?: boolean | number;
+}
+
+export type ChurchAuthMasterDataTable = Record | unknown[];
+
+export interface ChurchAuthMasterData {
+ auth_table: Record>;
+ churchauth?: Record;
+}
+
+interface ChurchAuthMasterDataResponse {
+ data?: Partial;
+ auth_table?: ChurchAuthMasterData["auth_table"];
+ churchauth?: ChurchAuthMasterData["churchauth"];
+}
+
+export interface PermissionRightDefinition {
+ authId: number;
+ module: string;
+ technicalName: string;
+ description: string;
+ scopeField: string | null;
+ revocable: boolean;
+}
+
+/**
+ * Read and validate the one legacy master-data payload shared by catalog capture and reports.
+ * Keeping the envelope handling here prevents the two consumers from silently drifting apart.
+ */
+export async function fetchChurchAuthMasterData(
+ client: Pick,
+): Promise {
+ const response = await client.legacyPostForm("churchauth/ajax", {
+ func: "getMasterData",
+ });
+ // The fallback is per FIELD, not per envelope: instances have been seen answering with a `data`
+ // object that carries only part of the payload, the rest sitting at the top level. Picking the
+ // envelope first and then reading every field out of it would reject those.
+ const authTable = response.data?.auth_table ?? response.auth_table;
+ if (!authTable || typeof authTable !== "object" || Array.isArray(authTable)) {
+ throw new Error(
+ "Unexpected response from churchauth/ajax getMasterData: no data.auth_table. The legacy endpoint " +
+ "or its shape may have changed.",
+ );
+ }
+ const churchauth = response.data?.churchauth ?? response.churchauth;
+ return {
+ auth_table: authTable,
+ ...(churchauth ? { churchauth } : {}),
+ };
+}
+
+/**
+ * Normalize auth_table once for every consumer of the ChurchAuth master-data response.
+ * `onDuplicate` is injectable so tests can assert the warning without capturing stderr.
+ */
+export function permissionRightDefinitions(
+ master: ChurchAuthMasterData,
+ onDuplicate: (message: string) => void = warn,
+): PermissionRightDefinition[] {
+ const result: PermissionRightDefinition[] = [];
+ const seen = new Set();
+ for (const [module, rights] of Object.entries(master.auth_table)) {
+ for (const [technicalName, raw] of Object.entries(rights ?? {})) {
+ const authId = Number(raw.id);
+ if (!Number.isFinite(authId)) {
+ throw new Error(
+ `Unexpected response from churchauth/ajax getMasterData: ${module}:${technicalName} has no numeric id.`,
+ );
+ }
+ if (seen.has(authId)) {
+ // An instance whose plugin set aliases one right under two modules must not break
+ // `ct permissions catalog --refresh` — that command is the only way to act on the
+ // staleness warning. Keep the first definition and say what was dropped.
+ onDuplicate(
+ `churchauth/ajax getMasterData reports authId ${authId} twice; ignoring the later ` +
+ `definition at ${module}:${technicalName}.`,
+ );
+ continue;
+ }
+ seen.add(authId);
+ const scopeField = raw.datenfeld == null ? "" : String(raw.datenfeld).trim();
+ result.push({
+ authId,
+ module,
+ technicalName,
+ description: raw.bezeichnung == null ? "" : String(raw.bezeichnung),
+ scopeField: scopeField || null,
+ revocable: Boolean(raw.isRevocable),
+ });
+ }
+ }
+ return result;
+}
diff --git a/src/reports/permissions/collect.ts b/src/reports/permissions/collect.ts
new file mode 100644
index 0000000..3d93069
--- /dev/null
+++ b/src/reports/permissions/collect.ts
@@ -0,0 +1,152 @@
+import type { CtClient } from "../../api/ctClient.js";
+import { fetchPermissionRows, type PermissionReader } from "../../permissions/fetch.js";
+import type { RawPermission } from "../../permissions/grants.js";
+import {
+ fetchChurchAuthMasterData,
+ permissionRightDefinitions,
+ type ChurchAuthMasterData,
+ type ChurchAuthMasterDataTable,
+ type PermissionRightDefinition,
+} from "../../permissions/masterdata.js";
+import type { PermissionAssignment, PermissionDataset, PermissionSubject } from "./model.js";
+
+export async function collectLivePermissions(
+ client: PermissionReader & Pick,
+): Promise {
+ const [master, person, status, groupTypeRole, groupRole] = await Promise.all([
+ fetchChurchAuthMasterData(client),
+ fetchPermissionRows(client, "/permissions/person"),
+ fetchPermissionRows(client, "/permissions/status"),
+ fetchPermissionRows(client, "/permissions/group_type_role"),
+ fetchPermissionRows(client, "/permissions/group_role"),
+ ]);
+ return collectLiveRows(master, { person, status, group_type_role: groupTypeRole, group_role: groupRole });
+}
+
+function rowsById(table: ChurchAuthMasterDataTable | undefined): Map> {
+ const result = new Map>();
+ if (!table || typeof table !== "object") return result;
+ for (const [key, value] of Object.entries(table)) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
+ const row = value as Record;
+ result.set(String(row.id ?? key), row);
+ }
+ return result;
+}
+
+function labelOf(row: Record | undefined, fallback: string): string {
+ if (!row) return fallback;
+ for (const key of ["bezeichnung", "name", "identifier", "kuerzel", "shorty"]) {
+ const value = row[key];
+ // Use trim only to reject an all-whitespace value. Returning the original string is
+ // intentional: two ChurchTools objects may differ solely by surrounding whitespace.
+ if (typeof value === "string" && value.trim()) return value;
+ }
+ return fallback;
+}
+
+function liveSubject(
+ domainType: keyof LiveRows,
+ domainId: number,
+ tables: Record>>,
+): PermissionSubject {
+ if (domainType === "person") {
+ return {
+ type: "PRS",
+ id: domainId,
+ label: labelOf(tables.person?.get(String(domainId)), `Person #${domainId}`),
+ };
+ }
+ if (domainType === "status") {
+ return {
+ type: "ST",
+ id: domainId,
+ label: labelOf(tables.status?.get(String(domainId)), `Status #${domainId}`),
+ };
+ }
+ if (domainType === "group_type_role") {
+ const role = tables.grouptypeMemberstatus?.get(String(domainId));
+ const groupTypeId = String(role?.gruppentyp_id ?? "");
+ const groupType = labelOf(tables.grouptype?.get(groupTypeId), `Gruppentyp #${groupTypeId || "?"}`);
+ const roleLabel = labelOf(role, `Rolle #${domainId}`);
+ const shorty = tables.grouptype?.get(groupTypeId)?.shorty;
+ const groupTypeShort =
+ typeof shorty === "string" && shorty.trim() ? shorty.trim() : (groupType.split(/\s+/)[0] ?? groupType);
+ return {
+ type: "GTRL",
+ id: domainId,
+ label: `${groupTypeShort} ${roleLabel}`,
+ objectLabel: `${groupType}: [${roleLabel}]`,
+ };
+ }
+ const role = tables.groupMemberstatus?.get(String(domainId));
+ const groupId = String(role?.group_id ?? "");
+ const typeRoleId = String(role?.grouptype_memberstatus_id ?? role?.group_type_role_id ?? "");
+ const group = labelOf(tables.group?.get(groupId), `Gruppe #${groupId || "?"}`);
+ const roleLabel = labelOf(
+ tables.grouptypeMemberstatus?.get(typeRoleId),
+ `Rolle #${typeRoleId || domainId}`,
+ );
+ return {
+ type: "GRRL",
+ id: domainId,
+ label: `${group} ${roleLabel}`,
+ objectLabel: `${group}: [${roleLabel}]`,
+ };
+}
+
+type LiveRows = Record<"person" | "status" | "group_type_role" | "group_role", RawPermission[]>;
+
+function collectLiveRows(master: ChurchAuthMasterData, rows: LiveRows): PermissionDataset {
+ const rawTables = master.churchauth ?? {};
+ const tables: Record>> = {};
+ for (const [name, table] of Object.entries(rawTables)) tables[name] = rowsById(table);
+ const rights = new Map(
+ permissionRightDefinitions(master).map((definition) => [definition.authId, definition]),
+ );
+ const result = new Map();
+
+ const subjects = new Map();
+ const addSubject = (subject: PermissionSubject): void => {
+ subjects.set(`${subject.type}\0${String(subject.id)}`, subject);
+ };
+
+ // Empty statuses and group-type roles are intentionally visible: an expected role/status with no
+ // direct rights is a useful permission gap. Listing every empty person and concrete group-role
+ // pairing would instead drown those gaps in thousands of irrelevant rows, so PRS/GRRL enter the
+ // dataset only when a direct permission row references them.
+ for (const id of tables.status?.keys() ?? []) addSubject(liveSubject("status", Number(id), tables));
+ for (const id of tables.grouptypeMemberstatus?.keys() ?? [])
+ addSubject(liveSubject("group_type_role", Number(id), tables));
+
+ for (const [domainType, permissions] of Object.entries(rows) as Array<[keyof LiveRows, RawPermission[]]>) {
+ for (const row of permissions) {
+ const right = rights.get(Number(row.authId));
+ const subject = liveSubject(domainType, Number(row.domainId), tables);
+ addSubject(subject);
+ const assignment: PermissionAssignment = {
+ subject,
+ right: right
+ ? {
+ authId: right.authId,
+ name: right.description.trim() || `${right.module}:${right.technicalName}`,
+ technicalName: right.technicalName,
+ }
+ : { authId: row.authId, name: `authId ${row.authId}` },
+ effect: row.type,
+ };
+ if (row.dataId != null) {
+ const id = String(row.dataId);
+ const scopeField = right?.scopeField ?? "unknown";
+ assignment.object = {
+ type: scopeField,
+ id: row.dataId,
+ label: row.dataId === -1 ? "alle" : labelOf(tables[scopeField]?.get(id), `${scopeField} #${id}`),
+ };
+ }
+ const key = [domainType, row.domainId, row.authId, row.dataId ?? "", row.type].join(":");
+ result.set(key, assignment);
+ }
+ }
+ return { subjects: [...subjects.values()], assignments: [...result.values()] };
+}
diff --git a/src/reports/permissions/hash.ts b/src/reports/permissions/hash.ts
new file mode 100644
index 0000000..db3d33d
--- /dev/null
+++ b/src/reports/permissions/hash.ts
@@ -0,0 +1,23 @@
+import { createHash } from "node:crypto";
+import type { PermissionAssignment } from "./model.js";
+
+function canonical(a: PermissionAssignment): string {
+ return JSON.stringify({
+ authId: String(a.right.authId),
+ rightName: a.right.name,
+ technicalName: a.right.technicalName ?? "",
+ objectType: a.object?.type ?? "",
+ objectId: a.object ? String(a.object.id) : "",
+ objectLabel: a.object?.label ?? "",
+ effect: a.effect ?? "grant",
+ });
+}
+
+export function permissionSetHash(assignments: PermissionAssignment[]): string {
+ // Names and ids both belong to the documented fingerprint: ids distinguish equally named
+ // objects, while a rename deliberately changes the report hash.
+ const values = [...new Set(assignments.map(canonical))].sort();
+ // MD5 is used only as a compact deterministic grouping key, never for authentication or
+ // integrity. Empty sets are rendered explicitly and never exposed as a misleading fingerprint.
+ return createHash("md5").update(values.join("\n")).digest("hex");
+}
diff --git a/src/reports/permissions/model.ts b/src/reports/permissions/model.ts
new file mode 100644
index 0000000..0b446e9
--- /dev/null
+++ b/src/reports/permissions/model.ts
@@ -0,0 +1,38 @@
+export type PermissionSubjectType = "PRS" | "ST" | "GTRL" | "GRRL" | string;
+
+export interface PermissionSubject {
+ type: PermissionSubjectType;
+ id: string | number;
+ label: string;
+ /** Expanded label used by the object-oriented report, e.g. "Group: [Role]". */
+ objectLabel?: string;
+}
+
+export interface PermissionRight {
+ authId: string | number;
+ name: string;
+ technicalName?: string;
+}
+
+export interface PermissionObject {
+ type: string;
+ id: string | number;
+ label: string;
+}
+
+export interface PermissionAssignment {
+ subject: PermissionSubject;
+ right: PermissionRight;
+ object?: PermissionObject;
+ /** ChurchTools can store an explicit deny beside grants. Omitted means grant for fixture compatibility. */
+ effect?: "grant" | "revoke";
+}
+
+/**
+ * Subjects are deliberately independent from assignments: an existing subject can have an empty
+ * permission set and must still be distinguishable from an unknown subject in the report.
+ */
+export interface PermissionDataset {
+ subjects: PermissionSubject[];
+ assignments: PermissionAssignment[];
+}
diff --git a/src/reports/permissions/render.ts b/src/reports/permissions/render.ts
new file mode 100644
index 0000000..c849252
--- /dev/null
+++ b/src/reports/permissions/render.ts
@@ -0,0 +1,166 @@
+import { permissionSetHash } from "./hash.js";
+import type {
+ PermissionAssignment,
+ PermissionDataset,
+ PermissionObject,
+ PermissionSubject,
+} from "./model.js";
+
+function objectText(object?: PermissionObject, withIdFingerprint = true): string {
+ if (!object) return "";
+ return `${object.label} (${object.type}: ${object.id})${withIdFingerprint ? ` [${object.id}]` : ""}`;
+}
+
+function rightText(a: PermissionAssignment): string {
+ const technical = a.right.technicalName ? ` [${a.right.technicalName}]` : "";
+ const effect = a.effect === "revoke" ? " [REVOKE]" : "";
+ return `${a.right.name}${technical} (auth_table: ${a.right.authId})${effect}`;
+}
+
+function subjectRightText(a: PermissionAssignment): string {
+ const unscopedId = a.object ? "" : ` [${a.right.authId}]`;
+ return `${rightText(a)}${unscopedId}`;
+}
+
+/**
+ * Assignments arrive in raw API row order, which is neither grouped by right nor stable between
+ * runs. Sorting them makes the "* right / objects beneath it" nesting correct even when a
+ * subject's rows for one right are not contiguous, and keeps the report diff-stable — the point
+ * of grouping subjects by hash. Exact duplicates collapse, matching the hash's set semantics.
+ */
+function orderedAssignments(list: PermissionAssignment[]): PermissionAssignment[] {
+ const sortable = (value: string | number | undefined): string => {
+ if (value === undefined) return "";
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? String(numeric).padStart(12, "0") : String(value);
+ };
+ const key = (a: PermissionAssignment): string =>
+ [
+ sortable(a.right.authId),
+ a.right.name,
+ a.right.technicalName ?? "",
+ a.effect ?? "grant",
+ a.object?.type ?? "",
+ sortable(a.object?.id),
+ a.object?.label ?? "",
+ ].join("\u0000");
+ const unique = new Map();
+ for (const a of list) {
+ const k = key(a);
+ if (!unique.has(k)) unique.set(k, a);
+ }
+ return [...unique.entries()].sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)).map(([, a]) => a);
+}
+
+export function renderBySubject(dataset: PermissionDataset): string {
+ const bySubject = new Map();
+ const subjects = new Map();
+ for (const subject of dataset.subjects) {
+ const key = `${subject.type}\0${String(subject.id)}`;
+ subjects.set(key, subject);
+ bySubject.set(key, []);
+ }
+ for (const a of dataset.assignments) {
+ const key = `${a.subject.type}\0${String(a.subject.id)}`;
+ subjects.set(key, a.subject);
+ const list = bySubject.get(key) ?? [];
+ list.push(a);
+ bySubject.set(key, list);
+ }
+ const groups = new Map<
+ string,
+ Array<{ subject: PermissionSubject; assignments: PermissionAssignment[] }>
+ >();
+ const emptySubjects: PermissionSubject[] = [];
+ for (const [key, list] of bySubject) {
+ const subject = subjects.get(key);
+ if (!subject) continue;
+ if (list.length === 0) {
+ emptySubjects.push(subject);
+ continue;
+ }
+ const hash = permissionSetHash(list);
+ const group = groups.get(hash) ?? [];
+ group.push({ subject, assignments: list });
+ groups.set(hash, group);
+ }
+ const lines: string[] = [];
+ for (const [hash, subjectSets] of groups) {
+ lines.push(`# ${hash}`);
+ for (const entry of subjectSets.sort((a, b) => {
+ const as = a.subject;
+ const bs = b.subject;
+ return `${as.type} ${as.label}`.localeCompare(`${bs.type} ${bs.label}`);
+ })) {
+ lines.push(`## ${entry.subject.type} ${entry.subject.label}`);
+ }
+ // Every set in this hash group is canonically identical. Render it once; including each
+ // subject's copy would repeat every right N times under an N-subject heading group.
+ const rights = orderedAssignments(subjectSets[0]?.assignments ?? []);
+ let lastRight = "";
+ if (rights.length > 0) lines.push("");
+ for (const a of rights) {
+ const current = subjectRightText(a);
+ if (current !== lastRight) {
+ lines.push(`* ${current}`);
+ lastRight = current;
+ }
+ if (a.object) lines.push(` * ${objectText(a.object)}`);
+ }
+ lines.push("");
+ }
+ if (emptySubjects.length > 0) {
+ lines.push("# Keine Berechtigungen");
+ for (const subject of emptySubjects.sort((a, b) =>
+ `${a.type} ${a.label}`.localeCompare(`${b.type} ${b.label}`),
+ )) {
+ lines.push(`## ${subject.type} ${subject.label}`);
+ }
+ lines.push("", "(keine Berechtigungen)", "");
+ }
+ return lines.join("\n").trimEnd() + "\n";
+}
+
+const SUBJECT_ORDER: Record = { ST: 1, GTRL: 2, GRRL: 3, PRS: 4 };
+
+export function renderByObject(dataset: PermissionDataset): string {
+ const grouped = new Map();
+ for (const a of dataset.assignments) {
+ const key = [
+ a.object?.type ?? "",
+ String(a.object?.id ?? ""),
+ String(a.right.authId),
+ a.right.name,
+ a.right.technicalName ?? "",
+ a.effect ?? "grant",
+ ].join("\0");
+ const list = grouped.get(key) ?? [];
+ list.push(a);
+ grouped.set(key, list);
+ }
+ const lines: string[] = [];
+ const heading = (a: PermissionAssignment): string =>
+ a.object ? `${objectText(a.object, false)} (${rightText(a)})` : rightText(a);
+ for (const list of [...grouped.values()].sort((a, b) =>
+ a[0] && b[0] ? heading(a[0]).localeCompare(heading(b[0]), "de") : 0,
+ )) {
+ const first = list[0];
+ if (!first) continue;
+ lines.push(`## ${heading(first)}`, "");
+ const subjects = [
+ ...new Map(list.map((a) => [`${a.subject.type}\0${a.subject.id}`, a.subject])).values(),
+ ].sort(
+ (a, b) =>
+ (SUBJECT_ORDER[a.type] ?? 99) - (SUBJECT_ORDER[b.type] ?? 99) || a.label.localeCompare(b.label),
+ );
+ for (const subject of subjects) {
+ const label =
+ { ST: "Status", GTRL: "Gruppentyp", GRRL: "Gruppe", PRS: "Person" }[subject.type] ?? subject.type;
+ lines.push(
+ ` ${SUBJECT_ORDER[subject.type] ?? 99} ${label.padEnd(11)}: ${subject.objectLabel ?? subject.label}`,
+ );
+ }
+ lines.push("");
+ }
+ return lines.join("\n").trimEnd() + "\n";
+}
diff --git a/tests/cli.test.ts b/tests/cli.test.ts
index e3acc39..310fd44 100644
--- a/tests/cli.test.ts
+++ b/tests/cli.test.ts
@@ -5,7 +5,17 @@ describe("ct program", () => {
it("registers the core command surface", () => {
const names = buildProgram().commands.map((c) => c.name());
expect(names).toEqual(
- expect.arrayContaining(["init", "auth", "get", "adopt", "plan", "apply", "destroy", "completion"]),
+ expect.arrayContaining([
+ "init",
+ "auth",
+ "get",
+ "adopt",
+ "report",
+ "plan",
+ "apply",
+ "destroy",
+ "completion",
+ ]),
);
});
@@ -63,4 +73,13 @@ describe("ct program", () => {
expect(cmd.options.some((o) => o.long === "--confirm-env")).toBe(true);
}
});
+
+ it("offers one-pass subject and object permission report options", () => {
+ const report = buildProgram().commands.find((c) => c.name() === "report")!;
+ const permissions = report.commands.find((c) => c.name() === "permissions")!;
+ const options = permissions.options.map((o) => o.long);
+ expect(options).toEqual(expect.arrayContaining(["--by-subject", "--by-object", "--by-both"]));
+ expect(options).not.toContain("--by");
+ expect(options).not.toContain("--output");
+ });
});
diff --git a/tests/permission-masterdata.test.ts b/tests/permission-masterdata.test.ts
new file mode 100644
index 0000000..a6d873e
--- /dev/null
+++ b/tests/permission-masterdata.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import { fetchChurchAuthMasterData, permissionRightDefinitions } from "../src/permissions/masterdata.js";
+
+describe("shared ChurchAuth master-data decoder", () => {
+ it("unwraps and normalizes the right catalog once for reports and catalog capture", async () => {
+ const calls: Array<[string, Record]> = [];
+ const master = await fetchChurchAuthMasterData({
+ legacyPostForm: async (query: string, form: Record) => {
+ calls.push([query, form]);
+ return {
+ data: {
+ auth_table: {
+ churchwiki: {
+ "view category": {
+ id: "502",
+ datenfeld: " cc_wikicategory ",
+ bezeichnung: "Wiki sehen ",
+ isRevocable: 1,
+ },
+ },
+ },
+ churchauth: { status: { "2": { id: 2, bezeichnung: "Aktiv" } } },
+ },
+ } as T;
+ },
+ });
+
+ expect(calls).toEqual([["churchauth/ajax", { func: "getMasterData" }]]);
+ expect(permissionRightDefinitions(master)).toEqual([
+ {
+ authId: 502,
+ module: "churchwiki",
+ technicalName: "view category",
+ description: "Wiki sehen ",
+ scopeField: "cc_wikicategory",
+ revocable: true,
+ },
+ ]);
+ expect(master.churchauth).toHaveProperty("status");
+ });
+
+ it("fails loudly when auth_table is missing", async () => {
+ await expect(
+ fetchChurchAuthMasterData({ legacyPostForm: async () => ({ data: {} }) as T }),
+ ).rejects.toThrow(/no data\.auth_table/);
+ });
+
+ it("rejects malformed right ids instead of letting consumers disagree", () => {
+ expect(() =>
+ permissionRightDefinitions({
+ auth_table: { churchdb: { view: { bezeichnung: "Ansehen" } } },
+ }),
+ ).toThrow(/has no numeric id/);
+ });
+
+ it("warns and keeps the first definition when an authId is aliased under two modules", () => {
+ const warnings: string[] = [];
+ const definitions = permissionRightDefinitions(
+ {
+ auth_table: {
+ churchdb: { view: { id: 1, bezeichnung: "Ansehen" } },
+ churchwiki: { view: { id: 1, bezeichnung: "Ansehen (Wiki)" } },
+ },
+ },
+ (message) => warnings.push(message),
+ );
+
+ // `ct permissions catalog --refresh` is the only way to act on a staleness warning; a
+ // duplicate must degrade, not kill it.
+ expect(definitions).toHaveLength(1);
+ expect(definitions[0]).toMatchObject({ authId: 1, module: "churchdb" });
+ expect(warnings).toEqual([
+ "churchauth/ajax getMasterData reports authId 1 twice; ignoring the later definition at churchwiki:view.",
+ ]);
+ });
+
+ it("falls back per field when the response splits the payload across the envelope", async () => {
+ const master = await fetchChurchAuthMasterData({
+ legacyPostForm: async () =>
+ ({
+ data: { churchauth: { person: [] } },
+ auth_table: { churchdb: { view: { id: 1, bezeichnung: "Ansehen" } } },
+ }) as T,
+ });
+
+ expect(Object.keys(master.auth_table)).toEqual(["churchdb"]);
+ expect(master.churchauth).toEqual({ person: [] });
+ });
+});
diff --git a/tests/permission-report.test.ts b/tests/permission-report.test.ts
new file mode 100644
index 0000000..4a61b09
--- /dev/null
+++ b/tests/permission-report.test.ts
@@ -0,0 +1,357 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { ensureReportDirectories, permissionReportTargets } from "../src/commands/report.js";
+import { collectLivePermissions } from "../src/reports/permissions/collect.js";
+import { permissionSetHash } from "../src/reports/permissions/hash.js";
+import { renderByObject, renderBySubject } from "../src/reports/permissions/render.js";
+import type { PermissionAssignment, PermissionDataset } from "../src/reports/permissions/model.js";
+
+const assignment = (subject: PermissionAssignment["subject"], authId = 42, id = 7): PermissionAssignment => ({
+ subject,
+ right: { authId, name: "Ein Recht", technicalName: "view thing" },
+ object: { type: "cc_wikicategory", id, label: "Wiki" },
+});
+
+const dataset = (
+ assignments: PermissionAssignment[],
+ subjects = assignments.map((a) => a.subject),
+): PermissionDataset => ({
+ subjects,
+ assignments,
+});
+
+describe("permission report targets", () => {
+ it("uses default filenames when both one-pass options omit their values", () => {
+ expect(permissionReportTargets({ bySubject: true, byObject: true })).toEqual([
+ { by: "subject", output: "permissions-by-subject.md" },
+ { by: "object", output: "permissions-by-object.md" },
+ ]);
+ });
+
+ it("accepts explicit filenames", () => {
+ expect(permissionReportTargets({ bySubject: "subject.md", byObject: "object.md" })).toEqual([
+ { by: "subject", output: "subject.md" },
+ { by: "object", output: "object.md" },
+ ]);
+ });
+
+ it("derives both filenames from one base path", () => {
+ expect(permissionReportTargets({ byBoth: "/foo/bar/permission-report" })).toEqual([
+ { by: "subject", output: "/foo/bar/permission-report_by-subject" },
+ { by: "object", output: "/foo/bar/permission-report_by-object" },
+ ]);
+ expect(permissionReportTargets({ byBoth: "reports/permission-report.md" })).toEqual([
+ { by: "subject", output: "reports/permission-report_by-subject.md" },
+ { by: "object", output: "reports/permission-report_by-object.md" },
+ ]);
+ });
+
+ it("rejects ambiguous or missing output selections", () => {
+ expect(() => permissionReportTargets({})).toThrow("Select");
+ expect(() => permissionReportTargets({ byBoth: "report", byObject: true })).toThrow("cannot be combined");
+ expect(() => permissionReportTargets({ bySubject: "same.md", byObject: "same.md" })).toThrow("same file");
+ });
+});
+
+describe("permission report output directories", () => {
+ const directories: string[] = [];
+ afterEach(async () => {
+ await Promise.all(
+ directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
+ );
+ });
+
+ it("creates a missing output directory instead of failing after the live collection", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "ct-report-"));
+ directories.push(directory);
+ const targets = permissionReportTargets({ byBoth: join(directory, "reports", "permissions.md") });
+
+ await ensureReportDirectories(targets);
+
+ // `reports/` is gitignored and absent in a fresh clone; writing must now succeed.
+ await Promise.all(targets.map((target) => writeFile(target.output, "x", "utf8")));
+ });
+});
+
+describe("permission report hashing", () => {
+ it("uses the same hash for identical sets and ignores API order", () => {
+ const subject = { type: "PRS", id: 1, label: "Person" };
+ const a = [assignment(subject), assignment(subject, 43, 8)];
+ expect(permissionSetHash(a)).toBe(permissionSetHash([...a].reverse()));
+ });
+
+ it("changes when a right is added or removed", () => {
+ const subject = { type: "PRS", id: 1, label: "Person" };
+ const a = [assignment(subject)];
+ expect(permissionSetHash(a)).not.toBe(permissionSetHash([...a, assignment(subject, 43)]));
+ });
+
+ it("uses both names and ids as permission identity", () => {
+ const subject = { type: "PRS", id: 1, label: "Person" };
+ const original = assignment(subject);
+ const renamed: PermissionAssignment = {
+ ...original,
+ right: { ...original.right, name: "Umbenannt", technicalName: "renamed" },
+ object: { ...original.object!, label: "Wiki " },
+ };
+ const sameNamesButDifferentObject = assignment(subject, 42, 8);
+
+ expect(permissionSetHash([original])).not.toBe(permissionSetHash([renamed]));
+ expect(permissionSetHash([original])).not.toBe(permissionSetHash([sameNamesButDifferentObject]));
+ });
+
+ it("hashes a permission set rather than duplicate rows", () => {
+ const value = assignment({ type: "PRS", id: 1, label: "Person" });
+ expect(permissionSetHash([value])).toBe(permissionSetHash([value, value]));
+ });
+});
+
+describe("permission report renderers", () => {
+ it("groups subjects with the same complete set under one hash", () => {
+ const a = assignment({ type: "PRS", id: 1, label: "Anna" });
+ const b = assignment({ type: "PRS", id: 2, label: "Berta" });
+ const text = renderBySubject(dataset([a, b]));
+ expect(text.match(/^# /gm)).toHaveLength(1);
+ expect(text).toContain("## PRS Anna");
+ expect(text).toContain("## PRS Berta");
+ expect(text.match(/^\* Ein Recht/gm)).toHaveLength(1);
+ });
+
+ it("inverts an assignment for the object report", () => {
+ const text = renderByObject(dataset([assignment({ type: "GTRL", id: 9, label: "EG Team Leiter" })]));
+ expect(text).toContain("Wiki (cc_wikicategory: 7)");
+ expect(text).toContain("2 Gruppentyp");
+ expect(text).toContain("EG Team Leiter");
+ });
+
+ it("renders unscoped assignments without inventing an object", () => {
+ const assignments: PermissionAssignment[] = [
+ {
+ subject: { type: "ST", id: "active", label: "active" },
+ right: { authId: 1, name: "Personen sehen" },
+ },
+ ];
+ const text = renderBySubject(dataset(assignments));
+ expect(text).toContain("Personen sehen (auth_table: 1)");
+ expect(text).toContain("[1]");
+ expect(text).not.toContain("unknown");
+ expect(renderByObject(dataset(assignments))).toContain("## Personen sehen (auth_table: 1)");
+ });
+
+ it("emits one bullet per right and a stable order regardless of API row order", () => {
+ const subject: PermissionAssignment["subject"] = { type: "PRS", id: 1, label: "Anna" };
+ // The API can order rows by object rather than by right, so a subject's rows for one
+ // right are not necessarily contiguous.
+ const interleaved: PermissionAssignment[] = [
+ { ...assignment(subject, 42, 7), object: { type: "cc_wikicategory", id: 7, label: "Wiki A" } },
+ {
+ subject,
+ right: { authId: 9, name: "Anderes Recht", technicalName: "edit thing" },
+ object: { type: "cc_wikicategory", id: 8, label: "Wiki B" },
+ },
+ { ...assignment(subject, 42, 9), object: { type: "cc_wikicategory", id: 9, label: "Wiki C" } },
+ ];
+
+ const text = renderBySubject(dataset(interleaved));
+ expect(text.match(/^\* Ein Recht/gm)).toHaveLength(1);
+ expect(text).toContain(" * Wiki A (cc_wikicategory: 7) [7]");
+ expect(text).toContain(" * Wiki C (cc_wikicategory: 9) [9]");
+
+ // Same set, different row order → byte-identical report.
+ expect(renderBySubject(dataset([...interleaved].reverse()))).toBe(text);
+ // authId 9 sorts before 42 numerically, not lexically.
+ expect(text.indexOf("* Anderes Recht")).toBeLessThan(text.indexOf("* Ein Recht"));
+ });
+
+ it("collapses exactly duplicated rows the way the hash does", () => {
+ const subject: PermissionAssignment["subject"] = { type: "ST", id: 1, label: "active" };
+ const row = assignment(subject);
+ const text = renderBySubject(dataset([row, { ...row }]));
+ expect(text.match(/^ {4}\* Wiki/gm)).toHaveLength(1);
+ });
+
+ it("renders known subjects with no rights explicitly and without a fingerprint", () => {
+ const text = renderBySubject(
+ dataset(
+ [],
+ [
+ { type: "ST", id: 2, label: "Ohne Statusrechte" },
+ { type: "GTRL", id: 3, label: "Team Beobachter" },
+ ],
+ ),
+ );
+ expect(text).toContain("# Keine Berechtigungen");
+ expect(text).toContain("## ST Ohne Statusrechte");
+ expect(text).toContain("## GTRL Team Beobachter");
+ expect(text).toContain("(keine Berechtigungen)");
+ expect(text).not.toMatch(/^# [a-f0-9]{32}$/m);
+ expect(text).not.toMatch(/^\* /m);
+ });
+});
+
+describe("churchauth collector", () => {
+ it("keeps live categories distinct when only one has a trailing space", async () => {
+ const rows = {
+ "/permissions/person": [
+ { domainId: 10, authId: 502, dataId: 7, type: "grant", isInherited: false },
+ { domainId: 10, authId: 502, dataId: 8, type: "grant", isInherited: false },
+ ],
+ "/permissions/status": [],
+ "/permissions/group_type_role": [],
+ "/permissions/group_role": [],
+ } as const;
+ const result = await collectLivePermissions({
+ get: async (path: string) => (rows[path as keyof typeof rows] ?? []) as T,
+ legacyPostForm: async () =>
+ ({
+ data: {
+ auth_table: {
+ churchwiki: {
+ "view category": {
+ id: 502,
+ bezeichnung: "Wiki sehen",
+ datenfeld: "cc_wikicategory",
+ },
+ },
+ },
+ churchauth: {
+ person: { "10": { id: 10, bezeichnung: "Anna" } },
+ cc_wikicategory: {
+ "7": { id: 7, bezeichnung: "Kategorie" },
+ "8": { id: 8, bezeichnung: "Kategorie " },
+ },
+ },
+ },
+ }) as T,
+ });
+
+ expect(result.assignments.map((a) => [a.object?.id, a.object?.label])).toEqual([
+ [7, "Kategorie"],
+ [8, "Kategorie "],
+ ]);
+ const report = renderByObject(result);
+ expect(report).toContain("## Kategorie (cc_wikicategory: 7)");
+ expect(report).toContain("## Kategorie (cc_wikicategory: 8)");
+ });
+
+ it("uses the existing authenticated AJAX reader without contacting a live instance in the test", async () => {
+ const calls: Array<[string, Record]> = [];
+ const result = await collectLivePermissions({
+ get: async () => [] as T,
+ legacyPostForm: async (module: string, params: Record) => {
+ calls.push([module, params]);
+ return { data: { auth_table: {}, churchauth: {} } } as T;
+ },
+ });
+ expect(result).toEqual({ subjects: [], assignments: [] });
+ expect(calls).toEqual([["churchauth/ajax", { func: "getMasterData" }]]);
+ });
+
+ it("joins live domains and exposes empty ST/GTRL gaps without listing every empty PRS/GRRL", async () => {
+ const paths: string[] = [];
+ const rows = {
+ "/permissions/person": [{ domainId: 10, authId: 502, dataId: 7, type: "grant", isInherited: false }],
+ "/permissions/status": [{ domainId: 2, authId: 101, dataId: null, type: "grant", isInherited: false }],
+ "/permissions/group_type_role": [
+ { domainId: 20, authId: 101, dataId: null, type: "grant", isInherited: false },
+ ],
+ "/permissions/group_role": [
+ { domainId: 30, authId: 101, dataId: null, type: "grant", isInherited: false },
+ ],
+ } as const;
+ const master = {
+ data: {
+ auth_table: {
+ churchdb: { view: { id: 101, bezeichnung: "Personen sehen", datenfeld: "" } },
+ churchwiki: {
+ "view category": {
+ id: 502,
+ bezeichnung: "Wiki sehen",
+ datenfeld: "cc_wikicategory",
+ },
+ },
+ },
+ churchauth: {
+ person: {
+ "10": { id: 10, bezeichnung: "Anna" },
+ "11": { id: 11, bezeichnung: "Chuck Norris" },
+ },
+ status: {
+ "2": { id: 2, bezeichnung: "Aktiv" },
+ "3": { id: 3, bezeichnung: "Ohne Rechte" },
+ },
+ grouptype: { "3": { id: 3, bezeichnung: "Eventgruppe" } },
+ grouptypeMemberstatus: {
+ "20": { id: 20, gruppentyp_id: 3, bezeichnung: "Leiter" },
+ "21": { id: 21, gruppentyp_id: 3, bezeichnung: "Beobachter" },
+ },
+ group: { "4": { id: 4, bezeichnung: "Technik" } },
+ groupMemberstatus: {
+ "30": { id: 30, group_id: 4, grouptype_memberstatus_id: 20 },
+ "31": { id: 31, group_id: 4, grouptype_memberstatus_id: 21 },
+ },
+ cc_wikicategory: { "7": { id: 7, bezeichnung: "Intern" } },
+ },
+ },
+ };
+ const result = await collectLivePermissions({
+ get: async (path: string) => {
+ paths.push(path);
+ return (rows[path as keyof typeof rows] ?? []) as T;
+ },
+ legacyPostForm: async () => master as T,
+ });
+ expect(paths.sort()).toEqual(Object.keys(rows).sort());
+ expect(result.assignments).toHaveLength(4);
+ expect(result.assignments).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ subject: { type: "PRS", id: 10, label: "Anna" },
+ right: { authId: 502, name: "Wiki sehen", technicalName: "view category" },
+ object: { type: "cc_wikicategory", id: 7, label: "Intern" },
+ }),
+ expect.objectContaining({ subject: { type: "ST", id: 2, label: "Aktiv" } }),
+ expect.objectContaining({
+ subject: {
+ type: "GTRL",
+ id: 20,
+ label: "Eventgruppe Leiter",
+ objectLabel: "Eventgruppe: [Leiter]",
+ },
+ }),
+ expect.objectContaining({
+ subject: {
+ type: "GRRL",
+ id: 30,
+ label: "Technik Leiter",
+ objectLabel: "Technik: [Leiter]",
+ },
+ }),
+ ]),
+ );
+ expect(result.subjects).toEqual(
+ expect.arrayContaining([
+ { type: "PRS", id: 10, label: "Anna" },
+ { type: "ST", id: 2, label: "Aktiv" },
+ { type: "ST", id: 3, label: "Ohne Rechte" },
+ {
+ type: "GTRL",
+ id: 21,
+ label: "Eventgruppe Beobachter",
+ objectLabel: "Eventgruppe: [Beobachter]",
+ },
+ ]),
+ );
+ // Empty statuses and type roles expose actionable permission gaps.
+ // Empty people and concrete role pairings would only flood the report with irrelevant rows.
+ expect(result.subjects).not.toContainEqual({ type: "PRS", id: 11, label: "Chuck Norris" });
+ expect(result.subjects).not.toContainEqual(expect.objectContaining({ type: "GRRL", id: 31 }));
+ const subjectReport = renderBySubject(result);
+ expect(subjectReport).not.toContain("## PRS Chuck Norris");
+ expect(subjectReport).not.toContain("## GRRL Technik Beobachter");
+ expect(subjectReport).toContain("## ST Ohne Rechte");
+ expect(subjectReport).toContain("## GTRL Eventgruppe Beobachter");
+ });
+});