diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 7d20beac170..49a7a7d16a6 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-23 at `d45e01440`. +Measured on 2026-10-23 at `487d57fed`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 716 | +| tracked non-test sources scanned | 723 | | engine-shaped types recognised | 87 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 172 | +| same-named calls subtracted as non-engine | 174 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index b0d3c769962..7957e04a13b 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-23 at `d45e01440`. +Measured on 2026-10-23 at `487d57fed`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 716 | +| tracked non-test sources scanned | 723 | | engine-shaped types recognised | 87 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 172 | +| same-named calls subtracted as non-engine | 174 | ## Every site @@ -132,12 +132,12 @@ Measured on 2026-10-23 at `d45e01440`. | `packages/plugins/plugin-auth/src/adopt-membership.ts` | `update` | `SystemObjectName.MEMBER` | undecidable | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/audience-gate-test-support.ts` | `insert` | `sys_invitation` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts` | `update` | `sys_user` | enabled | options unreadable | 2 | +| `packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_account` | enabled | options unreadable | 1 | | `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_session` | enabled | options unreadable | 3 | | `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 | | `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_user` | enabled | options unreadable | 3 | | `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts` | `update` | `sys_user` | enabled | options unreadable | 3 | -| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 | diff --git a/packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts b/packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts new file mode 100644 index 00000000000..e064d46a794 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25224 cut 8] Who may join, and what joining settles — the audience gate + * better-auth's `user.validateUserInfo` callback runs on every user creation, + * the membership reconciler's one assembly of inputs that both creation seams + * call, and the self-registration permission set a self-serve sign-up was + * promised (landed, or reported as not granted) — moved out of + * `auth-manager.ts` whole. + * + * A MOVE, not a redesign. Each function below is the body of the + * `AuthManager` method of the same name as it stood, re-indented one level + * and otherwise unedited: the same audience verdicts and refusal codes, the + * same bootstrap, invitation and operator-provisioning questions asked of the + * manager, the same membership policy read, the same grant landing and the + * same degradation report. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` + * header carries the full reasoning), which `auth-manager-actor-guards.ts`, + * `auth-manager-sign-in-lockout.ts`, `auth-manager-password-session-policy.ts`, + * `auth-manager-database-hooks.ts`, `auth-manager-email-sms-delivery.ts`, + * `auth-manager-plugin-assembly.ts` and `auth-manager-database-secret.ts` + * follow, applied per method. The class keeps all four methods — the + * published `dist/*.d.ts` lists each one — and each body is one literal of + * exactly the {@link MembershipAdmissionHost} members that method reads, plus + * a `.call`. The three that were `async` no longer are: each returns the moved + * function's own promise, so a caller awaits the same promise the body + * settles, with no extra wrapper. + * + * A method goes as a closure that calls it on the manager at call time, so a + * test double or spy set on the manager is still the one a body reaches: the + * bootstrap, invitation and operator-provisioning probes the audience gate + * asks (which stay on the class), the stagings it writes, and + * `getMembershipPolicy` (a live platform setting). `config` goes as a GETTER: + * the class reassigns it after construction (`setRuntimeBaseUrl` and + * `applyConfigPatch`), so it is read off the manager when a body reads it, as + * before. + * + * `AuthManager` is imported here by VALUE, for one read: the public static + * `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`, inside + * `validateAudienceAdmission`, when better-auth calls the gate. The two + * modules import each other, and that is safe because nothing reads the class + * while they are first evaluated: this module's top level is imports, an + * interface and function declarations. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import { AuthManager } from './auth-manager.js'; +import { + classifyCreationMethod, + decideAudienceAdmission, + AUDIENCE_CONFIG_ERROR, + type AudienceCreationClass, +} from './audience-posture.js'; +import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; +import { withSystemReadContext } from './objectql-adapter.js'; +import { reconcileMembership, type ReconcileOutcome } from './reconcile-membership.js'; +import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js'; + +/** + * The `AuthManager` members the functions below read — every one of them, and + * no other; each function's `this` is the `Pick` of the members IT reads. Each + * is typed AS the class member (an indexed access on the class), so a change + * to a member's signature on the class is a change here too, and naming a + * member the class does not have is a compile error. See the module header for + * why `config` is a getter. + */ +export interface MembershipAdmissionHost { + readonly config: AuthManager['config']; + audienceLogError: AuthManager['audienceLogError']; + enterpriseOAuthProviderIds: AuthManager['enterpriseOAuthProviderIds']; + getAudience: AuthManager['getAudience']; + getMembershipPolicy: AuthManager['getMembershipPolicy']; + hasPendingInvitationFor: AuthManager['hasPendingInvitationFor']; + isBootstrapCreation: AuthManager['isBootstrapCreation']; + isOperatorProvisioning: AuthManager['isOperatorProvisioning']; + selfRegistrationSetResolvable: AuthManager['selfRegistrationSetResolvable']; + stageOwnerVerifiedStamp: AuthManager['stageOwnerVerifiedStamp']; + stageSelfRegistrationGrant: AuthManager['stageSelfRegistrationGrant']; +} + +/** The body of `AuthManager#settleMembership`; the method's docblock there is its contract. */ +export async function settleMembership(this: Pick, userId: unknown): Promise { + try { + const result = await reconcileMembership( + this.config.dataEngine, + typeof userId === 'string' && userId ? userId : undefined, + { + // #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). + return tenancy ? await tenancy.defaultOrgId() : null; + }, + logger: this.config.logger, + }, + ); + return result.outcome; + } catch { + // reconcileMembership never throws, but guard regardless — membership + // bookkeeping must never break user creation or session creation. + return undefined; + } +} + +/** The body of `AuthManager#validateAudienceAdmission`; the method's docblock there is its contract. */ +export async function validateAudienceAdmission( + this: Pick, + data: { + user?: Record; + source?: { action?: string; method?: string; oauth?: { providerId?: string } }; + }, + ctx?: unknown, +): Promise<{ error: string; errorDescription?: string } | undefined> { + try { + // link-account / provider sign-in concern an EXISTING user's identity, + // not audience admission — the posture judges only who may COME INTO + // EXISTENCE. + if (data?.source?.action !== 'create-user') return undefined; + const audience = this.getAudience(); + const email = typeof data?.user?.email === 'string' ? (data.user.email as string) : undefined; + // [#14157] A creation the deployment's own boot command staged is the + // OPERATOR class — the vendor's `source.method` cannot carry that, + // because the seed reaches better-auth through the same `signUpEmail` + // API a person's sign-up does. See `stageOperatorProvisioning` for why + // this is a declared ticket and not a wider bootstrap probe. + // [#14373] …and why email alone no longer decides it: the ticket's + // random value must also match, read off the SAME endpoint context the + // `disableSignUp` before-hook already reads `ctx.body.email` from + // (`getCurrentAuthEndpointContext()` — one context per request, shared + // across the before/after hooks and this validateUserInfo callback). + const provisioningTicket = (ctx as { body?: Record } | undefined)?.body?.[ + AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD + ]; + const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket) + ? 'operator' + : classifyCreationMethod(data?.source, { + enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(), + }); + let isBootstrap = false; + let hasPendingInvitation = false; + if (creationClass === 'self-serve') { + // Only the gated class pays for the probes. + isBootstrap = await this.isBootstrapCreation(); + if (!isBootstrap && email) hasPendingInvitation = await this.hasPendingInvitationFor(email); + } + const verdict = decideAudienceAdmission({ + audience, + creationClass, + email, + hasPendingInvitation, + isBootstrap, + }); + if (!verdict.admit) { + if (verdict.code === AUDIENCE_CONFIG_ERROR) { + // Config-unusable refusals are the operator's bug, not policy — + // report at error level with the offending value in the line + // (#5205: never let "your posture is not a posture" read as + // "policy said no"). + this.audienceLogError(`[audience] refusing self-registration: ${verdict.message}`, { + method: data?.source?.method, + }); + } else { + this.config.logger?.info?.('[audience] self-registration refused by posture', { + code: verdict.code, + method: data?.source?.method, + posture: audience.posture, + }); + } + return { error: verdict.code, errorDescription: verdict.message }; + } + // [#12751] Walled deployments: an ADMITTED creation of the declared + // platform owner through an operator provisioning path (operator class, + // or the bootstrap carve-out) is stamped email-verified at creation — + // maintainer ruling 2026-08-28, 「运营方创建即视为已验证」. The decision + // (and the per-path argument) lives in `walled-owner-operator-stamp.ts`; + // this seam only STAGES it, because the admission gate is the one place + // that holds the vendor's own `source.method` signal AND the bootstrap + // probe. Consumed once by the composed `user.create.before` hook, so the + // row is BORN verified; an email UPDATE can never traverse that seam, + // which is what keeps a later change-to-owner-address from inheriting + // the stamp. + if ( + email && + shouldStampOwnerVerifiedAtCreation({ email, creationClass, isBootstrap }) + ) { + this.stageOwnerVerifiedStamp(email); + } + if (verdict.grantPermissionSet) { + const setName = audience.selfRegistrationPermissionSet; + if (!setName) { + // Unreachable past entry validation; keep the fail-closed floor — + // admitting without the declared grant would be ADR-0078's + // "declared but inert" at runtime. + const message = + `audience posture '${audience.posture}' permits self-registration but declares no ` + + 'selfRegistrationPermissionSet — self-registration is refused until the configuration is fixed.'; + this.audienceLogError(`[audience] ${message}`); + return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; + } + const resolvable = await this.selfRegistrationSetResolvable(setName); + if (!resolvable) { + const message = + `the declared self-registration permission set '${setName}' cannot be resolved ` + + '(not in the security catalog, switched off in sys_metadata_activation, or the store ' + + 'is unavailable) — ' + + 'self-registration is refused rather than admitting an ungranted user. ' + + 'Declare or switch on the set, or point selfRegistrationPermissionSet at an existing one.'; + this.audienceLogError(`[audience] ${message}`); + return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; + } + if (email) this.stageSelfRegistrationGrant(email, setName); + } + return undefined; + } catch (error) { + // Fail CLOSED: an admission question this gate cannot answer must not + // admit. Operator/provider classes never reach I/O, so they cannot land + // here. + const message = 'audience admission could not be evaluated — self-registration refused.'; + this.audienceLogError(`[audience] ${message}`, { + error: (error as Error)?.message ?? String(error), + }); + return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; + } +} + +/** The body of `AuthManager#landSelfRegistrationGrant`; the method's docblock there is its contract. */ +export async function landSelfRegistrationGrant( + this: Pick, + engine: unknown, + userId: string, + setName: string, + organizationId: string, +): Promise { + const sys = withSystemReadContext(engine as never) as any; + const existing: any[] = await sys.find('sys_user_permission_set', { + where: { user_id: userId, [GRANT_SET_NAME_FIELD]: setName }, + limit: 1, + }); + if (existing.length > 0) return; + const id = `ups_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + await sys.insert( + 'sys_user_permission_set', + { id, user_id: userId, [GRANT_SET_NAME_FIELD]: setName, organization_id: organizationId }, + { context: { isSystem: true } }, + ); + this.config.logger?.info?.('[audience] granted the declared self-registration permission set', { + userId, + permissionSet: setName, + organizationId, + }); +} + +/** The body of `AuthManager#reportUngrantedSelfRegistrant`; the method's docblock there is its contract. */ +export function reportUngrantedSelfRegistrant(this: Pick, userId: string, setName: string, cause: string): void { + this.audienceLogError( + `[audience] a self-registrant was ADMITTED but the declared permission set '${setName}' was NOT granted ` + + `(user ${userId}) — the account works and everything looks normal, but it holds only baseline access, ` + + `and nothing retries this. Fix: verify the set exists and is active for the target organization, then grant it ` + + `manually (sys_user_permission_set) or have the user re-created. Cause: ${cause}`, + ); +} diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 1cfabacea25..12417a9fb22 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -20,17 +20,12 @@ import { } from '@objectstack/spec/system'; import { assertAudienceConfig, - classifyCreationMethod, - decideAudienceAdmission, isHumanUserRow, resolveAudience, resolveEmailVerificationRequirement, - AUDIENCE_CONFIG_ERROR, - type AudienceCreationClass, type ResolvedAudience, type VerificationDeclarant, } from './audience-posture.js'; -import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js'; import { PLATFORM_IDP_PROVIDER_ID, type LinkingAuthContext, @@ -119,14 +114,12 @@ import { resolveClientAddress } from './client-address.js'; import { vendorClientAddressOptions, withVendorClientAddress } from './client-address-vendor.js'; import { clientAddressStateEntries, readRequestClientAddress } from './request-client-address.js'; import { - reconcileMembership, type MembershipPolicy, type ReconcileOutcome, } from './reconcile-membership.js'; import type { TenancyService } from './tenancy-service.js'; import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js'; import type { CounterStore } from './rate-limit-storage.js'; -import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; import { isLastLocalCredentialHolder, LAST_LOCAL_CREDENTIAL_CODE, @@ -239,6 +232,16 @@ import { resolveAuthSecret, type DatabaseSecretHost, } from './auth-manager-database-secret.js'; +// [#25224 cut 8] The audience gate, the membership reconciler's assembly and +// the self-registration grant's landing; the methods of the same names below +// hand each body the members it reads (`MembershipAdmissionHost`). +import { + landSelfRegistrationGrant, + reportUngrantedSelfRegistrant, + settleMembership, + validateAudienceAdmission, + type MembershipAdmissionHost, +} from './auth-manager-membership-admission.js'; /** * Detect WebContainer (StackBlitz) environment. @@ -4896,29 +4899,14 @@ export class AuthManager { * "policy says there will never be one" and skip a pointless re-read; * `undefined` means the reconciler could not be consulted at all. */ - private async settleMembership(userId: unknown): Promise { - try { - const result = await reconcileMembership( - this.config.dataEngine, - typeof userId === 'string' && userId ? userId : undefined, - { - // #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). - return tenancy ? await tenancy.defaultOrgId() : null; - }, - logger: this.config.logger, - }, - ); - return result.outcome; - } catch { - // reconcileMembership never throws, but guard regardless — membership - // bookkeeping must never break user creation or session creation. - return undefined; - } + private settleMembership(userId: unknown): Promise { + // [#25224 cut 8] The body moved to `auth-manager-membership-admission.ts` (see `MembershipAdmissionHost`). + const readConfig = () => this.config; + const host: Pick = { + get config() { return readConfig(); }, + getMembershipPolicy: (...a) => this.getMembershipPolicy(...a), + }; + return settleMembership.call(host, userId); } /** @@ -5155,123 +5143,28 @@ export class AuthManager { * of an already-parsed value, not a re-derived I/O probe, so it does not * reopen what #11767 closed. */ - private async validateAudienceAdmission( + private validateAudienceAdmission( data: { user?: Record; source?: { action?: string; method?: string; oauth?: { providerId?: string } }; }, ctx?: unknown, ): Promise<{ error: string; errorDescription?: string } | undefined> { - try { - // link-account / provider sign-in concern an EXISTING user's identity, - // not audience admission — the posture judges only who may COME INTO - // EXISTENCE. - if (data?.source?.action !== 'create-user') return undefined; - const audience = this.getAudience(); - const email = typeof data?.user?.email === 'string' ? (data.user.email as string) : undefined; - // [#14157] A creation the deployment's own boot command staged is the - // OPERATOR class — the vendor's `source.method` cannot carry that, - // because the seed reaches better-auth through the same `signUpEmail` - // API a person's sign-up does. See `stageOperatorProvisioning` for why - // this is a declared ticket and not a wider bootstrap probe. - // [#14373] …and why email alone no longer decides it: the ticket's - // random value must also match, read off the SAME endpoint context the - // `disableSignUp` before-hook already reads `ctx.body.email` from - // (`getCurrentAuthEndpointContext()` — one context per request, shared - // across the before/after hooks and this validateUserInfo callback). - const provisioningTicket = (ctx as { body?: Record } | undefined)?.body?.[ - AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD - ]; - const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket) - ? 'operator' - : classifyCreationMethod(data?.source, { - enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(), - }); - let isBootstrap = false; - let hasPendingInvitation = false; - if (creationClass === 'self-serve') { - // Only the gated class pays for the probes. - isBootstrap = await this.isBootstrapCreation(); - if (!isBootstrap && email) hasPendingInvitation = await this.hasPendingInvitationFor(email); - } - const verdict = decideAudienceAdmission({ - audience, - creationClass, - email, - hasPendingInvitation, - isBootstrap, - }); - if (!verdict.admit) { - if (verdict.code === AUDIENCE_CONFIG_ERROR) { - // Config-unusable refusals are the operator's bug, not policy — - // report at error level with the offending value in the line - // (#5205: never let "your posture is not a posture" read as - // "policy said no"). - this.audienceLogError(`[audience] refusing self-registration: ${verdict.message}`, { - method: data?.source?.method, - }); - } else { - this.config.logger?.info?.('[audience] self-registration refused by posture', { - code: verdict.code, - method: data?.source?.method, - posture: audience.posture, - }); - } - return { error: verdict.code, errorDescription: verdict.message }; - } - // [#12751] Walled deployments: an ADMITTED creation of the declared - // platform owner through an operator provisioning path (operator class, - // or the bootstrap carve-out) is stamped email-verified at creation — - // maintainer ruling 2026-08-28, 「运营方创建即视为已验证」. The decision - // (and the per-path argument) lives in `walled-owner-operator-stamp.ts`; - // this seam only STAGES it, because the admission gate is the one place - // that holds the vendor's own `source.method` signal AND the bootstrap - // probe. Consumed once by the composed `user.create.before` hook, so the - // row is BORN verified; an email UPDATE can never traverse that seam, - // which is what keeps a later change-to-owner-address from inheriting - // the stamp. - if ( - email && - shouldStampOwnerVerifiedAtCreation({ email, creationClass, isBootstrap }) - ) { - this.stageOwnerVerifiedStamp(email); - } - if (verdict.grantPermissionSet) { - const setName = audience.selfRegistrationPermissionSet; - if (!setName) { - // Unreachable past entry validation; keep the fail-closed floor — - // admitting without the declared grant would be ADR-0078's - // "declared but inert" at runtime. - const message = - `audience posture '${audience.posture}' permits self-registration but declares no ` + - 'selfRegistrationPermissionSet — self-registration is refused until the configuration is fixed.'; - this.audienceLogError(`[audience] ${message}`); - return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; - } - const resolvable = await this.selfRegistrationSetResolvable(setName); - if (!resolvable) { - const message = - `the declared self-registration permission set '${setName}' cannot be resolved ` + - '(not in the security catalog, switched off in sys_metadata_activation, or the store ' + - 'is unavailable) — ' + - 'self-registration is refused rather than admitting an ungranted user. ' + - 'Declare or switch on the set, or point selfRegistrationPermissionSet at an existing one.'; - this.audienceLogError(`[audience] ${message}`); - return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; - } - if (email) this.stageSelfRegistrationGrant(email, setName); - } - return undefined; - } catch (error) { - // Fail CLOSED: an admission question this gate cannot answer must not - // admit. Operator/provider classes never reach I/O, so they cannot land - // here. - const message = 'audience admission could not be evaluated — self-registration refused.'; - this.audienceLogError(`[audience] ${message}`, { - error: (error as Error)?.message ?? String(error), - }); - return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; - } + // [#25224 cut 8] The body moved to `auth-manager-membership-admission.ts` (see `MembershipAdmissionHost`). + const readConfig = () => this.config; + const host: Pick = { + audienceLogError: (...a) => this.audienceLogError(...a), + get config() { return readConfig(); }, + enterpriseOAuthProviderIds: (...a) => this.enterpriseOAuthProviderIds(...a), + getAudience: (...a) => this.getAudience(...a), + hasPendingInvitationFor: (...a) => this.hasPendingInvitationFor(...a), + isBootstrapCreation: (...a) => this.isBootstrapCreation(...a), + isOperatorProvisioning: (...a) => this.isOperatorProvisioning(...a), + selfRegistrationSetResolvable: (...a) => this.selfRegistrationSetResolvable(...a), + stageOwnerVerifiedStamp: (...a) => this.stageOwnerVerifiedStamp(...a), + stageSelfRegistrationGrant: (...a) => this.stageSelfRegistrationGrant(...a), + }; + return validateAudienceAdmission.call(host, data, ctx); } /** @@ -5891,29 +5784,18 @@ export class AuthManager { * the self-registrant's one declared set, so a second membership (or a * replayed claim) writes no second grant. */ - private async landSelfRegistrationGrant( + private landSelfRegistrationGrant( engine: unknown, userId: string, setName: string, organizationId: string, ): Promise { - const sys = withSystemReadContext(engine as never) as any; - const existing: any[] = await sys.find('sys_user_permission_set', { - where: { user_id: userId, [GRANT_SET_NAME_FIELD]: setName }, - limit: 1, - }); - if (existing.length > 0) return; - const id = `ups_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; - await sys.insert( - 'sys_user_permission_set', - { id, user_id: userId, [GRANT_SET_NAME_FIELD]: setName, organization_id: organizationId }, - { context: { isSystem: true } }, - ); - this.config.logger?.info?.('[audience] granted the declared self-registration permission set', { - userId, - permissionSet: setName, - organizationId, - }); + // [#25224 cut 8] The body moved to `auth-manager-membership-admission.ts` (see `MembershipAdmissionHost`). + const readConfig = () => this.config; + const host: Pick = { + get config() { return readConfig(); }, + }; + return landSelfRegistrationGrant.call(host, engine, userId, setName, organizationId); } /** @@ -6055,12 +5937,11 @@ export class AuthManager { } private reportUngrantedSelfRegistrant(userId: string, setName: string, cause: string): void { - this.audienceLogError( - `[audience] a self-registrant was ADMITTED but the declared permission set '${setName}' was NOT granted ` + - `(user ${userId}) — the account works and everything looks normal, but it holds only baseline access, ` + - `and nothing retries this. Fix: verify the set exists and is active for the target organization, then grant it ` + - `manually (sys_user_permission_set) or have the user re-created. Cause: ${cause}`, - ); + // [#25224 cut 8] The body moved to `auth-manager-membership-admission.ts` (see `MembershipAdmissionHost`). + const host: Pick = { + audienceLogError: (...a) => this.audienceLogError(...a), + }; + return reportUngrantedSelfRegistrant.call(host, userId, setName, cause); } /** diff --git a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts index 5d6b4fc808c..b65a3129624 100644 --- a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts +++ b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts @@ -206,7 +206,7 @@ const BLOCKS: Array<{ }, { id: 'B5 settleSelfRegistrationGrant', - site: 'auth-manager.ts — sys_user_permission_set existence read', + site: 'auth-manager-membership-admission.ts — sys_user_permission_set existence read', populated: (e) => (withSystemReadContext(e) as never as { find: Function }).find('sys_user_permission_set', { where: { user_id: 'usr_1', permission_set_id: 'ps_1' }, limit: 1 }), empty: (e) => (withSystemReadContext(e) as never as { find: Function }).find('sys_user_permission_set', { where: { user_id: 'ghost', permission_set_id: 'ps_1' }, limit: 1 }), },