diff --git a/.changeset/auth-audience-settings-surface.md b/.changeset/auth-audience-settings-surface.md new file mode 100644 index 0000000000..73fac60302 --- /dev/null +++ b/.changeset/auth-audience-settings-surface.md @@ -0,0 +1,33 @@ +--- +"@objectstack/service-settings": minor +"@objectstack/plugin-auth": minor +--- + +feat(settings,auth): expose the audience posture in the `auth` settings namespace (#11768) + +The audience posture shipped by #11739 (`invite_only | email_domain | open`, +default `invite_only`) was switchable only from stack config at boot; a +self-host admin had no console channel. The `auth` settings namespace now +carries an `audience` group — three new authorable keys, which is what a host +sees and why this is `minor`: + +- `audience_posture` — a select over the closed vocabulary (the option table + is enforced on `setMany` and on the `OS_AUTH_AUDIENCE_POSTURE` env-override + door); +- `audience_allowed_email_domains` — newline- or comma-separated bare domains + (exact, case-insensitive matching; subdomains need their own entries); +- `audience_self_registration_permission_set` — the `sys_permission_set` name + each self-registrant receives. + +`bindAuthSettings` maps the three keys — one atomic declaration — to one +`AuthManager.applyConfigPatch({ audience })`, which replaces the whole +audience object and validates the MERGED result. Every #11739 invariant holds +through the new channel: a self-registration posture with verification +explicitly off, an empty domain list under `email_domain`, and a missing or +`admin_full_access` permission set are all refused loudly (the standing +config keeps ruling — fail closed), and off-vocabulary postures are refused, +never coerced, per the `membership_policy` precedent (#5152). Only EXPLICIT +settings values apply: the manifest defaults never mask a deployment's +boot-config declaration. Switching back to `invite_only` always applies — +leftover text in the posture-hidden sibling fields cannot make closing the +wall refusable. diff --git a/packages/plugins/plugin-auth/src/audience-posture-setting.test.ts b/packages/plugins/plugin-auth/src/audience-posture-setting.test.ts new file mode 100644 index 0000000000..6435236c46 --- /dev/null +++ b/packages/plugins/plugin-auth/src/audience-posture-setting.test.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `auth.audience_*` — the console switch surface for the audience posture + * (#11768, consuming the #11739 / PR #11767 contract). + * + * The three settings keys (`audience_posture`, `audience_allowed_email_domains`, + * `audience_self_registration_permission_set`) are ONE atomic declaration that + * `bindAuthSettings` maps to ONE `applyConfigPatch({ audience })` — the patch + * replaces the whole audience object and validates the MERGED result + * (`assertAudienceConfig`), so the settings channel can never reach a posture + * the boot-config channel could not. + * + * The load-bearing pins here are the REFUSALS (#5152: explicit-only, loud + * refusal, never coercion). A suite that only sets valid postures cannot tell + * an enforcing binding from a pass-through, so each ruled invariant is driven + * through the settings channel to its refusal: + * + * - empty domain list under `email_domain`; + * - missing / forbidden (`admin_full_access`) self-registration permission set; + * - posture ≠ `invite_only` with verification explicitly off. + * + * Envelope note: refusals at THIS seam are log-line refusals (the binding runs + * inside `applySettings`, not on an HTTP surface), so the assertions pin the + * logger level + message content. The settings channel's envelope-carrying + * refusal (`SETTINGS_VALIDATION` + `invalid_option` on `setMany`) is pinned in + * `service-settings`' auth.manifest.test.ts, and the admission-time envelope + * (403 + `AUTH_CONFIG_ERROR`/`SELF_REGISTRATION_CLOSED`) is pinned in + * audience-posture.test.ts — the settings channel converges on the same + * `getAudience()` accessor those tests exercise. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import { AUDIENCE_POSTURES } from '@objectstack/spec/system'; +import { AuthPlugin } from './auth-plugin.js'; +import { AuthManager } from './auth-manager.js'; + +const SECRET = 'test-secret-at-least-32-chars-long'; + +type SettingEntry = { value: unknown; source: string }; + +/** Minimal engine: enough for boot-time hooks (backfill sees zero users). */ +function makeEngine() { + return { + insert: vi.fn(async (_object: string, row: any) => row), + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + }; +} + +describe('auth.audience_* — the settings switch surface (#11768)', () => { + let mockContext: PluginContext; + let hookHandlers: Map Promise>>; + + const settingsStore: { values: Record } = { values: {} }; + let subscribers: Array<() => void>; + + const makeSettings = () => ({ + getNamespace: vi.fn(async (namespace: string) => + namespace === 'auth' ? { values: settingsStore.values } : { values: {} }, + ), + subscribe: vi.fn((namespace: string, cb: () => void) => { + if (namespace === 'auth') subscribers.push(cb); + }), + }); + + beforeEach(() => { + settingsStore.values = {}; + subscribers = []; + hookHandlers = new Map(); + mockContext = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'settings') return makeSettings(); + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn((name: string, handler: () => Promise) => { + if (!hookHandlers.has(name)) hookHandlers.set(name, []); + hookHandlers.get(name)!.push(handler); + }), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + } as unknown as PluginContext; + }); + + const fire = async (name: string) => { + for (const h of hookHandlers.get(name) ?? []) await h(); + }; + + /** Boot exactly as a host does: init → start → kernel:ready (settings bind there). */ + const boot = async (opts: { + settings?: Record; + pluginOptions?: Record; + } = {}) => { + settingsStore.values = opts.settings ?? {}; + const engine = makeEngine(); + (mockContext.getService as any).mockImplementation((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'settings') return makeSettings(); + if (name === 'data' || name === 'objectql') return engine; + return undefined; + }); + + const plugin = new AuthPlugin({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + ...(opts.pluginOptions ?? {}), + }); + await plugin.init(mockContext); + const manager = (mockContext.registerService as any).mock.calls.find( + ([name]: [string]) => name === 'auth', + )?.[1] as AuthManager; + await plugin.start(mockContext); + await fire('kernel:ready'); + return { plugin, manager, engine }; + }; + + const errorLines = () => + (mockContext.logger.error as any).mock.calls.map((c: any[]) => String(c[0])); + + // ── 1. The declaration reaches the runtime, whole, through ONE patch ───── + + it('an explicit email_domain declaration patches the live audience in one stroke', async () => { + const { manager } = await boot({ + settings: { + audience_posture: { value: 'email_domain', source: 'global' }, + audience_allowed_email_domains: { value: 'acme.com\n Beta.Org , partner.example', source: 'global' }, + audience_self_registration_permission_set: { value: ' portal_user ', source: 'global' }, + }, + }); + const audience = manager.getAudience(); + expect(audience.posture).toBe('email_domain'); + // Newline- AND comma-separated, trimmed; case preserved (matching is + // case-insensitive at admission, declaration keeps the typed form). + expect(audience.allowedEmailDomains).toEqual(['acme.com', 'Beta.Org', 'partner.example']); + expect(audience.selfRegistrationPermissionSet).toBe('portal_user'); + }); + + it('re-applies on a settings change without a restart', async () => { + const { manager } = await boot(); + expect(manager.getAudience().posture).toBe('invite_only'); + + settingsStore.values = { + audience_posture: { value: 'email_domain', source: 'global' }, + audience_allowed_email_domains: { value: 'acme.com', source: 'global' }, + audience_self_registration_permission_set: { value: 'member_default', source: 'global' }, + }; + expect(subscribers.length).toBeGreaterThan(0); + for (const cb of subscribers) cb(); + await vi.waitFor(() => expect(manager.getAudience().posture).toBe('email_domain')); + }); + + // ── 2. Explicit-only (#5152): a UI default must not mask deployment config ─ + + it('a manifest default does not mask a deployment that declared its audience in code', async () => { + const { manager } = await boot({ + pluginOptions: { + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, + }, + settings: { audience_posture: { value: 'invite_only', source: 'default' } }, + }); + expect(manager.getAudience().posture).toBe('open'); + }); + + // ── 3. Off-vocabulary: refused loudly, never coerced ───────────────────── + + it('an off-vocabulary posture is refused loudly, never coerced (#5152)', async () => { + // `invite-only` is the MEMBERSHIP policy spelling — the most plausible + // operator typo for this select's `invite_only`. Coercing it (to either + // end of the vocabulary) would silently pick a posture the operator did + // not declare; refusing keeps the standing config ruling and says so. + const { manager } = await boot({ + pluginOptions: { + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, + }, + settings: { audience_posture: { value: 'invite-only', source: 'env' } }, + }); + expect(manager.getAudience().posture).toBe('open'); // standing, NOT a coerced guess + const logged = errorLines(); + expect(logged.some((m: string) => m.includes("'invite-only'"))).toBe(true); + expect(logged.some((m: string) => m.includes(AUDIENCE_POSTURES.join(', ')))).toBe(true); + }); + + // ── 4. The ruled invariants hold THROUGH the settings channel ──────────── + // `applyConfigPatch` validates the merged result and throws; the binding + // catches and reports, and the standing config keeps ruling (fail closed). + + it('an EMPTY domain list under email_domain is refused through the settings channel', async () => { + const { manager } = await boot({ + settings: { + audience_posture: { value: 'email_domain', source: 'global' }, + // Explicit but blank (whitespace + separators only) — parses to []. + audience_allowed_email_domains: { value: ' \n , ', source: 'global' }, + audience_self_registration_permission_set: { value: 'portal_user', source: 'global' }, + }, + }); + expect(manager.getAudience().posture).toBe('invite_only'); // standing default keeps ruling + const logged = errorLines(); + expect(logged.some((m: string) => m.includes('audience settings REFUSED'))).toBe(true); + expect(logged.some((m: string) => m.includes('allowedEmailDomains'))).toBe(true); + }); + + it('a MISSING self-registration permission set is refused through the settings channel', async () => { + const { manager } = await boot({ + settings: { audience_posture: { value: 'open', source: 'global' } }, + }); + expect(manager.getAudience().posture).toBe('invite_only'); + const logged = errorLines(); + expect(logged.some((m: string) => m.includes('audience settings REFUSED'))).toBe(true); + expect(logged.some((m: string) => m.includes('selfRegistrationPermissionSet'))).toBe(true); + }); + + it('admin_full_access as the self-registration permission set is refused', async () => { + const { manager } = await boot({ + settings: { + audience_posture: { value: 'open', source: 'global' }, + audience_self_registration_permission_set: { value: 'admin_full_access', source: 'global' }, + }, + }); + expect(manager.getAudience().posture).toBe('invite_only'); + expect(errorLines().some((m: string) => m.includes('admin_full_access'))).toBe(true); + }); + + it('a self-registration posture with verification explicitly OFF is refused', async () => { + // The audience patch is applied AFTER the main patch, so the merged-result + // validation judges it against the `require_email_verification: false` + // this same pass just applied — the #11739 "verification forced when + // posture permits self-registration" invariant, held through the new door. + const { manager } = await boot({ + settings: { + require_email_verification: { value: false, source: 'global' }, + audience_posture: { value: 'open', source: 'global' }, + audience_self_registration_permission_set: { value: 'member_default', source: 'global' }, + }, + }); + // The main patch itself applied… + expect((manager as any).config.emailAndPassword?.requireEmailVerification).toBe(false); + // …and the audience that contradicts it was refused: standing rules. + expect(manager.getAudience().posture).toBe('invite_only'); + expect(errorLines().some((m: string) => m.includes('requireEmailVerification'))).toBe(true); + }); + + // ── 5. Composition: posture anchors the declaration ────────────────────── + + it('switching BACK to invite_only never fails on leftover sibling fields', async () => { + // The console keeps stored values for fields the posture select now hides. + // Sending them would make CLOSING the wall refusable (inert-declaration + // refusal) while the previous, more open posture keeps ruling — the one + // direction that must not fail. The binding composes the declaration from + // the keys the selected posture READS: `{ posture: 'invite_only' }` alone. + const { manager } = await boot({ + pluginOptions: { + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, + }, + settings: { + audience_posture: { value: 'invite_only', source: 'global' }, + audience_allowed_email_domains: { value: 'acme.com', source: 'global' }, + audience_self_registration_permission_set: { value: 'member_default', source: 'global' }, + }, + }); + expect(manager.getAudience().posture).toBe('invite_only'); + expect(errorLines()).toEqual([]); + }); + + it('a domain list without an explicit posture is refused, not guessed', async () => { + const { manager } = await boot({ + settings: { audience_allowed_email_domains: { value: 'acme.com', source: 'global' } }, + }); + expect(manager.getAudience().posture).toBe('invite_only'); + expect((manager as any).config.audience).toBeUndefined(); // no patch went out + expect(errorLines().some((m: string) => m.includes('without a posture'))).toBe(true); + }); + + // ── 6. Blast radius: a refused audience never blocks sibling settings ──── + + it('a refused audience declaration does not block sibling auth settings', async () => { + const { manager } = await boot({ + settings: { + session_expiry_days: { value: 3, source: 'global' }, + audience_posture: { value: 'open', source: 'global' }, // no permission set ⇒ refused + }, + }); + expect((manager as any).config.session?.expiresIn).toBe(3 * 86_400); + expect(manager.getAudience().posture).toBe('invite_only'); + expect(errorLines().some((m: string) => m.includes('audience settings REFUSED'))).toBe(true); + }); + + // ── 7. Dangling names are an ADMISSION-time refusal, same accessor ─────── + + it('a well-formed but dangling permission-set name flows to the ONE accessor the admission gate reads', async () => { + // The patch validator cannot resolve names (no data access); the dangling + // declaration is refused at ADMISSION time with 403 AUTH_CONFIG_ERROR — + // pinned in audience-posture.test.ts ("a DANGLING declared permission set + // refuses admission…"). That gate reads `getAudience()`, so this pin — + // the settings channel landing on the same accessor — is what connects + // the two: no captured copy, no second read site. + const { manager } = await boot({ + settings: { + audience_posture: { value: 'open', source: 'global' }, + audience_self_registration_permission_set: { value: 'ghost', source: 'global' }, + }, + }); + const audience = manager.getAudience(); + expect(audience.posture).toBe('open'); + expect(audience.selfRegistrationPermissionSet).toBe('ghost'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index a86c3aa7e5..8363440517 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -4,6 +4,10 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; import type { BetterAuthOptions } from 'better-auth'; import { AuthConfig, + type AudienceConfig, + AUDIENCE_POSTURES, + audiencePermitsSelfRegistration, + isAudiencePosture, type SocialProviderConfig, type SettingsChangeHandler, type SettingsUnsubscribe, @@ -1531,6 +1535,94 @@ export class AuthPlugin implements Plugin { if (Object.keys(patch).length > 0) { this.authManager.applyConfigPatch(patch); } + + // [#11768] Audience posture (#11739) — the console switch for + // `invite_only | email_domain | open`. The three `audience_*` keys are + // ONE atomic declaration mapped to ONE `applyConfigPatch({ audience })` + // (the #11767 contract: the patch replaces the WHOLE audience object + // and validates the MERGED result), applied AFTER the main patch so + // that validation judges the audience against the `emailAndPassword` + // state this same pass just applied — an explicit + // `require_email_verification: false` beside a self-registration + // posture is a contradiction `assertAudienceConfig` refuses. + // + // #5152's rules, exactly as `membership_policy` above: + // - EXPLICIT-only. The manifest default (`invite_only`) is a UI + // default and must not mask a deployment that declared its + // audience in stack config at boot. + // - An off-vocabulary posture is REFUSED loudly, never coerced — + // a coerced value on this setting fails OPEN (an operator who + // typed `invite-only` believing the wall is up while sign-ups + // ride whatever posture the coercion picked). + // + // Composition rule: the declaration carries only the keys the + // explicitly selected posture READS (the spec marks the siblings + // "only read under" their postures). In particular, switching BACK to + // `invite_only` sends `{ posture: 'invite_only' }` alone — leftover + // text in the (hidden) domain-list field must never make CLOSING the + // wall refusable, because that refusal would leave the previous, more + // open posture ruling: the one direction that must not fail. + // Missing required siblings are NOT guessed: the patch goes out + // without them and `applyConfigPatch` refuses the merged result + // (standing config keeps ruling — refusing to OPEN fails closed). + const audienceKeys = [ + 'audience_posture', + 'audience_allowed_email_domains', + 'audience_self_registration_permission_set', + ]; + if (audienceKeys.some((key) => isExplicit(key))) { + const rawPosture = values.audience_posture; + if (!isExplicit('audience_posture')) { + ctx.logger.error( + '[auth] audience settings IGNORED — a domain list or permission set is declared without a posture. ' + + `The standing posture ('${this.authManager.getAudience().posture}') keeps ruling. ` + + `Set auth.audience_posture (or OS_AUTH_AUDIENCE_POSTURE) to one of: ${AUDIENCE_POSTURES.join(', ')}.`, + ); + } else if (!isAudiencePosture(rawPosture)) { + ctx.logger.error( + `[auth] audience_posture '${String(rawPosture)}' is not a recognized audience posture — IGNORED, the deployment keeps its ` + + `current posture ('${this.authManager.getAudience().posture}') and self-registration continues to follow it. ` + + `Set auth.audience_posture (or OS_AUTH_AUDIENCE_POSTURE) to one of: ${AUDIENCE_POSTURES.join(', ')}.`, + ); + } else { + const audience: AudienceConfig = { posture: rawPosture }; + if (rawPosture === 'email_domain' && isExplicit('audience_allowed_email_domains')) { + const rawDomains = typeof values.audience_allowed_email_domains === 'string' + ? values.audience_allowed_email_domains + : ''; + // Same textarea convention as `allowed_ip_ranges`: newline- or + // comma-separated. Entries are NOT filtered for shape here — + // a malformed domain must be refused loudly by the validator, + // never silently dropped from the allowlist it was typed into. + audience.allowedEmailDomains = rawDomains + .split(/[\n,]+/) + .map((d) => d.trim()) + .filter(Boolean); + } + if ( + audiencePermitsSelfRegistration(rawPosture) && + isExplicit('audience_self_registration_permission_set') + ) { + const set = asTrimmedString(values.audience_self_registration_permission_set); + if (set !== undefined) audience.selfRegistrationPermissionSet = set; + } + try { + this.authManager.applyConfigPatch({ audience }); + } catch (audienceErr: any) { + // The merged-result validation refused the declaration (empty + // domain list, missing/forbidden permission set, verification + // contradiction, …). The standing config keeps ruling — report + // with the validator's own remedy-bearing message. `error`, not + // `warn`, for the #5152 reason: the console shows the saved + // values while the runtime refused them, so nothing else looks + // broken afterwards. + ctx.logger.error( + `[auth] audience settings REFUSED — the standing posture ('${this.authManager.getAudience().posture}') keeps ruling. ` + + String(audienceErr?.message ?? audienceErr), + ); + } + } + } } catch (err: any) { ctx.logger.warn('Auth: failed to apply auth settings: ' + (err?.message ?? err)); } diff --git a/packages/services/service-settings/src/manifests/auth.manifest.test.ts b/packages/services/service-settings/src/manifests/auth.manifest.test.ts index 060b3416c5..c29e1a5f9c 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -151,4 +151,74 @@ describe('authSettingsManifest', () => { expect(clientSecret.encrypted).toBe(true); expect(clientSecret.visible).toBe("${data.google_enabled !== false}"); }); + + // [#11768] Audience posture (#11739, epic #11723) — the console switch + // surface for `invite_only | email_domain | open`. The option table IS the + // closed vocabulary: `setMany` (and the env-override door, which judges the + // same table — #5204/#6580) refuses anything outside it, and the binding in + // plugin-auth refuses off-vocabulary again with `isAudiencePosture` before + // the value ever reaches `applyConfigPatch`. + it('exposes audience_posture as a closed three-value select in its own group (#11768)', () => { + const specs = authSettingsManifest.specifiers as any[]; + const posture = specs.find((s) => s.key === 'audience_posture'); + + expect(posture.type).toBe('select'); + // UI default only — `bindAuthSettings` applies EXPLICIT values alone, so + // this default never masks a deployment's boot-config declaration. It + // matches the spec's undeclared default (the safe end). + expect(posture.default).toBe('invite_only'); + expect(posture.options.map((o: any) => o.value)).toEqual([ + 'invite_only', + 'email_domain', + 'open', + ]); + + // The posture judges EVERY self-serve creation path — social-provider + // OAuth included — so like `membership_policy` it must not be hidden + // behind the email/password provider toggle. + expect(posture.visible).toBeUndefined(); + expect(specs.filter((s) => s.type === 'group').map((s) => s.id)).toContain('audience'); + }); + + it('gates the audience sibling fields on the posture that reads them (#11768)', () => { + const specs = authSettingsManifest.specifiers as any[]; + const domains = specs.find((s) => s.key === 'audience_allowed_email_domains'); + const permissionSet = specs.find((s) => s.key === 'audience_self_registration_permission_set'); + + expect(domains.type).toBe('textarea'); + expect(domains.visible).toBe("${data.audience_posture === 'email_domain'}"); + + expect(permissionSet.type).toBe('text'); + expect(permissionSet.visible).toBe( + "${data.audience_posture === 'email_domain' || data.audience_posture === 'open'}", + ); + + // Explicit-only application: neither sibling ships a default that could + // read as an operator's declaration. + expect(domains.default).toBeUndefined(); + expect(permissionSet.default).toBeUndefined(); + }); + + it('refuses an audience posture outside the option table at the write API (#11768)', async () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(authSettingsManifest as any); + + // `invite-only` is the MEMBERSHIP policy spelling — the most plausible + // typo for this select. The write path refuses it with the coded envelope + // (ADR-0114 field vocabulary on the SETTINGS_VALIDATION error); silently + // storing it would hand the binding a value it can only refuse at apply + // time, after the console already said "saved". + await expect(svc.setMany('auth', { audience_posture: 'invite-only' })).rejects.toMatchObject({ + code: 'SETTINGS_VALIDATION', + fields: [ + { + field: 'audience_posture', + code: 'invalid_option', + constraint: { allowed: 'invite_only, email_domain, open' }, + }, + ], + }); + await expect(svc.setMany('auth', { audience_posture: 'email_domain' })).resolves.toBeDefined(); + expect((await svc.get('auth', 'audience_posture')).value).toBe('email_domain'); + }); }); diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index c90b9939ab..3ca06b89b0 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -73,6 +73,57 @@ const manifest = { 'Automatic binds every new user to this deployment\'s default organization. Invitation only grants membership solely through an explicit act — creating a workspace, accepting an invitation, being added by an admin, or SSO just-in-time provisioning. Applies to the backfill of pre-existing member-less users too.', }, + // [#11768] Audience posture (#11739, epic #11723) — who may BECOME a user + // of this environment's apps. Like `membership` above, deliberately its + // OWN group and never gated on `email_password_enabled`: the posture + // judges every self-serve creation path (email sign-up AND social-provider + // OAuth), so a social-login-only deployment still needs the switch. The + // three keys are ONE atomic declaration — `bindAuthSettings` writes the + // full audience object in one stroke (the #11767 contract: the patch + // replaces the whole object and validates the MERGED result), so the + // domain list and permission set only apply together with an explicitly + // selected posture. + { + type: 'group', + id: 'audience', + label: 'Audience', + required: false, + description: + 'Who may become a user of this environment\'s apps. Postures other than invitation-only force email verification on. Invitations, admin-created users, SCIM provisioning, and enterprise SSO are admitted under every posture.', + }, + { + type: 'select', + key: 'audience_posture', + label: 'Self-registration audience', + required: false, + default: 'invite_only', + options: [ + { value: 'invite_only', label: 'Invitation only — no self-registration (default)' }, + { value: 'email_domain', label: 'Allowlisted email domains only' }, + { value: 'open', label: 'Open — anyone may self-register' }, + ], + description: + 'invite_only closes self-registration: users come into existence only through an operator-side act (invitation, admin create/import, SCIM, enterprise SSO). email_domain opens it to the allowlisted domains below. open admits anyone. Any posture other than invite_only forces email verification on and requires the self-registration permission set below.', + }, + { + type: 'textarea', + key: 'audience_allowed_email_domains', + label: 'Allowed email domains', + required: false, + description: + 'Bare domains, one per line or comma-separated (e.g. acme.com). Matching is exact and case-insensitive; subdomains need their own entries; no wildcards. Required (non-empty) when the audience is allowlisted email domains.', + visible: "${data.audience_posture === 'email_domain'}", + }, + { + type: 'text', + key: 'audience_self_registration_permission_set', + label: 'Self-registration permission set', + required: false, + description: + 'sys_permission_set name granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', + visible: "${data.audience_posture === 'email_domain' || data.audience_posture === 'open'}", + }, + { type: 'group', id: 'password_policy', diff --git a/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts b/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts index 110a2d1f64..f7b35d0921 100644 --- a/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts +++ b/packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts @@ -63,7 +63,7 @@ describe('settings `visible` — declaration ⇄ evaluator', () => { expect(refusals).toEqual([]); }); - it('the corpus is still the size the ruling measured — 10 manifests, 94 predicates', () => { + it('the corpus is still the size the ruling measured — 10 manifests, 96 predicates', () => { // If a bundled manifest gains or loses a `visible`, this number moves and // the reader is sent back to the measurement rather than trusting a stale // one. It is a tripwire on the premise, not a rule about how many @@ -73,7 +73,10 @@ describe('settings `visible` — declaration ⇄ evaluator', () => { ...(m.specifiers ?? []).filter((s: any) => typeof s.visible !== 'undefined').map((s: any) => s.visible), ]); expect(builtinSettingsManifests).toHaveLength(10); - expect(predicates).toHaveLength(94); + // 94 -> 96 at #11768: the `audience` group added two gated fields + // (`audience_allowed_email_domains`, `audience_self_registration_permission_set`), + // each with one `visible` predicate over `data.audience_posture`. + expect(predicates).toHaveLength(96); }); it('every bundled predicate is accepted by BOTH sides', () => { diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index 5c431f0fc1..f4b8d4a1fe 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -139,6 +139,10 @@ export const en: TranslationData = { title: 'Membership', description: 'What a newly created user joins. Pairs with self-service registration above.', }, + audience: { + title: 'Audience', + description: 'Who may become a user of this environment\'s apps. Postures other than invitation-only force email verification on. Invitations, admin-created users, SCIM provisioning, and enterprise SSO are admitted under every posture.', + }, password_policy: { title: 'Password policy', description: 'Length bounds enforced by the auth provider on sign-up and password reset.', @@ -165,6 +169,23 @@ export const en: TranslationData = { 'invite-only': 'Invitation only — never join automatically', }, }, + audience_posture: { + label: 'Self-registration audience', + help: 'invite_only closes self-registration: users come into existence only through an operator-side act (invitation, admin create/import, SCIM, enterprise SSO). email_domain opens it to the allowlisted domains below. open admits anyone. Any posture other than invite_only forces email verification on and requires the self-registration permission set below.', + options: { + invite_only: 'Invitation only — no self-registration (default)', + email_domain: 'Allowlisted email domains only', + open: 'Open — anyone may self-register', + }, + }, + audience_allowed_email_domains: { + label: 'Allowed email domains', + help: 'Bare domains, one per line or comma-separated (e.g. acme.com). Matching is exact and case-insensitive; subdomains need their own entries; no wildcards.', + }, + audience_self_registration_permission_set: { + label: 'Self-registration permission set', + help: 'sys_permission_set name granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', + }, password_min_length: { label: 'Minimum password length' }, password_max_length: { label: 'Maximum password length', help: 'Guards against denial-of-service via very long password hashing.' }, session_expiry_days: { label: 'Session lifetime (days)', help: 'A session expires this many days after sign-in.' }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 042ac4acf0..91a0469585 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -89,6 +89,10 @@ export const esES: TranslationData = { title: 'Pertenencia', description: 'A qué se une un usuario recién creado. Complementa el registro de autoservicio anterior.', }, + audience: { + title: 'Audiencia', + description: 'Quién puede convertirse en usuario de las aplicaciones de este entorno. Las posturas distintas de «solo por invitación» fuerzan la verificación de correo. Las invitaciones, los usuarios creados por administradores, el aprovisionamiento SCIM y el SSO empresarial se admiten bajo cualquier postura.', + }, password_policy: { title: 'Política de contraseñas', description: 'Límites de longitud que el proveedor de autenticación exige en el registro y el restablecimiento de contraseña.', @@ -127,6 +131,23 @@ export const esES: TranslationData = { 'invite-only': 'Solo por invitación: nunca se une automáticamente', }, }, + audience_posture: { + label: 'Audiencia de autorregistro', + help: '«Solo por invitación» cierra el autorregistro: los usuarios solo se crean mediante un acto del operador (invitación, creación/importación por un administrador, SCIM o SSO empresarial). «Dominios de correo» lo abre solo a los dominios de la lista inferior; «Abierto» admite a cualquiera. Cualquier postura distinta de «solo por invitación» fuerza la verificación de correo y requiere el conjunto de permisos de autorregistro inferior.', + options: { + invite_only: 'Solo por invitación — sin autorregistro (predeterminado)', + email_domain: 'Solo dominios de correo permitidos', + open: 'Abierto — cualquiera puede registrarse', + }, + }, + audience_allowed_email_domains: { + label: 'Dominios de correo permitidos', + help: 'Dominios simples, uno por línea o separados por comas (p. ej. acme.com). Coincidencia exacta sin distinguir mayúsculas; los subdominios necesitan su propia entrada; sin comodines.', + }, + audience_self_registration_permission_set: { + label: 'Conjunto de permisos de autorregistro', + help: 'Nombre del sys_permission_set que recibe cada usuario autorregistrado (declarar member_default explícitamente es válido; admin_full_access se rechaza).', + }, password_min_length: { label: 'Longitud mínima de contraseña' }, password_max_length: { label: 'Longitud máxima de contraseña', help: 'Un límite superior protege frente a la denegación de servicio por el hasheo de contraseñas muy largas.' }, password_reject_breached: { diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 4b5378fac7..7411c5bc96 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -89,6 +89,10 @@ export const jaJP: TranslationData = { title: 'メンバーシップ', description: '新しく作成されたユーザーが所属する先。上のセルフサービス登録と対になる設定です。', }, + audience: { + title: '登録対象', + description: 'この環境のアプリのユーザーになれる対象。「招待のみ」以外のポスチャではメール確認が強制されます。招待、管理者による作成、SCIM プロビジョニング、エンタープライズ SSO はどのポスチャでも許可されます。', + }, password_policy: { title: 'パスワードポリシー', description: 'サインアップおよびパスワードリセット時に認証プロバイダーが強制する長さ制限。', @@ -127,6 +131,23 @@ export const jaJP: TranslationData = { 'invite-only': '招待のみ — 自動では参加しない', }, }, + audience_posture: { + label: 'セルフ登録の対象', + help: '「招待のみ」はセルフ登録を閉じます。ユーザーは運用側の行為(招待、管理者による作成/インポート、SCIM、エンタープライズ SSO)によってのみ作成されます。「メールドメイン」は下の許可リストのドメインにのみ開放し、「オープン」は誰でも登録できます。「招待のみ」以外ではメール確認が強制され、下のセルフ登録権限セットの指定が必要です。', + options: { + invite_only: '招待のみ — セルフ登録なし(既定)', + email_domain: '許可リストのメールドメインのみ', + open: 'オープン — 誰でもセルフ登録可能', + }, + }, + audience_allowed_email_domains: { + label: '許可するメールドメイン', + help: '裸のドメイン名を 1 行に 1 つ、またはカンマ区切りで指定します(例: acme.com)。完全一致・大文字小文字は区別しません。サブドメインは個別に指定が必要で、ワイルドカードは使えません。', + }, + audience_self_registration_permission_set: { + label: 'セルフ登録の権限セット', + help: '各セルフ登録ユーザーに付与される sys_permission_set 名(member_default の明示宣言は可。admin_full_access は拒否されます)。', + }, password_min_length: { label: 'パスワードの最小文字数' }, password_max_length: { label: 'パスワードの最大文字数', help: '非常に長いパスワードのハッシュ化によるサービス拒否を防ぎます。' }, password_reject_breached: { diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index af88619077..b505112dce 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -255,6 +255,10 @@ export const zhCN: TranslationData = { title: '成员归属', description: '新建用户加入什么。与上方的自助注册成对配置。', }, + audience: { + title: '注册受众', + description: '谁可以成为本环境应用的用户。除「仅限邀请」外的口径会强制开启邮箱验证。邀请、管理员创建、SCIM 开通和企业 SSO 在任何口径下都可进入。', + }, password_policy: { title: '密码策略', description: '由认证提供商在注册和重置密码时强制的长度限制。', @@ -292,6 +296,23 @@ export const zhCN: TranslationData = { 'invite-only': '仅限邀请——绝不自动加入', }, }, + audience_posture: { + label: '自助注册受众', + help: '「仅限邀请」关闭自助注册:用户只能通过运营侧行为进入——邀请、管理员创建/导入、SCIM 开通或企业 SSO。「邮箱域名」仅向下方允许列表中的域名开放;「开放」允许任何人自助注册。除「仅限邀请」外的口径会强制开启邮箱验证,并要求配置下方的自助注册权限集。', + options: { + invite_only: '仅限邀请——不开放自助注册(默认)', + email_domain: '仅允许列表中的邮箱域名', + open: '开放——任何人都可自助注册', + }, + }, + audience_allowed_email_domains: { + label: '允许的邮箱域名', + help: '裸域名,每行一个或用逗号分隔(如 acme.com)。精确且不区分大小写匹配;子域名需单独列出;不支持通配符。', + }, + audience_self_registration_permission_set: { + label: '自助注册权限集', + help: '每个自助注册用户获得的 sys_permission_set 名称(可显式声明 member_default;admin_full_access 会被拒绝)。', + }, password_min_length: { label: '密码最小长度' }, password_max_length: { label: '密码最大长度', help: '防止超长密码哈希导致的拒绝服务。' }, password_reject_breached: {