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