Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 61 additions & 2 deletions docs/handbuch/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
85 changes: 85 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
@@ -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 <base>.");
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<void> {
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 <base>", "write both reports with _by-subject/_by-object suffixes")
.option("-e, --env <name>", "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);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand Down
39 changes: 9 additions & 30 deletions src/permissions/catalog-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -94,18 +95,6 @@ function assertCatalogShape(parsed: Record<string, unknown>, 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<string, Record<string, RawRight>> };
auth_table?: Record<string, Record<string, RawRight>>;
}

/**
* Capture the catalog from a live instance and return it in `catalog.json`'s exact schema.
*
Expand All @@ -117,25 +106,15 @@ interface MasterData {
export async function capturePermissionCatalog(
client: Pick<CtClient, "legacyPostForm" | "host" | "version">,
): Promise<Record<string, unknown>> {
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<string, CatalogEntry> = {};
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 {
Expand Down
101 changes: 101 additions & 0 deletions src/permissions/masterdata.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | unknown[];

export interface ChurchAuthMasterData {
auth_table: Record<string, Record<string, RawChurchAuthRight>>;
churchauth?: Record<string, ChurchAuthMasterDataTable>;
}

interface ChurchAuthMasterDataResponse {
data?: Partial<ChurchAuthMasterData>;
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<CtClient, "legacyPostForm">,
): Promise<ChurchAuthMasterData> {
const response = await client.legacyPostForm<ChurchAuthMasterDataResponse>("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<number>();
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;
}
Loading
Loading