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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ Shape:
}
]
},
"permissions": [ /* PermissionPlanItem[]: { key, domainType, domainId, diff: { toPut, toDelete, preserved } } */ ],
"permissions": [ /* PermissionPlanItem[]: { key, domainType, domainId, pendingDomain?, diff: { toPut, toDelete, preserved } } */ ],
"summary": {
"resources": { "create": 0, "update": 1, "delete": 0, "no-op": 3 },
"drifted": 1,
Expand Down Expand Up @@ -453,6 +453,19 @@ Shape:
`diff.toPut`/`diff.toDelete` is honestly just desired-vs-actual — this is
the one place the tool cannot make the distinction, so it doesn't
pretend to.
- **A permission domain declared by reference to a same-run-created group
type** (e.g. `ct.groupTypeRole({ groupType: "struktur", ... })` against a
fresh instance where `struktur` is itself in the create-set) plans as a
**pending domain** instead of aborting. Its `domainId` is `null` and it
carries a `pendingDomain` object (the logical reference, e.g.
`{ kind: "group-type", key: "struktur", __ctRef: true }`); the human render
shows `<group-type:struktur (created this apply)>`. Its grants land in
`diff.toPut` and count toward `summary.permissions.toPut`, `hasChanges`,
and exit code `2` — so a fresh-instance `ct plan` reports the create-set +
pending grants rather than failing. `ct apply` re-resolves the real domain
id after the group type is created and reconciles the grants in the same
run. The hard error is reserved for references that resolve to nothing at
all (a key absent from the config, state, and the live catalog — a typo).

### Posting a plan as a PR comment

Expand Down
27 changes: 27 additions & 0 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,33 @@ resolution, two declarations that resolve to the **same** `(domainType,
domainId)` are rejected (they would otherwise diff against each other's grants
forever) — even if one used a name and the other a raw id.

### Domains created in the same run (fresh-instance rehearsal, #69)

When a `groupType` reference names a group type that is **created in this same
run** (empty/partial state — the type is part of the create-set), the domain is
handled as a **pending domain** rather than aborting the plan:

- `ct plan` renders the grant block with a
`<group-type:<key> (created this apply)>` marker (consistent with resource
pending refs, #20/#46) and counts its grants in `--json`
(`domainId: null` + a `pendingDomain` reference) and toward exit code `2`.
- `ct apply` runs permission reconciliation **after** the resources are
created, re-resolving the domain id from the fresh group type and granting in
the same run — so a single `ct apply` converges fully. This reuses the same
re-resolution machinery as resource pending refs.
- The hard error (`references a resource created in the same run` → now only a
genuine unresolvable) is reserved for references that resolve to **nothing**:
a key absent from the config, state, and the live catalog (a typo).

**`group_role` is deliberately NOT symmetric here.** A `group_role` domain id is
the (group, role) **pairing** id, which only exists on
`GET /groups/{groupId}/roles` — re-resolving it needs a *live fetch* after the
group exists, not just a post-execute state lookup. So a `group_role` domain
referencing a same-run-created **group** still fails fast with its own
actionable message ("apply the group first, or pass a numeric id"). Its harder
deferral is out of scope for #69 (which targets the #23 `group_type_role`
scenario).

## Scope resolution

A scoped grant's `scope: [...]` is a list where each entry is either a
Expand Down
18 changes: 17 additions & 1 deletion src/permissions/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { mapConcurrent } from "../util/concurrency.js";
import type { PermissionPlanItem } from "./plan.js";
import type { GrantTuple } from "./grants.js";
import { reresolveTuple } from "./scope.js";
import { pendingRef, refLabel } from "../resolve/refs.js";
import { reresolvePendingValue } from "../resolve/resolver.js";

/** How many permission tuples to write at once. Tuples are independent rows, so a modest fan-out is safe. */
const WRITE_CONCURRENCY = 6;
Expand Down Expand Up @@ -68,7 +70,21 @@ export async function applyPermissionPlan(
// the same (domainType, domainId) — so all ops for a path come from ONE item's disjoint diff.
// Programmatic callers bypassing evaluateConfig must uphold that invariant themselves.
for (const item of items) {
const path = `/permissions/${item.domainType}/${item.domainId}`;
// A pending domain (#69) is a group type created THIS run: its numeric id is only known after
// executePlan, so re-resolve it against the POST-execute state now — reusing the SAME machinery
// that re-resolves resource pending refs (reresolvePendingValue). Requires `state`: a pending
// domain can never be applied statelessly.
let domainId = item.domainId;
if (item.pendingDomain) {
if (!state) {
throw new Error(
`Pending permission domain ${refLabel(item.pendingDomain)} ("${item.key}") cannot be applied ` +
`without post-execute state — it names a resource created in the same run.`,
);
}
domainId = reresolvePendingValue(pendingRef(item.pendingDomain), state) as number;
}
const path = `/permissions/${item.domainType}/${domainId}`;
assertNotPeople(path);
for (const t of item.diff.toPut) ops.push({ method: "PUT", path, tuple: t });
for (const t of item.diff.toDelete) ops.push({ method: "DELETE", path, tuple: t });
Expand Down
80 changes: 60 additions & 20 deletions src/permissions/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ 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 type { DesiredPermission } from "./types.js";
import { Resolver } from "../resolve/resolver.js";
import { isPendingRef } from "../resolve/refs.js";
import { isPendingRef, refKey, refLabel, type Ref } from "../resolve/refs.js";

export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number; diff: GrantDiff }
/**
* One resolved permission domain in the plan.
*
* `domainId` is the concrete numeric domain, EXCEPT when `pendingDomain` is set: the domain is a
* logical Ref to a group type created in THIS SAME run (#69), whose id is unknown until the resource
* tier applies. Then `domainId` is `null` and `pendingDomain` carries the Ref, re-resolved against
* post-execute state at apply time (see `applyPermissionPlan`) — mirroring resource pending refs
* (#20/#46) and the scope pending path (#29). A pending domain has no live grants yet, so its diff
* is `desired → toPut` against an empty actual set.
*/
export interface PermissionPlanItem { key: string; domainType: DomainType; domainId: number | null; pendingDomain?: Ref; diff: GrantDiff }

/**
* Fan out each grant to (authId, dataId) tuples. ChurchTools reads a scoped grant back as
Expand Down Expand Up @@ -64,45 +74,56 @@ export function desiredTuples(
});
}

/** A permission whose domainId has been resolved from a logical Ref to a concrete numeric id. */
type ResolvedPermission = DesiredPermission & { domainId: number };
/**
* A permission whose domainId has been resolved. Either a concrete numeric domain, or — when the
* domain is a group type created in this same run (#69) — a `pendingDomain` Ref with `domainId: null`,
* re-resolved at apply time.
*/
type ResolvedPermission =
| (DesiredPermission & { domainId: number; pendingDomain?: undefined })
| (Omit<DesiredPermission, "domainId"> & { domainId: null; pendingDomain: Ref });

/**
* Resolve every permission's domainId to a number (#20). A numeric domainId passes straight through;
* a Ref (e.g. `groupType: "…"`) resolves against the live catalog. A domainId that resolves to a
* same-run-created resource (PendingRef) is rejected — the permission plan needs a concrete id to
* fetch actuals and build the write path, and the permission subsystem does not defer that. A
* group_role ref resolves to its concrete (group, role) pairing id in the resolver (#25).
* Resolve every permission's domainId (#20). A numeric domainId passes straight through; a Ref
* (e.g. `groupType: "…"`) resolves against managed state ∪ the live catalog. A domainId that
* resolves to a same-run-created group type (PendingRef) is NOT rejected (#69): it is carried as a
* `pendingDomain` and re-resolved against post-execute state at apply time — this is what lets a
* fresh-instance plan render the create-set + pending grants instead of aborting. A group_role ref
* resolves to its concrete (group, role) pairing id in the resolver and never goes pending — the
* pairing id needs a live `/groups/{id}/roles` fetch, so a same-run group is a hard error there (#25).
* The hard error remains ONLY for genuinely unresolvable references (key not in config/state at all).
*
* After resolution, the authoritative duplicate-target guard runs on the CONCRETE ids: two different
* refs (or a ref and a number) that collide on one (domainType, domainId) would otherwise each diff
* against the other's grants and churn forever. Mirrors the eval-time guard in config/context.ts.
* After resolution, the authoritative duplicate-target guard runs on the resolved identities (concrete
* id, or the pending Ref's key): two different refs (or a ref and a number) that collide on one domain
* would otherwise each diff against the other's grants and churn forever. Mirrors config/context.ts.
*/
async function resolveDomainIds(
permissions: DesiredPermission[], resolver: Resolver,
): Promise<ResolvedPermission[]> {
const resolved: ResolvedPermission[] = [];
for (const p of permissions) {
if (typeof p.domainId === "number") {
resolved.push(p as ResolvedPermission);
resolved.push({ ...p, domainId: p.domainId });
continue;
}
const site = `${p.domainType} "${p.key}".domainId`;
const res = await resolver.resolve(p.domainId, site);
if (isPendingRef(res)) {
throw new Error(
`${site}: references a resource created in the same run — apply it first, or use a numeric id.`,
);
resolved.push({ key: p.key, domainType: p.domainType, grants: p.grants, domainId: null, pendingDomain: res.__pendingRef });
continue;
}
resolved.push({ ...p, domainId: res });
}
const seen = new Map<string, string>();
for (const p of resolved) {
const key = `${p.domainType}:${p.domainId}`;
const key = p.pendingDomain
? `${p.domainType}:pending:${refKey(p.pendingDomain)}`
: `${p.domainType}:${p.domainId}`;
const label = p.pendingDomain ? `<${refLabel(p.pendingDomain)}>` : `#${p.domainId}`;
const prev = seen.get(key);
if (prev) {
throw new Error(
`Duplicate permission target after resolution: ${p.domainType} #${p.domainId} is declared by ` +
`Duplicate permission target after resolution: ${p.domainType} ${label} is declared by ` +
`both "${prev}" and "${p.key}". Merge their grants into one declaration.`,
);
}
Expand Down Expand Up @@ -133,9 +154,11 @@ export async function buildPermissionPlan(
const resolved = await resolveDomainIds(permissions, resolver ?? new Resolver({ client, state, desired }));
// 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
// one bulk fetch per distinct domainType — but only for CONCRETE domains. A pending domain (#69)
// is a group type created this run: it has no live grants, so nothing to fetch (and on a fresh
// instance the fetch would be a pure waste, or a spurious fetchError).
const byType = new Map<DomainType, RawPermission[] | null>();
for (const dt of new Set(resolved.map((p) => p.domainType))) {
for (const dt of new Set(resolved.filter((p) => p.pendingDomain === undefined).map((p) => p.domainType))) {
try {
byType.set(dt, await client.get<RawPermission[]>(`/permissions/${dt}`));
} catch (err) {
Expand All @@ -145,6 +168,23 @@ export async function buildPermissionPlan(
}
}
for (const p of resolved) {
if (p.pendingDomain !== undefined) {
// The domain (a group type) is created THIS run (#69), so it has no live grants yet: the
// actual set is empty and every desired grant lands in toPut as a pending grant block. Its
// numeric domainId is unknown until the resource tier applies — the pending marker is
// re-resolved against post-execute state at apply time (applyPermissionPlan). Rendered with a
// `<groupType:x (created this apply)>` marker consistent with resource pending refs.
items.push({
key: p.key,
domainType: p.domainType,
domainId: null,
pendingDomain: p.pendingDomain,
// domainId is irrelevant to desiredTuples (it only reads key/domainType/grants); pass the
// pending Ref through so the shape stays a valid DesiredPermission.
diff: diffGrants(desiredTuples({ ...p, domainId: p.pendingDomain }, state, declaredGroupKeys), []),
});
continue;
}
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));
Expand Down
14 changes: 13 additions & 1 deletion src/permissions/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
import pc from "picocolors";
import type { PermissionPlanItem } from "./plan.js";
import type { GrantTuple } from "./grants.js";
import { refLabel } from "../resolve/refs.js";

/**
* The domain identifier for a plan line: a concrete `#id`, or — when the domain is a group type
* created in this same run (#69) — a `<group-type:x (created this apply)>` marker consistent with the
* resource pending-ref rendering (src/engine/render.ts). Its real id is filled in at apply time.
*/
function fmtDomain(item: PermissionPlanItem): string {
return item.pendingDomain
? `<${refLabel(item.pendingDomain)} (created this apply)>`
: `#${item.domainId}`;
}

function fmtTuple(t: GrantTuple): string {
let scope = "";
Expand Down Expand Up @@ -37,7 +49,7 @@ export function renderPermissionPlan(items: PermissionPlanItem[]): string {
totalGrant += grantCount;
totalRevoke += revokeCount;
lines.push(
` ${item.domainType} #${item.domainId} (${item.key}): ${pc.green(`+${grantCount} grant(s)`)}, ${pc.red(`-${revokeCount} remove(s)`)}`,
` ${item.domainType} ${fmtDomain(item)} (${item.key}): ${pc.green(`+${grantCount} grant(s)`)}, ${pc.red(`-${revokeCount} remove(s)`)}`,
);
for (const t of item.diff.toPut) {
lines.push(` ${pc.green("+")} ${fmtTuple(t)}`);
Expand Down
Loading
Loading