From 7446111998a1474da7a0466598123304e2283d88 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Thu, 20 Aug 2026 21:10:05 +0200 Subject: [PATCH 1/5] feat: add complete permission reports --- .gitignore | 3 + docs/handbuch/permissions.md | 50 ++++ package-lock.json | 4 +- package.json | 2 +- src/commands/report.ts | 75 ++++++ src/index.ts | 5 +- src/reports/permissions/collect.ts | 355 ++++++++++++++++++++++++ src/reports/permissions/hash.ts | 25 ++ src/reports/permissions/model.ts | 38 +++ src/reports/permissions/render.ts | 122 +++++++++ tests/cli.test.ts | 17 +- tests/permission-report.test.ts | 417 +++++++++++++++++++++++++++++ 12 files changed, 1108 insertions(+), 5 deletions(-) create mode 100644 src/commands/report.ts create mode 100644 src/reports/permissions/collect.ts create mode 100644 src/reports/permissions/hash.ts create mode 100644 src/reports/permissions/model.ts create mode 100644 src/reports/permissions/render.ts create mode 100644 tests/permission-report.test.ts 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 1144be5..4ca2f5f 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -11,6 +11,56 @@ reviewed: 2026-08-17 # 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`) ohne eigene Zuweisung erscheinen ebenfalls: Sie teilen +sich wie im Legacy-Report den Hash `d751713988987e9331980363e24189ce` der leeren +Rechte-Menge. Personen (`PRS`) und konkrete Gruppenrollen (`GRRL`) werden dagegen +nur aufgeführt, wenn mindestens eine direkte Permission existiert. Über Status +oder Rollen geerbte effektive Personenrechte werden nicht zu einem synthetischen +Personen-Rechte-Set verrechnet. `object` beantwortet „Wer +darf auf dieses Objekt was?“ und verwendet die historische Sortierung für +Status, Gruppentyp, Gruppe und Person, soweit diese Subjekttypen vorhanden sind. + +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/package-lock.json b/package-lock.json index e1a0d42..0408a20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ct-cli", - "version": "0.0.0", + "version": "3.0.0-bwl", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ct-cli", - "version": "0.0.0", + "version": "3.0.0-bwl", "dependencies": { "commander": "^12.1.0", "jiti": "^2.4.0", diff --git a/package.json b/package.json index c37f8f7..0a161c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eqrm/ct-cli", - "version": "0.1.0", + "version": "3.0.0-bwl", "description": "ChurchTools structure-as-code CLI — declarative plan/apply for the overarching, rights-bearing structure (campuses, groups, hierarchies, permissions, auto-groups). Never manages people.", "license": "MIT", "type": "module", diff --git a/src/commands/report.ts b/src/commands/report.ts new file mode 100644 index 0000000..d678a04 --- /dev/null +++ b/src/commands/report.ts @@ -0,0 +1,75 @@ +import { writeFile } from "node:fs/promises"; +import { format, parse } 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; +} + +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 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 eab581c..63c1496 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ #!/usr/bin/env node import { Command } from "commander"; +import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./commands/auth.js"; import { getCommand } from "./commands/get.js"; 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"; @@ -22,7 +24,7 @@ export function buildProgram(): Command { "ChurchTools structure-as-code CLI — declarative plan/apply for the overarching, " + "rights-bearing structure. Never manages people.", ) - .version("0.0.0"); + .version(packageJson.version); program.addCommand(authCommand()); program.addCommand(getCommand()); @@ -30,6 +32,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/reports/permissions/collect.ts b/src/reports/permissions/collect.ts new file mode 100644 index 0000000..71bf0da --- /dev/null +++ b/src/reports/permissions/collect.ts @@ -0,0 +1,355 @@ +import type { CtClient } from "../../api/ctClient.js"; +import { fetchPermissionRows, type PermissionReader } from "../../permissions/fetch.js"; +import type { RawPermission } from "../../permissions/grants.js"; +import type { + PermissionAssignment, + PermissionDataset, + PermissionObject, + PermissionSubject, +} from "./model.js"; + +interface RawEntry { + [key: string]: unknown; +} +interface RawAuthSubject extends RawEntry { + person?: string; + person_id?: string | number; + grouptype?: string; + grouptypeMemberstatus_id?: string | number; + membertype?: string; + group?: string; + group_id?: string | number; + role?: string; + groupMemberstatus_id?: string | number; + auth?: Record; + resolved_auth?: Array>; +} + +export interface ChurchAuthMasterData { + auth_by_person?: Record; + auth_by_status?: Record; + auth_by_grouptypes?: Record; + auth_by_groups?: Record>; +} + +interface ChurchAuthResponse { + data?: ChurchAuthMasterData & LiveMasterData; + response?: ChurchAuthMasterData; + auth_by_person?: ChurchAuthMasterData["auth_by_person"]; + auth_by_status?: ChurchAuthMasterData["auth_by_status"]; + auth_by_grouptypes?: ChurchAuthMasterData["auth_by_grouptypes"]; + auth_by_groups?: ChurchAuthMasterData["auth_by_groups"]; +} + +interface RawRightDefinition { + id?: string | number; + datenfeld?: string | null; + bezeichnung?: string; +} + +type MasterDataTable = Record | unknown[]; + +interface LiveMasterData { + auth_table?: Record>; + churchauth?: Record; +} + +interface LiveRight { + authId: number; + name: string; + technicalName: string; + scopeField: string | null; +} + +const RIGHT_RE = /^(.*?)\s+\[([^\]]+)\]\s+\(auth_table:\s*([^)]+)\)$/; +// Consume exactly the one separator space inserted before the metadata. Any additional space +// belongs to the ChurchTools label and must remain visible in this lossless report. +const OBJECT_RE = /^(.*) \(([^:()]+):\s*([^)]+)\)$/; + +function rightInfo( + value: string, + authId: string, +): { name: string; technicalName?: string; authId: string | number } { + const match = RIGHT_RE.exec(value.trim()); + return match + ? { name: match[1] ?? value.trim(), technicalName: match[2], authId: match[3] || authId } + : { name: value.trim(), authId }; +} + +function objectInfo(value: string, id: string): PermissionObject { + const match = OBJECT_RE.exec(value.trim()); + return match + ? { label: match[1] ?? value.trim(), type: match[2] ?? "unknown", id: match[3] ?? id } + : { label: value.trim(), type: "unknown", id }; +} + +function addResolved( + subject: PermissionSubject, + raw: RawAuthSubject, + output: PermissionAssignment[], +): Set { + const seen = new Set(); + for (const item of raw.resolved_auth ?? []) { + for (const [rightLabel, objects] of Object.entries(item)) { + const parsed = rightInfo(rightLabel, rightLabel); + const authId = String(parsed.authId); + if (Array.isArray(objects)) { + for (const entry of objects) { + if (!entry || typeof entry !== "object") continue; + for (const [label, id] of Object.entries(entry)) { + const object = objectInfo(label, String(id)); + output.push({ subject, right: parsed, object }); + seen.add(`${authId}:${String(id)}`); + } + } + } else { + output.push({ subject, right: parsed }); + seen.add(`${authId}:`); + } + } + } + return seen; +} + +function addRawFallback( + subject: PermissionSubject, + raw: RawAuthSubject, + output: PermissionAssignment[], + seen: Set, +): void { + for (const [authId, scope] of Object.entries(raw.auth ?? {})) { + // The legacy PHP fixture uses [] for "no assignment" on a few catalog rights. It is not an + // unscoped grant and must not become a synthetic `authId N [N]` report line. + if (Array.isArray(scope) && scope.length === 0) continue; + if (Array.isArray(scope)) { + for (const value of scope) { + const id = String(value); + if (!seen.has(`${authId}:${id}`)) { + output.push({ + subject, + right: { authId, name: `authId ${authId}` }, + object: objectInfo("Objekt", id), + }); + } + } + continue; + } + if (scope && typeof scope === "object" && !Array.isArray(scope)) { + for (const [id] of Object.entries(scope)) { + if (!seen.has(`${authId}:${id}`)) + output.push({ + subject, + right: { authId, name: `authId ${authId}` }, + object: objectInfo(`Objekt`, id), + }); + } + } else if (!seen.has(`${authId}:`)) { + output.push({ subject, right: { authId, name: `authId ${authId}` } }); + } + } +} + +function readSubject( + type: string, + id: string | number, + label: string, + raw: RawAuthSubject, + objectLabel?: string, +): { subject: PermissionSubject; assignments: PermissionAssignment[] } { + const subject = { type, id, label, ...(objectLabel ? { objectLabel } : {}) }; + const result: PermissionAssignment[] = []; + const seen = addResolved(subject, raw, result); + addRawFallback(subject, raw, result, seen); + return { subject, assignments: result }; +} + +export function collectPermissionAssignments(data: ChurchAuthMasterData): PermissionDataset { + const subjects: PermissionSubject[] = []; + const assignments: PermissionAssignment[] = []; + const add = (entry: ReturnType): void => { + subjects.push(entry.subject); + assignments.push(...entry.assignments); + }; + for (const [label, raw] of Object.entries(data.auth_by_person ?? {})) { + add(readSubject("PRS", raw.person_id ?? label, label, raw)); + } + for (const [label, raw] of Object.entries(data.auth_by_status ?? {})) { + add(readSubject("ST", label, label, raw)); + } + for (const [groupType, rows] of Object.entries(data.auth_by_grouptypes ?? {})) { + for (const raw of rows ?? []) { + const role = raw.membertype ?? ""; + const label = `${groupType.split(/\s+/)[0] ?? groupType} ${role}`.trim(); + add(readSubject("GTRL", raw.grouptypeMemberstatus_id ?? label, label, raw, `${groupType}: [${role}]`)); + } + } + for (const [group, roles] of Object.entries(data.auth_by_groups ?? {})) { + for (const [role, raw] of Object.entries(roles ?? {})) { + const row = raw as RawAuthSubject; + const label = `${group} ${row.role ?? role}`.trim(); + add( + readSubject( + "GRRL", + row.groupMemberstatus_id ?? `${row.group_id ?? group}:${role}`, + label, + row, + `${group}: [${row.role ?? role}]`, + ), + ); + } + } + return { subjects, assignments }; +} + +export async function collectLivePermissions( + client: PermissionReader & Pick, +): Promise { + const [masterResponse, person, status, groupTypeRole, groupRole] = await Promise.all([ + client.legacyPostForm("churchauth/ajax", { func: "getMasterData" }), + fetchPermissionRows(client, "/permissions/person"), + fetchPermissionRows(client, "/permissions/status"), + fetchPermissionRows(client, "/permissions/group_type_role"), + fetchPermissionRows(client, "/permissions/group_role"), + ]); + const master = (masterResponse.data ?? masterResponse) as LiveMasterData; + return collectLiveRows(master, { person, status, group_type_role: groupTypeRole, group_role: groupRole }); +} + +function rowsById(table: MasterDataTable | 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 liveRightCatalog(authTable: LiveMasterData["auth_table"]): Map { + const result = new Map(); + for (const [module, definitions] of Object.entries(authTable ?? {})) { + for (const [technicalName, raw] of Object.entries(definitions ?? {})) { + const authId = Number(raw.id); + if (!Number.isFinite(authId) || result.has(authId)) continue; + result.set(authId, { + authId, + name: raw.bezeichnung?.trim() || `${module}:${technicalName}`, + technicalName, + scopeField: + raw.datenfeld == null || String(raw.datenfeld).trim() === "" ? null : String(raw.datenfeld), + }); + } + } + return result; +} + +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: LiveMasterData, rows: LiveRows): PermissionDataset { + const rawTables = master.churchauth ?? {}; + const tables: Record>> = {}; + for (const [name, table] of Object.entries(rawTables)) tables[name] = rowsById(table); + const rights = liveRightCatalog(master.auth_table); + const result = new Map(); + + const subjects = new Map(); + const addSubject = (subject: PermissionSubject): void => { + subjects.set(`${subject.type}\0${String(subject.id)}`, subject); + }; + + // Matching the legacy report, empty catalog subjects are emitted only for statuses and + // group-type roles. PRS and concrete GRRL subjects are added below only when a permission row + // references them; mere existence as a person or group-role pairing is not enough. + 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.name, 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..131cf29 --- /dev/null +++ b/src/reports/permissions/hash.ts @@ -0,0 +1,25 @@ +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(); + // The old PHP report used 32-character fingerprints. MD5 is used only as a stable + // report fingerprint, never for authentication or integrity. + // Preserve its fingerprint for the empty JSON permission set (`md5("[]")`). + const serialized = values.length === 0 ? "[]" : values.join("\n"); + return createHash("md5").update(serialized).digest("hex"); +} diff --git a/src/reports/permissions/model.ts b/src/reports/permissions/model.ts new file mode 100644 index 0000000..4e4daa7 --- /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 historical by-object 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..82bde47 --- /dev/null +++ b/src/reports/permissions/render.ts @@ -0,0 +1,122 @@ +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}`; +} + +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[] }> + >(); + for (const [key, list] of bySubject) { + const subject = subjects.get(key); + if (!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 = 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(""); + } + 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 7917e96..6767f2c 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,9 +2,15 @@ import { describe, it, expect } from "vitest"; import { buildProgram } from "../src/index.js"; describe("ct program", () => { + it("exposes the package version", () => { + expect(buildProgram().version()).toBe("3.0.0-bwl"); + }); + it("registers the core command surface", () => { const names = buildProgram().commands.map((c) => c.name()); - expect(names).toEqual(expect.arrayContaining(["auth", "get", "adopt", "plan", "apply", "destroy"])); + expect(names).toEqual( + expect.arrayContaining(["auth", "get", "adopt", "report", "plan", "apply", "destroy"]), + ); }); it("exposes auth subcommands", () => { @@ -36,4 +42,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-report.test.ts b/tests/permission-report.test.ts new file mode 100644 index 0000000..04c98a3 --- /dev/null +++ b/tests/permission-report.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from "vitest"; +import { permissionReportTargets } from "../src/commands/report.js"; +import { + collectLivePermissions, + collectPermissionAssignments, + type ChurchAuthMasterData, +} 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 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])); + }); + + it("uses the legacy fingerprint for the empty permission set", () => { + expect(permissionSetHash([])).toBe("d751713988987e9331980363e24189ce"); + }); +}); + +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("renders known subjects with no rights under the shared empty-set hash", () => { + const text = renderBySubject( + dataset( + [], + [ + { type: "ST", id: 2, label: "Ohne Statusrechte" }, + { type: "GTRL", id: 3, label: "Team Beobachter" }, + ], + ), + ); + expect(text).toContain("# d751713988987e9331980363e24189ce"); + expect(text).toContain("## ST Ohne Statusrechte"); + expect(text).toContain("## GTRL Team Beobachter"); + expect(text).not.toMatch(/^\* /m); + }); +}); + +describe("churchauth collector", () => { + it("does not turn a legacy empty scope array into an unscoped grant", () => { + expect( + collectPermissionAssignments({ + auth_by_status: { Aktiv: { auth: { "502": [] }, resolved_auth: [] } }, + }), + ).toEqual({ + subjects: [{ type: "ST", id: "Aktiv", label: "Aktiv" }], + assignments: [], + }); + }); + + it("deduplicates a legacy array scope already represented by resolved_auth", () => { + const { assignments } = collectPermissionAssignments({ + auth_by_status: { + Aktiv: { + auth: { "502": ["0"] }, + resolved_auth: [ + { + "Wiki sehen [view category] (auth_table: 502)": [ + { "Gemeindeinformationen (cc_wikicategory: 0)": 0 }, + ], + }, + ], + }, + }, + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]?.object).toEqual({ + type: "cc_wikicategory", + id: "0", + label: "Gemeindeinformationen", + }); + }); + + it("preserves a trailing space in a legacy object label", () => { + const { assignments } = collectPermissionAssignments({ + auth_by_status: { + Aktiv: { + auth: { "502": { "15": "15" } }, + resolved_auth: [ + { + "Wiki sehen [view category] (auth_table: 502)": [ + { "EG Kinderkirche (cc_calcategory: 15)": 15 }, + ], + }, + ], + }, + }, + }); + expect(assignments[0]?.object?.label).toBe("EG Kinderkirche "); + expect(renderByObject(dataset(assignments))).toContain("## EG Kinderkirche (cc_calcategory: 15)"); + }); + + 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 data: ChurchAuthMasterData = { auth_by_status: {} }; + const result = await collectLivePermissions({ + get: async () => [] as T, + legacyPostForm: async (module: string, params: Record) => { + calls.push([module, params]); + return data as T; + }, + }); + expect(result).toEqual({ subjects: [], assignments: [] }); + expect(calls).toEqual([["churchauth/ajax", { func: "getMasterData" }]]); + }); + + it("joins live permission domains with right, subject and scope master data", 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]", + }, + ]), + ); + 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"); + }); + + it("keeps all subject types and non-declarable dimensions", () => { + const data: ChurchAuthMasterData = { + auth_by_person: { + Anna: { + person: "Anna", + person_id: "10", + auth: { "305": { "1": "1" } }, + resolved_auth: [ + { + "Dienste bearbeiten [edit servicegroup] (auth_table: 305)": [ + { "Programm (cs_servicegroup: 1)": 1 }, + ], + }, + ], + }, + }, + auth_by_status: { + Aktiv: { + auth: { "17": "17" }, + resolved_auth: [{ "Vorlagen verwenden [use template] (auth_table: 17)": 17 }], + }, + }, + auth_by_grouptypes: { + EG: [ + { + grouptypeMemberstatus_id: "20", + grouptype: "EG", + membertype: "Leiter", + auth: { "502": { "-1": "-1" } }, + resolved_auth: [ + { "Wiki sehen [view category] (auth_table: 502)": [{ "alle (cc_wikicategory: -1)": -1 }] }, + ], + }, + ], + }, + auth_by_groups: { + Gruppe: { + Leiter: { + group_id: "30", + groupMemberstatus_id: "31", + role: "Leiter", + auth: { "1105": { "123": "123" } }, + resolved_auth: [ + { "Gruppe bearbeiten [edit group] (auth_table: 1105)": [{ "Gruppe (cdb_gruppe: 123)": 123 }] }, + ], + }, + }, + }, + }; + const { assignments } = collectPermissionAssignments(data); + expect(assignments.map((a) => a.subject.type)).toEqual( + expect.arrayContaining(["PRS", "ST", "GTRL", "GRRL"]), + ); + expect(assignments.some((a) => a.object?.type === "cc_wikicategory" && a.object.id === "-1")).toBe(true); + expect(assignments.some((a) => a.object?.type === "cs_servicegroup")).toBe(true); + }); +}); From 9f76252bf6de8fa96260a51d10e566744b620573 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Thu, 20 Aug 2026 21:16:59 +0200 Subject: [PATCH 2/5] chore: ignore local IDE and release artifacts --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 4e89127..5d91674 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ dist/ *.log .DS_Store +# Local IDE metadata and locally compiled release artifacts +/.idea/ +/release/ + # Env & secrets — tokens live in the OS keychain or an untracked .env, never in git .env .env.* From dcf9a708fa443885da30ef2ce1f5c14ef6e7c0b2 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Mon, 24 Aug 2026 23:21:04 +0200 Subject: [PATCH 3/5] fix(report): address permission report review --- .gitignore | 4 - docs/handbuch/permissions.md | 22 ++- src/permissions/catalog-store.ts | 39 +---- src/permissions/masterdata.ts | 86 ++++++++++ src/reports/permissions/collect.ts | 251 +++------------------------- src/reports/permissions/hash.ts | 8 +- src/reports/permissions/model.ts | 2 +- src/reports/permissions/render.ts | 14 ++ tests/permission-masterdata.test.ts | 64 +++++++ tests/permission-report.test.ts | 133 +-------------- 10 files changed, 223 insertions(+), 400 deletions(-) create mode 100644 src/permissions/masterdata.ts create mode 100644 tests/permission-masterdata.test.ts diff --git a/.gitignore b/.gitignore index 5d91674..4e89127 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,6 @@ dist/ *.log .DS_Store -# Local IDE metadata and locally compiled release artifacts -/.idea/ -/release/ - # Env & secrets — tokens live in the OS keychain or an untracked .env, never in git .env .env.* diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index c202fee..e13a264 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,7 +5,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 3ce80118edd98c48 +sources_hash: f98e4836777ab248 reviewed: 2026-08-17 --- @@ -41,14 +41,18 @@ 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`) ohne eigene Zuweisung erscheinen ebenfalls: Sie teilen -sich wie im Legacy-Report den Hash `d751713988987e9331980363e24189ce` der leeren -Rechte-Menge. Personen (`PRS`) und konkrete Gruppenrollen (`GRRL`) werden dagegen -nur aufgeführt, wenn mindestens eine direkte Permission existiert. Über Status -oder Rollen geerbte effektive Personenrechte werden nicht zu einem synthetischen -Personen-Rechte-Set verrechnet. `object` beantwortet „Wer -darf auf dieses Objekt was?“ und verwendet die historische Sortierung für -Status, Gruppentyp, Gruppe und Person, soweit diese Subjekttypen vorhanden sind. +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. `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 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..c604807 --- /dev/null +++ b/src/permissions/masterdata.ts @@ -0,0 +1,86 @@ +import type { CtClient } from "../api/ctClient.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", + }); + const master = response.data ?? response; + if (!master.auth_table || typeof master.auth_table !== "object" || Array.isArray(master.auth_table)) { + throw new Error( + "Unexpected response from churchauth/ajax getMasterData: no data.auth_table. The legacy endpoint " + + "or its shape may have changed.", + ); + } + return { + auth_table: master.auth_table, + ...(master.churchauth ? { churchauth: master.churchauth } : {}), + }; +} + +/** Normalize auth_table once for every consumer of the ChurchAuth master-data response. */ +export function permissionRightDefinitions(master: ChurchAuthMasterData): 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)) { + throw new Error( + `Unexpected response from churchauth/ajax getMasterData: duplicate authId ${authId} ` + + `at ${module}:${technicalName}.`, + ); + } + 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 index 71bf0da..3d93069 100644 --- a/src/reports/permissions/collect.ts +++ b/src/reports/permissions/collect.ts @@ -1,221 +1,29 @@ import type { CtClient } from "../../api/ctClient.js"; import { fetchPermissionRows, type PermissionReader } from "../../permissions/fetch.js"; import type { RawPermission } from "../../permissions/grants.js"; -import type { - PermissionAssignment, - PermissionDataset, - PermissionObject, - PermissionSubject, -} from "./model.js"; - -interface RawEntry { - [key: string]: unknown; -} -interface RawAuthSubject extends RawEntry { - person?: string; - person_id?: string | number; - grouptype?: string; - grouptypeMemberstatus_id?: string | number; - membertype?: string; - group?: string; - group_id?: string | number; - role?: string; - groupMemberstatus_id?: string | number; - auth?: Record; - resolved_auth?: Array>; -} - -export interface ChurchAuthMasterData { - auth_by_person?: Record; - auth_by_status?: Record; - auth_by_grouptypes?: Record; - auth_by_groups?: Record>; -} - -interface ChurchAuthResponse { - data?: ChurchAuthMasterData & LiveMasterData; - response?: ChurchAuthMasterData; - auth_by_person?: ChurchAuthMasterData["auth_by_person"]; - auth_by_status?: ChurchAuthMasterData["auth_by_status"]; - auth_by_grouptypes?: ChurchAuthMasterData["auth_by_grouptypes"]; - auth_by_groups?: ChurchAuthMasterData["auth_by_groups"]; -} - -interface RawRightDefinition { - id?: string | number; - datenfeld?: string | null; - bezeichnung?: string; -} - -type MasterDataTable = Record | unknown[]; - -interface LiveMasterData { - auth_table?: Record>; - churchauth?: Record; -} - -interface LiveRight { - authId: number; - name: string; - technicalName: string; - scopeField: string | null; -} - -const RIGHT_RE = /^(.*?)\s+\[([^\]]+)\]\s+\(auth_table:\s*([^)]+)\)$/; -// Consume exactly the one separator space inserted before the metadata. Any additional space -// belongs to the ChurchTools label and must remain visible in this lossless report. -const OBJECT_RE = /^(.*) \(([^:()]+):\s*([^)]+)\)$/; - -function rightInfo( - value: string, - authId: string, -): { name: string; technicalName?: string; authId: string | number } { - const match = RIGHT_RE.exec(value.trim()); - return match - ? { name: match[1] ?? value.trim(), technicalName: match[2], authId: match[3] || authId } - : { name: value.trim(), authId }; -} - -function objectInfo(value: string, id: string): PermissionObject { - const match = OBJECT_RE.exec(value.trim()); - return match - ? { label: match[1] ?? value.trim(), type: match[2] ?? "unknown", id: match[3] ?? id } - : { label: value.trim(), type: "unknown", id }; -} - -function addResolved( - subject: PermissionSubject, - raw: RawAuthSubject, - output: PermissionAssignment[], -): Set { - const seen = new Set(); - for (const item of raw.resolved_auth ?? []) { - for (const [rightLabel, objects] of Object.entries(item)) { - const parsed = rightInfo(rightLabel, rightLabel); - const authId = String(parsed.authId); - if (Array.isArray(objects)) { - for (const entry of objects) { - if (!entry || typeof entry !== "object") continue; - for (const [label, id] of Object.entries(entry)) { - const object = objectInfo(label, String(id)); - output.push({ subject, right: parsed, object }); - seen.add(`${authId}:${String(id)}`); - } - } - } else { - output.push({ subject, right: parsed }); - seen.add(`${authId}:`); - } - } - } - return seen; -} - -function addRawFallback( - subject: PermissionSubject, - raw: RawAuthSubject, - output: PermissionAssignment[], - seen: Set, -): void { - for (const [authId, scope] of Object.entries(raw.auth ?? {})) { - // The legacy PHP fixture uses [] for "no assignment" on a few catalog rights. It is not an - // unscoped grant and must not become a synthetic `authId N [N]` report line. - if (Array.isArray(scope) && scope.length === 0) continue; - if (Array.isArray(scope)) { - for (const value of scope) { - const id = String(value); - if (!seen.has(`${authId}:${id}`)) { - output.push({ - subject, - right: { authId, name: `authId ${authId}` }, - object: objectInfo("Objekt", id), - }); - } - } - continue; - } - if (scope && typeof scope === "object" && !Array.isArray(scope)) { - for (const [id] of Object.entries(scope)) { - if (!seen.has(`${authId}:${id}`)) - output.push({ - subject, - right: { authId, name: `authId ${authId}` }, - object: objectInfo(`Objekt`, id), - }); - } - } else if (!seen.has(`${authId}:`)) { - output.push({ subject, right: { authId, name: `authId ${authId}` } }); - } - } -} - -function readSubject( - type: string, - id: string | number, - label: string, - raw: RawAuthSubject, - objectLabel?: string, -): { subject: PermissionSubject; assignments: PermissionAssignment[] } { - const subject = { type, id, label, ...(objectLabel ? { objectLabel } : {}) }; - const result: PermissionAssignment[] = []; - const seen = addResolved(subject, raw, result); - addRawFallback(subject, raw, result, seen); - return { subject, assignments: result }; -} - -export function collectPermissionAssignments(data: ChurchAuthMasterData): PermissionDataset { - const subjects: PermissionSubject[] = []; - const assignments: PermissionAssignment[] = []; - const add = (entry: ReturnType): void => { - subjects.push(entry.subject); - assignments.push(...entry.assignments); - }; - for (const [label, raw] of Object.entries(data.auth_by_person ?? {})) { - add(readSubject("PRS", raw.person_id ?? label, label, raw)); - } - for (const [label, raw] of Object.entries(data.auth_by_status ?? {})) { - add(readSubject("ST", label, label, raw)); - } - for (const [groupType, rows] of Object.entries(data.auth_by_grouptypes ?? {})) { - for (const raw of rows ?? []) { - const role = raw.membertype ?? ""; - const label = `${groupType.split(/\s+/)[0] ?? groupType} ${role}`.trim(); - add(readSubject("GTRL", raw.grouptypeMemberstatus_id ?? label, label, raw, `${groupType}: [${role}]`)); - } - } - for (const [group, roles] of Object.entries(data.auth_by_groups ?? {})) { - for (const [role, raw] of Object.entries(roles ?? {})) { - const row = raw as RawAuthSubject; - const label = `${group} ${row.role ?? role}`.trim(); - add( - readSubject( - "GRRL", - row.groupMemberstatus_id ?? `${row.group_id ?? group}:${role}`, - label, - row, - `${group}: [${row.role ?? role}]`, - ), - ); - } - } - return { subjects, assignments }; -} +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 [masterResponse, person, status, groupTypeRole, groupRole] = await Promise.all([ - client.legacyPostForm("churchauth/ajax", { func: "getMasterData" }), + 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"), ]); - const master = (masterResponse.data ?? masterResponse) as LiveMasterData; return collectLiveRows(master, { person, status, group_type_role: groupTypeRole, group_role: groupRole }); } -function rowsById(table: MasterDataTable | undefined): Map> { +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)) { @@ -237,24 +45,6 @@ function labelOf(row: Record | undefined, fallback: string): st return fallback; } -function liveRightCatalog(authTable: LiveMasterData["auth_table"]): Map { - const result = new Map(); - for (const [module, definitions] of Object.entries(authTable ?? {})) { - for (const [technicalName, raw] of Object.entries(definitions ?? {})) { - const authId = Number(raw.id); - if (!Number.isFinite(authId) || result.has(authId)) continue; - result.set(authId, { - authId, - name: raw.bezeichnung?.trim() || `${module}:${technicalName}`, - technicalName, - scopeField: - raw.datenfeld == null || String(raw.datenfeld).trim() === "" ? null : String(raw.datenfeld), - }); - } - } - return result; -} - function liveSubject( domainType: keyof LiveRows, domainId: number, @@ -307,11 +97,13 @@ function liveSubject( type LiveRows = Record<"person" | "status" | "group_type_role" | "group_role", RawPermission[]>; -function collectLiveRows(master: LiveMasterData, rows: LiveRows): PermissionDataset { +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 = liveRightCatalog(master.auth_table); + const rights = new Map( + permissionRightDefinitions(master).map((definition) => [definition.authId, definition]), + ); const result = new Map(); const subjects = new Map(); @@ -319,9 +111,10 @@ function collectLiveRows(master: LiveMasterData, rows: LiveRows): PermissionData subjects.set(`${subject.type}\0${String(subject.id)}`, subject); }; - // Matching the legacy report, empty catalog subjects are emitted only for statuses and - // group-type roles. PRS and concrete GRRL subjects are added below only when a permission row - // references them; mere existence as a person or group-role pairing is not enough. + // 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)); @@ -334,7 +127,11 @@ function collectLiveRows(master: LiveMasterData, rows: LiveRows): PermissionData const assignment: PermissionAssignment = { subject, right: right - ? { authId: right.authId, name: right.name, technicalName: right.technicalName } + ? { + authId: right.authId, + name: right.description.trim() || `${right.module}:${right.technicalName}`, + technicalName: right.technicalName, + } : { authId: row.authId, name: `authId ${row.authId}` }, effect: row.type, }; diff --git a/src/reports/permissions/hash.ts b/src/reports/permissions/hash.ts index 131cf29..db3d33d 100644 --- a/src/reports/permissions/hash.ts +++ b/src/reports/permissions/hash.ts @@ -17,9 +17,7 @@ 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(); - // The old PHP report used 32-character fingerprints. MD5 is used only as a stable - // report fingerprint, never for authentication or integrity. - // Preserve its fingerprint for the empty JSON permission set (`md5("[]")`). - const serialized = values.length === 0 ? "[]" : values.join("\n"); - return createHash("md5").update(serialized).digest("hex"); + // 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 index 4e4daa7..0b446e9 100644 --- a/src/reports/permissions/model.ts +++ b/src/reports/permissions/model.ts @@ -4,7 +4,7 @@ export interface PermissionSubject { type: PermissionSubjectType; id: string | number; label: string; - /** Expanded label used by the historical by-object report, e.g. "Group: [Role]". */ + /** Expanded label used by the object-oriented report, e.g. "Group: [Role]". */ objectLabel?: string; } diff --git a/src/reports/permissions/render.ts b/src/reports/permissions/render.ts index 82bde47..9cc236c 100644 --- a/src/reports/permissions/render.ts +++ b/src/reports/permissions/render.ts @@ -41,9 +41,14 @@ export function renderBySubject(dataset: PermissionDataset): string { 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 }); @@ -74,6 +79,15 @@ export function renderBySubject(dataset: PermissionDataset): string { } 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"; } diff --git a/tests/permission-masterdata.test.ts b/tests/permission-masterdata.test.ts new file mode 100644 index 0000000..91ac9d3 --- /dev/null +++ b/tests/permission-masterdata.test.ts @@ -0,0 +1,64 @@ +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 and duplicate right ids instead of letting consumers disagree", () => { + expect(() => + permissionRightDefinitions({ + auth_table: { churchdb: { view: { bezeichnung: "Ansehen" } } }, + }), + ).toThrow(/has no numeric id/); + + expect(() => + permissionRightDefinitions({ + auth_table: { + churchdb: { view: { id: 1 } }, + churchwiki: { view: { id: 1 } }, + }, + }), + ).toThrow(/duplicate authId 1/); + }); +}); diff --git a/tests/permission-report.test.ts b/tests/permission-report.test.ts index 04c98a3..f47407c 100644 --- a/tests/permission-report.test.ts +++ b/tests/permission-report.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest"; import { permissionReportTargets } from "../src/commands/report.js"; -import { - collectLivePermissions, - collectPermissionAssignments, - type ChurchAuthMasterData, -} from "../src/reports/permissions/collect.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"; @@ -87,10 +83,6 @@ describe("permission report hashing", () => { const value = assignment({ type: "PRS", id: 1, label: "Person" }); expect(permissionSetHash([value])).toBe(permissionSetHash([value, value])); }); - - it("uses the legacy fingerprint for the empty permission set", () => { - expect(permissionSetHash([])).toBe("d751713988987e9331980363e24189ce"); - }); }); describe("permission report renderers", () => { @@ -125,7 +117,7 @@ describe("permission report renderers", () => { expect(renderByObject(dataset(assignments))).toContain("## Personen sehen (auth_table: 1)"); }); - it("renders known subjects with no rights under the shared empty-set hash", () => { + it("renders known subjects with no rights explicitly and without a fingerprint", () => { const text = renderBySubject( dataset( [], @@ -135,67 +127,16 @@ describe("permission report renderers", () => { ], ), ); - expect(text).toContain("# d751713988987e9331980363e24189ce"); + 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("does not turn a legacy empty scope array into an unscoped grant", () => { - expect( - collectPermissionAssignments({ - auth_by_status: { Aktiv: { auth: { "502": [] }, resolved_auth: [] } }, - }), - ).toEqual({ - subjects: [{ type: "ST", id: "Aktiv", label: "Aktiv" }], - assignments: [], - }); - }); - - it("deduplicates a legacy array scope already represented by resolved_auth", () => { - const { assignments } = collectPermissionAssignments({ - auth_by_status: { - Aktiv: { - auth: { "502": ["0"] }, - resolved_auth: [ - { - "Wiki sehen [view category] (auth_table: 502)": [ - { "Gemeindeinformationen (cc_wikicategory: 0)": 0 }, - ], - }, - ], - }, - }, - }); - expect(assignments).toHaveLength(1); - expect(assignments[0]?.object).toEqual({ - type: "cc_wikicategory", - id: "0", - label: "Gemeindeinformationen", - }); - }); - - it("preserves a trailing space in a legacy object label", () => { - const { assignments } = collectPermissionAssignments({ - auth_by_status: { - Aktiv: { - auth: { "502": { "15": "15" } }, - resolved_auth: [ - { - "Wiki sehen [view category] (auth_table: 502)": [ - { "EG Kinderkirche (cc_calcategory: 15)": 15 }, - ], - }, - ], - }, - }, - }); - expect(assignments[0]?.object?.label).toBe("EG Kinderkirche "); - expect(renderByObject(dataset(assignments))).toContain("## EG Kinderkirche (cc_calcategory: 15)"); - }); - it("keeps live categories distinct when only one has a trailing space", async () => { const rows = { "/permissions/person": [ @@ -242,19 +183,18 @@ describe("churchauth collector", () => { it("uses the existing authenticated AJAX reader without contacting a live instance in the test", async () => { const calls: Array<[string, Record]> = []; - const data: ChurchAuthMasterData = { auth_by_status: {} }; const result = await collectLivePermissions({ get: async () => [] as T, legacyPostForm: async (module: string, params: Record) => { calls.push([module, params]); - return data as T; + return { data: { auth_table: {}, churchauth: {} } } as T; }, }); expect(result).toEqual({ subjects: [], assignments: [] }); expect(calls).toEqual([["churchauth/ajax", { func: "getMasterData" }]]); }); - it("joins live permission domains with right, subject and scope master data", async () => { + 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 }], @@ -349,6 +289,8 @@ describe("churchauth collector", () => { }, ]), ); + // 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); @@ -357,61 +299,4 @@ describe("churchauth collector", () => { expect(subjectReport).toContain("## ST Ohne Rechte"); expect(subjectReport).toContain("## GTRL Eventgruppe Beobachter"); }); - - it("keeps all subject types and non-declarable dimensions", () => { - const data: ChurchAuthMasterData = { - auth_by_person: { - Anna: { - person: "Anna", - person_id: "10", - auth: { "305": { "1": "1" } }, - resolved_auth: [ - { - "Dienste bearbeiten [edit servicegroup] (auth_table: 305)": [ - { "Programm (cs_servicegroup: 1)": 1 }, - ], - }, - ], - }, - }, - auth_by_status: { - Aktiv: { - auth: { "17": "17" }, - resolved_auth: [{ "Vorlagen verwenden [use template] (auth_table: 17)": 17 }], - }, - }, - auth_by_grouptypes: { - EG: [ - { - grouptypeMemberstatus_id: "20", - grouptype: "EG", - membertype: "Leiter", - auth: { "502": { "-1": "-1" } }, - resolved_auth: [ - { "Wiki sehen [view category] (auth_table: 502)": [{ "alle (cc_wikicategory: -1)": -1 }] }, - ], - }, - ], - }, - auth_by_groups: { - Gruppe: { - Leiter: { - group_id: "30", - groupMemberstatus_id: "31", - role: "Leiter", - auth: { "1105": { "123": "123" } }, - resolved_auth: [ - { "Gruppe bearbeiten [edit group] (auth_table: 1105)": [{ "Gruppe (cdb_gruppe: 123)": 123 }] }, - ], - }, - }, - }, - }; - const { assignments } = collectPermissionAssignments(data); - expect(assignments.map((a) => a.subject.type)).toEqual( - expect.arrayContaining(["PRS", "ST", "GTRL", "GRRL"]), - ); - expect(assignments.some((a) => a.object?.type === "cc_wikicategory" && a.object.id === "-1")).toBe(true); - expect(assignments.some((a) => a.object?.type === "cs_servicegroup")).toBe(true); - }); }); From 5a91587e99a44035fcd3d61368f86a0776f8536d Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 25 Aug 2026 18:06:43 +0200 Subject: [PATCH 4/5] fix(report): create output dirs, order report rows, soften master-data reads Review follow-ups on #133: - `ct report permissions` creates its output directories. The documented invocation writes into `reports/`, which this branch gitignores and which does not exist in a fresh clone, so the command ran the full live collection and then died with a raw ENOENT. - `fetchChurchAuthMasterData` falls back per FIELD again, not per envelope. `response.data ?? response` rejected a payload split across `data` and the top level, which the code it replaced accepted. - A duplicate authId warns and keeps the first definition instead of throwing. `ct permissions catalog --refresh` is the only way to act on a staleness warning and must degrade, not die, on an instance whose plugin set aliases a right under two modules. - The subject report sorts each hash group's rights and drops exact duplicates. The bullet de-duplication only collapsed adjacent repeats, so a subject whose rows for one right were not contiguous got the same right several times, each with a subset of its objects. The report is now independent of API row order and diff-stable. - docs/handbuch/permissions.md declares src/reports/permissions/** and src/commands/report.ts as sources, so the staleness gate covers the code it documents, and records the new row ordering. Claude-Session: https://claude.ai/code/session_01SaAzHDgDPSvLnfkKcnDj37 --- docs/handbuch/permissions.md | 15 +++++--- src/commands/report.ts | 14 ++++++- src/permissions/masterdata.ts | 33 +++++++++++----- src/reports/permissions/render.ts | 32 +++++++++++++++- tests/permission-masterdata.test.ts | 39 +++++++++++++++---- tests/permission-report.test.ts | 59 ++++++++++++++++++++++++++++- 6 files changed, 166 insertions(+), 26 deletions(-) diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index e13a264..08a7b42 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -2,11 +2,13 @@ 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: f98e4836777ab248 -reviewed: 2026-08-17 +sources_hash: ed81ccd01fe7a4d5 +reviewed: 2026-08-25 --- # Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`) @@ -50,9 +52,12 @@ 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. `object` beantwortet „Wer darf auf dieses Objekt -was?“ und sortiert vorhandene Subjekttypen fest nach Status, Gruppentyp, Gruppe -und Person. +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 diff --git a/src/commands/report.ts b/src/commands/report.ts index d678a04..17170b2 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,5 +1,5 @@ -import { writeFile } from "node:fs/promises"; -import { format, parse } from "node:path"; +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"; @@ -54,6 +54,15 @@ export function permissionReportTargets(opts: ReportOptions): PermissionReportTa 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)") @@ -63,6 +72,7 @@ export function reportCommand(): Command { .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); diff --git a/src/permissions/masterdata.ts b/src/permissions/masterdata.ts index c604807..dd13796 100644 --- a/src/permissions/masterdata.ts +++ b/src/permissions/masterdata.ts @@ -1,4 +1,5 @@ import type { CtClient } from "../api/ctClient.js"; +import { warn } from "../ui.js"; export interface RawChurchAuthRight { id?: string | number; @@ -39,21 +40,31 @@ export async function fetchChurchAuthMasterData( const response = await client.legacyPostForm("churchauth/ajax", { func: "getMasterData", }); - const master = response.data ?? response; - if (!master.auth_table || typeof master.auth_table !== "object" || Array.isArray(master.auth_table)) { + // 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: master.auth_table, - ...(master.churchauth ? { churchauth: master.churchauth } : {}), + auth_table: authTable, + ...(churchauth ? { churchauth } : {}), }; } -/** Normalize auth_table once for every consumer of the ChurchAuth master-data response. */ -export function permissionRightDefinitions(master: ChurchAuthMasterData): PermissionRightDefinition[] { +/** + * 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)) { @@ -65,10 +76,14 @@ export function permissionRightDefinitions(master: ChurchAuthMasterData): Permis ); } if (seen.has(authId)) { - throw new Error( - `Unexpected response from churchauth/ajax getMasterData: duplicate authId ${authId} ` + - `at ${module}:${technicalName}.`, + // 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(); diff --git a/src/reports/permissions/render.ts b/src/reports/permissions/render.ts index 9cc236c..c849252 100644 --- a/src/reports/permissions/render.ts +++ b/src/reports/permissions/render.ts @@ -22,6 +22,36 @@ function subjectRightText(a: PermissionAssignment): string { 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(); @@ -66,7 +96,7 @@ export function renderBySubject(dataset: PermissionDataset): string { } // 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 = subjectSets[0]?.assignments ?? []; + const rights = orderedAssignments(subjectSets[0]?.assignments ?? []); let lastRight = ""; if (rights.length > 0) lines.push(""); for (const a of rights) { diff --git a/tests/permission-masterdata.test.ts b/tests/permission-masterdata.test.ts index 91ac9d3..a6d873e 100644 --- a/tests/permission-masterdata.test.ts +++ b/tests/permission-masterdata.test.ts @@ -45,20 +45,45 @@ describe("shared ChurchAuth master-data decoder", () => { ).rejects.toThrow(/no data\.auth_table/); }); - it("rejects malformed and duplicate right ids instead of letting consumers disagree", () => { + it("rejects malformed right ids instead of letting consumers disagree", () => { expect(() => permissionRightDefinitions({ auth_table: { churchdb: { view: { bezeichnung: "Ansehen" } } }, }), ).toThrow(/has no numeric id/); + }); - expect(() => - permissionRightDefinitions({ + 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 } }, - churchwiki: { view: { id: 1 } }, + churchdb: { view: { id: 1, bezeichnung: "Ansehen" } }, + churchwiki: { view: { id: 1, bezeichnung: "Ansehen (Wiki)" } }, }, - }), - ).toThrow(/duplicate authId 1/); + }, + (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 index f47407c..4a61b09 100644 --- a/tests/permission-report.test.ts +++ b/tests/permission-report.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { permissionReportTargets } from "../src/commands/report.js"; +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"; @@ -52,6 +55,26 @@ describe("permission report targets", () => { }); }); +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" }; @@ -117,6 +140,38 @@ describe("permission report renderers", () => { 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( From e6f2457ae6c314eb6847dff55648ddce9817157e Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 25 Aug 2026 18:07:53 +0200 Subject: [PATCH 5/5] fix(docs): re-sign permissions.md after the renderer reformat Claude-Session: https://claude.ai/code/session_01SaAzHDgDPSvLnfkKcnDj37 --- docs/handbuch/permissions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 08a7b42..9bb49d2 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -7,7 +7,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: ed81ccd01fe7a4d5 +sources_hash: eaea3f87de51a46f reviewed: 2026-08-25 ---