Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/33339-create-user-membership-created.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth): `POST /api/v1/auth/admin/create-user` reports the membership the store holds for the new account

Clause-②: no
Metadata-protocol: none

`membershipCreated`, in the answer and in the `user.admin_created` audit row, now says the new account holds a membership after the call, whichever writer made the row during this creation. The account did not exist before the call, so any membership it holds was made by this creation.

- Single-org posture: the `user.create.after` membership reconciler (the default-organization join) binds the new user while the account is being created, so the endpoint's own reconcile finds the row and yields. That used to answer `membershipCreated: false` next to the `organizationId` while `sys_member` held the row; it now answers `true`. When the endpoint inserts the row itself it answers `true`, as before.
- Multi-org (walled) posture: a row already in the administrator's current organization is still respected (no second add-member), and is now reported as `membershipCreated: true` for the same reason.
- Unchanged: under `membershipPolicy: 'invite-only'` on the single-org posture the answer and audit row carry no `organizationId` and `membershipCreated: false`, and no `sys_member` row is written. A bind that fails, finds no target organization, or meets an AI service account still answers `false`.
35 changes: 27 additions & 8 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,21 +451,35 @@ describe('runAdminCreateUser', () => {
expect(m.engineInsert.mock.calls.some((c) => c[0] === 'sys_member')).toBe(false);
});

it('is idempotent when a membership already exists', async () => {
const m = makeDepsWithOrgs({
orgs: [{ id: 'org_only' }],
members: [{ organization_id: 'org_only', user_id: 'user-9' }],
// The `user.create.after` reconciler (the bootstrap join) binds the new user
// DURING `createUser`, so the endpoint's own reconcile finds the row and
// yields. The account did not exist before this call, so that row is this
// creation's membership: the answer and the audit row both say `true`. It
// used to answer `false` here while `sys_member` held the row, which read as
// "a member-less account was created".
it('the bootstrap join already bound the new user: no second insert, membershipCreated true in the answer and the audit row', async () => {
const members: Array<{ organization_id: string; user_id: string }> = [];
const m = makeDepsWithOrgs({ orgs: [{ id: 'org_only' }], members });
const createUser = vi.fn(async ({ body }: any) => {
members.push({ organization_id: 'org_only', user_id: 'user-9' }); // the hook's bind
return { user: { id: 'user-9', email: body.email, name: body.name } };
});
m.deps.getAuthApi = async () => ({ createUser }) as any;
const res = await runAdminCreateUser(
m.deps,
makeRequest({ email: 'a@b.co', generatePassword: true }),
ACTOR,
);
expect(res.status).toBe(200);
expect(createUser).toHaveBeenCalledTimes(1);
const data = res.body.data as any;
expect(data.organizationId).toBe('org_only');
expect(data.membershipCreated).toBe(false);
expect(m.engineInsert.mock.calls.some((c) => c[0] === 'sys_member')).toBe(false);
expect(data.membershipCreated).toBe(true);
// The endpoint wrote no second row: one membership, the hook's.
expect(callsOf(m.engineInsert).some((c) => c[0] === 'sys_member')).toBe(false);
const auditRow = callsOf(m.engineInsert).find((c) => c[0] === 'sys_audit_log')?.[1];
expect(auditRow).toBeTruthy();
expect(JSON.parse(auditRow.metadata)).toMatchObject({ organizationId: 'org_only', membershipCreated: true });
});

it('does not fail account creation when the membership bind throws', async () => {
Expand Down Expand Up @@ -1521,15 +1535,20 @@ describe('create-user in multi-org mode: the new user joins the administrator\'s
expect(j.addMember).not.toHaveBeenCalled();
});

it('a membership already there is respected: no second addMember, membershipCreated false', async () => {
// The account is brand-new, so a row in the current organization was written
// during this creation (a host's `user.create.after` hook): it is reported as
// this creation's membership, the same rule the single-org bind answers by.
it('a membership written during the creation is respected: no second addMember, membershipCreated true in the answer and the audit row', async () => {
const j = makeJoinDeps({
callerActiveOrg: 'org_current',
members: [{ organization_id: 'org_current', user_id: 'user-9' }],
});
const res = await runAdminCreateUser(j.deps, makeRequest({ email: 'e@b.co', password: 'Explicit1!' }), ACTOR);
expect(res.status).toBe(200);
expect(j.addMember).not.toHaveBeenCalled();
expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: false });
expect(res.body.data).toMatchObject({ organizationId: 'org_current', membershipCreated: true });
const audit = callsOf(j.engineInsert).find(([object]) => object === 'sys_audit_log')![1];
expect(JSON.parse(audit.metadata)).toMatchObject({ organizationId: 'org_current', membershipCreated: true });
});

it('refused by the vendor: its status and code, the account rolls back, nothing is audited', async () => {
Expand Down
33 changes: 21 additions & 12 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,13 +466,18 @@ async function stampMustChangePassword(
* endpoint created the user" as a determinant — an admin-created account is
* not an implicit invitation.
*
* Returns the shape the response/audit consumed pre-ADR-0093:
* `membershipCreated` is true only when THIS call inserted the row (a `bound`
* outcome); a `yielded` outcome (the hook or a race already bound it) reports
* the org with `membershipCreated: false`. Under `invite-only` the reconciler
* answers `policy-skip` before it looks at any org, so the caller reports no
* organization and `membershipCreated: false` — the account is created, the
* membership is not.
* Returns what the response and the audit row report: `membershipCreated`
* says the store holds a membership for the new account after this call,
* whichever writer made the row. The account did not exist before this call,
* so any membership it holds was created by this creation — a `bound` outcome
* (this call inserted it) and a `yielded` outcome (the `user.create.after`
* reconciler or a host hook bound it moments earlier, during the same
* `createUser`) both answer `true`. Reporting `yielded` as `false` told the
* administrator a member-less account had been made while `sys_member` held
* the row. Under `invite-only` the reconciler answers `policy-skip` before it
* looks at any org, so the caller reports no organization and
* `membershipCreated: false` — the account is created, the membership is not.
* A `failed` / `no-target-org` / `agent-account` outcome reports `false`.
*/
async function bindUserToSoleOrganization(
deps: AdminUserEndpointDeps,
Expand All @@ -497,7 +502,7 @@ async function bindUserToSoleOrganization(
});
return {
organizationId: result.organizationId ?? null,
membershipCreated: result.outcome === 'bound',
membershipCreated: result.outcome === 'bound' || result.outcome === 'yielded',
};
}

Expand All @@ -512,7 +517,8 @@ async function bindUserToSoleOrganization(
* - `placeholderEmail` — the admin created a phone-only identity, so the
* address on the account is a generated placeholder. The decision, never
* the address.
* - `membershipCreated` — this call bound the membership (ADR-0093 D2).
* - `membershipCreated` — the new account holds a membership after this call
* (ADR-0093 D2), whichever writer made the row during this creation.
* - `organizationId` — a reference to the organization bound to, a record of
* its own; present only when one was resolved.
*
Expand Down Expand Up @@ -967,8 +973,11 @@ class CreationRefused extends Error {
* `membershipPolicy: 'invite-only'` admits (D1). The role is `member`, the D1
* default: elevation stays the separate, audited `update-member-role`.
*
* A membership already there is respected and reported as
* `membershipCreated: false`, exactly as the reconciler reports it.
* A membership already there is respected (no second `addMember`) and
* reported as `membershipCreated: true`: the account was created by this call,
* so a row in the current organization was written during this creation (a
* host's `user.create.after` hook, say) — the same rule the single-org bind
* answers by.
*/
async function joinCurrentOrganization(
deps: AdminUserEndpointDeps,
Expand All @@ -983,7 +992,7 @@ async function joinCurrentOrganization(
{ where: { organization_id: organizationId, user_id: userId }, limit: 1 },
{ context: SYSTEM_CTX },
);
if (Array.isArray(existing) && existing.length > 0) return { organizationId, membershipCreated: false };
if (Array.isArray(existing) && existing.length > 0) return { organizationId, membershipCreated: true };
}
try {
await addMember({ body: { userId, role: 'member', organizationId } });
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/test-typecheck-debt.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"TS18048: 'auditRow' is possibly 'undefined'.": 5,
"TS2345: Argument of type 'null' is not assignable to parameter of type '…'.": 1,
"TS2345: Argument of type '…' is not assignable to parameter of type '…'.": 1,
"TS2493: Tuple type '…' of length '0' has no element at index '0'.": 7,
"TS2493: Tuple type '…' of length '0' has no element at index '0'.": 6,
"TS2493: Tuple type '…' of length '0' has no element at index '1'.": 4
},
"src/auth-manager.jwt-eddsa-fallback.test.ts": {
Expand Down
Loading