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
fix(plugin-auth)!: POST /admin/create-user reads the deployment membership policy instead of hard-coding auto (#16683) (#17443)
* fix(plugin-auth)!: admin/create-user reads the deployment membership policy
`POST /api/v1/auth/admin/create-user` handed the membership reconciler a
literal `policy: 'auto'`, making it the one membership-writing path that did
not consult `AuthManager.getMembershipPolicy()`. On an `invite-only`
deployment the `user.create.after` reconciler correctly bound nobody and this
endpoint-side belt-and-suspenders bind then bound them anyway, answering
`membershipCreated: true`. The failure direction was OPEN: it granted the
membership the policy exists to withhold.
ADR-0093 D1 enumerates the `invite-only` flows as a closed set and refuses
"which endpoint created the user" as a determinant, so the endpoint now reads
the live accessor through a new optional `AdminUserEndpointDeps.getMembershipPolicy`
dep, wired at the mount in auth-plugin.ts. Absent dep falls back to `'auto'`,
the accessor's own default, so unwired hosts are byte-identical.
The accessor's docblock enumerated "both membership paths" while a third
writer sat outside the accounting; it now names all three.
Tests: invite-only binds nothing (single-org and multi-org), the org lookup is
never reached so `policy-skip` stays distinguishable from `no-target-org`, and
a negative control pins the `auto` path byte-identical across an explicit
`'auto'`, an unwired dep and a dep returning `undefined`.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
* fix(plugin-auth): the new admin-endpoint assertions add no test-type debt
`check:test-typecheck` ledgers this file's TS2493 / TS18048 counts and only
ratchets DOWN. The invite-only and negative-control assertions read
`engineInsert.mock.calls` directly, and `vi.fn(async () => ({}))` infers a
ZERO-length parameter tuple, so every `c[0]` was a fresh TS2493 and the
`![1]` audit-row read a fresh TS18048 — six new errors across three ledgered
signatures.
Read those calls through a typed `callsOf()` helper instead. No type is
loosened, no ledger number is raised, no test is skipped: the recorded
counts return to exactly what the ledger already holds.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth)!: `POST /admin/create-user` reads the deployment's membership policy instead of hard-coding `auto` (#16683)
6
+
7
+
**BREAKING** — the membership this published endpoint writes moves for existing inputs on `invite-only` deployments. The route, its request body, its response fields and every exported signature are byte-identical; what changes is what an existing call does on a deployment that declared a non-default policy, stated as a FROM/TO pair below.
8
+
9
+
ADR-0093 D1 makes the deployment's `membershipPolicy` the one answer to "does this new account get an organization membership", and enumerates the `invite-only` flows as a closed set — "which endpoint created the user" is explicitly not a determinant. The `user.create.after` reconciler and the D6 backfill both read it through `AuthManager.getMembershipPolicy()`. This endpoint did not: its belt-and-suspenders bind handed the reconciler a literal `'auto'`, so it was the one membership-writing path in the product that ignored the setting.
10
+
11
+
FROM: on a deployment declaring `membershipPolicy: 'invite-only'`, an account created through `POST /api/v1/auth/admin/create-user` was bound to the default organization anyway, and the 200 response answered `membershipCreated: true`. The `user.create.after` reconciler had already declined to bind it; this endpoint bound it afterwards.
12
+
13
+
TO: the same call creates the account and binds no membership. The response answers `membershipCreated: false` and omits `organizationId`, and the audit row records the same. The account is created and can sign in — `invite-only` withholds the membership, not the login.
14
+
15
+
Who is affected: only deployments that set `auth.membership_policy` (or `OS_AUTH_MEMBERSHIP_POLICY`) to `invite-only`. Under the default `auto` posture behaviour is unchanged in every observable respect — response body, `sys_member` write and audit metadata — and that equivalence is pinned by a test rather than asserted here.
16
+
17
+
If you relied on admin-created accounts acquiring a membership on an `invite-only` deployment, the supported way to keep it is to bind the membership explicitly (the `add_member` action / `POST /organization/add-member`), which is what `invite-only` means: memberships are granted deliberately, never as a side effect of account creation. Setting the deployment back to `auto` restores the old behaviour for every path at once, including sign-up.
18
+
19
+
The direction of the old defect was open, not closed: it GRANTED a membership the operator had configured the platform to withhold, and reported success while doing it. An operator who set `invite-only` specifically to keep a shared organization identity off their users got one anyway.
20
+
21
+
<!-- adr-0087: not-required (no-migration-prescription) nothing authorable changes shape: no spec key, no Zod schema, no stored metadata and no exported symbol is added, removed or renamed, so `os migrate meta` has no edit to make and no ledger id to carry. What moves is one runtime decision inside an HTTP handler, already governed by the `auth.membership_policy` setting an operator sets and can change back. -->
0 commit comments