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
50 changes: 50 additions & 0 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,56 @@ 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.

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

To bring an instance's existing rights under management without hand-transcribing
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
```

It fetches `GET /permissions/<domainType>/<domainId>`, runs the rows through the
**same** normalization the planner uses (`normalizeActual`), and prints a
`ct.groupRole` / `ct.groupTypeRole` block whose every emitted grant is guaranteed
to be accepted by `ct plan` (the round trip is locked by tests):

- **Excluded, as reconciliation excludes them:** the system baseline
(`meta.modifiedPid === -1`) and inherited rows.
- **Revoke/deny rows are preserved, not emitted.** The reconciler never deletes a
deny it did not author; if any exist, the block ends with a `NOTE` comment
saying so (authoring denies as config is a separate, unshipped feature).
- **`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.
- **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.
- **Only `group_role` / `group_type_role`** are valid; people domains are
refused (the same hard boundary as everywhere else).

> **Warning — comment-only grants are pending revocations.** Reconciliation is
> set-based: a live grant absent from the pasted declaration lands in
> `toDelete`. So any grant the adopter could only express as a `WARNING`/`NOTE`
> comment is still **live on the instance but missing from your config** —
> applying the block as-is will **revoke** it. The block prints a header saying
> exactly how many such grants exist; resolve every one (adopt the group,
> regenerate the catalog, …) before `ct apply`. `ct plan` is only a no-op once
> no comment-only grants remain.

Grants are **not** a state-tracked resource, so this prints config **only** — it
never writes the state file (unlike `ct adopt <type> <id>`). Pick a real logical
`key` (the emitted one is a rename-to-taste placeholder), paste into your config,
and `ct plan`.

## Domain rules (validated, throw on violation)

- **`group_type_role` requires `authId < 10000`.** Rights with `authId >=
Expand Down
7 changes: 4 additions & 3 deletions docs/runbook-manual-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ this doc's structure.
| Portable/logical references | Config still hardcodes numeric CT ids (`groupTypeId`, `groupStatusId`, `campusId`, permission `domainId`, dynamic-group ruleset `var` values like `q.eq("ctgroup.campusId", 4)`) instead of resolving keys/names per host | [#20](https://github.com/eqrm/ct-cli/issues/20) | Hand-resolve each id per target host (`ct get group-types`, `ct get campuses`, etc.) and hardcode it in config; a config authored against one instance will not plan correctly against another until this lands |
| Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today |
| Permission `domainId` by reference | `ct.groupRole`/`ct.groupTypeRole` require the numeric `domainId` supplied by hand — for `group_role` this is CT's internal (group, role) _pairing_ id, with **no CLI lookup helper** | [#25](https://github.com/eqrm/ct-cli/issues/25) | Find the pairing id via the CT permission editor, or an existing `GET /permissions/group_role` response for a group+role you already have, and hardcode it ([`docs/permissions.md`](permissions.md) "domainId semantics") |
| Grant adoption | No `ct adopt grants <domain>` — existing rights structures on a live instance must be hand-transcribed into `grants: [...]` config blocks | [#25](https://github.com/eqrm/ct-cli/issues/25) | `ct get raw /permissions/group_role/<id>` (or `group_type_role`), read off the non-inherited, non-baseline rows (`isInherited: false`, `meta.modifiedPid !== -1`), and hand-author the equivalent `grants:` array |
| ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants <domainType> <domainId>` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/permissions.md`](permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role <id>` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config |
| Permission catalog lifecycle | `catalog.json` is a one-off HAR-trace snapshot of a single CT version, with no staleness detection | [#25](https://github.com/eqrm/ct-cli/issues/25) | Manual regeneration procedure below (**Permission catalog lifecycle**) |
| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a group-status write) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) |

Expand Down Expand Up @@ -116,8 +116,9 @@ in where they'd otherwise be silently skipped:
before trusting `ct plan`'s permission diff.
6. **Grants not yet expressed as config** — for any domain object with
hand-set rights not covered by a `ct.groupRole`/`ct.groupTypeRole`
declaration, transcribe them into config now (adoption workaround above)
so they don't silently diverge from what `ct plan` believes is desired.
declaration, adopt them now with `ct adopt grants <domainType> <domainId>`
(paste the emitted block into config) so they don't silently diverge from
what `ct plan` believes is desired.
7. **Anything from the "out of tool scope" table** — persons, memberships,
calendars, services, resource booking, forms, check-in, wiki, finance,
sync, module-level settings, custom fields, i18n — configure per your
Expand Down
63 changes: 63 additions & 0 deletions src/commands/adopt-grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Command } from "commander";
import { authedSession } from "../api/session.js";
import { resolveConfig } from "../config.js";
import { assertNotPeople } from "../engine/guard.js";
import { emitAdoptedGrants } from "../permissions/adopt.js";
import type { DomainType, RawPermission } from "../permissions/grants.js";
import { loadState, resolveStatePath } from "../state/state.js";
import { info, warn } from "../ui.js";

interface AdoptGrantsOptions {
state?: string;
}

/** 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;
throw new Error(
`Invalid domain type "${raw}" — expected "group_role" or "group_type_role" (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
* 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("<domainId>", "the domainId of the permission domain object")
.option("-s, --state <path>", "state file path (or set CT_STATE) — used to resolve scope group ids to keys")
.action(async (rawType: string, rawId: string, opts: AdoptGrantsOptions) => {
const domainType = normalizeDomainType(rawType);
if (!/^\d+$/.test(rawId.trim())) {
throw new Error(`Invalid domainId "${rawId}" — expected a non-negative integer.`);
}
const domainId = Number.parseInt(rawId, 10);
const path = `/permissions/${domainType}/${domainId}`;
assertNotPeople(path); // belt-and-suspenders: the domain-type guard already excludes people

// Load + validate the state file (host guard) BEFORE any network call, mirroring `ct adopt`,
// so a state file recorded against another instance never triggers a request to the wrong host.
const config = await resolveConfig();
const statePath = resolveStatePath(opts.state);
const state = await loadState(statePath, config.host);

const { client } = await authedSession();
const rows = await client.get<RawPermission[]>(path);

const block = emitAdoptedGrants({ domainType, domainId, rows, state });

info(`Grants are not state-tracked — this prints config only and does NOT write ${statePath}.`);
info(`Paste the block below into your config, then run \`ct plan\`:`);
process.stdout.write(`${block}\n`);
warn(
"Any grant left as a WARNING/NOTE comment in the block is still LIVE on the instance but absent " +
"from the declaration — applying the block will REVOKE it. Resolve every comment first; `ct plan` " +
"is only a no-op once none remain.",
);
});
}
9 changes: 8 additions & 1 deletion src/commands/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolveConfig } from "../config.js";
import { resourceType, configSnippet } from "../resources/registry.js";
import { loadState, saveState, resolveStatePath, upsert } from "../state/state.js";
import { success, info, warn, out } from "../ui.js";
import { adoptGrantsCommand } from "./adopt-grants.js";

interface AdoptOptions {
key?: string;
Expand All @@ -12,7 +13,7 @@ interface AdoptOptions {
}

export function adoptCommand(): Command {
return new Command("adopt")
const cmd = new Command("adopt")
.description("Put one existing ChurchTools resource under management (adds it to the state file)")
.argument("<type>", "resource type, e.g. campus | group | group-type")
.argument("<id>", "ChurchTools id of the resource")
Expand Down Expand Up @@ -59,4 +60,10 @@ export function adoptCommand(): Command {
warn("This resource was already managed — its snapshot was refreshed.");
}
});

// `ct adopt grants <domainType> <domainId>` — grants are not state-tracked, so this subcommand
// prints a config block only and never writes state. Commander matches the "grants" subcommand
// name before falling through to the `<type> <id>` action above.
cmd.addCommand(adoptGrantsCommand());
return cmd;
}
Loading
Loading