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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,9 @@ runnable example.

### Permissions

`ct.groupRole` / `ct.groupTypeRole` declare ChurchTools permission grants
(group-role and group-type-role rights) as code, reconciled with the same
`plan`/`apply` workflow:
`ct.groupRole` / `ct.groupTypeRole` / `ct.status` declare ChurchTools permission
grants (group-role, group-type-role and person-status rights) as code,
reconciled with the same `plan`/`apply` workflow:

```ts
ct.groupTypeRole({
Expand All @@ -244,6 +244,14 @@ ct.groupTypeRole({
{ right: "churchgroup:view group", scope: ["kids_area"] }, // scoped to a managed group
],
});

ct.status({
key: "core_external_login",
personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90)
// A status grant reaches EVERY person carrying that status — the instance-wide lever.
// -1 is CT's "all values of this dimension" sentinel (here: every external system).
grants: [{ right: "churchcore:login to external system", scope: [-1] }],
});
```

Right names (`"module:right"`) are validated against a static, offline
Expand Down
59 changes: 50 additions & 9 deletions docs/permissions.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Permissions (`ct.groupRole` / `ct.groupTypeRole`)
# Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`)

Declare ChurchTools permission grants — group-role and group-type-role rights —
as code, and reconcile them idempotently with the same `ct plan` / `ct apply`
workflow used for structural resources (issue #13).
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).

## The two DSL functions
## The three DSL functions

```ts
export default (ct) => {
Expand All @@ -24,10 +24,17 @@ export default (ct) => {
// "edit group memberships of group" is a scoped right, so it takes a `scope: [...]`.
grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }],
});

ct.status({
key: "core_external_login",
personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90)
// -1 is ChurchTools' "all values of this dimension" sentinel (here: every external system).
grants: [{ right: "churchcore:login to external system", scope: [-1] }],
});
};
```

Both take `{ key, <domain>, grants }`, where `<domain>` is either a logical
All three take `{ key, <domain>, grants }`, where `<domain>` is either a logical
reference or a numeric `id`:

- **`key`** — the logical key, unique across the whole config (shared
Expand All @@ -44,6 +51,10 @@ reference or a numeric `id`:
Declaring both a logical form and a numeric `id` is a conflict and throws.
See "domainId semantics" for the resolution assumption still to be
confirmed live.
- `ct.status` — `personStatus: "<name>"` resolves against the live
`/statuses` catalog per host, or `id: <statusId>` targets one directly.
**Person** statuses ("0 - First", "3 - Group Active", …), not group statuses
— see "domainId semantics".
- **`grants`** — an array of `Grant`s, each either:
- a bare string, `"module:right"` — an **unscoped** grant, or
- an object `{ right: "module:right", scope: (string | number)[] }` — a
Expand Down Expand Up @@ -121,6 +132,20 @@ The two DSL functions manage two different ChurchTools "domain types," and
specific group's specific role, *not* the group's id and *not* the role's
id. Declare it portably as `group: "<key>", role: "<name>"` (resolved per
host, #25) or directly as `id: <domainId>`.
- **`status`** (`ct.status`) — the domain is a **person status's own id**
(`GET /statuses`: `0 Unbekannt`, `1 0 - First`, …). A grant here applies to
**every person carrying that status**, which makes it the only instance-wide
lever CT offers short of granting per person — and people domains are
permanently out of scope (`src/engine/guard.ts`). Declare it portably as
`personStatus: "<name>"` (#90) or directly as `id: <statusId>`. Note `id: 0`
is a real, declarable domain ("Unbekannt"), so the numeric guard is a type
check, not a truthiness one.

> **Person status ≠ group status.** `groupStatusId` (`ct.group`) is a
> different dimension with **no** REST catalog at all (#67) and must always be
> written as a number. Person statuses do have one (`GET /statuses`, flat
> array of `{id, name}` — live-verified 2026-08-10 on eqrm prod), so they
> resolve by name like campuses and group types.

> **ASSUMPTION — verify once on a live instance (`eqrm-dev`).** The reference
> form resolves by reading the group's own role list
Expand Down Expand Up @@ -222,6 +247,20 @@ therefore be a plain number instead of a string:
{ right: "churchdb:security level view own data", scope: [1, 2, 3, 5] },
```

A numeric entry must be an integer `>= 0`, or the special value `-1` —
ChurchTools' **"all values of this dimension" sentinel**. CT stores and reads
`-1` back verbatim (it is expanded only in the derived `/permissions/global`
view), so a declared `-1` diffs against a live `-1` and stays a clean no-op:

```ts
// every external system, present and future
{ right: "churchcore:login to external system", scope: [-1] },
```

Note `0` is a legitimate dataId on several dimensions (campus "Mainz" is id 0 on
eqrm prod), so it is accepted like any other — only values below `-1` and
non-integers are rejected.

Numeric entries pass straight through with no state lookup, no pending
resolution, and no re-resolution at apply time (their `dataId` is already
final). They can be freely mixed with logical group keys in the same `scope`
Expand All @@ -235,7 +274,7 @@ To bring an instance's existing rights under management without hand-transcribin
them, read the live rows and emit a paste-ready config block:

```bash
ct adopt grants group_type_role 42 # or: group_role, and the hyphenated group-type-role
ct adopt grants group_type_role 42 # or: group_role / status, and the hyphenated group-type-role
```

It fetches `GET /permissions/<domainType>/<domainId>`, runs the rows through the
Expand Down Expand Up @@ -276,8 +315,10 @@ to be accepted by `ct plan` (the round trip is locked by tests):
direct grants — **including** the writable `authId >= 10000` `churchdb:+…`
member rights CT lets you set on `group_type_role` — are emitted as active
grants (no authId cutoff; #65).
- **Only `group_role` / `group_type_role`** are valid; people domains are
refused (the same hard boundary as everywhere else).
- **Only `group_role` / `group_type_role` / `status`** are valid; people domains
are refused (the same hard boundary as everywhere else). A `status` block is
emitted with a numeric `id:` — rename it to the portable
`personStatus: "<name>"` form when you paste it in.

> **Warning — comment-only grants are pending revocations.** Reconciliation is
> set-based: a live grant absent from the pasted declaration lands in
Expand Down
14 changes: 14 additions & 0 deletions examples/permissions.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,18 @@ export default (ct: ConfigContext): void => {
role: "Leiter",
grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }],
});

// status (#90): the domain is a PERSON status, declared by name and resolved
// against the live `/statuses` catalog per host (numeric escape hatch:
// `id: <statusId>`). A grant here reaches EVERY person carrying that status,
// so this is the instance-wide lever — there is deliberately no per-person
// domain (people are never managed by this tool).
//
// `scope: [-1]` is ChurchTools' "all values of this dimension" sentinel — here
// "every external system". CT reads -1 back verbatim, so it stays a no-op.
ct.status({
key: "core_external_login",
personStatus: "5 - Core",
grants: [{ right: "churchcore:login to external system", scope: [-1] }],
});
};
8 changes: 4 additions & 4 deletions src/commands/adopt-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,23 @@ interface AdoptGrantsOptions {
/** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */
function normalizeDomainType(raw: string): DomainType {
const t = raw.trim().replace(/-/g, "_");
if (t === "group_role" || t === "group_type_role") return t;
if (t === "group_role" || t === "group_type_role" || t === "status") return t;
throw new Error(
`Invalid domain type "${raw}" — expected "group_role" or "group_type_role" (people domains are never managed).`,
`Invalid domain type "${raw}" — expected "group_role", "group_type_role" or "status" (people domains are never managed).`,
);
}

/**
* `ct adopt grants <domainType> <domainId>` — read the live permission rows for a domain and print
* a paste-ready `ct.groupRole` / `ct.groupTypeRole` config block. Grants are NOT state-tracked, so
* a paste-ready `ct.groupRole` / `ct.groupTypeRole` / `ct.status` config block. Grants are NOT state-tracked, so
* this prints config only; it never writes the state file (contrast `ct adopt <type> <id>`).
*/
export function adoptGrantsCommand(): Command {
return new Command("grants")
.description(
"Print a paste-ready grants config block from a live domain's permission rows (does not write state)",
)
.argument("<domainType>", "group_role | group_type_role")
.argument("<domainType>", "group_role | group_type_role | status")
.argument("<domainId>", "the domainId of the permission domain object")
.option(
"-s, --state <path>",
Expand Down
31 changes: 29 additions & 2 deletions src/config/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export interface PermissionInput {
group?: string;
/** `group_role`: the role name (paired with `group`) — resolves to the pairing domainId (#25). */
role?: string;
/** `status`: the PERSON status by name/key (`/statuses`) — sugars into a Ref-valued domainId (#90). */
personStatus?: string;
grants: Grant[];
}

Expand Down Expand Up @@ -153,8 +155,17 @@ function domainKeyPart(domainId: number | Ref): string {
* - `group_type_role`: numeric `id`, or logical `groupType: "<key>"` → `ref.groupType(...)`.
* - `group_role`: numeric `id`, or logical `group` + `role` → `ref.groupRole(...)` (the resolver
* maps the pair to its pairing domainId at plan time; see #25).
* - `status`: numeric `id`, or logical `personStatus: "<key>"` → `ref.personStatus(...)`, resolved
* against the `/statuses` catalog (#90).
* Declaring both a numeric `id` and a logical form is a conflict.
*/
/** The logical field name each domain type offers, for the "provide id or ..." error message. */
const LOGICAL_FIELD: Record<DomainType, string> = {
group_type_role: '"groupType"',
group_role: '"group" + "role"',
status: '"personStatus"',
};

function resolveDomainInput(domainType: DomainType, input: PermissionInput): number | Ref {
const hasId = input.id !== undefined;
const bothError = (logical: string): Error =>
Expand All @@ -168,6 +179,13 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num
throw new Error(`${domainType} "${input.key}": "groupType" must be a non-empty group-type key.`);
return ref.groupType(input.groupType);
}
} else if (domainType === "status") {
if (input.personStatus !== undefined) {
if (hasId) throw bothError('"personStatus"');
if (typeof input.personStatus !== "string" || !input.personStatus)
throw new Error(`${domainType} "${input.key}": "personStatus" must be a non-empty person-status key.`);
return ref.personStatus(input.personStatus);
}
} else {
// group_role
if (input.group !== undefined || input.role !== undefined) {
Expand All @@ -177,10 +195,11 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num
return ref.groupRole(input.group, input.role);
}
}
// A person status id may legitimately be 0 ("Unbekannt"), so this guard must stay a type/finite
// check — never a truthiness one.
if (typeof input.id !== "number" || !Number.isFinite(input.id)) {
const logical = domainType === "group_type_role" ? '"groupType"' : '"group" + "role"';
throw new Error(
`${domainType} "${input.key}": provide a numeric "id" (the domainId) or the logical ${logical} form.`,
`${domainType} "${input.key}": provide a numeric "id" (the domainId) or the logical ${LOGICAL_FIELD[domainType]} form.`,
);
}
return input.id;
Expand All @@ -198,6 +217,13 @@ export interface ConfigContext {
roleDefinition(input: ResourceInput): void;
groupRole(input: PermissionInput): void;
groupTypeRole(input: PermissionInput): void;
/**
* Grants on a PERSON status (`status` domain, #90) — they apply to every person carrying that
* status, so this is the instance-wide lever. Addressed by `personStatus: "<name/key>"` (resolved
* against `/statuses`) or the numeric `id:` escape hatch. Note that person statuses are a different
* dimension from group statuses (`groupStatusId`), which have no catalog at all (#67).
*/
status(input: PermissionInput): void;
}

export type ConfigModule = (ct: ConfigContext) => void | Promise<void>;
Expand Down Expand Up @@ -458,6 +484,7 @@ export function createContext(): {
roleDefinition: define("group-role"),
groupRole: definePermission("group_role"),
groupTypeRole: definePermission("group_type_role"),
status: definePermission("status"),
};
return { ct, resources, permissions };
}
Expand Down
1 change: 1 addition & 0 deletions src/permissions/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { normalizeActual, type DomainType, type GrantTuple, type RawPermission }
const DSL_FN: Record<DomainType, string> = {
group_role: "ct.groupRole",
group_type_role: "ct.groupTypeRole",
status: "ct.status",
};

/**
Expand Down
7 changes: 6 additions & 1 deletion src/permissions/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
* Actuals exclude the self-re-adding system baseline (modifiedPid === -1) and inherited rows,
* so reconciliation owns only user-authored grants and never fights the platform.
*/
export type DomainType = "group_role" | "group_type_role";
/**
* The permission domains this tool reconciles. `status` is CT's PERSON-status domain (#90) — a grant
* there applies to every person carrying that status, which is the only instance-wide lever CT offers
* short of granting per person (a people domain, permanently out of scope — see engine/guard.ts).
*/
export type DomainType = "group_role" | "group_type_role" | "status";

// What reconciliation owns vs. leaves untouched is decided SOLELY by `normalizeActual` below:
// it drops the self-re-adding system baseline (`modifiedPid === -1`) and any `isInherited` row, so
Expand Down
17 changes: 15 additions & 2 deletions src/permissions/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import type { GrantTuple } from "./grants.js";
*/
export interface ScopeResolution { key: string; id: number | null; numeric?: boolean }

/**
* ChurchTools' "every value of this dimension" dataId. CT both accepts it on write and reads it back
* verbatim, expanding it only in the derived `/permissions/global` view — so a declared `-1` diffs
* against a live `-1` and stays a clean no-op.
*/
export const ALL_SCOPE_SENTINEL = -1;

/**
* Resolve a scope array against DESIRED ∪ STATE. Each entry is either:
*
Expand Down Expand Up @@ -40,8 +47,14 @@ export function resolveScope(
const pending: ScopeResolution[] = [];
for (const key of scopeKeys) {
if (typeof key === "number") {
if (!Number.isInteger(key) || key <= 0) {
throw new Error(`Invalid numeric scope entry ${JSON.stringify(key)} — a numeric scope must be a positive integer dataId.`);
// `-1` is ChurchTools' ALL sentinel (verified live 2026-08-10: `churchcore:login to external
// system` with `dataId: -1` reads back through `/permissions/global` expanded to every external
// system id). `0` is a real dataId on more than one dimension (campus "Mainz" is id 0 on eqrm
// prod). So the floor is -1, not 1 — anything below that is a typo, not a sentinel.
if (!Number.isInteger(key) || key < ALL_SCOPE_SENTINEL) {
throw new Error(
`Invalid numeric scope entry ${JSON.stringify(key)} — a numeric scope must be an integer dataId (>= 0), or ${ALL_SCOPE_SENTINEL} for ChurchTools' "all" sentinel.`,
);
}
resolved.push({ key: String(key), id: key, numeric: true });
continue;
Expand Down
12 changes: 10 additions & 2 deletions src/resolve/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type RefKind =
| "campus"
| "group-type"
| "group-status"
| "person-status"
| "role-def"
| "group"
| "group-role"
Expand All @@ -42,10 +43,10 @@ export const GROUP_STATUS_NO_CATALOG =
`group statuses have no REST catalog (GET /group/memberstatus is a different dimension: member ` +
`statuses, string ids — verified 2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").`;

/** Simple key-addressed reference: campus / group type / group status / role definition / group. */
/** Simple key-addressed reference: campus / group type / group status / person status / role definition / group. */
export interface SimpleRef {
__ctRef: true;
kind: "campus" | "group-type" | "group-status" | "role-def" | "group";
kind: "campus" | "group-type" | "group-status" | "person-status" | "role-def" | "group";
key: string;
}

Expand Down Expand Up @@ -99,6 +100,13 @@ export const ref = {
campus: (key: string): SimpleRef => ({ __ctRef: true, kind: "campus", key: requireKey("campus", key) }),
groupType: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-type", key: requireKey("group-type", key) }),
status: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-status", key: requireKey("group-status", key) }),
/**
* A PERSON status (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `status`
* permission declaration. Unrelated to {@link ref.status} (GROUP status, `groupStatusId`), which
* has no catalog at all (#67) — person statuses do, so this one resolves by name like any other
* master-data ref.
*/
personStatus: (key: string): SimpleRef => ({ __ctRef: true, kind: "person-status", key: requireKey("person-status", key) }),
roleDef: (key: string): SimpleRef => ({ __ctRef: true, kind: "role-def", key: requireKey("role-def", key) }),
group: (key: string): SimpleRef => ({ __ctRef: true, kind: "group", key: requireKey("group", key) }),
/**
Expand Down
5 changes: 5 additions & 0 deletions src/resolve/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ const REF_KIND_TYPE: Partial<Record<RefKind, string>> = {
const CATALOG_PATH: Partial<Record<RefKind, string>> = {
campus: "/campuses",
"group-type": "/group/grouptypes",
// PERSON statuses — the domain of a `status` permission declaration (#90). Unlike GROUP statuses
// (see the note above), these DO have a flat REST catalog: `GET /statuses` returns
// `[{id, name, shorty, …}]` — live-verified 2026-08-10 on eqrm prod. (`/person/masterdata` carries
// the same rows under a `statuses` key, but nested; this catalog reader expects a top-level array.)
"person-status": "/statuses",
"role-def": "/group/roles",
};

Expand Down
Loading
Loading