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
36 changes: 24 additions & 12 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,30 @@ The two DSL functions manage two different ChurchTools "domain types," and

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. At plan time each key is resolved to that group's `dataId`
via the state file; a key that isn't a managed group throws:

```
Scope key "kids_area" does not resolve to a managed group. Declare/adopt it,
or use a group already under management.
```

This is a deliberate constraint (see `src/permissions/scope.ts`): scope
targets must be tool-visible so `ct plan` can show what a grant actually
resolves to, and so renaming/re-keying a group doesn't silently orphan a
grant's scope.
ChurchTools ids. Each key is resolved against **desired ∪ state**
(`src/permissions/scope.ts`):

- A key already in state resolves to that group's `dataId`.
- A key **declared in this config but not yet created** resolves to a *pending*
target: the plan renders it as `scope=[<key> (created this apply)]`, and its
real `dataId` is filled in at apply time — so a config can declare a group AND
a grant scoped to it and still plan/apply in one run (no bootstrap deadlock).
- A key that is neither in state nor declared throws:

```
Scope key "kids_area" does not resolve to a managed group. Declare/adopt it,
or use a group already under management.
```

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.

## Domain rules (validated, throw on violation)

Expand Down
5 changes: 5 additions & 0 deletions src/api/ctClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export class CtClient {

/** Run the login-token handshake and cache the session cookie + CSRF token. */
async authenticate(loginToken: string): Promise<WhoAmI> {
// The token rides as a URL query param (it lands in the server's access logs). This is
// unavoidable for this token class: the handshake above is documented to require the
// `login_token` query param — an `Authorization` header yields a null CSRF token and breaks
// writes. The token↔host binding enforced in `authedSession` (issue #30) makes this safe by
// guaranteeing the token is only ever sent to the host it was captured against.
const url = `${this.config.host}/api/whoami?login_token=${encodeURIComponent(loginToken)}`;
const res = await fetchWithRetry(
url,
Expand Down
32 changes: 29 additions & 3 deletions src/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,46 @@
* Centralised so every command fails the same friendly way when logged out.
*/
import { CtClient, type WhoAmI } from "./ctClient.js";
import { readToken } from "../auth/tokenStore.js";
import { resolveConfig } from "../config.js";
import { readCredentials } from "../auth/tokenStore.js";
import { normalizeHost, resolveConfig } from "../config.js";

export interface AuthedSession {
client: CtClient;
me: WhoAmI;
}

/**
* Build an authenticated {@link CtClient} from the stored credentials.
*
* Enforces the host↔token binding BEFORE any network call: the stored token is
* bound to the host it was captured against (`tokenStore` stores them together).
* If the resolved host (which gives `CT_HOST` precedence) differs from the host
* the stored token belongs to, we refuse — otherwise the secret would be sent
* (as a `login_token` URL query param) to a foreign server and land in its logs,
* even on a failed login. See issue #30.
*
* An explicit `CT_LOGINTOKEN` env token carries no stored-host binding, so the
* caller owns pairing it with the intended `CT_HOST` — no check applies there.
*/
export async function authedSession(): Promise<AuthedSession> {
const config = await resolveConfig();
const token = await readToken();
const envToken = process.env.CT_LOGINTOKEN?.trim();
const stored = await readCredentials();
const token = envToken || stored?.token;
if (!token) {
throw new Error("Not logged in. Run `ct auth login --host <url> --token <token>` first.");
}

// Only the *stored* token is host-bound. Refuse to send it to a different host.
if (!envToken && stored && normalizeHost(stored.host) !== config.host) {
throw new Error(
`Refusing to send the stored login token: it belongs to ${normalizeHost(stored.host)}, ` +
`but the resolved host is ${config.host} (from CT_HOST). ` +
`Run \`ct auth login --host ${config.host} --token <token>\` for that host, ` +
`or unset CT_HOST to use ${normalizeHost(stored.host)}.`,
);
}

const client = new CtClient(config);
const me = await client.authenticate(token);
return { client, me };
Expand Down
6 changes: 4 additions & 2 deletions src/commands/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function applyCommand(): Command {

const { client } = await authedSession();
const { plan, actual, fetchErrors } = await buildPlan(client, state, desired, { configDir });
const { items: permItems, fetchErrors: permFetchErrors } = await buildPermissionPlan(client, state, permissions);
const { items: permItems, fetchErrors: permFetchErrors } = await buildPermissionPlan(client, state, permissions, desired);

const allFetchErrors = [...fetchErrors, ...permFetchErrors];
if (allFetchErrors.length > 0) {
Expand Down Expand Up @@ -156,7 +156,9 @@ export function applyCommand(): Command {
return;
}

const permResult = await applyPermissionPlan(permItems, client);
// Re-resolve scope dataIds against the POST-execute state (executePlan has upserted every
// created/recreated group's real id) so grants are never written with a stale/pending id.
const permResult = await applyPermissionPlan(permItems, client, state);
if (permResult.granted > 0 || permResult.deleted > 0) {
success(`Permissions applied: ${permResult.granted} granted, ${permResult.deleted} deleted.`);
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function planCommand(): Command {

const { client } = await authedSession();
const { plan, fetchErrors } = await buildPlan(client, state, desired, { configDir });
const { items: permItems, fetchErrors: permFetchErrors } = await buildPermissionPlan(client, state, permissions);
const { items: permItems, fetchErrors: permFetchErrors } = await buildPermissionPlan(client, state, permissions, desired);
if (opts.json) {
out({ plan, permissions: permItems });
} else {
Expand Down
16 changes: 15 additions & 1 deletion src/permissions/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,41 @@
* as belt-and-suspenders atop the structural-only registry.
*/
import type { CtClient } from "../api/ctClient.js";
import type { State } from "../state/state.js";
import { assertNotPeople } from "../engine/guard.js";
import type { PermissionPlanItem } from "./plan.js";
import type { GrantTuple } from "./grants.js";
import { reresolveTuple } from "./scope.js";

function body(t: GrantTuple): Record<string, unknown> {
if (t.pending) {
// A pending tuple's dataId is unknown until it is re-resolved against post-execute state.
// Reaching here means re-resolution was skipped — refuse rather than emit a silent GLOBAL grant.
throw new Error(`Grant scoped to "${t.scopeKey}" was not re-resolved before apply — refusing to write it without a dataId.`);
}
const b: Record<string, unknown> = { authId: t.authId, type: t.type };
if (t.dataId.length) b.dataId = t.dataId; // omit when unscoped
return b;
}

/**
* Apply a permission plan. When `state` is provided, each scoped grant's dataId is RE-RESOLVED
* against it just before the PUT — `state` here is the POST-execute state (executePlan has upserted
* every created/recreated group), so grants are always written with fresh ids and a group created in
* the same apply gets its real id (#29, #33.3). Without `state`, tuples are written as-is.
*/
export async function applyPermissionPlan(
items: PermissionPlanItem[],
client: Pick<CtClient, "request">,
state?: State,
): Promise<{ granted: number; deleted: number }> {
let granted = 0;
let deleted = 0;
for (const item of items) {
const path = `/permissions/${item.domainType}/${item.domainId}`;
assertNotPeople(path);
for (const t of item.diff.toPut) {
await client.request("PUT", path, body(t));
await client.request("PUT", path, body(state ? reresolveTuple(t, state) : t));
granted++;
}
for (const t of item.diff.toDelete) {
Expand Down
31 changes: 28 additions & 3 deletions src/permissions/grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,39 @@
*/
export type DomainType = "group_role" | "group_type_role";

export interface GrantTuple { authId: number; dataId: number[]; type: "grant" | "revoke" }
export interface GrantTuple {
authId: number;
dataId: number[];
type: "grant" | "revoke";
/**
* For a scoped grant: the symbolic scope key this tuple was resolved from. Retained so the
* dataId can be RE-RESOLVED against post-execute state at apply time — which fixes both a grant
* scoped to a group created in the same apply (its dataId is `pending` at plan time) and a stale
* dataId after a scope-target group is recreated (#29, #33.3). Absent on unscoped grants and on
* actual tuples read back from ChurchTools.
*/
scopeKey?: string;
/**
* True when `scopeKey` names a group DECLARED in this config but not yet created (absent from
* state at plan time). Its real dataId is unknown until `executePlan` runs, so the plan renders
* it as pending and it always diffs into `toPut`. Cleared once re-resolved at apply time.
*/
pending?: boolean;
}
export interface RawPermission {
authId: number; dataId: number | null; type: "grant" | "revoke"; domainId: number;
isInherited?: boolean; meta?: { modifiedPid?: number };
}

export function tupleKey(t: { authId: number; dataId: number[]; type: string }): string {
return `${t.type}:${t.authId}:${[...t.dataId].sort((a, b) => a - b).join(",")}`;
/**
* Identity key for set reconciliation. A pending tuple has no resolved dataId yet, so key it by
* its symbolic scope key instead — that keeps it distinct from an unscoped grant (`dataId: []`)
* and guarantees it can never collide with an actual row (actuals never carry a scopeKey), so it
* always lands in `toPut`.
*/
export function tupleKey(t: { authId: number; dataId: number[]; type: string; scopeKey?: string; pending?: boolean }): string {
const scope = t.pending && t.scopeKey != null ? `pending:${t.scopeKey}` : [...t.dataId].sort((a, b) => a - b).join(",");
return `${t.type}:${t.authId}:${scope}`;
}

export function normalizeActual(rows: RawPermission[]): GrantTuple[] {
Expand Down
24 changes: 19 additions & 5 deletions src/permissions/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { CtClient } from "../api/ctClient.js";
import { CtApiError } from "../api/ctClient.js";
import type { State } from "../state/state.js";
import type { DesiredResource } from "../engine/types.js";
import { resolveAuthId } from "./catalog.js";
import { resolveScope } from "./scope.js";
import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js";
Expand All @@ -21,8 +22,12 @@ export interface PermissionPlanItem { key: string; domainType: DomainType; domai
* scalar read shape, a scoped grant `{right, scope:[a,b]}` becomes TWO single-dataId tuples,
* not one two-element tuple.
*/
export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[] {
return p.grants.flatMap((g) => {
export function desiredTuples(
p: DesiredPermission,
state: State,
declaredGroupKeys: ReadonlySet<string> = new Set(),
): GrantTuple[] {
return p.grants.flatMap((g): GrantTuple[] => {
const name = typeof g === "string" ? g : g.right;
const entry = resolveAuthId(name);
if (p.domainType === "group_type_role" && entry.authId >= 10000) {
Expand All @@ -41,15 +46,24 @@ export function desiredTuples(p: DesiredPermission, state: State): GrantTuple[]
if (entry.scopeField == null) {
throw new Error(`${p.domainType} "${p.key}": "${name}" is not a scoped right (no scopeField) — remove "scope" or use a scoped right.`);
}
return resolveScope(g.scope, state).map((id) => ({ authId: entry.authId, dataId: [id], type: "grant" as const }));
// 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 }) =>
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 },
);
});
}

export async function buildPermissionPlan(
client: Pick<CtClient, "get">, state: State, permissions: DesiredPermission[],
client: Pick<CtClient, "get">, state: State, permissions: DesiredPermission[], desired: DesiredResource[] = [],
): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }> {
const items: PermissionPlanItem[] = [];
const fetchErrors: string[] = [];
// Keys declared as groups in the config — valid scope targets even before they are created.
const declaredGroupKeys = new Set(desired.filter((r) => r.type === "group").map((r) => r.key));
// one bulk fetch per distinct domainType
const byType = new Map<DomainType, RawPermission[] | null>();
for (const dt of new Set(permissions.map((p) => p.domainType))) {
Expand All @@ -65,7 +79,7 @@ export async function buildPermissionPlan(
const all = byType.get(p.domainType);
if (all == null) continue; // fetch failed for this domainType — recorded above
const actual = normalizeActual(all.filter((r) => r.domainId === p.domainId));
items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state), actual) });
items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state, declaredGroupKeys), actual) });
}
return { items, fetchErrors };
}
7 changes: 6 additions & 1 deletion src/permissions/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import type { PermissionPlanItem } from "./plan.js";
import type { GrantTuple } from "./grants.js";

function fmtTuple(t: GrantTuple): string {
const scope = t.dataId.length ? ` scope=[${t.dataId.join(",")}]` : "";
let scope = "";
if (t.pending && t.scopeKey != null) {
scope = ` scope=[${t.scopeKey} (created this apply)]`;
} else if (t.dataId.length) {
scope = ` scope=[${t.dataId.join(",")}]`;
}
return `authId=${t.authId}${scope} (${t.type})`;
}

Expand Down
59 changes: 52 additions & 7 deletions src/permissions/scope.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,59 @@
import type { State } from "../state/state.js";
import type { GrantTuple } from "./grants.js";

/** Resolve scope logical keys to sorted group dataIds. MVP: scope keys must be managed groups. */
export function resolveScope(scopeKeys: string[], state: State): number[] {
const ids: number[] = [];
/** 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 }

/**
* Resolve scope logical keys against DESIRED ∪ STATE.
*
* 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.
*
* 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.
*/
export function resolveScope(
scopeKeys: string[],
state: State,
declaredGroupKeys: ReadonlySet<string> = new Set(),
): ScopeResolution[] {
const resolved: ScopeResolution[] = [];
const pending: ScopeResolution[] = [];
for (const key of scopeKeys) {
const m = state.resources[key];
if (!m || m.type !== "group") {
throw new Error(`Scope key "${key}" does not resolve to a managed group. Declare/adopt it, or use a group already under management.`);
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.`,
);
}
ids.push(m.id);
}
return ids.sort((a, b) => a - b);
resolved.sort((a, b) => (a.id as number) - (b.id as number));
pending.sort((a, b) => a.key.localeCompare(b.key));
return [...resolved, ...pending];
}

/**
* Re-resolve a scoped grant tuple's dataId against the current (post-execute) state, using the
* symbolic scopeKey retained on the tuple. Fixes stale ids after a recreate and fills in the id of
* a group created in the same apply. Unscoped tuples (no scopeKey) pass through unchanged. Throws
* if the scope key no longer resolves to a managed group — which should be impossible once the
* resource tier has applied, so it signals a real inconsistency rather than being silently skipped.
*/
export function reresolveTuple(t: GrantTuple, state: State): GrantTuple {
if (t.scopeKey == null) return t;
const m = state.resources[t.scopeKey];
if (!m || m.type !== "group") {
throw new Error(
`Scope key "${t.scopeKey}" did not resolve to a managed group after apply — cannot write its grant with a valid dataId.`,
);
}
return { ...t, dataId: [m.id], pending: false };
}
Loading
Loading