From 0025da14fadc8795075989862967c56f1c471297 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:11:17 +0800 Subject: [PATCH] feat(plugin-auth): cap self-created orgs per user (OS_ORG_LIMIT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward better-auth's `organizationLimit` in FUNCTION form, counting only the caller's `role=owner` memberships (orgs they created) — never orgs they were invited into — read via `withSystemReadContext` so the cross-tenant `sys_member` count isn't filtered to empty by org-scoping. Threshold from `OS_ORG_LIMIT` (new `resolveOrgLimit()`); unset → unlimited (self-host default). Fail-open if the count can't be taken, so an infra hiccup never blocks a legitimate user. Why owner-scoped, not a flat count: better-auth's number form counts ALL of a user's memberships, which would block a collaborator invited into many orgs from creating their own. We only want to bound scripted org/free-env spam (each new org auto-provisions a free environment on the cloud control plane), so capping self-created orgs is the right axis. No-op in single-org mode (create is already denied by beforeCreateOrganization). Verified on the local rig: a user owning 4 orgs but a member of 5 is allowed to create at limit=5 (the invited org is not counted), then blocked once they own 5. Co-Authored-By: Claude Opus 4.8 --- .../plugins/plugin-auth/src/auth-manager.ts | 28 ++++++++++++++++++- packages/types/src/env.ts | 19 +++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 4027f1d87..2c8b35855 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -12,7 +12,7 @@ import type { } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; import type { IEmailService } from '@objectstack/spec/contracts'; -import { readEnvWithDeprecation, resolveMultiOrgEnabled } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from '@objectstack/types'; import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { @@ -910,6 +910,32 @@ export class AuthManager { // the built-in /accept-invitation route usable for pilots; operators // who wire a real mailer can re-enable downstream. requireEmailVerificationOnInvitation: false, + // Cap how many orgs a user can CREATE (OS_ORG_LIMIT). Counts only orgs + // the user OWNS (role=owner) — never orgs they were merely invited into — + // so a generous cap stops scripted org/free-env spam (each new org can + // auto-provision a free environment on the cloud control plane) WITHOUT + // ever blocking a collaborator who belongs to many orgs. Unset → no + // limit (self-host default). Fail-open: if the count can't be taken we + // allow creation rather than block a legitimate user on an infra hiccup. + organizationLimit: async (user: { id?: string }) => { + const limit = resolveOrgLimit(); + if (limit == null) return false; + const engine = this.config.dataEngine; + const uid = typeof user?.id === 'string' ? user.id : ''; + if (!engine || !uid) return false; + try { + // `sys_member` is tenant-scoped (organization_id). We need to count + // the user's owned orgs ACROSS tenants, so read with the system + // context (isSystem) to bypass org-scoping — otherwise the query + // returns nothing and the limit never fires. + const owned = await withSystemReadContext(engine).count('sys_member', { + where: { user_id: uid, role: 'owner' }, + }); + return (owned ?? 0) >= limit; + } catch { + return false; + } + }, ...(customOrgRoles ? { roles: customOrgRoles } : {}), // ── Slug-change guard ───────────────────────────────────── // An org's slug is baked into every env hostname at creation diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index aa4784747..06c77679c 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -103,6 +103,25 @@ export function resolveMultiOrgEnabled(): boolean { return String(raw ?? 'false').toLowerCase() !== 'false'; } +/** + * Maximum number of organizations a single user may CREATE, from `OS_ORG_LIMIT`. + * The auth plugin forwards this as better-auth's `organizationLimit` in function + * form, counting only the caller's `role=owner` memberships — so it caps + * self-created orgs (each of which can auto-provision a free environment on the + * cloud control plane) without penalising a user invited into many orgs. + * + * Only meaningful when multi-org is enabled ({@link resolveMultiOrgEnabled}). + * Returns `undefined` when unset or non-positive → no limit (better-auth treats + * an absent `organizationLimit` as unlimited), preserving self-host behaviour. + * Deployments that let users self-create orgs SHOULD set a generous cap. + */ +export function resolveOrgLimit(): number | undefined { + const raw = readEnvWithDeprecation('OS_ORG_LIMIT', [], { silent: true }); + if (raw == null || String(raw).trim() === '') return undefined; + const n = Number.parseInt(String(raw), 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + /** * Internal: clear the dedupe set. Test-only; exposed so suite-wide * deprecation warnings don't bleed between tests.