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
83 changes: 63 additions & 20 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ reference or a numeric `id`:
logical form and a numeric `id` is a conflict and throws.
- **`grants`** — an array of `Grant`s, each either:
- a bare string, `"module:right"` — an **unscoped** grant, or
- an object `{ right: "module:right", scope: string[] }` — a **scoped**
grant, where `scope` is a list of logical keys of managed groups
(resolved to their ChurchTools `dataId`s at plan time — see "Scope
resolution" below).
- an object `{ right: "module:right", scope: (string | number)[] }` — a
**scoped** grant, where each `scope` entry is either a logical key of a
managed group, or a raw numeric `dataId` (the escape hatch, #49 — see
"Scope resolution" below).

## Discovering right names — `ct get permissions-catalog`

Expand Down Expand Up @@ -96,9 +96,10 @@ other's grants forever) — even if one used a name and the other a raw id.

## Scope resolution

A scoped grant's `scope: [...]` is a list of **logical keys of groups managed
by this tool** (declared via `ct.group` or adopted into state) — not raw
ChurchTools ids. Each key is resolved against **desired ∪ state**
A scoped grant's `scope: [...]` is a list where each entry is either a
**logical key of a group managed by this tool** (declared via `ct.group` or
adopted into state), or a **raw numeric `dataId`** (the escape hatch — see
below). String entries are resolved against **desired ∪ state**
(`src/permissions/scope.ts`):

- A key already in state resolves to that group's `dataId`.
Expand All @@ -110,18 +111,48 @@ ChurchTools ids. Each key is resolved against **desired ∪ state**

```
Scope key "kids_area" does not resolve to a managed group. Declare/adopt it,
or use a group already under management.
use a group already under management, or pass a raw numeric dataId if this
right's scope is not a group (see the catalog's scopeField).
```

The requirement that scope targets be tool-visible is deliberate: so `ct plan`
can show what a grant resolves to, and so renaming/re-keying a group doesn't
silently orphan a grant's scope.

**Re-resolution at apply time.** Every scoped tuple retains its symbolic scope
key. Immediately before grants are written (after the resource tier has run),
each key is re-resolved against the post-execute state. This means a group
*created* or *recreated* in the same apply always gets its grant written with
its fresh `dataId`, never a pending placeholder or a stale, dangling id.
**Re-resolution at apply time.** Every scoped tuple resolved from a logical
group key retains its symbolic scope key. Immediately before grants are
written (after the resource tier has run), each key is re-resolved against the
post-execute state. This means a group *created* or *recreated* in the same
apply always gets its grant written with its fresh `dataId`, never a pending
placeholder or a stale, dangling id.

### Numeric scope escape hatch (#49)

Not every scoped right's `scope` dimension is a **group**. The catalog's
`scopeField` names the actual ChurchTools data-field a scoped right applies
to (`src/permissions/catalog.json`) — for most scoped rights that field is
`"cdb_gruppe"` (a group), but some rights scope by something else entirely,
e.g.:

- `churchdb:view comments` → `scopeField: "cdb_comment_viewer"`
- `churchdb:security level view own data` / `edit own data` → `scopeField:
"cc_securitylevel"`

For these, a `dataId` like `1`, `2`, `3` names a security level or a
comment-viewer bucket — **not** a group — so `GET /groups/{1,2,3}` 404s and
there is no logical/managed key to reference it by. A `scope` array entry may
therefore be a plain number instead of a string:

```ts
{ right: "churchdb:security level view own data", scope: [1, 2, 3, 5] },
```

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`
array. `ct adopt grants` emits this form automatically for any scoped right
whose `scopeField` is not the group dimension (see below) — never a `ct adopt
group <id>` hint for a dataId that was never a group.

## Adopting existing grants — `ct adopt grants <domainType> <domainId>`

Expand All @@ -145,13 +176,25 @@ to be accepted by `ct plan` (the round trip is locked by tests):
- **`authId` → `module:right` via the catalog** (reverse lookup). An `authId`
with no catalog entry becomes a `WARNING` comment (regenerate the catalog or add
the right by hand) rather than failing the whole adoption.
- **Scoped rights** carry a group `dataId`. If it matches a group **managed in
your state file**, the scope is emitted as that group's logical key
(`scope: ["kids"]`). If it is unmanaged, you get a clearly-marked placeholder
comment telling you to `ct adopt group <id>` first — scope keys must be state
keys (see [Scope resolution](#scope-resolution)). A scoped right granted
**globally** in CT (row with no `dataId`) is a `WARNING` comment too — the DSL
deliberately cannot declare a global grant of a scoped right.
- **Scoped rights** are resolved by the right's actual `scopeField`
(#49) — only rights scoped by the **group** dimension
(`scopeField: "cdb_gruppe"`) are round-tripped as logical group refs; every
other scope dimension is emitted as the [numeric escape
hatch](#numeric-scope-escape-hatch-49) instead:
- **Group-scoped** (`cdb_gruppe`): if the `dataId` matches a group
**managed in your state file**, the scope is emitted as that group's
logical key (`scope: ["kids"]`). If it is unmanaged, you get a
clearly-marked placeholder comment telling you to `ct adopt group <id>`
first — scope keys must be state keys (see [Scope
resolution](#scope-resolution)).
- **Any other scope dimension** (`cc_securitylevel`, `cdb_comment_viewer`,
…): there is no group to adopt, so the `dataId`(s) are emitted directly
as a numeric `scope: [1, 2, 3]` — always an active line, with a comment
naming the right's actual scope dimension. `ct adopt group <id>` is never
suggested for these.
- A scoped right granted **globally** in CT (row with no `dataId`) is a
`WARNING` comment either way — the DSL deliberately cannot declare a
global grant of a scoped right.
- **Not-writable rights become `NOTE` comments.** On `group_type_role`, rights
with `authId >= 10000` (the `churchdb:+…` family) are readable via inheritance
but rejected at plan time (see "Domain rules" below), so they are never
Expand Down
4 changes: 4 additions & 0 deletions examples/permissions.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default (ct: ConfigContext): void => {
"churchgroup:view",
// Scoped: applies only to the listed managed group(s). authId 1104
// (`scopeField: "cdb_gruppe"` in the catalog — a scoped right).
// `scope` entries are logical group keys here; a right whose scopeField
// is NOT a group (e.g. "cc_securitylevel") instead takes a raw numeric
// dataId, e.g. `scope: [1, 2]` — see docs/permissions.md "Numeric scope
// escape hatch (#49)".
{ right: "churchgroup:view group", scope: ["kids_area"] },
],
});
Expand Down
14 changes: 12 additions & 2 deletions src/config/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,18 @@ export function createContext(): {
const right = typeof g === "string" ? g : g?.right;
if (typeof right !== "string" || !right.includes(":"))
throw new Error(`${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`);
if (typeof g === "object" && !Array.isArray(g.scope))
throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": string[].`);
if (typeof g === "object") {
if (!Array.isArray(g.scope))
throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": (string | number)[].`);
// Each entry is a logical group key, or a raw numeric dataId (#49 escape hatch — for scope
// dimensions that aren't groups, e.g. security levels, which have no logical/managed form).
for (const s of g.scope) {
if (typeof s === "string" ? s.length === 0 : typeof s !== "number")
throw new Error(
`${domainType} "${input.key}": scope entries must be a non-empty string (logical group key) or a number (raw dataId), got ${JSON.stringify(s)}.`,
);
}
}
}
if (seen.has(input.key)) throw new Error(`Duplicate logical key "${input.key}" in config.`);
seen.add(input.key);
Expand Down
5 changes: 5 additions & 0 deletions src/permissions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ changes materially.
- **authId** — numeric id sent in the `PermissionRequest` write body.
- **scopeField** — the ChurchTools data-field a scoped right applies to (`datenfeld`), or `null`
for an unscoped/global right. A non-null `scopeField` is a right that accepts a `dataId[]` scope.
Most scoped rights carry `scopeField: "cdb_gruppe"` (a group) — those are the only ones
declarable via a **logical group key** in the DSL's `scope: [...]`. Any other non-null
`scopeField` (`cc_securitylevel`, `cdb_comment_viewer`, `cdb_station`, …) names a dimension this
tool has no managed representation for, so it can only be declared via the **numeric scope
escape hatch** (`scope: [1, 2, 3]`, #49) — see `docs/permissions.md`.
- **revocable** — whether the right supports `type: "revoke"` (`isRevocable` in the source).
- **desc** — human description (`bezeichnung`).

Expand Down
52 changes: 40 additions & 12 deletions src/permissions/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,18 @@ const DSL_FN: Record<DomainType, string> = {
group_type_role: "ct.groupTypeRole",
};

/**
* The catalog `scopeField` value for rights that scope by GROUP. Only rights carrying this exact
* scopeField have a logical/managed representation (`ct.group` / state) to resolve their dataIds
* against — every other non-null scopeField (`cc_securitylevel`, `cdb_comment_viewer`, `cdb_station`,
* …) names a different ChurchTools dimension with no group under management to look up, so their
* dataIds pass through as the numeric scope escape hatch (#49) instead.
*/
const GROUP_SCOPE_FIELD = "cdb_gruppe";

interface ReverseEntry {
name: string;
scoped: boolean;
scopeField: string | null;
}

/**
Expand All @@ -39,7 +48,7 @@ function reverseCatalog(): Map<number, ReverseEntry> {
const rev = new Map<number, ReverseEntry>();
for (const [name, entry] of Object.entries(CATALOG)) {
if (!rev.has(entry.authId)) {
rev.set(entry.authId, { name, scoped: entry.scopeField != null });
rev.set(entry.authId, { name, scopeField: entry.scopeField });
}
}
return rev;
Expand Down Expand Up @@ -186,16 +195,35 @@ function grantLines(
};
}

if (entry.scoped) {
// Scoped right → resolve each dataId back to a MANAGED group's logical key. Scope keys must be
// state keys (see src/permissions/scope.ts), so an unmanaged dataId cannot be emitted as a key.
//
// NOTE on the lookup: `findByTypeId(state, "group", id)` assumes every scoped dataId is a GROUP
// id, but only `cdb_gruppe`-scoped rights actually scope by group — other scopeFields
// (cdb_station, cc_securitylevel, …) carry ids from different namespaces that could collide
// with a managed group's id. This mirrors the tool-wide constraint that scope declarations
// only support managed groups today (src/permissions/scope.ts); revisit if non-group scopes
// ever become declarable.
if (entry.scopeField != null && entry.scopeField !== GROUP_SCOPE_FIELD) {
// Scoped right whose scope dimension is NOT a group (e.g. "cc_securitylevel", "cdb_comment_viewer")
// — its dataIds name something this tool has no managed/logical representation for (a security
// level, a comment-viewer bucket, …), so `findByTypeId(state, "group", id)` would never find them
// (and could even collide with an unrelated group's id in a different namespace). There is nothing
// to "adopt" here: the numeric scope escape hatch (#49, src/permissions/scope.ts) declares these
// dataIds directly, so the grant is always emitted as an ACTIVE line, never a WARNING placeholder.
const out: string[] = [];
let omitted = false;
if (g.hasUnscoped) {
out.push(` // WARNING: "${entry.name}" is granted GLOBALLY here (scoped right, no dataId). The config`);
out.push(" // DSL cannot declare a global grant of a scoped right; re-grant it with an explicit");
out.push(" // scope in CT, or leave this domain unmanaged.");
omitted = true;
}
if (g.dataIds.length > 0) {
const scope = [...g.dataIds].sort((a, b) => a - b).join(", ");
out.push(` // "${entry.name}" scopes by "${entry.scopeField}", not a group — using its numeric dataId(s) directly.`);
out.push(` { right: ${JSON.stringify(entry.name)}, scope: [${scope}] },`);
}
return { lines: out, omitted };
}

if (entry.scopeField != null) {
// Scoped right on the GROUP dimension → resolve each dataId back to a MANAGED group's logical
// key. Scope keys must be state keys (see src/permissions/scope.ts), so an unmanaged dataId
// cannot be emitted as a key — the numeric escape hatch is not offered here on purpose: a
// `cdb_gruppe` dataId names an actual group, and `ct adopt group <id>` is the guided path to
// bring it under management (rather than silently declaring an opaque numeric scope for it).
const resolvedKeys: string[] = [];
const unmanaged: number[] = [];
for (const id of g.dataIds) {
Expand Down
10 changes: 7 additions & 3 deletions src/permissions/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@ export function desiredTuples(
}
// Retain the symbolic scopeKey on every scoped tuple so its dataId is re-resolved against
// post-execute state at apply time. `id === null` means the group is declared but not yet
// created (pending); it renders in the plan and always diffs into toPut (#29, #33.3).
return resolveScope(g.scope, state, declaredGroupKeys).map(({ key, id }) =>
// created (pending); it renders in the plan and always diffs into toPut (#29, #33.3). A `numeric`
// resolution (#49 escape hatch) carries no state-backed key to re-resolve — its dataId is already
// final, so no scopeKey is retained (apply.ts's `reresolveTuple` passes such a tuple through as-is).
return resolveScope(g.scope, state, declaredGroupKeys).map(({ key, id, numeric }) =>
id === null
? { authId: entry.authId, dataId: [], type: "grant" as const, scopeKey: key, pending: true }
: { authId: entry.authId, dataId: [id], type: "grant" as const, scopeKey: key },
: numeric
? { authId: entry.authId, dataId: [id], type: "grant" as const }
: { authId: entry.authId, dataId: [id], type: "grant" as const, scopeKey: key },
);
});
}
Expand Down
46 changes: 34 additions & 12 deletions src/permissions/scope.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,59 @@
import type { State } from "../state/state.js";
import type { GrantTuple } from "./grants.js";

/** One resolved scope key: `id` is the state group id, or `null` when the group is declared in the config but not yet created (pending). */
export interface ScopeResolution { key: string; id: number | null }
/**
* One resolved scope entry: `id` is the concrete ChurchTools dataId, or `null` when it names a
* group declared in the config but not yet created (pending). `numeric` marks an entry that came
* from a raw numeric scope literal (the #49 escape hatch) rather than a logical group key — such an
* entry carries no state-backed identity to re-resolve, only its already-known id (see
* {@link reresolveTuple}, which passes a tuple through unchanged when it has no `scopeKey`).
*/
export interface ScopeResolution { key: string; id: number | null; numeric?: boolean }

/**
* Resolve scope logical keys against DESIRED ∪ STATE.
* Resolve a scope array against DESIRED ∪ STATE. Each entry is either:
*
* A key that names a managed group in state resolves to its id. A key that names a group DECLARED
* in this config but not yet in state (`declaredGroupKeys`) resolves to `null` (pending) — its id
* is only known after the resource tier applies, so it is re-resolved at apply time (see
* {@link reresolveTuple}). This is what lets a config declare a group AND a grant scoped to it and
* still plan/apply in one run (#29). A key that is neither in state nor declared stays a hard error.
* - a **logical group key** (`string`) — resolved against managed groups, exactly as before, or
* - a **raw numeric dataId** (`number`, #49) — an escape hatch that passes straight through with no
* state lookup at all. This is required for scoped rights whose `scopeField` is NOT the group
* dimension (`cdb_gruppe`) — e.g. `cc_securitylevel`, `cdb_comment_viewer` — where the dataId
* names something this tool has no managed representation for (a security level, not a group),
* so a logical key can never be offered for it.
*
* Resolved (in-state) keys sort ascending by id — ChurchTools reads scoped grants back one row per
* dataId, so a stable order keeps multi-scope grants idempotent. Pending keys follow, sorted by key.
* A logical key that names a managed group in state resolves to its id. A logical key that names a
* group DECLARED in this config but not yet in state (`declaredGroupKeys`) resolves to `null`
* (pending) — its id is only known after the resource tier applies, so it is re-resolved at apply
* time (see {@link reresolveTuple}). This is what lets a config declare a group AND a grant scoped
* to it and still plan/apply in one run (#29). A logical key that is neither in state nor declared
* stays a hard error.
*
* Resolved (in-state or numeric) entries sort ascending by id — ChurchTools reads scoped grants
* back one row per dataId, so a stable order keeps multi-scope grants idempotent. Pending keys
* follow, sorted by key.
*/
export function resolveScope(
scopeKeys: string[],
scopeKeys: (string | number)[],
state: State,
declaredGroupKeys: ReadonlySet<string> = new Set(),
): ScopeResolution[] {
const resolved: ScopeResolution[] = [];
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.`);
}
resolved.push({ key: String(key), id: key, numeric: true });
continue;
}
const m = state.resources[key];
if (m && m.type === "group") {
resolved.push({ key, id: m.id });
} else if (declaredGroupKeys.has(key)) {
pending.push({ key, id: null });
} else {
throw new Error(
`Scope key "${key}" does not resolve to a managed group. Declare/adopt it, or use a group already under management.`,
`Scope key "${key}" does not resolve to a managed group. Declare/adopt it, use a group already under management, or pass a raw numeric dataId if this right's scope is not a group (see the catalog's scopeField).`,
);
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/permissions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
import type { DomainType } from "./grants.js";
import type { Ref } from "../resolve/refs.js";

export type Grant = string | { right: string; scope: string[] };
/**
* A scope entry is either a logical key of a group managed by this tool, or a raw numeric dataId
* (#49 escape hatch) — required for scoped rights whose scope dimension (catalog `scopeField`) is
* not a group, since there is no logical/managed representation to reference by key there.
*/
export type Grant = string | { right: string; scope: (string | number)[] };

export interface DesiredPermission {
key: string;
Expand Down
Loading
Loading