You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit c6bdcac
Browse filesBrowse the repository at this point in the historyBrowse files
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>
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.
0 commit comments