diff --git a/docs/permissions.md b/docs/permissions.md index b5ce096..3137287 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -270,10 +270,12 @@ to be accepted by `ct plan` (the round trip is locked by tests): - 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 - emitted as grants. +- **System-baseline and inherited rows are dropped, not emitted.** Adoption runs + the live rows through `normalizeActual`, which excludes the self-re-adding + system baseline (`modifiedPid === -1`) and any `isInherited` row. Admin-authored + 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). @@ -293,11 +295,14 @@ and `ct plan`. ## Domain rules (validated, throw on violation) -- **`group_type_role` requires `authId < 10000`.** Rights with `authId >= - 10000` are not writable through this domain type in ChurchTools; declaring - one under `ct.groupTypeRole` throws at plan/evaluation time - (`src/permissions/plan.ts`, `desiredTuples`). Use `ct.groupRole` for those - rights instead. +- **No authId cutoff.** Admin-authored member rights (`authId >= 10000`, the + `churchdb:+…` family) ARE writable on `group_type_role` and can be declared + under `ct.groupTypeRole` — verified live (eqrm prod `group_type_role/9` carries + 24 such admin-set rows). What ct never reconciles is decided by the live row's + flags, not its authId: `normalizeActual` drops the system baseline + (`modifiedPid === -1`) and every `isInherited` row, so those are neither adopted + nor revoked (#65). Earlier versions blocked `authId >= 10000` outright — that was + too broad and is removed. - **Revocation is a later extension, not exposed yet.** `GrantTuple.type` is typed as `"grant" | "revoke"`, but the DSL and `desiredTuples` currently only ever *emit* `"grant"` tuples — there is no config-level way to declare diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index 126a892..9c83d24 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -17,7 +17,7 @@ import type { State } from "../state/state.js"; import { findByTypeId } from "../state/state.js"; import { CATALOG } from "./catalog.js"; -import { normalizeActual, isInheritedOnlyRight, type DomainType, type GrantTuple, type RawPermission } from "./grants.js"; +import { normalizeActual, type DomainType, type GrantTuple, type RawPermission } from "./grants.js"; /** DSL function name for each domain type — the call the emitted block should be pasted as. */ const DSL_FN: Record = { @@ -113,7 +113,7 @@ export function emitAdoptedGrants(args: { } else { body.push(" grants: ["); for (const g of collapse(grants)) { - const r = grantLines(g, rev, state, domainType); + const r = grantLines(g, rev, state); body.push(...r.lines); if (r.omitted) omitted += 1; } @@ -159,14 +159,13 @@ interface GrantLinesResult { * * Round-trip invariant (locked by tests): every ACTIVE (non-comment) line this emits must pass * `desiredTuples` (`src/permissions/plan.ts`) without throwing — anything the planner would - * reject (group_type_role authIds >= 10000, a scoped right without a declarable scope, an - * unscoped right carrying dataIds) is emitted as a comment instead. + * reject (a scoped right without a declarable scope, an unscoped right carrying dataIds) is emitted + * as a comment instead. */ function grantLines( g: CollapsedGrant, rev: Map, state: State, - domainType: DomainType, ): GrantLinesResult { const entry = rev.get(g.authId); if (!entry) { @@ -181,21 +180,10 @@ function grantLines( }; } - // Mirror the planner's writability guard: `desiredTuples` throws for group_type_role grants - // with authId >= 10000 (the `churchdb:+…` family) — they are readable here via inheritance but - // not writable on this domain type, so emitting them would break the paste-and-plan round trip. - // The reconciler excludes these SAME rows from the actual set via the shared predicate (#65). - if (isInheritedOnlyRight(domainType, g.authId)) { - return { - omitted: true, - lines: [ - ` // NOTE: "${entry.name}" (authId ${g.authId}) is not writable on group_type_role — this right`, - " // reaches roles via inheritance only (authId >= 10000). Declaring it here would be", - " // rejected at plan time, so it is intentionally left as a comment.", - ], - }; - } - + // No authId cutoff (#65): the grants reaching here already passed `normalizeActual` (line ~100), + // which drops the system baseline (`modifiedPid === -1`) and every `isInherited` row — so what is + // left is admin-authored DIRECT grants, INCLUDING the writable `authId >= 10000` group-member rights + // Equippers curates on group_type_role. Those ARE declarable and are emitted as active grants. 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 diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index d0e89d5..4cc1281 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -5,31 +5,17 @@ */ export type DomainType = "group_role" | "group_type_role"; -/** - * Smallest authId of the inheritance-only rights family (the `churchdb:+…` rights). These reach - * roles via inheritance and are NOT writable on a group_type_role domain — CT exposes them as - * readable rows there, but any write is rejected. - */ -export const INHERITED_RIGHT_MIN_AUTH_ID = 10000; - -/** - * Whether a right is readable-but-not-writable on this domain type: it reaches roles via - * inheritance only, so it can neither be declared (`desiredTuples` throws for it) nor adopted - * (adopt emits it as a NOTE comment, never an active grant). - * - * SINGLE SOURCE OF TRUTH — this predicate MUST be shared by three sites that would otherwise drift - * and reopen issue #65: - * 1. `desiredTuples` (plan.ts) rejects declaring such a right, - * 2. `emitAdoptedGrants` (adopt.ts) excludes it from the emitted block, and - * 3. `buildPermissionPlan` (plan.ts) excludes matching LIVE rows from the ACTUAL diff set. - * If (1)/(2) exclude a right but (3) keeps it, the plan demands a revoke the DSL can never satisfy - * — pasted adopt output can never converge to a no-op (the #65 bug). - * - * Today only group_type_role carries such rights (authId >= 10000). - */ -export function isInheritedOnlyRight(domainType: DomainType, authId: number): boolean { - return domainType === "group_type_role" && authId >= INHERITED_RIGHT_MIN_AUTH_ID; -} +// 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 +// only admin-authored DIRECT grants are managed. There is intentionally NO authId-based cutoff. +// +// (History, #65: an earlier `isInheritedOnlyRight(domainType, authId) = authId >= 10000` predicate +// gated declaration/adoption/diff on the authId. That was too broad — it also excluded the +// admin-authored `authId >= 10000` group-member rights (e.g. "Add group members" 10107) that CT DOES +// let you write on a group_type_role domain and that Equippers prod actually curates. Verified live +// on eqrm prod `group_type_role/9`: 24 such rows carry `isInherited:false` and `modifiedPid` 1/3891. +// The real, verified boundary is inheritance + system-baseline, which normalizeActual already +// enforces on every path that reads actuals — the #65 no-op guarantee holds without the authId cutoff.) export interface GrantTuple { authId: number; diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index e4af9ff..d9edbd9 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -11,7 +11,7 @@ import type { DesiredResource } from "../engine/types.js"; import { resolveAuthId, CATALOG_META, KNOWN_AUTH_IDS } from "./catalog.js"; import { compareVersions } from "../api/version.js"; import { resolveScope } from "./scope.js"; -import { normalizeActual, diffGrants, isInheritedOnlyRight, INHERITED_RIGHT_MIN_AUTH_ID, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; +import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; import type { DesiredPermission } from "./types.js"; import { Resolver } from "../resolve/resolver.js"; import { isPendingRef, refKey, refLabel, type Ref } from "../resolve/refs.js"; @@ -43,9 +43,6 @@ export function desiredTuples( return p.grants.flatMap((g): GrantTuple[] => { const name = typeof g === "string" ? g : g.right; const entry = resolveAuthId(name); - if (isInheritedOnlyRight(p.domainType, entry.authId)) { - throw new Error(`${p.domainType} "${p.key}": "${name}" (authId ${entry.authId}) is not writable — ${p.domainType} requires authId < 10000.`); - } if (typeof g === "string") { // A scoped right declared as a bare string would emit `dataId: []` — a silent GLOBAL grant. // Refuse it: a scoped right must be declared as `{ right, scope: [...] }` so the scope is explicit. @@ -188,14 +185,11 @@ export async function buildPermissionPlan( const all = byType.get(p.domainType); if (all == null) continue; // fetch failed for this domainType — recorded above const normalizedAll = normalizeActual(all.filter((r) => r.domainId === p.domainId)); - // Inherited-rights guard (#65): a group_type_role reads back rights it acquires via inheritance - // (the `churchdb:+…` family, authId >= 10000) as live rows, but those rights are NOT writable on - // this domain — `desiredTuples` refuses to declare them and `ct adopt grants` emits them only as - // NOTE comments. If they stayed in the ACTUAL set they would have no desired counterpart and land - // in `toDelete`, so `ct plan` would demand revokes the DSL can never satisfy — a no-op would be - // impossible for any role domain with inheritance. Exclude them here (below, after the - // unknown-authId guard), mirroring the exact predicate the declaration/adopt sides use, and - // surface a single informational summary line. + // Actuals are already filtered by `normalizeActual`: the self-re-adding system baseline + // (`modifiedPid === -1`) and every `isInherited` row are dropped, so what remains is admin-authored + // DIRECT grants — INCLUDING the writable `authId >= 10000` group-member rights Equippers curates on + // group_type_role (e.g. "Add group members" 10107). Those are reconciled like any other grant; there + // is no authId cutoff (see grants.ts — the old `authId >= 10000` exclusion was too broad, #65). // // Unknown-authId guard (#25): a live GRANT whose authId is absent from the catalog cannot be // named or described. Keep it OUT of the diff — otherwise, having no desired counterpart, it @@ -203,22 +197,13 @@ export async function buildPermissionPlan( // Instead, warn (naming authId + domain) and leave it untouched. Idempotent: excluded every run. // (Revoke/deny rows with an unknown authId are already `preserved` by diffGrants, so ignore them // here — only unknown grant rows are the churn/silent-revoke hazard.) - // - // Order matters: the unknown-authId check runs FIRST so an unknown authId that merely happens to - // be >= 10000 is reported as unknown, not misclassified as an inherited catalog right. Only KNOWN - // rights reach the inherited-rights check. const knownActual: GrantTuple[] = []; const unknownAuthIds = new Set(); - const inheritedAuthIds = new Set(); for (const t of normalizedAll) { if (t.type === "grant" && !KNOWN_AUTH_IDS.has(t.authId)) { unknownAuthIds.add(t.authId); continue; } - if (isInheritedOnlyRight(p.domainType, t.authId)) { - inheritedAuthIds.add(t.authId); - continue; - } knownActual.push(t); } for (const authId of [...unknownAuthIds].sort((a, b) => a - b)) { @@ -228,13 +213,6 @@ export async function buildPermissionPlan( `(\`npm run regenerate:permission-catalog\`) if this right should be manageable.`, ); } - if (inheritedAuthIds.size > 0) { - warnings.push( - `${p.domainType} #${p.domainId} ("${p.key}"): ${inheritedAuthIds.size} inherited right(s) ` + - `(authId >= ${INHERITED_RIGHT_MIN_AUTH_ID}) are readable but not writable on ${p.domainType} — left untouched ` + - `(never revoked); they reach roles via inheritance only.`, - ); - } items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state, declaredGroupKeys), knownActual) }); } return { items, fetchErrors, warnings }; diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index ac24e23..7c8b0e7 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 { diffGrants, normalizeActual, isInheritedOnlyRight, type DomainType, type 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"; @@ -133,18 +133,19 @@ describe("emitAdoptedGrants", () => { expect(block).toContain("grants: [],"); }); - it("group_type_role right with authId >= 10000 → NOTE comment, never an active grant", () => { - // "churchdb:+edit group infos" (authId 10122) — desiredTuples rejects it on group_type_role. + it("group_type_role admin-authored authId >= 10000 member right IS emitted as an active grant (#65)", () => { + // "churchdb:+edit group infos" (authId 10122) — an admin-authored (pid 5) MEMBER right CT lets you + // write on group_type_role. It is now managed: emitted as an active grant, never a NOTE comment. const rows: RawPermission[] = [ { authId: 10122, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, ]; const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 42, rows, state: emptyState() }); - expect(block).toContain('NOTE: "churchdb:+edit group infos" (authId 10122) is not writable on group_type_role'); - expect(parseEmittedGrants(block)).toEqual([]); + expect(block).not.toContain("NOTE:"); + expect(parseEmittedGrants(block)).toEqual(["churchdb:+edit group infos"]); }); - it("group_role right with authId >= 10000 IS emitted (only group_type_role rejects it)", () => { + it("group_role right with authId >= 10000 IS emitted (both domains manage member rights)", () => { const rows: RawPermission[] = [ { authId: 10122, dataId: null, type: "grant", domainId: 7, meta: { modifiedPid: 5 } }, ]; @@ -178,8 +179,8 @@ describe("emitAdoptedGrants", () => { domainType: "group_type_role", domainId: 42, rows: [ - { authId: 999999, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // unknown - { authId: 10122, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // GTR-unwritable + { authId: 999999, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // unknown authId + { authId: 1104, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // scoped right granted globally ], state: emptyState(), }); @@ -242,32 +243,40 @@ describe("emitAdoptedGrants", () => { expect(diff.toDelete).toEqual([]); }); - it("full round-trip on a role domain WITH inherited rights is a no-op (#65 acceptance)", () => { - // eqrm prod, group_type_role 9 (/Struktur): writable user grants PLUS inherited churchdb:+… - // rows (authId >= 10000) that adopt emits only as NOTE comments. Pasting the emitted block - // verbatim and diffing it against the SAME live rows must be a no-op — the inherited rows must - // NOT show up as `toDelete` (the #65 bug was "0 to grant, 24 to remove"). + it("admin-authored member rights (authId >= 10000) are managed; system/inherited rows excluded (#65)", () => { + // eqrm prod, group_type_role 9 (/Struktur): admin-authored churchdb:+… MEMBER rights (authId >= + // 10000, isInherited:false, modifiedPid != -1) that CT lets you write — verified live (24 such + // rows, modifiedPid 1/3891). They must be adopted as ACTIVE grants and round-trip to a no-op. + // The self-re-adding system baseline (modifiedPid === -1) and truly-inherited rows must be + // EXCLUDED by normalizeActual and NEVER show up as `toDelete` (the #65 bug was "0 grant, 24 remove"). + // The reconciliation boundary is inheritance + system-baseline, NOT the authId — hence the + // excluded rows below also carry authId >= 10000, proving the authId is not the cutoff. const rows: RawPermission[] = [ - // writable, user-authored grants that ARE emitted as active config - { authId: 1113, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:administer groups (unscoped) + // writable, user-authored grants (unscoped + group-scoped) — emitted active + { authId: 1113, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:administer groups { authId: 1104, dataId: 99, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchgroup:view group scoped to managed "kids" - // inherited-only rows: unscoped, scope [1], scope [2] — emitted only as NOTE comments - { authId: 10101, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+see persons - { authId: 10102, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+see group - { authId: 10133, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+edit group member fields scope [2] + // admin-authored MEMBER rights (authId >= 10000) — NOW managed: unscoped + security-level scopes + { authId: 10107, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+add person (unscoped) + { authId: 10101, dataId: 2, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+see persons scope [2] + { authId: 10133, dataId: 1, type: "grant", domainId: 9, meta: { modifiedPid: 5 } }, // churchdb:+edit group member fields scope [1] + // EXCLUDED (authId >= 10000 too): system baseline + a truly-inherited row → never revoked + { authId: 10122, dataId: null, type: "grant", domainId: 9, meta: { modifiedPid: -1 } }, // system baseline + { authId: 10111, dataId: null, type: "grant", domainId: 9, isInherited: true }, // inherited ]; const state = stateWithKids(); const block = emitAdoptedGrants({ domainType: "group_type_role", domainId: 9, rows, state }); - // the inherited rows are surfaced as NOTE comments, never active grants - expect(block).toContain('NOTE: "churchdb:+see persons" (authId 10101) is not writable on group_type_role'); + // the admin-authored member rights are emitted as ACTIVE grants, never NOTE comments + expect(block).not.toContain("NOTE:"); + expect(block).toContain("churchdb:+add person"); const grants = parseEmittedGrants(block); - // Paste-and-plan: diff the emitted (writable-only) declaration against the FULL live row set. + // Paste-and-plan: diff the emitted declaration against the FULL live row set. normalizeActual + // drops the system-baseline + inherited rows, so they never appear as revokes. const desired = grants.flatMap((g) => desiredTuples({ key: "adopted", domainType: "group_type_role", domainId: 9, grants: [g] }, state)); - const actual = normalizeActual(rows).filter((t) => !isInheritedOnlyRight("group_type_role", t.authId)); + const actual = normalizeActual(rows); const diff = diffGrants(desired, actual); expect(diff.toPut).toEqual([]); - expect(diff.toDelete).toEqual([]); // critically: NOT the 3 inherited rows + expect(diff.toDelete).toEqual([]); // critically: the system/inherited rows are NOT revoked }); it("unmanaged group-dimension scope still gets the 'ct adopt group' hint (unchanged behavior)", () => { diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index 8d69180..3935e88 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -29,9 +29,11 @@ describe("desiredTuples", () => { { key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:view group"] }, state), ).toThrow(/is a scoped right.*must be declared as \{ right/is); }); - it("rejects authId >= 10000 on group_type_role", () => { - expect(() => desiredTuples({ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+see persons"] }, state)) - .toThrow(/10000/); // churchdb:+see persons is authId 10101 + it("accepts an admin-authored authId >= 10000 member right on group_type_role (#65)", () => { + // The old authId>=10000 rejection is gone — admin-authored member rights CT lets you write are + // now declarable. "churchdb:+add person" (authId 10107) is unscoped, so a bare string is valid. + expect(desiredTuples({ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchdb:+add person"] }, state)) + .toEqual([{ authId: 10107, dataId: [], type: "grant" }]); }); it("fans out a multi-element scope into one single-dataId tuple per dataId (idempotency: ChurchTools reads scoped grants back one row per dataId)", () => { @@ -143,34 +145,34 @@ describe("buildPermissionPlan", () => { expect(warnings.some((w) => w.includes("987654") && w.includes("group_type_role #8"))).toBe(true); }); - it("excludes inherited rights (authId >= 10000) from the ACTUAL diff set on group_type_role (#65)", async () => { - // eqrm prod, group_type_role 9: 5 writable user grants are declared, but the domain also reads - // back inherited churchdb:+… rows (authId >= 10000) that CANNOT be declared (desiredTuples - // rejects them) nor adopted (emitted as NOTE comments). If those live rows entered the ACTUAL - // set they would have no desired counterpart and land in toDelete — the #65 bug ("0 to grant, - // 24 to remove", a no-op that can never converge). They must be excluded from the diff. + it("reconciles admin-authored member rights; excludes system-baseline + inherited rows (#65)", async () => { + // eqrm prod, group_type_role 9: admin-authored churchdb:+… MEMBER rights (authId >= 10000, + // isInherited:false, modifiedPid != -1) ARE managed — an undeclared one must be revoked. The + // self-re-adding system baseline (modifiedPid === -1) and truly-inherited rows are excluded by + // normalizeActual and NEVER revoked (the #65 bug was "0 to grant, 24 to remove"). The boundary is + // inheritance + system-baseline, NOT the authId — so the excluded rows below carry authId >= 10000 too. const client = { get: vi.fn(async () => [ - // the one writable, user-authored grant that IS declared + // declared writable grant — matches, no diff { domainType: "group_type_role", domainId: 9, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - // inherited-only rows (authId >= 10000): unscoped, scoped [1], scoped [2] - { domainType: "group_type_role", domainId: 9, authId: 10101, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, - { domainType: "group_type_role", domainId: 9, authId: 10102, dataId: 1, type: "grant", meta: { modifiedPid: 5 } }, - { domainType: "group_type_role", domainId: 9, authId: 10133, dataId: 2, type: "grant", meta: { modifiedPid: 5 } }, + // admin-authored member right (authId >= 10000, pid 5) that is UNDECLARED → must be revoked + { domainType: "group_type_role", domainId: 9, authId: 10107, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, + // system baseline + inherited (authId >= 10000) → excluded, never revoked + { domainType: "group_type_role", domainId: 9, authId: 10122, dataId: null, type: "grant", meta: { modifiedPid: -1 } }, + { domainType: "group_type_role", domainId: 9, authId: 10111, dataId: null, type: "grant", isInherited: true }, ]) }; const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [{ key: "struktur", domainType: "group_type_role", domainId: 9, grants: ["churchgroup:administer groups"] }]); expect(fetchErrors).toEqual([]); - expect(items[0]?.diff.toPut).toEqual([]); // the declared writable grant already matches - expect(items[0]?.diff.toDelete).toEqual([]); // NONE of the inherited rows are proposed for revocation - // a single informational summary line, not one warning per inherited row - const inheritedWarnings = warnings.filter((w) => w.includes("inherited right")); - expect(inheritedWarnings).toHaveLength(1); - expect(inheritedWarnings[0]).toMatch(/group_type_role #9.*3 inherited right/); + expect(items[0]?.diff.toPut).toEqual([]); // the declared writable grant already matches + // the undeclared admin-authored member right IS revoked; the baseline + inherited rows are NOT + expect(items[0]?.diff.toDelete).toEqual([{ authId: 10107, dataId: [], type: "grant" }]); + // the old "inherited right" informational warning is gone (that authId-based exclusion is removed) + expect(warnings.filter((w) => w.includes("inherited right"))).toHaveLength(0); }); - it("group_role does NOT exclude authId >= 10000 — only group_type_role inherits (#65)", async () => { - // The predicate is domain-scoped: on group_role the churchdb:+… rights ARE writable/declarable, - // so a live one that is undeclared must still be revoked (no accidental blanket exclusion). + it("reconciles admin-authored authId >= 10000 rights on group_role too (no authId cutoff, #65)", async () => { + // No authId cutoff on either domain: on group_role the churchdb:+… rights ARE writable/declarable, + // so a live admin-authored one that is undeclared must still be revoked (no blanket exclusion). const client = { get: vi.fn(async (path: string) => { if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; if (path === "/permissions/group_role") return [ @@ -190,7 +192,7 @@ describe("buildPermissionPlan", () => { const client = { get: vi.fn(async () => [ { domainType: "group_type_role", domainId: 9, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, // declared { domainType: "group_type_role", domainId: 9, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 5 } }, // undeclared user grant - { domainType: "group_type_role", domainId: 9, authId: 10101, dataId: null, type: "grant", meta: { modifiedPid: 5 } }, // inherited → excluded + { domainType: "group_type_role", domainId: 9, authId: 10101, dataId: null, type: "grant", isInherited: true }, // inherited → excluded ]) }; const { items } = await buildPermissionPlan(client as never, state, [{ key: "struktur", domainType: "group_type_role", domainId: 9, grants: ["churchgroup:administer groups"] }]);