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
101 changes: 101 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,104 @@ baseline-tolerance model) and
[`examples/permissions.config.ts`](examples/permissions.config.ts) for a
runnable example.

## Environments (dev → prod promotion)

One config repo can drive several ChurchTools instances — e.g. an `eqrm-dev`
rehearsal env and the real `prod` — Terraform-workspace-style, with **no file
edits** when switching. Declare the environments once in a committed
`ct.envs.json` in the config repo (default path; override with `CT_ENVS`):

```json
{
"environments": {
"dev": { "host": "https://eqrm-dev.church.tools" },
"prod": {
"host": "https://eqrm.church.tools",
"state": "ct-state.prod.json",
"protected": true,
"tokenEnv": "CT_PROD_TOKEN"
}
}
}
```

Each profile is a `(host, state file, token reference)` triple:

- **`host`** — the instance this env targets (the source of truth for `--env`;
it overrides any ambient `CT_HOST`).
- **`state`** — the committed state file. Defaults to the `ct-state.<env>.json`
convention (`ct-state.dev.json`, `ct-state.prod.json`), overridable per env.
Both files live in the config repo and are committed, so `dev` and `prod`
never share a state file.
- **`tokenEnv`** — the **name** of an environment variable holding the login
token (for CI); never a literal secret, so the file is safe to commit.
- **`protected`** — see the guardrail below.

Every state/host-touching command takes `--env <name>` (`-e`):

```bash
ct plan --env dev # diff dev's config against the dev host, using ct-state.dev.json
ct apply --env dev
ct plan --env prod # SAME checkout, no edits — prod host + ct-state.prod.json
ct state list --env prod
ct get groups --env dev
```

Without `--env`, behaviour is unchanged (single stored login, `ct-state.json`).

**Token resolution** for a chosen env: `CT_LOGINTOKEN` env (CI — a profile
`tokenEnv` is copied here when set) → the host-keyed Keychain entry. `ct auth
login` now stores credentials **per host**, so one machine can hold logins for
`dev` and `prod` at once (a pre-existing single login still works as a fallback).

**Cross-contamination is impossible:** every state file is bound to its host, and
loading a state file against a different host is refused —
`State file host (…) does not match … Refusing to mix instances.` — so `--env prod`
can never read or write a dev-bound state file.

**Version gate per env:** envs may run different ChurchTools versions.
`ct plan --env <name>` surfaces the target env's name **and** its live CT version
in the header (e.g. `env: prod · host: … · ChurchTools 3.123.0 · …`), so a
dev/prod version skew is visible before you promote.

### Protected environments

Mark an env `"protected": true` and **apply/destroy against it ALWAYS require
typed confirmation of the environment name — even with `--auto-approve` (apply)
or `--force` (destroy)**. For non-interactive/CI use, pass `--confirm-env <name>`,
which must match the target env name exactly and substitutes for the typed input:

```bash
ct apply --env prod # prompts: type "prod" to confirm
ct apply --env prod --auto-approve # STILL prompts — auto-approve does not bypass a protected env
ct apply --env prod --auto-approve --confirm-env prod # CI: applies (flag matches)
ct destroy --env prod --target old --confirm-env prod # --force alone is NOT enough on a protected env
```

### Promotion workflow

Promote a change dev → prod, verifying against the rehearsal env before the real one:

```bash
# 1. Plan + apply against dev (rehearsal)
ct plan --env dev
ct apply --env dev

# 2. Verify the change on dev: re-plan should be a clean no-op (round-trip),
# optionally recomputing dynamic-group membership.
ct plan --env dev # expect "No changes"
ct apply --env dev --refresh # (only if the change touched dynamic groups)

# 3. Plan against prod — inspect the header's CT version and the diff carefully.
ct plan --env prod

# 4. Apply to prod. Protected → confirm the env name (or --confirm-env prod in CI).
ct apply --env prod
```

Commit both state files (`ct-state.dev.json`, `ct-state.prod.json`) after each
apply — they are the record of what is managed on each instance.

## Auth model

The personal login token authenticates via a session handshake, **not** an
Expand All @@ -250,6 +348,9 @@ npm run generate:client # regenerate the typed client from the live OpenAP
- `plan` is the default; `apply` is explicit, with a confirmation prompt.
- `apply` never deletes; destruction is explicit via `destroy --target`.
- Destroy-protection (`preventDestroy` config flag); never implicit deletions.
- Protected environments (`"protected": true`): apply/destroy always require typed
confirmation of the env name — `--auto-approve`/`--force` never bypass it.
- Per-env state files are host-bound: `--env prod` can never touch a dev-bound state.
- People/memberships are never touched (hard boundary in code).
- Backup/export before every `apply` and `destroy`.
- Rate-limit + retry on API calls (writes are never blindly re-sent on 5xx).
Expand Down
13 changes: 13 additions & 0 deletions examples/ct.envs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"environments": {
"dev": {
"host": "https://eqrm-dev.church.tools"
},
"prod": {
"host": "https://eqrm.church.tools",
"state": "ct-state.prod.json",
"protected": true,
"tokenEnv": "CT_PROD_TOKEN"
}
}
}
9 changes: 9 additions & 0 deletions src/api/ctClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ export class CtClient {
return this.config.host;
}

/**
* The ChurchTools release this client is talking to, once known (populated by
* {@link assertMinVersion} / any `/info` read). `null` until then. Surfaced in
* the `--env` plan header so a per-env version gate is visible (#22).
*/
get version(): string | null {
return this.ctVersion;
}

/**
* Hard-fail if the ChurchTools instance is below the minimum version the CLI
* requires (group hierarchy / metadata CRUD need v3.96+). One `/info` GET,
Expand Down
4 changes: 3 additions & 1 deletion src/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export interface AuthedSession {
export async function authedSession(): Promise<AuthedSession> {
const config = await resolveConfig();
const envToken = process.env.CT_LOGINTOKEN?.trim();
const stored = await readCredentials();
// Resolve the stored token for the host we intend to hit (multi-host, #22): with several logins on
// one machine, the per-host account is picked by host, keeping the token↔host binding below intact.
const stored = await readCredentials(config.host);
const token = envToken || stored?.token;
if (!token) {
throw new Error("Not logged in. Run `ct auth login --host <url> --token <token>` first.");
Expand Down
116 changes: 80 additions & 36 deletions src/auth/tokenStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
* personal **login token**, stored together so a token is always bound to the
* instance it authenticates against.
*
* They live in the macOS Keychain (via the `security` CLI) as a single JSON
* blob. There is no file fallback: on CI or non-macOS hosts, supply the host and
* token through the `CT_HOST` / `CT_LOGINTOKEN` environment variables instead.
* They live in the macOS Keychain (via the `security` CLI) as JSON blobs. There
* is no file fallback: on CI or non-macOS hosts, supply the host and token
* through the `CT_HOST` / `CT_LOGINTOKEN` environment variables instead.
*
* Multi-host (#22): each host's credentials are stored under a **per-host account**
* (account name = the host), so one machine can hold logins for several instances
* (e.g. `eqrm-dev` and `prod`) at once. A legacy single `"credentials"` account is
* still written as the "default / last login" pointer (so the single-host path —
* `readStoredHost()` with no host — keeps working) and is still READ as a fallback
* for a host that has no per-host account yet (a login made before this change).
*
* Read precedence is applied by the callers:
* - token: `CT_LOGINTOKEN` env → stored credentials
* - host: `CT_HOST` env → stored credentials (see config.ts)
* - token: `CT_LOGINTOKEN` env → stored credentials for the host
* - host: `CT_HOST` env → stored default host (see config.ts)
*
* Note: `security ... -w <value>` passes the value as an argv, briefly visible
* to `ps`. Acceptable for a local developer CLI; the value never touches git.
Expand All @@ -20,7 +27,8 @@ import { promisify } from "node:util";

const run = promisify(execFile);
const KEYCHAIN_SERVICE = "ct-cli";
const KEYCHAIN_ACCOUNT = "credentials";
/** The "default / last login" account — a single blob, also the single-host (pre-#22) location. */
const DEFAULT_ACCOUNT = "credentials";
/** Pre-host account name; a bare token used to live here. Cleared on logout so no secret is orphaned. */
const LEGACY_KEYCHAIN_ACCOUNT = "login-token";

Expand Down Expand Up @@ -51,52 +59,56 @@ export function parseCredentials(raw: string): Credentials | null {
return { host, token };
}

async function keychainSet(value: string): Promise<void> {
async function keychainSet(account: string, value: string): Promise<void> {
await run("security", [
"add-generic-password",
"-U",
"-s",
KEYCHAIN_SERVICE,
"-a",
KEYCHAIN_ACCOUNT,
account,
"-w",
value,
]);
}

/**
* Memoized keychain blob for this process. A single run resolves the host
* (via `resolveConfig`) AND the token (via `authedSession`) — each of which
* reaches for the stored credentials — so without this cache the same entry is
* fetched up to 3× per command, spawning `security find-generic-password`
* (and prompting to unlock a locked Keychain) every time. `undefined` = not yet
* read; `null` = read and absent. Invalidated on any write (`resetKeychainCache`).
* Memoized keychain reads for this process, keyed by account. A single run
* resolves the host (via `resolveConfig`) AND the token (via `authedSession`) —
* each of which reaches for the stored credentials — so without this cache the
* same entry is fetched multiple times per command, spawning
* `security find-generic-password` (and prompting to unlock a locked Keychain)
* every time. A cached value of `null` = read and absent. Invalidated wholesale
* on any write (`resetKeychainCache`).
*/
let cachedKeychainBlob: string | null | undefined;
const cachedBlobs = new Map<string, string | null>();

/** Drop the memoized keychain read. Called after every store/clear; exported for tests. */
/** Drop the memoized keychain reads. Called after every store/clear; exported for tests. */
export function resetKeychainCache(): void {
cachedKeychainBlob = undefined;
cachedBlobs.clear();
}

async function keychainGet(): Promise<string | null> {
if (cachedKeychainBlob !== undefined) {
return cachedKeychainBlob;
async function keychainGet(account: string): Promise<string | null> {
const cached = cachedBlobs.get(account);
if (cached !== undefined) {
return cached;
}
let value: string | null;
try {
const { stdout } = await run("security", [
"find-generic-password",
"-s",
KEYCHAIN_SERVICE,
"-a",
KEYCHAIN_ACCOUNT,
account,
"-w",
]);
cachedKeychainBlob = stdout.trim() || null;
value = stdout.trim() || null;
} catch {
cachedKeychainBlob = null;
value = null;
}
return cachedKeychainBlob;
cachedBlobs.set(account, value);
return value;
}

async function keychainDelete(account: string): Promise<void> {
Expand All @@ -107,44 +119,76 @@ async function keychainDelete(account: string): Promise<void> {
}
}

/** Persist host + token in the macOS Keychain; returns a human-readable location. */
/**
* Persist host + token in the macOS Keychain; returns a human-readable location.
* Written to BOTH the per-host account (so `readCredentials(host)` finds it) and
* the default `"credentials"` account (so the single-host path — resolve the host
* with no `--env` — keeps working, and this login becomes the default).
*/
export async function storeCredentials(creds: Credentials): Promise<string> {
if (!isMac()) {
throw new Error(
"Credential storage requires the macOS Keychain. On other platforms, set CT_HOST and CT_LOGINTOKEN instead.",
);
}
await keychainSet(JSON.stringify(creds));
const blob = JSON.stringify(creds);
await keychainSet(creds.host, blob);
await keychainSet(DEFAULT_ACCOUNT, blob);
resetKeychainCache(); // a fresh login must invalidate any read the process already cached
return `macOS Keychain (service "${KEYCHAIN_SERVICE}", account "${KEYCHAIN_ACCOUNT}")`;
return `macOS Keychain (service "${KEYCHAIN_SERVICE}", account "${creds.host}")`;
}

/** The stored credentials, or null when nothing valid is stored. */
export async function readCredentials(): Promise<Credentials | null> {
/**
* The stored credentials. With a `host`, prefer that host's per-host account,
* then fall back to the legacy default blob ONLY when its host matches (so a
* pre-#22 single login keeps working, but one host's token never leaks for
* another). With no `host`, return the default / last-login blob (single-host path).
*/
export async function readCredentials(host?: string): Promise<Credentials | null> {
if (!isMac()) {
return null;
}
const raw = await keychainGet();
return raw ? parseCredentials(raw) : null;
if (host === undefined) {
const raw = await keychainGet(DEFAULT_ACCOUNT);
return raw ? parseCredentials(raw) : null;
}
const keyed = await keychainGet(host);
const keyedCreds = keyed ? parseCredentials(keyed) : null;
if (keyedCreds) {
return keyedCreds;
}
const fallbackRaw = await keychainGet(DEFAULT_ACCOUNT);
const fallback = fallbackRaw ? parseCredentials(fallbackRaw) : null;
return fallback && fallback.host === host ? fallback : null;
}

/** The login token: `CT_LOGINTOKEN` env wins, else the stored credentials. */
export async function readToken(): Promise<string | null> {
/** The login token: `CT_LOGINTOKEN` env wins, else the stored credentials for `host`. */
export async function readToken(host?: string): Promise<string | null> {
const fromEnv = process.env.CT_LOGINTOKEN?.trim();
if (fromEnv) {
return fromEnv;
}
return (await readCredentials())?.token ?? null;
return (await readCredentials(host))?.token ?? null;
}

/** The stored instance host (no env fallback — env precedence lives in resolveConfig). */
/** The stored default instance host (no env fallback — env precedence lives in resolveConfig). */
export async function readStoredHost(): Promise<string | null> {
return (await readCredentials())?.host ?? null;
}

/**
* Remove stored credentials. Clears the default blob, the pre-host bare-token
* entry, and — for the current default login — its per-host account, so a
* single-host logout leaves no secret behind. (Additional per-host logins for
* OTHER hosts are left in place; re-login overwrites them.)
*/
export async function clearCredentials(): Promise<void> {
if (isMac()) {
await keychainDelete(KEYCHAIN_ACCOUNT);
const current = await readCredentials(); // default blob → its host's per-host account
if (current) {
await keychainDelete(current.host);
}
await keychainDelete(DEFAULT_ACCOUNT);
// Also drop the pre-host bare-token entry so an upgrade doesn't leave a secret behind.
await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT);
}
Expand Down
8 changes: 6 additions & 2 deletions src/commands/adopt-grants.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Command } from "commander";
import { authedSession } from "../api/session.js";
import { resolveConfig } from "../config.js";
import { prepareEnv } from "../env/context.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 { loadState } from "../state/state.js";
import { info, warn } from "../ui.js";

interface AdoptGrantsOptions {
state?: string;
env?: string;
}

/** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */
Expand All @@ -31,6 +33,7 @@ export function adoptGrantsCommand(): Command {
.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")
.option("-e, --env <name>", "environment profile from ct.envs.json (host + state + token)")
.action(async (rawType: string, rawId: string, opts: AdoptGrantsOptions) => {
const domainType = normalizeDomainType(rawType);
if (!/^\d+$/.test(rawId.trim())) {
Expand All @@ -42,8 +45,9 @@ export function adoptGrantsCommand(): Command {

// 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 cmdEnv = await prepareEnv(opts);
const config = await resolveConfig();
const statePath = resolveStatePath(opts.state);
const statePath = cmdEnv.statePath;
const state = await loadState(statePath, config.host);

const { client } = await authedSession();
Expand Down
Loading
Loading