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
1 change: 0 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"@objectstack/plugin-email",
"@objectstack/plugin-hono-server",
"@objectstack/mcp",
"@objectstack/plugin-org-scoping",
"@objectstack/plugin-reports",
"@objectstack/plugin-security",
"@objectstack/plugin-sharing",
Expand Down
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
c27bd3264f394bec5f5f71326118bfb115fbe884
195121ade6ec921e5786030093f0b23fc58d02f7
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/plugin-email": "workspace:*",
"@objectstack/plugin-hono-server": "workspace:*",
"@objectstack/plugin-org-scoping": "workspace:*",
"@objectstack/plugin-reports": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
Expand Down
29 changes: 18 additions & 11 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,21 +1279,28 @@ export default class Serve extends Command {
}
}

// Pair: OrgScopingPlugin (multi-tenant) — optional, must register BEFORE SecurityPlugin
// OrgScopingPlugin provides `organization_id` auto-stamp, per-org
// seed-replay, and default-org bootstrap. SecurityPlugin probes
// the `org-scoping` service at start() time and conditionally
// strips the wildcard `tenant_isolation` RLS when this plugin
// is absent — so registration order matters.
// Pair: OrganizationsPlugin (multi-org, ENTERPRISE) — must register
// BEFORE SecurityPlugin. The multi-org runtime (`organization_id`
// auto-stamp, per-org seed replay, multi-org default-org bootstrap)
// lives in the closed-source `@objectstack/organizations` package
// (ADR-0081 D2; it registers the historical `org-scoping` service
// SecurityPlugin probes at start() to keep vs strip the wildcard
// `tenant_isolation` RLS — so registration order matters). Without
// it, deployments are single-org: the open member-management
// basics (plugin-auth's default-org bootstrap + better-auth
// invitations) still work.
const multiTenant = resolveMultiOrgEnabled();
if (multiTenant) {
try {
const orgScopingPkg = '@objectstack/plugin-org-scoping';
const { OrgScopingPlugin } = await import(/* webpackIgnore: true */ orgScopingPkg);
await kernel.use(new OrgScopingPlugin());
trackPlugin('OrgScoping');
const organizationsPkg = '@objectstack/organizations';
const mod: any = await import(/* webpackIgnore: true */ organizationsPkg);
await kernel.use(new mod.OrganizationsPlugin());
trackPlugin('Organizations');
} catch {
// optional — multi-tenant mode requested but plugin not installed
// Requested multi-org without the enterprise package — loud,
// not silent: RLS tenant policies will be STRIPPED and every
// org boundary is inert until the package is installed.
console.warn(chalk.yellow(' ⚠ OS_MULTI_ORG_ENABLED=true but @objectstack/organizations (enterprise) is not installed — running single-org.'));
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default class Verify extends Command {
default: false,
}),
'multi-tenant': Flags.boolean({
description: 'Boot org-scoped (register plugin-org-scoping) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)',
description: 'Boot org-scoped (register the enterprise @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)',
default: false,
}),
json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }),
Expand Down
2 changes: 1 addition & 1 deletion packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
{ id: 'controlled-by-parent', summary: 'master-detail controlled_by_parent', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite', proof: 'controlled-by-parent.dogfood.test.ts' },
{ id: 'multi-tenant', summary: 'organization isolation', state: 'enforced',
enforcement: 'plugin-org-scoping + wildcard tenant RLS', proof: 'rls-multitenant.dogfood.test.ts' },
enforcement: '@objectstack/organizations (enterprise) + wildcard tenant RLS', proof: 'rls-multitenant.dogfood.test.ts' },
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
Expand Down
14 changes: 13 additions & 1 deletion packages/dogfood/test/rls-multitenant.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,19 @@ import crmStack from '@objectstack/example-crm';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { runRlsProofs, formatRlsReport, type RlsReport } from '@objectstack/verify';

describe('objectstack verify RLS: CRM multi-tenant (#1994 org-scoped)', () => {
// The multi-org runtime moved to the ENTERPRISE `@objectstack/organizations`
// package (ADR-0081 D2) — not part of this open workspace. Skip (loudly) when
// it isn't linked in; enterprise/cloud CI, which ships the package, runs this.
const organizationsPkg = '@objectstack/organizations';
const organizationsAvailable = await import(/* webpackIgnore: true */ organizationsPkg)
.then(() => true)
.catch(() => false);
if (!organizationsAvailable) {
// eslint-disable-next-line no-console
console.warn('[dogfood] @objectstack/organizations (enterprise) not installed — skipping the multi-org RLS gate');
}

describe.skipIf(!organizationsAvailable)('objectstack verify RLS: CRM multi-tenant (#1994 org-scoped)', () => {
let stack: VerifyStack;
let report: RlsReport;

Expand Down
16 changes: 14 additions & 2 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,22 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
priority: BASE_PRIORITY,
items: [
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' },
// The ACTIVE organization's record page (Members / Invitations / Teams
// tabs with the better-auth row actions), rendered inside the app shell
// (ADR-0081). `{current_org_id}` resolves from the session's active
// organization; unresolved (e.g. org-less admin before bootstrap) it
// falls back to the sys_organization list — one row in single-org.
{ id: 'nav_organization', type: 'object', label: 'Organization', objectName: 'sys_organization', recordId: '{current_org_id}', icon: 'building-2' },
{ id: 'nav_business_units', type: 'object', label: 'Business Units', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' },
{ id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round', requiresService: 'org-scoping' },
// Teams / Invitations no longer gate on `org-scoping` (ADR-0081 D1):
// the better-auth organization capability is always mounted, and
// plugin-auth's single-org default-org bootstrap guarantees an org to
// invite into — these are the OPEN member-management basics. Only the
// org LIST below keeps the gate: browsing organizations is meaningful
// only when more than one can exist (enterprise multi-org).
{ id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' },
{ id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2', requiresService: 'org-scoping' },
{ id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail', requiresService: 'org-scoping' },
{ id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' },
],
},
{
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const en: TranslationData = {

// People & Organization
nav_users: { label: 'Users' },
nav_organization: { label: 'Organization' },
nav_business_units: { label: 'Business Units' },
nav_teams: { label: 'Teams' },
nav_organizations: { label: 'Organizations' },
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const esES: TranslationData = {
nav_system_overview: { label: 'Resumen del Sistema' },

nav_users: { label: 'Usuarios' },
nav_organization: { label: 'Organización' },
nav_business_units: { label: 'Unidades de negocio' },
nav_teams: { label: 'Equipos' },
nav_organizations: { label: 'Organizaciones' },
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const jaJP: TranslationData = {
nav_system_overview: { label: 'システム概要' },

nav_users: { label: 'ユーザー' },
nav_organization: { label: '組織' },
nav_business_units: { label: 'ビジネスユニット' },
nav_teams: { label: 'チーム' },
nav_organizations: { label: '組織' },
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const zhCN: TranslationData = {
nav_system_overview: { label: '系统概览' },

nav_users: { label: '用户' },
nav_organization: { label: '组织' },
nav_business_units: { label: '业务单元' },
nav_teams: { label: '团队' },
nav_organizations: { label: '组织' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const SysInvitation = ObjectSchema.create({
// sys_organization.create_organization). The recipient-side
// accept/reject actions below stay record-gated — they are
// unreachable in single-org anyway (no invitation rows exist).
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
successMessage: 'Invitation sent',
refreshAfter: true,
params: [
Expand All @@ -68,7 +68,7 @@ export const SysInvitation = ObjectSchema.create({
type: 'api',
target: '/api/v1/auth/organization/cancel-invitation',
recordIdParam: 'invitationId',
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.',
successMessage: 'Invitation canceled',
refreshAfter: true,
Expand All @@ -82,7 +82,7 @@ export const SysInvitation = ObjectSchema.create({
type: 'api',
target: '/api/v1/auth/organization/invite-member',
bodyExtra: { resend: true },
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
successMessage: 'Invitation resent',
refreshAfter: true,
params: [
Expand Down
16 changes: 8 additions & 8 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ export const SysMember = ObjectSchema.create({
locations: ['list_toolbar'],
type: 'api',
target: '/api/v1/auth/organization/add-member',
// Org-membership mutations are multi-org-only: the better-auth
// endpoints resolve an active org that does not exist in single-org
// mode, so these actions would fail at the API. Gate every one on
// the multi-org flag (mirrors sys_organization.create_organization).
visible: 'features.multiOrgEnabled != false',
// Gated on the org CAPABILITY, not multi-org (ADR-0081 D1): the
// better-auth endpoints resolve the session's active org, which
// single-org mode now guarantees via plugin-auth's default-org
// bootstrap. Same gate on every membership mutation below.
visible: 'features.organization != false',
successMessage: 'Member added',
refreshAfter: true,
params: [
Expand All @@ -73,7 +73,7 @@ export const SysMember = ObjectSchema.create({
type: 'api',
target: '/api/v1/auth/organization/update-member-role',
recordIdParam: 'memberId',
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
successMessage: 'Member role updated',
refreshAfter: true,
params: [
Expand All @@ -90,7 +90,7 @@ export const SysMember = ObjectSchema.create({
type: 'api',
target: '/api/v1/auth/organization/remove-member',
recordIdParam: 'memberIdOrEmail',
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
confirmText: 'Remove this member from the organization? They will lose access to all org resources.',
successMessage: 'Member removed',
refreshAfter: true,
Expand All @@ -112,7 +112,7 @@ export const SysMember = ObjectSchema.create({
target: '/api/v1/auth/organization/update-member-role',
recordIdParam: 'memberId',
bodyExtra: { role: 'owner' },
visible: "record.role != 'owner' && features.multiOrgEnabled != false",
visible: "record.role != 'owner' && features.organization != false",
confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.',
successMessage: 'Ownership transferred',
refreshAfter: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const SysTeamMember = ObjectSchema.create({
// Team membership lives under organizations — multi-org-only. Gate
// both mutations so they vanish in single-org (mirrors
// sys_organization.create_organization).
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
successMessage: 'Team member added',
refreshAfter: true,
params: [
Expand All @@ -66,7 +66,7 @@ export const SysTeamMember = ObjectSchema.create({
locations: ['list_item'],
type: 'api',
target: '/api/v1/auth/organization/remove-team-member',
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
confirmText: 'Remove this user from the team? They will lose any team-scoped access.',
successMessage: 'Team member removed',
refreshAfter: true,
Expand Down
6 changes: 3 additions & 3 deletions packages/platform-objects/src/identity/sys-team.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const SysTeam = ObjectSchema.create({
// Teams are nested inside organizations — a multi-org-only concept.
// Gate every team mutation on the multi-org flag so the affordances
// disappear in single-org (mirrors sys_organization.create_organization).
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
successMessage: 'Team created',
refreshAfter: true,
params: [
Expand All @@ -69,7 +69,7 @@ export const SysTeam = ObjectSchema.create({
target: '/api/v1/auth/organization/update-team',
recordIdParam: 'teamId',
bodyShape: { wrap: 'data' },
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
successMessage: 'Team updated',
refreshAfter: true,
params: [
Expand All @@ -88,7 +88,7 @@ export const SysTeam = ObjectSchema.create({
type: 'api',
target: '/api/v1/auth/organization/remove-team',
recordIdParam: 'teamId',
visible: 'features.multiOrgEnabled != false',
visible: 'features.organization != false',
confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.',
successMessage: 'Team deleted',
refreshAfter: true,
Expand Down
15 changes: 8 additions & 7 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ export const SysUser = ObjectSchema.create({
locations: ['list_toolbar'],
type: 'api',
target: '/api/v1/auth/organization/invite-member',
// Org invitations are a multi-org-only flow (the endpoint resolves
// an active org that does not exist in single-org mode). Hide the
// affordance unless multi-org is enabled — matching the
// `create_organization` gate on sys_organization. This action is the
// most exposed of the set because the Users list is always reachable
// in single-org, unlike the org/membership lists.
visible: 'features.multiOrgEnabled != false',
// Gated on the org CAPABILITY, not multi-org (ADR-0081 D1): the
// better-auth organization plugin is always mounted, and single-org
// mode now bootstraps a Default Organization (plugin-auth) so the
// endpoint's active-org resolution works there too. This is THE
// "add a teammate" affordance — the Users list is always reachable —
// and every add flows through better-auth invitations, never bespoke
// sys_user CRUD.
visible: 'features.organization != false',
successMessage: 'Invitation sent',
refreshAfter: true,
params: [
Expand Down
Loading