diff --git a/docs/permissions.md b/docs/permissions.md index 90da60a..286d9d4 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -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` @@ -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`. @@ -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 ` hint for a dataId that was never a group. ## Adopting existing grants — `ct adopt grants ` @@ -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 ` 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 ` + 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 ` 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 diff --git a/examples/permissions.config.ts b/examples/permissions.config.ts index fe51ba2..3cb8b7f 100644 --- a/examples/permissions.config.ts +++ b/examples/permissions.config.ts @@ -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"] }, ], }); diff --git a/src/config/context.ts b/src/config/context.ts index 8e2be31..b6c5592 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -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); diff --git a/src/permissions/README.md b/src/permissions/README.md index 8ca6293..465eac6 100644 --- a/src/permissions/README.md +++ b/src/permissions/README.md @@ -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`). diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index dfd77c3..81387e8 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -25,9 +25,18 @@ const DSL_FN: Record = { 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; } /** @@ -39,7 +48,7 @@ function reverseCatalog(): Map { const rev = new Map(); 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; @@ -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 ` 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) { diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index c74c982..344dbb0 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -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 }, ); }); } diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts index 6ac5a44..396a40c 100644 --- a/src/permissions/scope.ts +++ b/src/permissions/scope.ts @@ -1,29 +1,51 @@ 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 = 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 }); @@ -31,7 +53,7 @@ export function resolveScope( 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).`, ); } } diff --git a/src/permissions/types.ts b/src/permissions/types.ts index 55be2d5..787b8bc 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -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; diff --git a/tests/context.test.ts b/tests/context.test.ts index 4d24976..f19dac1 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -265,6 +265,27 @@ describe("permission declarations", () => { ).rejects.toThrow(/numeric "id"/i); }); + it("accepts a raw numeric scope entry alongside logical group keys (#49 escape hatch)", async () => { + const { permissions } = await evaluateConfig((ct: ConfigContext) => { + ct.groupTypeRole({ key: "leiter_tpl", id: 8, grants: [ + { right: "churchdb:view comments", scope: [1, 2, 3] }, + { right: "churchdb:view group", scope: ["kids_area", 5] }, + ]}); + }); + expect(permissions[0]!.grants).toEqual([ + { right: "churchdb:view comments", scope: [1, 2, 3] }, + { right: "churchdb:view group", scope: ["kids_area", 5] }, + ]); + }); + + it("rejects a scope array with a non-string/non-number entry", async () => { + await expect( + evaluateConfig((ct: ConfigContext) => + ct.groupTypeRole({ key: "x", id: 8, grants: [{ right: "churchdb:view comments", scope: [null] }] } as never), + ), + ).rejects.toThrow(/scope/i); + }); + it("rejects two declarations targeting the same (domainType, domainId)", async () => { await expect( evaluateConfig((ct: ConfigContext) => { diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index c542ac5..4c98dfb 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { emitAdoptedGrants } from "../src/permissions/adopt.js"; -import type { DomainType, RawPermission } from "../src/permissions/grants.js"; +import { diffGrants, normalizeActual, type DomainType, type RawPermission } from "../src/permissions/grants.js"; import { desiredTuples } from "../src/permissions/plan.js"; import type { Grant } from "../src/permissions/types.js"; import type { State } from "../src/state/state.js"; @@ -42,7 +42,7 @@ function parseEmittedGrants(block: string): Grant[] { } const m = /^\{ right: ("(?:[^"\\]|\\.)*"), scope: \[(.*)\] \}$/.exec(entry); if (!m?.[1] || m[2] == null) throw new Error(`Unparseable emitted grant line: ${line}`); - grants.push({ right: JSON.parse(m[1]) as string, scope: JSON.parse(`[${m[2]}]`) as string[] }); + grants.push({ right: JSON.parse(m[1]) as string, scope: JSON.parse(`[${m[2]}]`) as (string | number)[] }); } return grants; } @@ -195,6 +195,63 @@ describe("emitAdoptedGrants", () => { expect(clean).not.toContain("REVOKE"); }); + it("scoped grant on a non-group scope dimension → emits the numeric scope form directly, never 'ct adopt group' (#49)", () => { + // churchdb:view comments (authId 113) is scoped by "cdb_comment_viewer", not a group — dataIds + // 1/2 will never resolve via `findByTypeId(state, "group", …)`. The right can still be declared: + // the numeric escape hatch lets adopt emit it as an ACTIVE grant instead of an unresolvable WARNING. + const rows: RawPermission[] = [ + { authId: 113, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, + { authId: 113, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state: emptyState() }); + + expect(block).toContain('{ right: "churchdb:view comments", scope: [1, 2] }'); + expect(block).not.toContain("ct adopt group"); + expect(block).not.toContain("WARNING"); + // names the right's actual scope dimension in the hint, per catalog scopeField + expect(block).toContain("cdb_comment_viewer"); + }); + + it("scoped grant on the churchdb security-level dimension (cc_securitylevel) round-trips numerically (#49 repro)", () => { + // Mirrors the real-world case from issue #49: churchdb:security level view/edit own data, + // scoped to dataIds 1,2,3,5 — none of which are groups (GET /groups/{1,2,3,5} 404s). + const rows: RawPermission[] = [ + { authId: 131, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, + { authId: 131, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, + { authId: 131, dataId: 3, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, + { authId: 131, dataId: 5, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, + { authId: 132, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state: emptyState() }); + const grants = parseEmittedGrants(block); + + expect(block).not.toContain("WARNING"); + expect(block).not.toContain("ct adopt group"); + expect(grants).toEqual([ + { right: "churchdb:security level view own data", scope: [1, 2, 3, 5] }, + { right: "churchdb:security level edit own data", scope: [1] }, + ]); + + // Full round-trip: pasting this block into config and diffing against the SAME live rows must + // be a no-op — no toPut, and critically no toDelete (a partial block must never revoke a live + // grant it could not express). + const desired = grants.flatMap((g) => desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, emptyState())); + const actual = normalizeActual(rows); + const diff = diffGrants(desired, actual); + expect(diff.toPut).toEqual([]); + expect(diff.toDelete).toEqual([]); + }); + + it("unmanaged group-dimension scope still gets the 'ct adopt group' hint (unchanged behavior)", () => { + // Regression guard: only NON-group scope dimensions bypass the group-resolution path — a + // cdb_gruppe-scoped right with an unmanaged dataId must still point at `ct adopt group `. + const rows: RawPermission[] = [ + { authId: 1104, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); + expect(block).toContain("ct adopt group 777"); + }); + it("round trip — every emitted grant passes the real desiredTuples, for any mix of rows", () => { const state = stateWithKids(); const rows: RawPermission[] = [ diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index 83b74e8..d5d0b54 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -53,6 +53,33 @@ describe("desiredTuples", () => { ]}, state), ).toThrow(/not a scoped right/); }); + + it("accepts a raw numeric scope entry (escape hatch, #49) for a right scoped by a non-group dimension", () => { + // churchdb:view comments (authId 113) is scoped by "cdb_comment_viewer" — not a group. There is + // no managed-group representation for it, so the DSL's numeric escape hatch is the only way to + // declare it. Numeric entries fan out just like logical keys, and MUST NOT retain a scopeKey — + // there is no state resource to re-resolve at apply time. + const tuples = desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: [ + { right: "churchdb:view comments", scope: [1, 2] }, + ]}, state); + expect(tuples).toEqual([ + { authId: 113, dataId: [1], type: "grant" }, + { authId: 113, dataId: [2], type: "grant" }, + ]); + expect(tuples.every((t) => t.scopeKey === undefined)).toBe(true); + }); + + it("mixes a numeric scope entry with a logical group key in the same declaration", () => { + const tuples = desiredTuples( + { key: "t", domainType: "group_type_role", domainId: 8, grants: [ + { right: "churchgroup:view group", scope: ["kids_area", 3] }, + ]}, state); + expect(tuples).toEqual([ + { authId: 1104, dataId: [3], type: "grant" }, + { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area" }, + ]); + }); }); describe("buildPermissionPlan", () => { diff --git a/tests/permission-scope.test.ts b/tests/permission-scope.test.ts index 7923a7c..109ee3f 100644 --- a/tests/permission-scope.test.ts +++ b/tests/permission-scope.test.ts @@ -27,4 +27,24 @@ describe("resolveScope", () => { it("throws for a key that is neither in state nor declared", () => { expect(() => resolveScope(["nope"], state)).toThrow(/scope key "nope"/i); }); + + it("passes a raw numeric scope entry through directly, without a state lookup (escape hatch, #49)", () => { + expect(resolveScope([5, "kids_area"], state)).toEqual([ + { key: "5", id: 5, numeric: true }, + { key: "kids_area", id: 42 }, + ]); + }); + + it("sorts numeric and resolved group entries together, ascending by id", () => { + expect(resolveScope(["kids_area", 3], state)).toEqual([ + { key: "3", id: 3, numeric: true }, + { key: "kids_area", id: 42 }, + ]); + }); + + it("rejects a non-positive-integer numeric scope entry", () => { + expect(() => resolveScope([0], state)).toThrow(/numeric scope/i); + expect(() => resolveScope([-3], state)).toThrow(/numeric scope/i); + expect(() => resolveScope([1.5], state)).toThrow(/numeric scope/i); + }); });