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
49 changes: 49 additions & 0 deletions .changeset/auth-membership-policy-setting.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
---
"@objectstack/plugin-auth": minor
"@objectstack/service-settings": minor
---

feat(auth): `membership_policy` is a platform setting, and sign-up and backfill read one source (#5152)

**What a new user joins is now configurable at runtime.** ADR-0093's
`membershipPolicy` decides whether a freshly created user is auto-bound to the
deployment's default organization (`auto`) or gets membership only from an
explicit act — creating a workspace, accepting an invitation, an admin adding
them, SSO just-in-time provisioning (`invite-only`). Until now it was settable
**only** as an `AuthPlugin` constructor option, and the AuthPlugin a self-hosted
stack gets is injected by the CLI, which passes no such option and has no env
fallback. Every self-hosted deployment therefore ran `auto`, with no way to say
otherwise. `invite-only` was, in practice, unreachable outside a custom host.

It is now `auth.membership_policy` in the platform settings — a two-value select
(`auto` / `invite-only`, default `auto`) alongside `signup_enabled`, which it
pairs with: one says whether people may self-register, the other says what they
join when they do. Set it in Setup → Authentication → Membership, or pin it
per-deployment with `OS_AUTH_MEMBERSHIP_POLICY`. It applies **without a
restart** — the existing `settings.subscribe('auth', …)` re-application seam
carries it, the same one the password-policy keys ride.

**No behaviour changes unless you set it.** Only an *explicit* value applies;
the manifest's `auto` default is a UI default and never masks a deployment that
configured the policy in code. A stack that sets nothing keeps today's
auto-binding exactly.

**Bug fix — the two membership paths read one source.** Sign-up (the reconciler
in better-auth's `user.create.after`) read the AuthManager's live config, while
the ADR-0093 D6 backfill of pre-existing member-less users read the plugin's
**constructor options**. Wiring a setting to the first and not the second would
have produced "sign-up honours the new policy, backfill still runs the old one"
— and the backfill binds in **bulk**, so it is the more dangerous half. Both now
resolve the policy through the new `AuthManager.getMembershipPolicy()`, and the
backfill waits for the settings namespace to bind before its first pass (the two
`kernel:ready` hooks fire in registration order, which was the wrong order).

**An invalid value is rejected, not coerced.** `PUT /api/settings/auth` refuses
a policy outside the declared option table (`invalid_option`, naming the allowed
set). A value arriving from `OS_AUTH_MEMBERSHIP_POLICY` — which bypasses that
validation — is logged at `error` and **ignored**, leaving the deployment's
current policy in force; it is never silently read as `auto`, because that would
leave an operator believing a wall is up while every sign-up is auto-bound.

New public API on `@objectstack/plugin-auth`: `AuthManager.getMembershipPolicy()`,
plus `MEMBERSHIP_POLICIES` and `isMembershipPolicy()` from `reconcile-membership`.
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,6 +78,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
| `OS_AUTH_MEMBERSHIP_POLICY` | `auto` \| `invite-only` | `auto` | Settings env override for `auth.membership_policy` — what a newly created user joins (ADR-0093 D1). `auto` binds every new user to the deployment's default organization. `invite-only` grants membership solely through an explicit act: creating a workspace, accepting an invitation, an admin adding them, or SSO just-in-time provisioning. Applies to sign-up **and** to the backfill of pre-existing member-less users. An unrecognized value is rejected with an `error` log and **ignored** — the deployment keeps its current policy rather than silently reverting to `auto`. |
| `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. |
| `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. |
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
Expand Down
7 changes: 7 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,13 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret
# Optional: lock auth settings from env. Env wins over Setup UI values.
OS_AUTH_SIGNUP_ENABLED=false
OS_AUTH_GOOGLE_ENABLED=true

# Optional: what a new user joins (ADR-0093 D1). `auto` (default) binds every
# new user to the default organization; `invite-only` grants membership only
# through an explicit act — creating a workspace, accepting an invitation, an
# admin adding them, or SSO just-in-time provisioning.
# Also configurable in Setup → Authentication → Membership.
OS_AUTH_MEMBERSHIP_POLICY=invite-only
```

> **Important**: Never commit `OS_AUTH_SECRET` to version control. Use a strong random string (minimum 32 characters).
Expand Down
24 changes: 23 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2661,6 +2661,26 @@ export class AuthManager {
}
}

/**
* ADR-0093 D1 — the deployment's membership policy **as it stands right now**.
*
* The ONE source both membership paths read (#5152):
* - sign-up: the reconciler composed into `user.create.after` (below);
* - backfill: `AuthPlugin`'s ADR-0093 D6 pass over pre-existing member-less
* users, which used to read the plugin's CONSTRUCTOR options instead.
*
* That split mattered because `this.config` is what {@link applyConfigPatch}
* targets: once `auth.membership_policy` became a platform setting, the
* constructor options stopped being current the moment an admin saved the
* form. Sign-up would honour the new policy while the backfill kept running
* the old one — and the backfill binds in BULK. Read the policy through here,
* never off a captured option, so a settings change reaches both without a
* restart.
*/
getMembershipPolicy(): MembershipPolicy {
return this.config.membershipPolicy ?? 'auto';
}

/**
* Inject (or replace) the outbound email service used by better-auth
* callbacks. Safe to call after construction but BEFORE the first
Expand DownExpand Up@@ -3647,7 +3667,9 @@ export class AuthManager {
const membershipReconciler = async (user: any) => {
try {
await reconcileMembership(this.config.dataEngine, user?.id, {
policy: this.config.membershipPolicy ?? 'auto',
// #5152 — read through the accessor, not `this.config` directly: it is
// the single source the backfill path reads too.
policy: this.getMembershipPolicy(),
resolveTargetOrg: async () => {
const tenancy = this.config.getTenancy?.();
// Single-org → default org; multi-org → none (invite/JIT own it).
Expand Down
84 changes: 80 additions & 4 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,12 @@ import { ensureDefaultOrganization } from './ensure-default-organization.js';
import { runAttributedToUser } from './auth-actor-attribution.js';
import type { ResolvedSocialProvider } from './backfill-account-issuer.js';
import { createTenancyService, type TenancyService } from './tenancy-service.js';
import { backfillMemberships, type MembershipPolicy } from './reconcile-membership.js';
import {
backfillMemberships,
isMembershipPolicy,
MEMBERSHIP_POLICIES,
type MembershipPolicy,
} from './reconcile-membership.js';
import {
registerIdentityWriteGuard,
registerManagedUpdateWhitelist,
Expand DownExpand Up@@ -234,6 +239,11 @@ export class AuthPlugin implements Plugin {
// session-snapshot refresh reads through this; undefined = refresh no-ops.
private effectiveSecondaryStorage: AuthManagerOptions['secondaryStorage'];

/**
* Memoized `bindAuthSettings()` run — see {@link ensureAuthSettingsBound}.
*/
private authSettingsBinding: Promise<void> | null = null;

constructor(options: AuthPluginOptions = {}) {
this.options = {
registerRoutes: true,
Expand DownExpand Up@@ -589,7 +599,7 @@ export class AuthPlugin implements Plugin {
// / sendMagicLink) can actually deliver mail. Resolved here on
// kernel:ready so EmailServicePlugin has had a chance to register.
if (this.authManager) {
await this.bindAuthSettings(ctx);
await this.ensureAuthSettingsBound(ctx);

let emailSvc: IEmailService | undefined;
try { emailSvc = ctx.getService<IEmailService>('email'); } catch { emailSvc = undefined; }
Expand DownExpand Up@@ -855,11 +865,27 @@ export class AuthPlugin implements Plugin {
const runBackfill = (source: string): Promise<void> => {
backfillChain = backfillChain.then(async () => {
try {
// #5152 — the policy this pass runs under is a SETTING, so bind the
// namespace before reading it. This hook is registered in `init()`
// and therefore fires ahead of the one in `start()` that normally
// binds; without this the first pass of a fresh boot would run the
// pre-settings policy. Idempotent and shared with that hook.
await this.ensureAuthSettingsBound(ctx);
const ql = ctx.getService<IDataEngine>('objectql');
const tenancy = this.tenancy;
if (!ql || !tenancy) return;
// #5152 — the policy is read off the AuthManager, the same object
// the sign-up reconciler reads and the same one `applyConfigPatch`
// targets. It used to be `this.options.membershipPolicy`, a
// constructor option that no settings change can reach: an admin
// switching to `invite-only` stopped sign-up auto-binds while this
// pass kept bulk-binding every member-less user. No `??` fallback
// to the options here on purpose — a second reading of the policy
// is exactly the defect. The manager exists from `init()`, so the
// guard is a precondition, not a degraded mode.
const manager = this.authManager;
if (!ql || !tenancy || !manager) return;
const res = await backfillMemberships(ql, {
policy: this.options.membershipPolicy ?? 'auto',
policy: manager.getMembershipPolicy(),
resolveTargetOrg: () => tenancy.defaultOrgId(),
logger: ctx.logger,
});
Expand DownExpand Up@@ -987,12 +1013,35 @@ export class AuthPlugin implements Plugin {
ctx.logger.info('Auth Plugin started successfully');
}

/**
* Bind the auth settings namespace once, whoever asks first.
*
* Two `kernel:ready` hooks need the settings applied, and they fire in
* REGISTRATION order, which is the opposite of the order they need
* (#5152): the ADR-0093 D6 membership backfill is registered in `init()`,
* the settings binding in `start()`. Left alone, the very first backfill of
* a fresh boot would read the pre-settings policy and bulk-bind every
* pre-existing member-less user on a deployment whose stored setting says
* `invite-only` — the exact failure the setting exists to prevent, on the
* one pass nobody gets to observe before it has happened.
*
* So neither hook owns the binding: both await this, the first one through
* performs it, and the memoized promise keeps `settings.subscribe` from
* being registered twice.
*/
private ensureAuthSettingsBound(ctx: PluginContext): Promise<void> {
this.authSettingsBinding ??= this.bindAuthSettings(ctx);
return this.authSettingsBinding;
}

/**
* Bind the small open-source auth settings namespace to better-auth config.
*
* Only explicit settings values (stored or OS_AUTH_* env overrides) affect
* runtime config. Manifest defaults are UI defaults and do not mask code or
* deployment configuration.
*
* Call through {@link ensureAuthSettingsBound}, never directly.
*/
private async bindAuthSettings(ctx: PluginContext): Promise<void> {
if (!this.authManager) return;
Expand DownExpand Up@@ -1047,6 +1096,33 @@ export class AuthPlugin implements Plugin {
false,
);
}

// ADR-0093 D1 / #5152 — membership policy. `signup_enabled` says whether
// people may self-register; this says what they join when they do, and
// the two are halves of one platform posture, so it rides the same
// settings seam. Only an EXPLICIT value applies: the manifest default
// (`auto`) is a UI default and must not mask a deployment that set the
// policy at construction.
//
// An unrecognised value is REJECTED, never coerced to `auto`. The
// settings service enforces the option table on `setMany`, but an
// `OS_AUTH_MEMBERSHIP_POLICY` env value bypasses that path entirely, and
// silently reading a typo'd `invite_only` as `auto` would leave an
// operator believing the wall is up while every sign-up is auto-bound —
// invisible until someone finds a stranger in their org. `error`, not
// `warn`: nothing looks broken afterwards.
if (isExplicit('membership_policy')) {
const raw = values.membership_policy;
if (isMembershipPolicy(raw)) {
patch.membershipPolicy = raw;
} else {
ctx.logger.error(
`[auth] membership_policy '${String(raw)}' is not a valid policy — IGNORED, the deployment keeps its current policy ` +
`('${this.authManager.getMembershipPolicy()}') and new sign-ups will continue to follow it. ` +
`Set auth.membership_policy (or OS_AUTH_MEMBERSHIP_POLICY) to one of: ${MEMBERSHIP_POLICIES.join(', ')}.`,
);
}
}
// Password policy — better-auth enforces these bounds on sign-up and
// password reset. Ignore malformed/non-positive values (keep the default).
if (isExplicit('password_min_length')) {
Expand Down
Loading
Loading