Skip to content

Commit 27af33a

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(plugin-auth): cap self-created orgs per user (OS_ORG_LIMIT) (#2357)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 911c7aa commit 27af33a

2 files changed

Lines changed: 46 additions & 1 deletion

File tree

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
} from '@objectstack/spec/system';
1313
import type { IDataEngine } from '@objectstack/core';
1414
import type { IEmailService } from '@objectstack/spec/contracts';
15-
import { readEnvWithDeprecation, resolveMultiOrgEnabled } from '@objectstack/types';
15+
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from '@objectstack/types';
1616
import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec';
1717
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
1818
import {
@@ -910,6 +910,32 @@ export class AuthManager {
910910
// the built-in /accept-invitation route usable for pilots; operators
911911
// who wire a real mailer can re-enable downstream.
912912
requireEmailVerificationOnInvitation: false,
913+
// Cap how many orgs a user can CREATE (OS_ORG_LIMIT). Counts only orgs
914+
// the user OWNS (role=owner) — never orgs they were merely invited into —
915+
// so a generous cap stops scripted org/free-env spam (each new org can
916+
// auto-provision a free environment on the cloud control plane) WITHOUT
917+
// ever blocking a collaborator who belongs to many orgs. Unset → no
918+
// limit (self-host default). Fail-open: if the count can't be taken we
919+
// allow creation rather than block a legitimate user on an infra hiccup.
920+
organizationLimit: async (user: { id?: string }) => {
921+
const limit = resolveOrgLimit();
922+
if (limit == null) return false;
923+
const engine = this.config.dataEngine;
924+
const uid = typeof user?.id === 'string' ? user.id : '';
925+
if (!engine || !uid) return false;
926+
try {
927+
// `sys_member` is tenant-scoped (organization_id). We need to count
928+
// the user's owned orgs ACROSS tenants, so read with the system
929+
// context (isSystem) to bypass org-scoping — otherwise the query
930+
// returns nothing and the limit never fires.
931+
const owned = await withSystemReadContext(engine).count('sys_member', {
932+
where: { user_id: uid, role: 'owner' },
933+
});
934+
return (owned ?? 0) >= limit;
935+
} catch {
936+
return false;
937+
}
938+
},
913939
...(customOrgRoles ? { roles: customOrgRoles } : {}),
914940
// ── Slug-change guard ─────────────────────────────────────
915941
// An org's slug is baked into every env hostname at creation

packages/types/src/env.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ export function resolveMultiOrgEnabled(): boolean {
103103
return String(raw ?? 'false').toLowerCase() !== 'false';
104104
}
105105

106+
/**
107+
* Maximum number of organizations a single user may CREATE, from `OS_ORG_LIMIT`.
108+
* The auth plugin forwards this as better-auth's `organizationLimit` in function
109+
* form, counting only the caller's `role=owner` memberships — so it caps
110+
* self-created orgs (each of which can auto-provision a free environment on the
111+
* cloud control plane) without penalising a user invited into many orgs.
112+
*
113+
* Only meaningful when multi-org is enabled ({@link resolveMultiOrgEnabled}).
114+
* Returns `undefined` when unset or non-positive → no limit (better-auth treats
115+
* an absent `organizationLimit` as unlimited), preserving self-host behaviour.
116+
* Deployments that let users self-create orgs SHOULD set a generous cap.
117+
*/
118+
export function resolveOrgLimit(): number | undefined {
119+
const raw = readEnvWithDeprecation('OS_ORG_LIMIT', [], { silent: true });
120+
if (raw == null || String(raw).trim() === '') return undefined;
121+
const n = Number.parseInt(String(raw), 10);
122+
return Number.isFinite(n) && n > 0 ? n : undefined;
123+
}
124+
106125
/**
107126
* Internal: clear the dedupe set. Test-only; exposed so suite-wide
108127
* deprecation warnings don't bleed between tests.

0 commit comments

Comments
 (0)