Skip to content

Commit c6bdcac

Browse files
hotlongclaude
andauthored
fix(plugin-auth)!: a cloud-managed environment refuses local self-registration, invitations (resends included), user creation and import, and sets passwords only on local accounts (#25399)
Fixes #25083 Clause-②: yes (narrowing) Metadata-protocol: none ## What this does This is the services half of the maintainer's ruling 6341690661 on #25083 (objectstack-ai/cloud#3132: hosted environments are managed; self-hosted is unaffected). On a cloud-managed environment, `isCloudManaged()` from `@objectstack/core` (#25306) narrows plugin-auth's local identity doors. With the variable unset, every door behaves exactly as on `main`. | Door | Managed (`OS_CLOUD_MANAGED=1`) | |:--|:--| | Audience posture | Only `invite_only`, at construction and on every merged `applyConfigPatch` | | Email sign-up | Both `disableSignUp` arms are forced on and advertised. The bootstrap owner's first sign-up still enters, as under SSO-only | | `organization/invite-member` | `409 RESOURCE_CONFLICT`, refused at the route in the `before` hook, **resends included**. The `beforeCreateInvitation` arm stays as the second line | | `admin/create-user`, `admin/import-users` | `409` after the admin check (a non-admin still hears `403`). Nothing is written | | `admin/set-user-password` | Refuses an `idp_provisioned` target with `409`. An `env_native` target is still answered, so the break-glass admin stays editable | | `set-initial-password` | Only a caller with platform-admin standing (`isPlatformAdminUser`). Another signed-in caller gets `409`; an anonymous caller still gets `401` | The four raw routes answer the ADR-0112 envelope through `cloudManagedRefusal(door)`. `invite-member` answers better-auth's own `{ code, message }` body, as every org-hook refusal in this package does. **For the cloud's twin** (objectstack-ai/cloud#3169, the per-environment `set-initial-password` the cloud mounts): the package entry now exports `cloudManagedRefusal`, `CLOUD_MANAGED_REFUSAL_CODE`, `CLOUD_MANAGED_REFUSAL_STATUS`, `CLOUD_MANAGED_REFUSAL_MESSAGES` and `CloudManagedDoor`. `isPlatformAdminUser` was already exported. That twin takes the same arm with the same refusal. This export is the widening in `Clause-②: yes (narrowing)`; the door changes are the narrowing. ## Provenance - The doors, the suite and the changeset were prepared by the `domain:services` seat 1 card thread (claim 6349623693) on `claude/project-thread-i0vfmz`, in commits `a6598847`, `7b0b758c` and `0366f01d`. They paused only because the core accessor and the spec flag had not landed. - The maintainer then handed the card to the cloud ops seat (takeover claim 6359752007). This branch carries those commits, merges `main`, and adds the following: - the only merge conflict was in `auth-manager.ts`'s import block; - `hasPlatformAdminStanding` is no longer imported there, because it moved with the actor guards in #25273; - the suite calls `resetCloudManagedForTesting()` on every switch, because `isCloudManaged()` memoizes; - the package-entry export above; - the invite-member resend fix below. - The served flag `features.cloudManaged` landed in #25309 and is not re-added here. ## Contract review (narrowing): PASS, with one should-fix fixed in this PR The review was run at `CONTRACT_REVIEW_TIER` on head `5a3d27f6`. Its findings and dispositions: 1. **should-fix, fixed.** better-auth answers `invite-member { resend: true }` for a pending invitee before it consults `beforeCreateInvitation`. It extends `expiresAt` and re-sends the e-mail, and the console reaches that branch through `sys_invitation`'s `resend_invitation`. The `before` hook now refuses the route under the managed mode. Pinned by a resend leg (409, expiry unchanged) and its unmanaged control (200, expiry extended). Ablating the new arm reds only the resend leg. 2. **nit, kept.** `set-user-password` refuses `source === 'idp_provisioned'` and admits other values. A null `source` is answered as local, which matches the ruling's wording ("refuses `idp_provisioned` targets"). `sys_user.source` defaults to `env_native`. 3. **nit, fixed.** The changeset now names both body shapes instead of claiming `error.code` everywhere. 4. **nit, noted.** The JIT path is pinned through `decideAudienceAdmission` only. This diff does not touch that path. ## Verification (head `f37350ae`) - `@objectstack/plugin-auth`: - test: Test Files 188 passed, Tests 3560 passed | 10 skipped; - typecheck (incl. `check:test-typecheck`): exit 0; - build: exit 0; - `src/cloud-managed.test.ts`: 32/32. - Ablations: each was committed first, run under a trap restore, and restored to HEAD. | Ablation | Result | |:--|:--| | `set-initial-password` arm off | 1 failed | | memo reset removed | 5 failed | | package export removed | 1 failed | | invite-member route arm off | 1 failed | - Full dogfood (owed by the `(narrowing)` arm), on the final head after building the `@objectstack/dogfood...` closure (66/66 tasks): Test Files 289 passed | 3 skipped, Tests 2491 passed | 18 skipped. - `dispatch-gates.mjs` derived 135 commands from this diff; 129 can run locally. 120 exit 0. The other 9 are not measured here and are left to CI: - 4 need a PR context: `check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`, `check-governed-queue-guard`. `check-partof-closing-keyword` was also run locally against this body; see below. - 2 need a whole-workspace build: `check:dual-build-cjs-loads`, `check:published-readme-exports`. - 2 are `check-issue-citations` runs (plain and `--census`) whose board read was refused with HTTP 403. The plain run was measured green on the previous head, and this head adds no new issue citation. - 1 is `select-shard-timings-run --self-test`, which needs bash `mapfile`; macOS bash 3.2 lacks it. - objectstack-ai/objectui#13330 (the console half) remains open and is being built separately. objectstack-ai/cloud#3169 remains open until this lands in the cloud's pin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c68b10 commit c6bdcac

8 files changed

Lines changed: 799 additions & 7 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: a cloud-managed environment (`OS_CLOUD_MANAGED=1`) refuses local self-registration, invitations, user creation and import, and sets passwords only on local accounts — self-hosted deployments are unchanged
6+
7+
Clause-②: yes (narrowing) — one widening and one narrowing. The widening: the package entry now exports `cloudManagedRefusal`, `CLOUD_MANAGED_REFUSAL_CODE`, `CLOUD_MANAGED_REFUSAL_STATUS`, `CLOUD_MANAGED_REFUSAL_MESSAGES` and `CloudManagedDoor`, so a host that mounts its own twin of one of these doors (the cloud's per-environment `set-initial-password`, objectstack-ai/cloud#3169) refuses with the same status, code and message. The narrowing: under the managed mode only, doors that admitted a request now refuse it: every audience posture but `invite_only`, email sign-up, member invitations, admin create-user and import-users, an admin password set on an identity-provider account, and a first password for a caller without platform-admin standing. A process that does not set `OS_CLOUD_MANAGED=1` answers every one of these doors exactly as before.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, settings key or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (the only export change is five added names), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer plugin-auth's runtime doors give on a process whose environment sets `OS_CLOUD_MANAGED=1`, which the cloud sets on its hosted containers; the remedy is to invite people from the organisation in the cloud console, never a rewrite of a package source or a stored row. `sys_user`, `sys_invitation` and `sys_account` rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** for a cloud-managed environment only (a process started with `OS_CLOUD_MANAGED=1`). A hosted environment's people are invited by the organisation that hosts it, in the cloud, and sign in through the cloud's identity provider; authorization (positions, permission sets) stays in the environment. The local doors that would let someone in another way now refuse, and the password doors split by population rather than switching off, so a local break-glass administrator stays reachable when the identity provider is not. Every refusal is `409` with code `RESOURCE_CONFLICT` (in the ADR-0112 envelope on the raw routes, in better-auth's own `{ code, message }` body on `invite-member`) and a message that says what was not done and where it is done instead.
13+
14+
- **Audience posture.** FROM: any posture booted and patched. TO: under the managed mode every posture but `invite_only` (the default) is refused at construction and on every merged config patch, with a message naming the posture and the fix.
15+
- **Email sign-up.** FROM: open unless `disableSignUp` or SSO-only was set. TO: forced off under the managed mode, and `getPublicConfig()` advertises `disableSignUp: true`. The bootstrap owner's first sign-up still enters, as it does under SSO-only.
16+
- **Invitations.** FROM: `organization/invite-member` created an invitation, and with `resend: true` extended a pending one and sent its e-mail again. TO: `409` on both, refused in the route's `before` hook, ahead of better-auth's body validation, session and permission checks and handler, so no invitation row is written or extended and no e-mail leaves.
17+
- **Admin create-user and import-users.** FROM: created or imported users. TO: `409` after the administrator check (a caller without it still gets `403`), and no user is written.
18+
- **Admin set-user-password.** FROM: set a password on any user. TO: refuses a target whose `source` is `idp_provisioned` with `409`; a local (`env_native`) target is still answered.
19+
- **Set-initial-password.** FROM: any signed-in user without a credential could set one. TO: only a caller with platform-admin standing may; any other signed-in caller gets `409`, and an anonymous caller still gets `401`.
20+
- **New exports.** `cloudManagedRefusal(door)` and the constants behind it, for a host twin of one of these doors.
21+
- **Unchanged:** every door on a process without `OS_CLOUD_MANAGED=1`, including one running SSO-only; existing users, invitations and credentials.
22+
- **The fix for an operator:** nothing on a self-hosted deployment. On a hosted environment, invite people from the organisation in the cloud console; their account arrives on their first sign-in. Keep a local password only for the break-glass administrator.

‎packages/plugins/plugin-auth/src/admin-user-endpoints.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ import { revokeSessionsAndApiKeys, type ForcedResetRevocationOutcome } from './f
209209
import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js';
210210
import { resolveDefaultOrgId } from './tenancy-service.js';
211211
import { readJsonRequestBody } from './request-json-body.js';
212+
import { cloudManagedRefusal } from './cloud-managed.js';
213+
import { isCloudManaged } from '@objectstack/core';
212214
import { resolveThrownHttpError, validationFailureDetails } from '@objectstack/types';
213215

214216
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
@@ -337,6 +339,32 @@ async function resolvePassword(
337339
* fail-open gate, so a failed stamp must not fail the whole operation; we
338340
* surface the *actual* stamped state in the response instead.
339341
*/
342+
/**
343+
* [#25083] The target's `sys_user.source` (`idp_provisioned` | `env_native`),
344+
* read as the system. `ok: false` when it cannot be read at all (no engine,
345+
* no read surface, or the read throws), so the cloud-managed check above
346+
* fails closed instead of reading an outage as a local account.
347+
*/
348+
async function readUserSource(
349+
deps: AdminUserEndpointDeps,
350+
userId: string,
351+
): Promise<{ ok: true; value: string | null } | { ok: false }> {
352+
const engine = deps.getDataEngine();
353+
if (!engine?.find) return { ok: false };
354+
try {
355+
const rows = await engine.find('sys_user', {
356+
where: { id: userId },
357+
fields: ['id', 'source'],
358+
limit: 1,
359+
context: SYSTEM_CTX,
360+
});
361+
const row = Array.isArray(rows) ? (rows[0] as { source?: unknown } | undefined) : undefined;
362+
return { ok: true, value: typeof row?.source === 'string' ? row.source : null };
363+
} catch {
364+
return { ok: false };
365+
}
366+
}
367+
340368
async function stampMustChangePassword(
341369
deps: AdminUserEndpointDeps,
342370
userId: string,
@@ -881,6 +909,30 @@ export async function runAdminSetUserPassword(
881909
return { status: 404, body: { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'User not found' } } };
882910
}
883911

912+
// [#25083] On a cloud-managed environment a password is set here only on
913+
// a local account. An `idp_provisioned` user signs in through the hosting
914+
// organisation's identity provider, which owns their password; an
915+
// `env_native` row (the break-glass administrator among them) still
916+
// answers, because ADR-0135 D5.2 narrows by population, never wholesale.
917+
if (isCloudManaged()) {
918+
const source = await readUserSource(deps, userId);
919+
if (!source.ok) {
920+
return {
921+
status: 503,
922+
body: {
923+
success: false,
924+
error: {
925+
code: 'SERVICE_UNAVAILABLE',
926+
message:
927+
"The user's identity source could not be read, so whether this cloud-managed environment may set "
928+
+ 'their password here cannot be judged. No password was changed.',
929+
},
930+
},
931+
};
932+
}
933+
if (source.value === 'idp_provisioned') return cloudManagedRefusal('setUserPassword');
934+
}
935+
884936
// Mirrors the stock /admin/set-user-password core: hash, then update the
885937
// credential account or create one for SSO/invite-onboarded users.
886938
const hashed = await authCtx.password.hash(resolved.password);

‎packages/plugins/plugin-auth/src/audience-posture.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
* direction this gate must never take.
9090
*/
9191

92+
import { isCloudManaged } from '@objectstack/core';
9293
import {
9394
AUDIENCE_POSTURES,
9495
SystemUserId,
@@ -228,6 +229,10 @@ export const OPEN_POSTURE_VERIFICATION_OFF_WARNING =
228229
* (#20389: a pre-production deployment with no mail transport otherwise
229230
* dead-ends every sign-up at the verify page), and refused when only the
230231
* console stored it (`verificationDeclaredBy: 'console'`).
232+
*
233+
* On a cloud-managed environment (`isCloudManaged()`, `@objectstack/core`)
234+
* every posture but `invite_only` is refused: the vocabulary is unchanged, the
235+
* refusal is the runtime's.
231236
*/
232237
export function assertAudienceConfig(
233238
raw: AudienceConfig | undefined,
@@ -246,6 +251,17 @@ export function assertAudienceConfig(
246251
);
247252
}
248253
const posture: AudiencePosture = (rawPosture as AudiencePosture | undefined) ?? 'invite_only';
254+
// [#25083] A cloud-managed environment never opens self-registration: the
255+
// organisation that hosts it invites its people in the cloud. Refused rather
256+
// than coerced, so a declared `open` never reads as in force — at
257+
// construction the refusal stops the boot, and on a merged patch the
258+
// standing posture keeps ruling.
259+
if (posture !== 'invite_only' && isCloudManaged()) {
260+
fail(
261+
`posture '${posture}' opens self-registration, which a cloud-managed environment (OS_CLOUD_MANAGED) never offers: ` +
262+
"the organisation that hosts it invites its people in the cloud. Use posture 'invite_only' (the default), or remove the posture.",
263+
);
264+
}
249265
const domains = raw.allowedEmailDomains;
250266
if (domains !== undefined && !Array.isArray(domains)) {
251267
fail('allowedEmailDomains must be an array of bare domain names');

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

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,16 @@ import {
3636
type LinkingAuthContext,
3737
refuseImplicitAccountLink,
3838
} from './implicit-account-linking.js';
39+
import { CLOUD_MANAGED_REFUSAL_CODE, CLOUD_MANAGED_REFUSAL_MESSAGES } from './cloud-managed.js';
3940
import { IdpLocaleSync } from './idp-locale-claim.js';
4041
import type { IDataEngine } from '@objectstack/core';
4142
import {
4243
API_KEY_DEFAULT_EXPIRY_DAYS,
4344
API_KEY_INACTIVITY_DAYS,
4445
API_KEY_MAX_EXPIRY_DAYS,
4546
accountScopeAuthGate,
47+
// [#25083] Is this process a cloud-managed (hosted) environment? The ONE
48+
// accessor every managed-mode judgement here asks, and nothing else.
4649
isCloudManaged,
4750
resolveUserAuthzGrants,
4851
} from '@objectstack/core';
@@ -1892,10 +1895,11 @@ export class AuthManager {
18921895
// lock the registration policy without relying on UI state.
18931896
emailAndPassword: (() => {
18941897
const disableSignUpFromEnv = readDisableSignUpEnv();
1895-
// SSO-only ("enforced") forces self-registration off (the managed team
1896-
// signs in via the IdP). `enabled` stays true so the break-glass
1897-
// password endpoint keeps working for the env owner / local admin.
1898-
const effectiveDisableSignUp = this.resolveSsoOnly()
1898+
// SSO-only ("enforced") and a cloud-managed environment force
1899+
// self-registration off (see `signUpForcedOff`). `enabled` stays true
1900+
// so the break-glass password endpoint keeps working for the env
1901+
// owner / local admin.
1902+
const effectiveDisableSignUp = this.signUpForcedOff()
18991903
? true
19001904
: (disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp);
19011905
return {
@@ -2748,6 +2752,28 @@ export class AuthManager {
27482752
// fall through — the vendor still decides everything it owns
27492753
}
27502754

2755+
// ── [#25083] cloud-managed: invite-member issues nothing ────
2756+
// `beforeCreateInvitation` refuses a NEW invitation, but
2757+
// better-auth answers `resend: true` for an invitee who is
2758+
// already pending BEFORE it consults that hook: it extends the
2759+
// row's `expiresAt` and sends the invitation e-mail again
2760+
// (`organization/routes/crud-invites.mjs`, better-auth 1.7.12).
2761+
// The console reaches exactly that branch through
2762+
// `sys_invitation`'s `resend_invitation` action. On a
2763+
// cloud-managed environment no invitation leaves this door in
2764+
// either shape, so the route refuses ahead of the vendor's
2765+
// handler; the hook keeps its own arm as the second line. The
2766+
// fact is public (`features.cloudManaged` on `/auth/config`), so
2767+
// answering it ahead of the session check tells an anonymous
2768+
// caller nothing new.
2769+
if (ctx?.path === '/organization/invite-member' && isCloudManaged()) {
2770+
const { APIError } = await import('better-auth/api');
2771+
throw new APIError('CONFLICT', {
2772+
message: CLOUD_MANAGED_REFUSAL_MESSAGES.invitation,
2773+
code: CLOUD_MANAGED_REFUSAL_CODE,
2774+
});
2775+
}
2776+
27512777
// ── ADR-0069 D2: account lockout (gate) ─────────────────────
27522778
// Reject a sign-in for a locked identity BEFORE better-auth checks
27532779
// the password — a lock must hold even against the correct password.
@@ -3650,6 +3676,20 @@ export class AuthManager {
36503676
// denied (auto-org-admin-grant.ts keeps it read-only on those
36513677
// tables precisely so a fresh org admin cannot rebind themselves).
36523678
beforeCreateInvitation: async ({ invitation, inviter, organization }: any) => {
3679+
// [#25083] A cloud-managed environment creates no invitation at
3680+
// all: the organisation that hosts it invites its people in the
3681+
// cloud. The `before` hook refuses `/organization/invite-member`
3682+
// first, resends included; this arm is the second line, for any
3683+
// other path that reaches the hook. First here, before the role
3684+
// cap and the placement gate, because no invitation is issuable
3685+
// whatever its role or placement.
3686+
if (isCloudManaged()) {
3687+
const { APIError } = await import('better-auth/api');
3688+
throw new APIError('CONFLICT', {
3689+
message: CLOUD_MANAGED_REFUSAL_MESSAGES.invitation,
3690+
code: CLOUD_MANAGED_REFUSAL_CODE,
3691+
});
3692+
}
36533693
const inviterUserId = inviter?.id ?? inviter?.userId ?? null;
36543694
const organizationId = organization?.id ?? invitation?.organizationId;
36553695

@@ -7555,6 +7595,18 @@ export class AuthManager {
75557595
return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false);
75567596
}
75577597

7598+
/**
7599+
* Is local self-registration forced off, whatever `disableSignUp` says?
7600+
* Under SSO-only ({@link resolveSsoOnly}), and [#25083] on a cloud-managed
7601+
* environment, whose people the hosting organisation invites in the cloud.
7602+
* Read by the wiring and by `getPublicConfig()` alike, so the advertised
7603+
* flag cannot disagree with the wired one. Break-glass is untouched: neither
7604+
* case disables `emailAndPassword.enabled`.
7605+
*/
7606+
private signUpForcedOff(): boolean {
7607+
return this.resolveSsoOnly() || isCloudManaged();
7608+
}
7609+
75587610
/**
75597611
* The tenancy posture ACTUALLY IN FORCE on this deployment — the one fact
75607612
* both `organization/create`'s gate and `/auth/config`'s
@@ -7663,7 +7715,8 @@ export class AuthManager {
76637715
const disableSignUpFromEnv = readDisableSignUpEnv();
76647716
// SSO-only ("enforced") hides the local password form + self-registration.
76657717
// `enabled` stays true (break-glass), but signup is forced off and the UI
7666-
// suppresses the password form via `features.ssoEnforced` below.
7718+
// suppresses the password form via `features.ssoEnforced` below. A
7719+
// cloud-managed environment forces signup off too (`signUpForcedOff`).
76677720
const ssoOnly = this.resolveSsoOnly();
76687721
// [#11739] The audience posture, resolved once for this response: the
76697722
// forced-verification mirror below and `features.audiencePosture` must
@@ -7675,7 +7728,7 @@ export class AuthManager {
76757728
const audience = this.getAudience();
76767729
const emailPassword = {
76777730
enabled: emailPasswordConfig.enabled !== false, // Default to true
7678-
disableSignUp: ssoOnly ? true : (disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false),
7731+
disableSignUp: this.signUpForcedOff() ? true : (disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false),
76797732
// Mirrors the wiring in createAuthInstance() through the SAME resolver:
76807733
// a self-registration-permitting posture forces verification ON, except
76817734
// an `open` deployment's explicit opt-out (#20389) — the advertised flag

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { Plugin, PluginContext, IHttpServer, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core';
3+
// [#25083] `isCloudManaged`: the ONE accessor the cloud-managed doors below ask.
4+
import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core';
45
import type { BetterAuthOptions } from 'better-auth';
56
import {
67
AuthConfig,
@@ -90,6 +91,7 @@ import {
9091
type SignInPathConfigView,
9192
} from './boot-sign-in-reachability.js';
9293
import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js';
94+
import { cloudManagedRefusal } from './cloud-managed.js';
9395
import {
9496
runAdminBanUser,
9597
runAdminUnbanUser,
@@ -2860,6 +2862,20 @@ export class AuthPlugin implements Plugin {
28602862
rawApp.post(`${basePath}/set-initial-password`, async (c: any) => {
28612863
try {
28622864
const authApi = await this.authManager!.getApi();
2865+
// [#25083] On a cloud-managed environment the door narrows to a caller
2866+
// with platform-admin standing: everyone else signs in through the
2867+
// hosting organisation's identity provider, while an administrator can
2868+
// still give themselves the local password break-glass needs (ADR-0135
2869+
// D5.2 splits by population, never wholesale). A caller with no
2870+
// session falls through to the body, which answers it as before.
2871+
if (isCloudManaged()) {
2872+
// [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`).
2873+
const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
2874+
if (session?.user?.id && !isPlatformAdminUser(session.user)) {
2875+
const refusal = cloudManagedRefusal('setInitialPassword');
2876+
return c.json(refusal.body, refusal.status);
2877+
}
2878+
}
28632879
const { status, body } = await runSetInitialPassword(authApi as any, c.req.raw);
28642880
return c.json(body, status);
28652881
} catch (error) {
@@ -3212,6 +3228,12 @@ export class AuthPlugin implements Plugin {
32123228
try {
32133229
const actor = await gateAdmin(c);
32143230
if (actor instanceof Response) return actor;
3231+
// [#25083] After the identity and admin checks, before any write: a
3232+
// cloud-managed environment's users are invited in the cloud.
3233+
if (isCloudManaged()) {
3234+
const refusal = cloudManagedRefusal('createUser');
3235+
return c.json(refusal.body, refusal.status);
3236+
}
32153237
const authApi: any = await this.authManager!.getApi();
32163238
if (typeof authApi.createUser !== 'function') {
32173239
return c.json(
@@ -3482,6 +3504,11 @@ export class AuthPlugin implements Plugin {
34823504
try {
34833505
const actor = await gateAdmin(c);
34843506
if (actor instanceof Response) return actor;
3507+
// [#25083] Same placement and reason as create-user's refusal.
3508+
if (isCloudManaged()) {
3509+
const refusal = cloudManagedRefusal('importUsers');
3510+
return c.json(refusal.body, refusal.status);
3511+
}
34853512
const { runAdminImportUsers } = await import('./admin-import-users.js');
34863513
// [#4251] Resolved from `protocol`, not `metadata`. `getMetaItem` is a
34873514
// PROTOCOL method (`ObjectStackProtocolImplementation`, registered by

0 commit comments

Comments
 (0)