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
26 changes: 26 additions & 0 deletions .changeset/authz-secfix-self-deleg-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/plugin-security': patch
---

Security fix: constrain self-delegation (D3) position anchor to prevent lateral
visibility escalation (cloud#830 follow-up).

cloud#830 (C1 position-anchor) made `sys_user_position.business_unit_id`
visibility **load-bearing** — it is the readScope depth anchor, so a
`unit`/`unit_and_below` holder sees the owner set rooted at that BU (and, for
`unit_and_below`, its whole subtree). The delegated-admin gate's self-service
delegation path (`assertSelfDelegation`) stamped this anchor with **no
subtree/source constraint**: a holder of a delegatable, non-admin-scope position
anchored at a LOW business unit could delegate it to a co-conspirator with an
**ancestor / arbitrary-high** anchor, leaking that BU's whole subtree of member
records — visibility beyond the delegator's own range. Mutual delegation could
grant it both ways.

The gate now requires a self-delegated `business_unit_id` to fall inside the
delegator's **own effective anchor** for that position (the subtree of their own
direct holding's anchor, or of their member BU when the holding is unanchored) —
the same "assignments must target your subtree" spirit as the D12
delegated-admin boundary. Fail-closed: an anchor that cannot be proven inside the
delegator's range is rejected. Unanchored delegation rows keep prior behavior
(the delegate resolves to their own member BU — not a widening). The
"anchoring only narrows, never widens" invariant now holds on the D3 path too.
133 changes: 133 additions & 0 deletions packages/plugins/plugin-security/src/delegated-admin-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,136 @@ describe('DelegatedAdminGate — self-service delegation of duty (ADR-0091 D3)',
.rejects.toThrow(/audience anchor/);
});
});

// ── [cloud#830 follow-up] Self-delegation anchor containment ────────────────
//
// cloud#830 (C1 position-anchor) made sys_user_position.business_unit_id
// visibility LOAD-BEARING: it is the readScope depth anchor, so a
// `unit`/`unit_and_below` holder sees the owner set rooted at that BU (and, for
// unit_and_below, its whole subtree). The self-delegation (D3) path stamped the
// anchor without any subtree/source constraint, so a holder of a delegatable
// non-admin position anchored at a LOW BU could delegate it to a co-conspirator
// with an ANCESTOR/arbitrary-high anchor — leaking that BU's whole subtree,
// beyond the delegator's own range (lateral visibility escalation). The fix
// requires the delegated anchor to fall inside the delegator's OWN effective
// anchor for the position (same spirit as the D12 delegated-admin subtree
// check), fail-closed.
//
// Topology:
// hq (bu_hq)
// ├── east (bu_east) ← u_boss's own approver anchor
// │ └── east_sales (bu_es)
// └── west (bu_west)
function makeAnchoredDelegationHarness(nowMs = T0) {
const tables: Record<string, any[]> = {
sys_business_unit: [
{ id: 'bu_hq', name: 'hq', parent_business_unit_id: null },
{ id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' },
{ id: 'bu_es', name: 'east_sales', parent_business_unit_id: 'bu_east' },
{ id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' },
],
sys_position: [{ id: 'p_appr', name: 'approver', delegatable: true }],
sys_permission_set: [{ id: 's_appr', name: 'approve_set' }],
sys_position_permission_set: [{ id: 'b_appr', position_id: 'p_appr', permission_set_id: 's_appr' }],
// u_boss holds approver DIRECTLY, anchored at east.
sys_user_position: [{ id: 'ha', user_id: 'u_boss', position: 'approver', business_unit_id: 'bu_east' }],
sys_business_unit_member: [{ id: 'm_boss', user_id: 'u_boss', business_unit_id: 'bu_es' }],
sys_user: [{ id: 'u_boss' }, { id: 'u_deleg' }],
};
const matches = (row: any, where: any): boolean =>
Object.entries(where ?? {}).every(([k, v]) => {
if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]);
return row[k] === v;
});
const ql = {
tables,
async find(object: string, opts: any) {
const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where));
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
},
async findOne(object: string, opts: any) {
return (tables[object] ?? []).filter((r) => matches(r, opts?.where))[0] ?? null;
},
} as any;
const gate = new DelegatedAdminGate({
ql,
resolveSets: async () => [{ name: 'member_default', objects: {} }],
now: () => nowMs,
});
const delegate = (userId: string, row: any) =>
gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: { userId, positions: [] } });
const base = (extra: any) => ({
user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss',
valid_until: iso(nowMs + 5 * DAY), reason: 'coverage', ...extra,
});
return { gate, ql, tables, delegate, base };
}

describe('DelegatedAdminGate — self-delegation anchor containment (cloud#830 follow-up)', () => {
it('anchor equal to the delegator\'s own anchor (east) is allowed', async () => {
const d = makeAnchoredDelegationHarness();
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_east' }))).resolves.toBeUndefined();
});

it('anchor inside the delegator\'s own subtree (east_sales) is allowed — narrowing', async () => {
const d = makeAnchoredDelegationHarness();
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_es' }))).resolves.toBeUndefined();
});

it('anchor at an ANCESTOR BU (hq) is DENIED — a delegation may not widen visibility', async () => {
const d = makeAnchoredDelegationHarness();
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_hq' })))
.rejects.toThrow(/only narrows|never widen/);
});

it('anchor at an UNRELATED sibling BU (west) is DENIED — outside your own effective anchor', async () => {
const d = makeAnchoredDelegationHarness();
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_west' })))
.rejects.toThrow(/outside your own effective anchor/);
});

it('an unanchored delegation row keeps prior behavior (no anchor to widen)', async () => {
const d = makeAnchoredDelegationHarness();
await expect(d.delegate('u_boss', d.base({}))).resolves.toBeUndefined();
});

it('mutual delegation cannot cross ranges: neither direction may hand out the other\'s BU', async () => {
// u_boss anchored east may not delegate a west anchor…
const d = makeAnchoredDelegationHarness();
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_west' })))
.rejects.toThrow(/outside your own effective anchor/);
// …and a would-be west holder cannot reach east either (no east holding to source it).
d.tables.sys_user_position.push({ id: 'hb', user_id: 'u_deleg', position: 'approver', business_unit_id: 'bu_west' });
await expect(d.delegate('u_deleg', { user_id: 'u_boss', position: 'approver', delegated_from: 'u_deleg', valid_until: iso(T0 + 5 * DAY), reason: 'x', business_unit_id: 'bu_east' }))
.rejects.toThrow(/outside your own effective anchor/);
});

it('when the delegator holds the position UNANCHORED, the anchor is bounded by their MEMBER BU', async () => {
const d = makeAnchoredDelegationHarness();
// Drop the anchor on u_boss's own holding; boss is a member of east_sales.
d.tables.sys_user_position.find((r: any) => r.id === 'ha').business_unit_id = null;
// Member-BU (east_sales) or below is allowed…
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_es' }))).resolves.toBeUndefined();
// …but the parent (east) — above the member BU — is not.
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_east' })))
.rejects.toThrow(/outside your own effective anchor/);
});

it('fail-closed: an anchor that cannot be validated (delegator has no resolvable range) is refused', async () => {
const d = makeAnchoredDelegationHarness();
// Boss holds approver unanchored AND has no BU membership → no provable range.
d.tables.sys_user_position.find((r: any) => r.id === 'ha').business_unit_id = null;
d.tables.sys_business_unit_member.length = 0;
await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_es' })))
.rejects.toThrow(/cannot be validated/);
});

it('a holding acquired VIA delegation cannot source an anchored re-delegation (chains stay cut)', async () => {
const d = makeAnchoredDelegationHarness();
// u_relay holds approver only via delegation, anchored east.
d.tables.sys_user.push({ id: 'u_relay' });
d.tables.sys_user_position.push({ id: 'hd', user_id: 'u_relay', position: 'approver', business_unit_id: 'bu_east', delegated_from: 'u_boss', valid_until: iso(T0 + 20 * DAY) });
await expect(d.delegate('u_relay', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_relay', valid_until: iso(T0 + 5 * DAY), reason: 'x', business_unit_id: 'bu_es' }))
.rejects.toThrow(/only via delegation/);
});
});
98 changes: 88 additions & 10 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,10 @@ export class DelegatedAdminGate {
* re-delegatable (chains are cut);
* 5. the position is `delegatable: true`;
* 6. the position distributes NO `adminScope`-carrying set (administration
* is never self-delegated — that would bypass the D12 containment gate).
* is never self-delegated — that would bypass the D12 containment gate);
* 7. [cloud#830 follow-up] if the delegation carries a `business_unit_id`
* anchor, that anchor falls inside the delegator's OWN effective anchor
* for the position — a delegation may NARROW visibility, never widen it.
* The writer is stamped into `granted_by` (dual audit: `granted_by` = writer,
* `delegated_from` = authority source).
*/
Expand Down Expand Up @@ -321,6 +324,36 @@ export class DelegatedAdminGate {
deny(`you do not currently hold '${positionName}' — only a current holder may delegate it`, { position: positionName });
}

// 4b. [cloud#830 follow-up] Anchor containment. `business_unit_id` is
// visibility LOAD-BEARING (cloud#830 made it the readScope depth
// anchor: a `unit`/`unit_and_below` holder sees the owner set rooted
// at this BU). "Anchoring only narrows, never widens" must therefore
// hold on THIS path too — otherwise a holder anchored at a low BU
// could hand a co-conspirator an ANCESTOR BU and leak that whole
// subtree's records, exceeding the delegator's own range (lateral
// escalation). So a delegated anchor must fall inside the delegator's
// OWN effective anchor for this position — same spirit as the D12
// delegated-admin subtree check (assertAssignmentWrite). Fail-closed:
// an anchor we cannot prove is inside the delegator's range is
// refused. An unanchored delegation row keeps the prior behavior (the
// delegate resolves to their own member BU — not a widening).
const rowAnchor =
row?.business_unit_id != null && row.business_unit_id !== '' ? String(row.business_unit_id) : null;
if (rowAnchor) {
const allowed = await this.delegatorAnchorSubtree(
String(ctx.userId),
holdings.filter((hd) => hd.direct),
);
if (!allowed.has(rowAnchor)) {
deny(
allowed.size === 0
? `business unit anchor '${rowAnchor}' cannot be validated against your own '${positionName}' anchor — an anchor that cannot be proven within your own range is refused (cloud#830: anchoring only narrows)`
: `business unit anchor '${rowAnchor}' is outside your own effective anchor for '${positionName}' — a delegation may only narrow visibility, never widen it (cloud#830: anchoring only narrows)`,
{ position: positionName, businessUnitId: rowAnchor },
);
}
}

// 5. The position must opt in to delegation.
if (!(await this.positionIsDelegatable(positionName))) {
deny(`position '${positionName}' is not delegatable — set delegatable: true on the position to allow it`, { position: positionName });
Expand All @@ -341,12 +374,15 @@ export class DelegatedAdminGate {

/** The actor's holdings of `positionName` that are inside their validity
* window, tagged `direct` when the holding itself did NOT arrive via
* delegation (only a direct holding is re-delegatable). */
* delegation (only a direct holding is re-delegatable) and carrying each
* holding's own `businessUnitId` anchor (null = unanchored). The anchor of a
* direct holding bounds what a self-delegation of that position may hand out
* (cloud#830 — the anchor is visibility load-bearing). */
private async activeHoldings(
userId: string,
positionName: string,
now: number,
): Promise<Array<{ direct: boolean }>> {
): Promise<Array<{ direct: boolean; businessUnitId: string | null }>> {
const ql = this.deps.ql;
if (!ql?.find) return [];
let rows: any[] = [];
Expand All @@ -361,7 +397,11 @@ export class DelegatedAdminGate {
}
return (Array.isArray(rows) ? rows : [])
.filter((r) => isGrantActive(r, now))
.map((r) => ({ direct: r?.delegated_from == null || r.delegated_from === '' }));
.map((r) => ({
direct: r?.delegated_from == null || r.delegated_from === '',
businessUnitId:
r?.business_unit_id != null && r.business_unit_id !== '' ? String(r.business_unit_id) : null,
}));
}

private async positionIsDelegatable(positionName: string): Promise<boolean> {
Expand Down Expand Up @@ -690,18 +730,28 @@ export class DelegatedAdminGate {

/** BU name → covered BU-id set (root + descendants when includeSubtree). */
private async resolveSubtree(businessUnitName: string, includeSubtree: boolean): Promise<Set<string>> {
const ids = new Set<string>();
const ql = this.deps.ql;
if (!ql?.find) return ids;
if (!ql?.find) return new Set<string>();
let root: any = null;
try {
const roots = await ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX });
root = Array.isArray(roots) && roots[0] ? roots[0] : null;
} catch { root = null; }
if (!root?.id) return ids; // misconfigured scope → approves nothing (fail closed)
ids.add(String(root.id));
if (!includeSubtree) return ids;
let frontier: string[] = [String(root.id)];
if (!root?.id) return new Set<string>(); // misconfigured scope → approves nothing (fail closed)
if (!includeSubtree) return new Set<string>([String(root.id)]);
return this.resolveSubtreeById(String(root.id));
}

/** BU id → covered BU-id set (root id + all descendants). Subtree is always
* walked: the delegator's readScope depth is not known on the delegation
* path, so the whole subtree is the containment bound — matching the D12
* admin subtree check. Fail-closed on unresolvable ids (empty set). */
private async resolveSubtreeById(rootId: string): Promise<Set<string>> {
const ids = new Set<string>();
const ql = this.deps.ql;
if (!ql?.find || !rootId) return ids;
ids.add(String(rootId));
let frontier: string[] = [String(rootId)];
for (let depth = 0; depth < MAX_TREE_DEPTH && frontier.length > 0; depth++) {
let children: any[] = [];
try {
Expand All @@ -721,6 +771,34 @@ export class DelegatedAdminGate {
return ids;
}

/** [cloud#830 follow-up] The union BU-subtree covered by the delegator's OWN
* DIRECT holdings of a position: subtree(anchor) for each anchored holding,
* plus subtree(member BU) for each unanchored holding (an unanchored holding
* resolves the depth anchor to the holder's own membership). A self-delegated
* `business_unit_id` anchor must fall inside this set. Fail-closed:
* unresolvable holdings/memberships contribute nothing, so an anchor that
* can't be proven inside the delegator's range is refused upstream. */
private async delegatorAnchorSubtree(
userId: string,
directHoldings: Array<{ businessUnitId: string | null }>,
): Promise<Set<string>> {
const allowed = new Set<string>();
let memberSubtreeResolved = false;
for (const h of directHoldings) {
if (h.businessUnitId) {
for (const id of await this.resolveSubtreeById(h.businessUnitId)) allowed.add(id);
} else if (!memberSubtreeResolved) {
// An unanchored direct holding resolves to the delegator's own member
// BU(s); resolve those once (they don't vary by holding).
memberSubtreeResolved = true;
for (const bu of await this.businessUnitsOfUser(userId)) {
for (const id of await this.resolveSubtreeById(bu)) allowed.add(id);
}
}
}
return allowed;
}

/** Rows targeted by this write: `{ next, prev }` per row (prev = pre-image on update/delete). */
private async materializeTargets(opCtx: any, object: string): Promise<Array<{ next: any | null; prev: any | null }>> {
const op = opCtx.operation;
Expand Down