Skip to content

feat(composition): SaaS multi-org shape — catalog-only tenant replay, a tenant admin, and the org-inheritance audit - #1362

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-1361-saas-multiorg-composition
Aug 27, 2026
Merged

feat(composition): SaaS multi-org shape — catalog-only tenant replay, a tenant admin, and the org-inheritance audit#1362
os-zhuang merged 2 commits into
mainfrom
claude/issue-1361-saas-multiorg-composition

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #1361

Part of objectstack-ai/objectstack#12701. HOTCRM_COMPOSITION=saas assembles the shape a multi-org operator deploys on the enterprise runtime behind a walled tenancy posture. Three registrations differ from the community app and nothing else does: no runtime branch anywhere in src/, no per-tenant mechanism, no enterprise import — an artifact built either way still runs on the community runtime.

Premise check, item by item

The card asks for premise-first work, and one of its four items turned out to be already true.

# Item Premise Outcome
1 Catalog-only tenant replay holds built
2a Exclude demo_bootstrap holds — and it is worse than redundant under the wall (measured below) built
2b Exclude demo-staffing already handled nothing to build
3 Walled-posture admin profile holds built, with the view_all_data audit answered
4 Org inheritance of hook-created records holds — and the answer is that it already works pinned, no code change

2b needs no exclusion. src/sharing/demo-staffing.ts is deliberately not exported from src/sharing/index.ts and is registered in no composition — the #640 hard constraint that a published artifact must contain no mechanism able to create a user. test/demo-staffing.test.ts:369 already pins it. The SaaS suite restates it for this shape anyway, because a synthetic login landing in a paying tenant's organization is the worst version of that defect.

What the SaaS composition changes

Seeds → the catalogue family only. The platform's seed-replayer already gives every newly founded organization its own private, editable, deletable copy of the registered dataset union — the shape the maintainer's ruling asks for. What the replayer has no notion of is which families to replay: it always replays the whole union. So the selection is made once, at composition time, uniformly for every tenant, with no per-tenant opt-in to author or mis-set. crm_product is also the only family with no outgoing reference, so the shrink cannot strand a lookup — every other family resolves accounts, contacts or products by natural key, which is why they cannot be cherry-picked back individually.

demo_bootstrap is not registered. Not merely redundant here — actively wrong. The flow runs runAs: 'system', and a system context is the one context the organization predicate does not constrain. Measured on a real engine, driving the flow's own two moves (a system-context select of rows whose ownership column is null, then an owner stamp), both derived from the shipped flow definition rather than transcribed:

a system context selecting ownerless rows sees:
  [{"name":"Ownerless A","organization_id":"org_a"},
   {"name":"Ownerless B","organization_id":"org_b"}]
after the sweep:
  [{"name":"Ownerless A","organization_id":"org_a","owner_id":"usr_a"},
   {"name":"Ownerless B","organization_id":"org_b","owner_id":"usr_a"}]

One tenant's user id, written onto another tenant's row. test/saas-composition.test.ts reproduces that rather than asserting it in prose, so if the platform ever stops behaving this way the block goes red and the exclusion can be revisited on evidence. (It is redundant too: the catalogue's crm_product declares no owner_id, so a catalog-only tenant has nothing ownerless to claim.)

system_admintenant_admin. Same authority over its own organization's records — every object, full CRUD, all-records depth, transfer and export — with the object map derived from SystemAdminProfile.objects rather than transcribed, so a new object cannot ship granted to one admin and denied to the other. What changes is the system permissions.

The view_all_data / modify_all_data audit the card asked for

Conclusion: keep them; they mean "every row of the tenant's own organization", and nothing they could say would reach further. Two measurements against the installed 17.1.0 line — not against a newer platform checkout, since an app on a released line can only grant what that line shipped:

  1. Neither string is read by anything. view_all_data and modify_all_data occur in 0 installed @objectstack/* JS files. Control probes in the same sweep, so the sweep can find a name it is looking for: manage_sharing 15 files, manage_metadata 25, setup.access 32. They are app-declared vocabulary; the authority actually enforced is the per-object viewAllRecords / modifyAllRecords bits, which the sharing layer reads on every request.
  2. The organization boundary is not in that layer. A tenant-scoped read is compiled by the driver from the execution context's tenant id, beneath permissions and sharing. Measured: a caller in org_a reading crm_account sees 1 of the 2 rows in the table — and still sees 1 of 2 with the tenancy posture flipped to single. That flip is what shows the boundary belongs to the driver and the context, not to a deployment knob a permission set might hope to out-rank.

So "all data" is a statement about depth inside one organization, and no permission-set bit widens it. The one context that does cross is isSystem, which no profile grants and no session produces.

manage_usersmanage_org_users. manage_users is scope: 'platform' in the platform's own registry — "across the platform" — which in a shared database is every other tenant's people. manage_org_users (scope: 'org') is declared in released @objectstack/spec 17.1.0, verified against the installed PLATFORM_CAPABILITIES. Both are declarations, not enforcement here: measured on 17.1.0, manage_users appears in exactly one comment and nothing reads either name — the member-management door belongs to the enterprise organizations runtime. That is the shape the 2026-08-11 ruling asks for and that objectstack.config.ts already follows for hierarchy-security: 「本项目是元数据app,在企业版运行就具备企业版相关的能力,不重复开发。」

Deliberately dropped: customize_application, manage_profiles, manage_roles. All three describe metadata authoring, and the only metadata-authoring capability the platform has is manage_metadatascope: 'platform', which also unlocks env-wide tier-B authoring with cross-tenant reach. Granting them would describe an authority the deployment cannot safely give.

Blocked-by: objectstack-ai/objectstack#12702 — org-scoped presentation customization authority. When that capability ships, tenant_admin gains it and this paragraph becomes a grant.

manage_sharing is granted: the platform declares it scope: 'org', which is precisely a tenant admin's job over their own organization's records.

The pin reads this off the platform's registry rather than a hand-written denylist: tenant_admin may hold no capability the registry marks platform, and the community system_admin does hold one (manage_users) — the discriminator, without which the rule would pass just as well against a profile referencing nothing.

Item 4 — org inheritance of hook-created records: already correct, now pinned

Not one of these inserts names an organization; all four rely on the engine stamping from the execution context they inherit, and three of the four hooks are async: true (dispatched fire-and-forget, so the triggering write returns before the handler runs). Measured on a real ObjectQL over SqliteWasmDriver, reading organization_id back out of SQL:

hook record created organization
case.hook.ts escalation crm_task org_a
quote.hook.ts acceptance crm_contract org_a
task.hook.ts recurrence crm_task clone org_a
lead.hook.ts qualification crm_task follow-up org_a
opportunity.hook.ts closed-won crm_task org_a

No platform gap, so no upstream card for this item. test/helpers/hook-harness.ts could never have answered it — it implements ctx.api over plain arrays, with no tenant column and no driver, so a hook could be leaking rows across the wall with every harness-based test in this repo staying green. driver-memory is not an option either, and the refusal is the platform's: it "has NO row-level tenant isolation" and fails at construction under any walled posture (objectstack#6915).

Reverse verification

Four ablations, each committed-then-mutated, each proving the mutation reached disk (marker counts + git hash-object against the HEAD blob) and each proving the restore put HEAD's bytes back (git diff HEAD empty + hash equality). Direction predicted before running in every case.

ablation predicted measured
isSaas forced false red red — the flow and permission assertions (2 failed)
data: pinned to the default union red red — the three catalogue assertions (3 failed)
case.hook.ts forges organization_id: 'org_b' uncertain redexpected 'org_b' to be 'org_a'
posture flipped to single in the hook pin red green — see below

The first two are two separate levers (isSaas gates flows and permissions; seedDataFor(composition) gates data), which is why one ablation could not cover both — the first run left the seed path unverified and the second closed it.

The third is worth recording beyond its own result: a hook's forged organization is not overwritten the way a forged value on the HTTP data plane is, because ctx.api is trusted server-side code. So this pin is the thing standing between an authoring slip and a cross-tenant row, rather than a restatement of a guarantee the engine already makes.

The fourth falsified my own framing, and the prose was corrected rather than the result explained away. The docblocks originally attributed the walled behaviour to OS_TENANCY_POSTURE=isolated; flipping it to single leaves every assertion passing. The posture is not the lever — the driver's tenant predicate is, compiled from the execution context, independent of that knob. The env is still set because a walled deployment sets it and because it makes the engine's registry organization-scoped, and all three files now say so, including that the property is stronger for not depending on it.

Review round

CodeQL, test/saas-composition.test.ts — "unneeded defensive code". Verified against the source rather than taken on report, and real: loadStack has exactly one call site, loadStack('saas'), so its value === undefined branch could never run. The parameter is now string and the branch is gone.

The restore guard four lines below looks like the same shape and is kept, because it is live in the opposite direction: the ambient environment genuinely may not carry HOTCRM_COMPOSITION, and on process.env a delete is not the same as assigning undefined — the latter stores the string "undefined", which resolveComposition would then refuse. The comment now states which is which so the next reader does not "simplify" the surviving one.

Gates

pnpm verify re-run in full on 7f3b95e — each verdict quoted from the gate itself:

✓ Validation passed (1017ms)                 pnpm validate
(no output)                                  pnpm typecheck  (tsc --noEmit)
✓ i18n lint gate: 0 `i18n/missing-*` issues  pnpm lint + lint:i18n-gate
✓ source hygiene clean                       pnpm hygiene
✓ source token ratchet clean                 pnpm hygiene:tokens
✓ Build complete (1196ms)                    pnpm build
 Test Files  147 passed (147)                pnpm test
      Tests  3058 passed | 1 skipped (3059)

The SaaS artifact was built too (HOTCRM_COMPOSITION=saas pnpm build, ✓ Build complete) and inspected rather than assumed:

data datasets: 1 crm_product   ·   data records: 13
flows: 25   demo_bootstrap? false
permissions: guest_portal,marketing_user,sales_manager,sales_rep,service_agent,tenant_admin
tenant_admin systemPermissions: ["view_setup","manage_org_users","view_all_data","modify_all_data","manage_sharing"]
tenant_admin object grants: 18

The cloud EE rigs are unaffected: scripts/build-hotcrm-artifact.sh sets no composition, so it keeps building the default shape, and hotcrm-multitenant.acceptance.ts derives expectedSeedCounts from the artifact's own data blocks rather than from a written-down number.

Notes

  • No enterprise package is imported and no dependency moved; @objectstack/* stays at released 17.1.0 with no git pins.
  • Unset, empty and default all resolve to the community app, so no existing build, script or CI job changes behaviour by saying nothing. An unrecognised value throws at config load — a silent fall-through on HOTCRM_COMPOSITION=sass would ship the full demo union into every tenant, discovered only after tenants had edited the rows.
  • Both exclusions filter by identity, not by name string, so a rename cannot quietly turn one into a no-op; the pin additionally asserts each removed exactly what it should and left everything else registered.
  • Out of scope, filed separately as [finding] The scheduled-flow org-partition guard only inspects create_record — an update_record writing a value sourced outside the swept row is uncovered #1363 (unassigned, finding): the scheduled-flow org-partition guard inspects create_record nodes only, so an update_record writing a value sourced outside the swept row is uncovered. demo_bootstrap was the one live instance and this PR removes it from the SaaS composition; all nine schedule-bound flows were surveyed to establish that every other one writes values derived from the row it updates.

Generated by Claude Code

…eplay, a tenant admin

`HOTCRM_COMPOSITION=saas` assembles the shape a multi-org operator deploys on
the enterprise runtime under a walled tenancy posture. The community app is
unchanged: unset, empty and `default` all resolve to exactly what was there
before, and an unrecognised value throws at config load rather than quietly
assembling the wrong app.

Three registrations differ and nothing else does — no runtime branch in `src/`,
no per-tenant mechanism, no enterprise import.

* `data` — the catalogue family only. The platform's seed replayer already
  gives each new organization a private, editable copy of the registered
  dataset union; it has no per-family selection, so the selection is made once,
  at composition time, uniformly for every tenant. `crm_product` is also the
  one family with no outgoing reference, so the shrink cannot strand a lookup.
* `flows` — `demo_bootstrap` is dropped, by identity rather than by name so a
  rename cannot silently turn the exclusion into a no-op. It runs
  `runAs: 'system'`, and a system context is the one context the organization
  predicate does not constrain: measured on a real engine under
  `OS_TENANCY_POSTURE=isolated`, its own shape selects ownerless rows in every
  organization and stamps one organization's user onto another's row.
* `permissions` — `system_admin` gives way to `tenant_admin`, which holds
  org-scoped `manage_org_users` instead of platform-scope `manage_users` and no
  platform-scoped capability at all. Its object grants are derived from
  `system_admin`'s rather than transcribed, so a new object cannot ship granted
  to one admin and denied to the other.

Two pins land with it. `test/saas-composition.test.ts` holds both compositions
in both directions and measures, rather than asserts, why the demo sweep is
excluded. `test/hook-org-inheritance.test.ts` closes the audit the card asked
for: on a real engine on a tenant-aware driver, the records this app's hooks
create — the case escalation task, the quote's auto-drafted contract, the
recurring-task clone, the qualified-lead follow-up — all carry the triggering
caller's organization, and none is born outside a partition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 27, 2026 12:41pm

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces configuration Build and app configuration files labels Aug 27, 2026
Comment thread test/saas-composition.test.ts Fixed
…fig loader

CodeQL, verified against the source rather than taken on report: `loadStack`
has exactly one call site, `loadStack('saas')`, so its `value === undefined`
branch could never run. Narrowed the parameter to `string` and removed the
branch.

The RESTORE guard a few lines down is deliberately kept — that one is live. The
ambient environment genuinely may not carry `HOTCRM_COMPOSITION`, and on
`process.env` a `delete` and an assignment of `undefined` are not the same
thing: the latter leaves the string "undefined" behind, which `resolveComposition`
would then refuse. Same shape, opposite verdict, so the comment now says which
is which.

Part of #1361

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline configuration Build and app configuration files metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SaaS multi-org composition: catalog-only tenant seed replay, walled-posture admin profile — verified against the cloud EE acceptance rigs

3 participants