Skip to content

Commit c73eacd

Browse files
qq9340100claude
andauthored
fix(security): reconcile audience-binding suggestions per organization (#8617) (#8674)
* fix(security): reconcile audience-binding suggestions per organization (#8617) The reconciler read and wrote through a module-level `SYSTEM_CTX = { isSystem: true }` carrying no tenant, so a shared-runtime multi-organization install held ONE organization-less suggestion row that every tenant read: the first admin to confirm or dismiss answered for all of them, while the binding their confirm created existed only in their own organization. - every read and write in `suggested-audience-bindings.ts` now carries `{ isSystem: true, tenantId }` — `findAnchorPositions`, `bindingExists`, the list/confirm/dismiss paths and the reconcile loop alike; - `reconcileAudienceBindingSuggestions` is the new entry point: one pass per organization under a walled posture, exactly one organization-less pass under `single`; - pre-fix organization-less rows are reaped first. Measured: without the reap the platform bucket (ADR-0120 D3) shows that row to every tenant and the per-organization passes create nothing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH * chore(changeset): per-organization audience-binding suggestion reconciler Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ead96d0 commit c73eacd

5 files changed

Lines changed: 577 additions & 83 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
Reconcile audience-binding suggestions per organization (ADR-0090 D5/D9)
6+
7+
`sys_audience_binding_suggestion` rows are per-tenant by construction — a
8+
package suggests, and a TENANT admin confirms — but the reconciler read and
9+
wrote through a module-level `{ isSystem: true }` context carrying no tenant.
10+
On a shared-runtime multi-organization installation that produced ONE
11+
organization-less row that every tenant read: the first admin to confirm or
12+
dismiss answered for all of them, while the binding their confirm created
13+
existed only in their own organization, so every other tenant's users never
14+
received the package's default permission set and the surface reported the
15+
suggestion resolved.
16+
17+
- every read and write in the module now carries `{ isSystem: true, tenantId }`
18+
— the anchor lookup, the "is it already bound?" lookup, and the
19+
list/confirm/dismiss paths, not just the writes;
20+
- `reconcileAudienceBindingSuggestions` is the new entry point the runtime
21+
calls: one pass per organization under a `group`/`isolated` posture, and the
22+
publishing organization alone on the package-door publish path;
23+
- pre-existing organization-less rows are reaped before the passes and
24+
regenerated per organization. Without that, ADR-0120 D3's platform bucket
25+
keeps showing the old row to every tenant and the per-organization passes
26+
create nothing at all. No permission binding is touched by the reap.
27+
28+
A `single`-posture deployment is unchanged: exactly one organization-less pass,
29+
and no reap.

packages/plugins/plugin-security/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,19 @@ export type { ExplainEngineDeps, ExplainInput } from './explain-engine.js';
7878
export type { DelegatedAdminGateDeps } from './delegated-admin-gate.js';
7979
export {
8080
syncAudienceBindingSuggestions,
81+
reconcileAudienceBindingSuggestions,
82+
reapOrganizationLessSuggestions,
83+
listSuggestionOrganizationIds,
8184
listAudienceBindingSuggestions,
8285
confirmAudienceBindingSuggestion,
8386
dismissAudienceBindingSuggestion,
8487
SuggestionNotFoundError,
8588
SuggestionStateError,
8689
} from './suggested-audience-bindings.js';
87-
export type { SuggestionDeps, SuggestionListFilter, SuggestionSyncOutcome } from './suggested-audience-bindings.js';
90+
export type {
91+
SuggestionDeps,
92+
SuggestionListFilter,
93+
SuggestionSyncOutcome,
94+
SuggestionReconcileOutcome,
95+
SuggestionReconcileScope,
96+
} from './suggested-audience-bindings.js';

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
} from './permission-set-projection.js';
3838
import { registerObjectPostureGate } from './object-posture-gate.js';
3939
import {
40-
syncAudienceBindingSuggestions,
40+
reconcileAudienceBindingSuggestions,
4141
listAudienceBindingSuggestions,
4242
confirmAudienceBindingSuggestion,
4343
dismissAudienceBindingSuggestion,
@@ -2546,14 +2546,25 @@ export class SecurityPlugin implements Plugin {
25462546
if (protocol && typeof protocol.registerPublishMaterializer === 'function') {
25472547
protocol.registerPublishMaterializer(
25482548
'permission',
2549-
async (args: { body: unknown; packageId: string | null }) => {
2549+
async (args: { body: unknown; packageId: string | null; organizationId: string | null }) => {
25502550
const r = await upsertPackagePermissionSet(ql, args.body, args.packageId, ctx.logger);
25512551
const applied = r.seeded + r.updated;
25522552
// [ADR-0090 D5] A published set carrying the install-time
25532553
// suggestion flag surfaces (or retires) its pending
25542554
// suggestion row right away — same convergent sync as boot.
2555+
//
2556+
// [#8617] Scoped to the PUBLISHING organization when the draft
2557+
// carried one: the publish is that tenant's act, and the
2558+
// suggestion row it produces is that tenant's prompt. A
2559+
// package-door publish with no org scope is installation-wide,
2560+
// so it reconciles every organization — the same sweep as boot.
25552561
if (applied > 0 && (args.body as { isDefault?: boolean } | null)?.isDefault !== undefined) {
2556-
try { await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger); } catch { /* non-fatal */ }
2562+
try {
2563+
await reconcileAudienceBindingSuggestions(ql, this.metadata, ctx.logger, {
2564+
posture: this.tenancyPosture,
2565+
organizationId: args.organizationId ?? undefined,
2566+
});
2567+
} catch { /* non-fatal */ }
25572568
}
25582569
// A publish that materialized nothing did NOT go live — report it
25592570
// as a failure with the reason so the package-door UI never shows
@@ -2688,8 +2699,18 @@ export class SecurityPlugin implements Plugin {
26882699
// confirmation — never auto-bound. Runs after the anchors are seeded
26892700
// and after the baseline binding above, so the app's own fallback set
26902701
// (already bound) never nags.
2702+
//
2703+
// [#8617] ONE PASS PER ORGANIZATION under a walled posture. The rows
2704+
// are per-tenant by construction (ADR-0090 D5/D9: resolved when a
2705+
// TENANT admin confirms), so a single tenant-less pass wrote one
2706+
// organization-less row that every tenant read — and the first admin
2707+
// to answer answered for all of them, while the binding their confirm
2708+
// created existed only in their own organization. `single` posture is
2709+
// unchanged: exactly one organization-less pass.
26912710
try {
2692-
await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger);
2711+
await reconcileAudienceBindingSuggestions(ql, this.metadata, ctx.logger, {
2712+
posture: this.tenancyPosture,
2713+
});
26932714
} catch (e) {
26942715
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
26952716
}

0 commit comments

Comments
 (0)