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
22 changes: 22 additions & 0 deletions .changeset/33181-pending-sso-users.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/platform-objects': patch
---

feat(auth)!: a managed environment adds pending, SSO-only people with their positions, permission sets and manager, and ban works without the admin plugin

Clause-②: yes (narrowing) — narrowed: ban works without the admin plugin — `admin/ban-user` now works without the admin plugin, and a banned row is refused a session, with the admin plugin's own `403 BANNED_USER` answer; both ban doors answered `501 NOT_IMPLEMENTED` there. Widened: two doors, `POST /admin/add-sso-user` and `POST /admin/import-sso-users` (cloud-managed environments only; `409 RESOURCE_CONFLICT` elsewhere); the `idp_pending` value of `sys_user.source`, which every password door and every session refuses; the auth plugin's optional `environmentUserHost` option; `AuthManager#isAdminPluginMounted()`; the exports of `pending-sso-marker`, `environment-user-host` and `pending-sso-users`; and, re-exported from the package root, the helpers `bindUserToSoleOrganization`, `writeAdminAudit`, `joinCurrentOrganization` and `multiOrgCreationJoinsCurrentOrganization`, the class `CreationRefused`, the type `AdminAuditDecisions` (with its `user.admin_added_pending` event), `SetInitialPasswordOptions` (the optional third argument of `runSetInitialPassword`) and the optional `getBanWriter`, `host`, `getDataEngine` and `logger` members of `AdminBanEndpointDeps`. The cloud-managed refusals of `create-user`, `import-users` and the invitation doors point to Setup → Users.
Metadata-protocol: none
Skills-docs: none — no skill under `skills/**` teaches the admin user doors, the ban doors or the cloud-managed refusals; `content/docs/permissions/authentication.mdx` gains the two doors, the pending state, the host option and the ban change in this change.

<!-- adr-0087: not-required (no-migration-prescription) No schema key or metadata type is added, removed, renamed or re-shaped. `sys_user.source` gains one select option, `idp_pending`, which no existing row carries; the narrowed answer is a runtime refusal at session creation for a row already marked banned, so there is nothing stored for a migration to rewrite and no tombstone. -->

**BREAKING** for a deployment that runs without the better-auth admin plugin (`auth.plugins.admin` off, and SCIM off): `admin/ban-user` now works without the admin plugin, and a banned row is refused a session, with the admin plugin's own `403 BANNED_USER` code and sentence. `admin/unban-user` lifts the ban there too. A ban with an expiry that has passed is lifted at the next sign-in, as the admin plugin does. A deployment with the admin plugin on answers exactly as before: the plugin still decides.

Unchanged: the admin plugin's own ban enforcement where it is mounted; `create-user` and `import-users` off a managed environment; every row whose `source` is `env_native` or `idp_provisioned`.

What to do: before upgrading a deployment that runs without the admin plugin, list the users whose `sys_user.banned` is true, platform administrators first, and unban every one who should keep signing in: turn the admin plugin on (`auth.plugins.admin`), call `POST /admin/unban-user` for each, then turn it off again. A banned sole platform administrator cannot reach `admin/unban-user` after the upgrade. After it, a user who should not be banned is unbanned with `POST /admin/unban-user`.

New on a cloud-managed environment (`OS_CLOUD_MANAGED=1`): a platform administrator adds a person ahead of their first sign-in with `POST /admin/add-sso-user` (email, name, and optionally positions, permission sets and the manager's email address), or many with `POST /admin/import-sso-users` (CSV or rows, at most 500, a result per row, a bad row failing alone). The person is stored with `sys_user.source` = `idp_pending`: no password, refused by `set-user-password`, `set-initial-password`, `reset-password` and `phone-number/reset-password`, and refused a session on every channel, until their first sign-in through the platform identity provider attaches to the row and moves it to `idp_provisioned`. Positions, permission sets and the manager apply from the moment the person is added. Adding a person who is still pending again re-tells the host and changes nothing else; positions, permission sets or a manager sent with it are named on the answer as `notApplied`. A host passed as `environmentUserHost` is told after a person is added, and asked before a person is deactivated (`ban-user`) or reactivated (`unban-user`), never inside a transaction; the event names the administrator, the administrator at the keyboard on an impersonation session (`impersonatedBy`), and the administrator's cloud user id from their linked `objectstack-cloud` account. A host may refuse with `EnvironmentUserHostRefusal` (`409` or `403`, such as `DEVELOPER_ORGANIZATION_MEMBER_LIMIT_REACHED`): the door answers with it, a refused deactivation or reactivation writes nothing, and a refused add takes that person back out (one import row fails alone). Any other host failure refuses a deactivation (`503`, nothing written), and never undoes an add or a reactivation, whose answer says so on the row.

`@objectstack/platform-objects`: `sys_user.source` gains the option `idp_pending` ("Pending SSO Sign-In").
61 changes: 61 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,63 @@ rows that resolve to a temporary password (all of them under `temporary`, only
the unreachable ones under `auto`) — reveals the generated passwords once with a
download, splitting files above 500 rows into batches automatically.

#### Adding people on a cloud-managed environment

On a cloud-managed environment (`OS_CLOUD_MANAGED=1`) people sign in through
the identity provider of the organisation that hosts the environment, so
`create-user` and `import-users` answer `409 RESOURCE_CONFLICT` there. Two
doors add a person ahead of their first sign-in instead. Neither needs the
admin plugin, and both are platform-admin gated:

- `POST /api/v1/auth/admin/add-sso-user` takes `email`, `name`, and optionally
`positions`, `permissionSets` (lists of machine names) and `manager` (the
manager's email address). It answers `201`, or `200` with `alreadyPending`
when the person was already added and has not signed in yet.
- `POST /api/v1/auth/admin/import-sso-users` takes `csv` (columns `email`,
`name`, `positions`, `permission_sets`, `manager_id`; lists separated by
`;`, `manager_id` holding the manager's email address) or `rows`, at most
500 rows. Each row is judged and written on its own, so a bad row fails
with its own message and the good rows land. The answer carries a
`summary` and one result per row.

The person is stored **pending** (`sys_user.source` reads `idp_pending`): no
password, and no session until they sign in through the hosting
organisation's identity provider. Every password door refuses a pending
person (`set-user-password`, `set-initial-password`, the email and phone
reset), and so does every other way to open a session. Their first sign-in
through the platform identity provider attaches to the row and the source
becomes `idp_provisioned`; only that provider clears the pending state, and a
sign-in through any other one leaves the person pending. Their positions, permission sets and manager apply
from the moment they are added, so seed data and approval flows can name them
before they sign in. Off a managed environment both doors answer `409`.

A host can be told about people as they come and go: the `environmentUserHost`
option of the auth plugin is called after a person is added, and before a
person is deactivated (`admin/ban-user`) or reactivated (`admin/unban-user`).
It is never called inside a database transaction. Each event names the
administrator who acted, the administrator at the keyboard when the session
is an impersonation (`impersonatedBy`), and, when the acting administrator
holds a linked `objectstack-cloud` account, their cloud user id from that
account. None of these is read from the request.

A host can refuse by throwing `EnvironmentUserHostRefusal` with a `409` (for
example, an organization owner or admin, who is removed in the console) or a
`403` (for example, `DEVELOPER_ORGANIZATION_MEMBER_LIMIT_REACHED`). The door
answers with that status, code and sentence: a refused deactivation or
reactivation writes nothing, and a refused add takes that person back out
(on an import, that row alone fails). Any other host failure refuses a
deactivation with `503` and writes nothing, since only the host can say
whether the person is an organization owner or admin. It never undoes or
blocks an add or a reactivation: the answer carries `host: { notified: false,
reason }`. Adding a person who is still pending again tells the host again
and changes nothing else: positions, permission sets or a manager sent with
it are listed on the answer as `notApplied`; set them on the person's record.

`admin/ban-user` and `admin/unban-user` work without the admin plugin: on a
deployment without it the ban is written by the platform, and a banned user is
refused a session with the same `403 BANNED_USER` answer the admin plugin
gives.

#### SCIM Connection Credentials

With SCIM enabled (`OS_SCIM_ENABLED=true`, or `plugins.scim`), an identity
Expand Down Expand Up @@ -1480,6 +1537,10 @@ All endpoints are available under `/api/v1/auth/*`:
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions; the user's API keys are refused at every door while the ban holds; their OAuth access tokens are refused at the MCP endpoint and read as not valid at `/oauth2/userinfo` and `/oauth2/introspect`, and the token endpoint issues them no new token; a ban with an expiry stops binding once it expires. Neither the keys nor the OAuth tokens are revoked)
- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in and its API keys (each key is still judged by its own lifecycle: a key left unused past the inactivity window during the ban answers as inactive, and needs replacing)
- `POST /api/v1/auth/admin/add-sso-user` - Add one pending person who signs in only through the hosting organisation's identity provider (cloud-managed environments only)
- `POST /api/v1/auth/admin/import-sso-users` - Add pending people from a CSV or JSON rows (cloud-managed environments only; ≤500 rows, per-row results)

`ban-user`, `unban-user`, `add-sso-user` and `import-sso-users` answer without `plugins.admin` too.

#### Organization Membership (requires `plugins.organization`; platform-admin gated)

Expand Down
40 changes: 20 additions & 20 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
changed, only whether this instrument could see them.

**The expensive failure direction is a keyword.** Sites whose receiver the author
typed `any` have no type to read, and there are 62 of them — just over a fifth
typed `any` have no type to read, and there are 66 of them — just over a fifth
of the population, concentrated in exactly the seed and bootstrap paths this
control exists for. Scoring an unreadable receiver as "not an engine" would have
dropped every one of them silently, with a clean exit and a smaller number that
Expand Down Expand Up @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **59 of the 314 sites are spelled that way**. A
forwarding shim cannot, and **59 of the 320 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **314** |
| 175 write call sites | quoted in the merged changeset | **320** |
| 24 carrying no tenant context | quoted in the merged changeset | **3** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **219 of 314** decidable, **95** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 177 decidably elevated, 0 decidably not, 125 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **225 of 320** decidable, **95** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 180 decidably elevated, 0 decidably not, 128 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -210,18 +210,18 @@ be stated is what this instrument counts, which is written above and re-runnable
at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 62 sites reached
one, and both are counted in the generated tables below: the 66 sites reached
through an erased (`any`) receiver, and the 86 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
177 of 314 (56%) as decidably elevated, with 125 more whose elevation is a
180 of 320 (56%) as decidably elevated, with 128 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `3 / 314`, and say what it is**: the sites whose options argument was
⇒ **Cite `3 / 320`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All @@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **314** |
| …whose object name is statically decidable | 219 |
| write call sites on the application surface | **320** |
| …whose object name is statically decidable | 225 |
| …whose object name is chosen at run time | 95 |
| …against an object with tenancy ENABLED | 212 |
| …against an object with tenancy ENABLED | 218 |
| …against an object that declares tenancy off | 7 |
| threading a tenant context | 243 |
| threading a tenant context | 249 |
| PROVABLY carrying none (options read, no context key) | **12** |
| …of those, against a decidably tenancy-enabled object | **3** |
| options argument UNREADABLE — may or may not carry one | 59 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 177 |
| threading a decidably ELEVATED (`isSystem`) context | 180 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 125 |
| threading a context whose elevation is a run-time fact | 128 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 252 |
| receiver erased, placed by the object NAME | 52 |
| receiver carried a readable engine type | 254 |
| receiver erased, placed by the object NAME | 56 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 133 |
| object name spelled inline | 139 |
| object name spelled through a `const` | 86 |
| object name is an `object: string` parameter | 10 |
| object name is some other run-time expression | 85 |
Expand Down Expand Up @@ -307,12 +307,12 @@ 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-11-14 at `aad3132fa`.
Measured on 2026-11-14 at `d22170763`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 1160 |
| engine-shaped types recognised | 138 |
| tracked non-test sources scanned | 1165 |
| engine-shaped types recognised | 140 |
| declared objects in the registry | 137 |
| same-named calls subtracted as non-engine | 298 |

Expand Down
Loading
Loading