feat(composition): SaaS multi-org shape — catalog-only tenant replay, a tenant admin, and the org-inheritance audit - #1362
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1361
Part of objectstack-ai/objectstack#12701.
HOTCRM_COMPOSITION=saasassembles 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 insrc/, 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.
demo_bootstrapdemo-staffingview_all_dataaudit answered2b needs no exclusion.
src/sharing/demo-staffing.tsis deliberately not exported fromsrc/sharing/index.tsand 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:369already 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-replayeralready 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_productis 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_bootstrapis not registered. Not merely redundant here — actively wrong. The flow runsrunAs: '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:One tenant's user id, written onto another tenant's row.
test/saas-composition.test.tsreproduces 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'scrm_productdeclares noowner_id, so a catalog-only tenant has nothing ownerless to claim.)system_admin→tenant_admin. Same authority over its own organization's records — every object, full CRUD, all-records depth, transfer and export — with the object map derived fromSystemAdminProfile.objectsrather 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_dataaudit the card asked forConclusion: 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:
view_all_dataandmodify_all_dataoccur in 0 installed@objectstack/*JS files. Control probes in the same sweep, so the sweep can find a name it is looking for:manage_sharing15 files,manage_metadata25,setup.access32. They are app-declared vocabulary; the authority actually enforced is the per-objectviewAllRecords/modifyAllRecordsbits, which the sharing layer reads on every request.org_areadingcrm_accountsees 1 of the 2 rows in the table — and still sees 1 of 2 with the tenancy posture flipped tosingle. 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_users→manage_org_users.manage_usersisscope: '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/spec17.1.0, verified against the installedPLATFORM_CAPABILITIES. Both are declarations, not enforcement here: measured on 17.1.0,manage_usersappears 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 thatobjectstack.config.tsalready follows forhierarchy-security: 「本项目是元数据app,在企业版运行就具备企业版相关的能力,不重复开发。」Deliberately dropped:
customize_application,manage_profiles,manage_roles. All three describe metadata authoring, and the only metadata-authoring capability the platform has ismanage_metadata—scope: '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_admingains it and this paragraph becomes a grant.manage_sharingis granted: the platform declares itscope: '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_adminmay hold no capability the registry marksplatform, and the communitysystem_admindoes 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 overSqliteWasmDriver, readingorganization_idback out of SQL:case.hook.tsescalationcrm_taskorg_a✓quote.hook.tsacceptancecrm_contractorg_a✓task.hook.tsrecurrencecrm_taskcloneorg_a✓lead.hook.tsqualificationcrm_taskfollow-uporg_a✓opportunity.hook.tsclosed-woncrm_taskorg_a✓No platform gap, so no upstream card for this item.
test/helpers/hook-harness.tscould never have answered it — it implementsctx.apiover 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-memoryis 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-objectagainst theHEADblob) and each proving the restore put HEAD's bytes back (git diff HEADempty + hash equality). Direction predicted before running in every case.isSaasforcedfalsedata:pinned to the default unioncase.hook.tsforgesorganization_id: 'org_b'expected 'org_b' to be 'org_a'singlein the hook pinThe first two are two separate levers (
isSaasgates 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.apiis 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 tosingleleaves 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:loadStackhas exactly one call site,loadStack('saas'), so itsvalue === undefinedbranch could never run. The parameter is nowstringand 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 onprocess.envadeleteis not the same as assigningundefined— the latter stores the string"undefined", whichresolveCompositionwould then refuse. The comment now states which is which so the next reader does not "simplify" the surviving one.Gates
pnpm verifyre-run in full on7f3b95e— each verdict quoted from the gate itself:The SaaS artifact was built too (
HOTCRM_COMPOSITION=saas pnpm build,✓ Build complete) and inspected rather than assumed:The cloud EE rigs are unaffected:
scripts/build-hotcrm-artifact.shsets no composition, so it keeps building the default shape, andhotcrm-multitenant.acceptance.tsderivesexpectedSeedCountsfrom the artifact's owndatablocks rather than from a written-down number.Notes
@objectstack/*stays at released 17.1.0 with no git pins.defaultall 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 onHOTCRM_COMPOSITION=sasswould ship the full demo union into every tenant, discovered only after tenants had edited the rows.create_record— anupdate_recordwriting a value sourced outside the swept row is uncovered #1363 (unassigned,finding): the scheduled-flow org-partition guard inspectscreate_recordnodes only, so anupdate_recordwriting a value sourced outside the swept row is uncovered.demo_bootstrapwas 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