|
| 1 | +# ADR-0135: Identity and access architecture — the open half, mirrored from cloud ADR-0024 |
| 2 | + |
| 3 | +**Status**: The DECISION is Accepted — it is `objectstack-ai/cloud` ADR-0024, accepted there on |
| 4 | +2026-06-25, and its mechanism half is implemented in this repository today. ⚠️ This **file** is a |
| 5 | +mirror awaiting the maintainer's hand-merge (`docs/adr/**` is a governed surface, AGENTS.md Prime |
| 6 | +Directive #14), so merging it settles the RECORD, not the decision. ⛔ Nothing below is decided |
| 7 | +here: a clause that cloud ADR-0024 did not decide is not decided by this file either. |
| 8 | +**Decided**: 2026-06-25, by the founder, in `objectstack-ai/cloud` |
| 9 | +`docs/adr/0024-identity-and-access-architecture.md` (Status: Accepted there). |
| 10 | +**Mirrored**: 2026-09-07, under the maintainer's ruling of 2026-09-02 on |
| 11 | +[#14496](https://github.com/objectstack-ai/objectstack/issues/14496) — verbatim and untranslated, |
| 12 | +「ok」 to option 2: mirror the open half, ⛔ do not move files, ⛔ do not renumber. Recorded by |
| 13 | +[#14506](https://github.com/objectstack-ai/objectstack/issues/14506). |
| 14 | +**Shape**: [ADR-0079](./0079-record-display-name.md) — the same cross-repo split, the same |
| 15 | +Provenance-section discipline. |
| 16 | +**Builds on**: [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (the one |
| 17 | +platform-admin derivation this record's D5 leans on), [ADR-0092](./0092-sys-user-profile-field-delegation.md) |
| 18 | +(the identity write guard that keeps the generic data path out of a better-auth table), |
| 19 | +[ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) and |
| 20 | +[ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) (membership lifecycle and |
| 21 | +org scope), [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) and |
| 22 | +[ADR-0108](./0108-membership-grade-is-not-a-capability-channel.md) (authorization is derived in |
| 23 | +the environment, and a membership grade is not a capability channel). |
| 24 | +**Consumers**: `@objectstack/plugin-auth` (the whole package), `@objectstack/platform-objects` |
| 25 | +(the `identity/` object set), `@objectstack/core` (`security/resolve-authz-context.ts`). |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Provenance — read this before citing this file |
| 30 | + |
| 31 | +**This file records, in this repository, decisions taken in `objectstack-ai/cloud` ADR-0024 on |
| 32 | +2026-06-25.** The cloud record is the original. It is not superseded, not moved and not |
| 33 | +renumbered; it keeps the commercial half of the same decision, and where this file and the cloud |
| 34 | +record differ, **the cloud record is the decision and this file is the bug** — say so in an issue |
| 35 | +and this file gets corrected. |
| 36 | + |
| 37 | +Three facts make that disclosure load-bearing rather than decorative. |
| 38 | + |
| 39 | +1. **Why the record is split at all.** AGENTS.md Prime Directive #13 states the rule this file |
| 40 | + obeys: *an ADR lives in the repository whose code it governs*; decisions that draw the |
| 41 | + open/closed or commercial boundary live in `cloud`, and when such a decision's **mechanism |
| 42 | + half** governs open code here, this repository carries its own ADR with its own number, a |
| 43 | + Provenance section naming the cloud record and its date, and the commercial half left where it |
| 44 | + was. That rule was itself adopted by the 2026-09-02 ruling cited above. |
| 45 | + |
| 46 | +2. **This file was written from the cloud record's decision list and from the code that |
| 47 | + implements it**, by a seat that could not open the `cloud` repository. What is quoted here is |
| 48 | + quoted from *this* tree; what is attributed to cloud ADR-0024 is attributed at the granularity |
| 49 | + the ruling's execution card carried (its decision letters and their headings), never at |
| 50 | + sentence granularity. Every mechanism claim below carries a symbol anchor into this tree, so a |
| 51 | + reader can check the mechanism half without cloud access. The attribution half is what cloud |
| 52 | + access would check. |
| 53 | + |
| 54 | +3. **⚠️ A bare `ADR-0024` in this repository's code does NOT resolve to this record — it never |
| 55 | + did, and this file does not change that.** `docs/adr/0024-mcp-connectors.md` is a real, |
| 56 | + unrelated record in this registry, and the two numbering series are independent, which is |
| 57 | + exactly why AGENTS.md forbids the bare form and requires `cloud ADR-NNNN`. The same trap sits |
| 58 | + one number away: cloud ADR-0071 is the env-side SCIM mechanism, while this registry's `0071` |
| 59 | + is the dataset semantic-layer depth record. Re-pointing the identity surface's existing bare |
| 60 | + citations at this record is |
| 61 | + [#14361](https://github.com/objectstack-ai/objectstack/issues/14361)'s job and ⛔ was |
| 62 | + deliberately not done in the change that added this file: on the commit that introduced it, |
| 63 | + `git grep -n -E "ADR-0024" -- packages/plugins/plugin-auth/src packages/platform-objects/src/identity` |
| 64 | + minus the `cloud ADR-0024` spellings still measured **64** citing lines. |
| 65 | + |
| 66 | +--- |
| 67 | + |
| 68 | +## Context |
| 69 | + |
| 70 | +An ObjectStack environment is a running system with its own database, its own metadata and its |
| 71 | +own users. Two questions had to be answered together, and answering either one alone produces an |
| 72 | +architecture that cannot hold: **where does an identity come from**, and **where is the decision |
| 73 | +made about what that identity may do**. |
| 74 | + |
| 75 | +The tempting answer for a multi-environment product is one central directory: users sign in once, |
| 76 | +the platform knows everybody, environments ask it. That answer forecloses the two properties this |
| 77 | +platform is built to keep — an environment that can be self-hosted with no vendor in the loop, |
| 78 | +and a customer whose employees already live in the customer's own identity provider. cloud |
| 79 | +ADR-0024 chose the other direction, and the mechanism half of that choice is what this repository |
| 80 | +implements: authentication runs *in* the environment, federation is something the environment's |
| 81 | +operator configures, and authorization is never delegated at all. |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +## Decision |
| 86 | + |
| 87 | +Each clause below is cloud ADR-0024's, restated in this repository's words, with the code that |
| 88 | +enforces it. The lettering is the cloud record's, kept so that a citation of `cloud ADR-0024 D5.2` |
| 89 | +and a citation of this record's D5.2 name the same clause. |
| 90 | + |
| 91 | +### D1 — Identity is per-environment, and never centralised |
| 92 | + |
| 93 | +Every environment authenticates its own users. The authentication stack runs **inside** the |
| 94 | +environment process; there is no central authentication service that an environment must reach in |
| 95 | +order to sign a user in. An environment cut off from every other ObjectStack deployment still |
| 96 | +works. |
| 97 | + |
| 98 | +The mechanism is better-auth, mounted by the environment's own plugin |
| 99 | +(`packages/plugins/plugin-auth/src/auth-plugin.ts#AuthPlugin`) and built in-process |
| 100 | +(`packages/plugins/plugin-auth/src/auth-manager.ts#AuthManager`). It persists through the |
| 101 | +environment's **own** data engine rather than any remote store: the vendor's model names are |
| 102 | +bridged onto this platform's system objects by |
| 103 | +`packages/plugins/plugin-auth/src/objectql-adapter.ts#AUTH_MODEL_TO_PROTOCOL`, so the directory is |
| 104 | +a set of tables in the environment's database. |
| 105 | + |
| 106 | +### D2 — Two user populations, two sources of truth |
| 107 | + |
| 108 | +The people who reach an environment are not one population. Some identities are **owned by an |
| 109 | +external directory** — an identity provider the operator registered; others are **the |
| 110 | +environment's own**. Each population keeps its own source of truth, and the platform does not |
| 111 | +collapse the two into a single authority: the environment's tables hold a row for both, but for a |
| 112 | +managed identity that row is a mirror, and the directory that owns it remains the authority for |
| 113 | +the facts it owns. |
| 114 | + |
| 115 | +The distinction is a first-class column, not an inference: |
| 116 | +`packages/platform-objects/src/identity/sys-user.object.ts#source` carries exactly two values, |
| 117 | +`idp_provisioned` and `env_native`, and its declaration states the split it exists for — |
| 118 | +federated-SSO JIT provisioning on one side, local signup and app end-users on the other. The |
| 119 | +provider links that justify a value live in |
| 120 | +`packages/platform-objects/src/identity/sys-account.object.ts`. |
| 121 | + |
| 122 | +### D4 — Source-of-truth marking: managed vs env-native |
| 123 | + |
| 124 | +Which side of D2 a row is on must be **marked**, and the marking must be produced by the system |
| 125 | +rather than typed by an operator, because everything downstream gates on it. |
| 126 | + |
| 127 | +Two markings do that work, at two levels: |
| 128 | + |
| 129 | +- **The table** is marked as owned by the auth vendor — |
| 130 | + `packages/platform-objects/src/identity/sys-user.object.ts#managedBy`, |
| 131 | + `packages/platform-objects/src/identity/sys-member.object.ts#managedBy`, |
| 132 | + `packages/platform-objects/src/identity/sys-sso-provider.object.ts#managedBy` all declare |
| 133 | + `better-auth`. Under [ADR-0092](./0092-sys-user-profile-field-delegation.md) that declaration is |
| 134 | + what makes the generic data path fail-closed on those tables. |
| 135 | +- **The row** is marked with its provenance — `sys_user.source`, stamped automatically as accounts |
| 136 | + are linked and never edited by hand. Two writers keep it true, because the two creation paths do |
| 137 | + not share a seam: better-auth's `account.create.after` hook in |
| 138 | + `packages/plugins/plugin-auth/src/auth-manager.ts`, and an ObjectQL `afterInsert` hook on |
| 139 | + `sys_account` in `packages/plugins/plugin-auth/src/auth-plugin.ts` for adapter-level creates |
| 140 | + that bypass the vendor hook. Both are idempotent, both fail open on the write, and both log |
| 141 | + loudly when the stamp does not land — a row that silently keeps the wrong `source` is how a |
| 142 | + managed user is offered the local-password action D5.2 exists to hide. |
| 143 | + |
| 144 | +### D5 — Identity comes from the IdP; authorization is decided in the environment |
| 145 | + |
| 146 | +Authentication may be delegated. **Authorization never is.** What a signed-in principal may do is |
| 147 | +derived from the environment's own grant tables at the moment it is asked, from evidence the |
| 148 | +environment stores — never from a claim the identity provider asserted, and never from a role |
| 149 | +string carried in on a token. |
| 150 | + |
| 151 | +`packages/core/src/security/resolve-authz-context.ts#resolveUserAuthzGrants` is that derivation, |
| 152 | +and `#hasPlatformAdminStanding` is its id-shaped projection: platform standing is an unscoped, |
| 153 | +in-window `sys_user_permission_set` grant of `admin_full_access`, held now — the ADR-0068 D2 |
| 154 | +definition, read from this environment's tables. Organization grade is read the same way, from |
| 155 | +`sys_member`, through the one predicate every consumer asks |
| 156 | +(`packages/plugins/plugin-auth/src/invitation-role-cap.ts#isOrgAdminGrade`), and |
| 157 | +`packages/plugins/plugin-auth/src/member-role-canonical.ts#registerMemberRoleCanonicalization` |
| 158 | +normalises the stored spelling before any guard judges it. |
| 159 | + |
| 160 | +### D5.2 — The local user-management surface under SSO, split by population |
| 161 | + |
| 162 | +An environment that has adopted SSO still needs a user-management surface, and that surface is |
| 163 | +**split by the D2/D4 population**, not switched off wholesale: |
| 164 | + |
| 165 | +- **Managed identities hold no local credential**, so the actions that would mint or change one |
| 166 | + are hidden for them rather than merely failing: |
| 167 | + `packages/platform-objects/src/identity/sys-user.object.ts#change_my_password` and |
| 168 | + `#change_my_email` are visible only when the row's `source` is not `idp_provisioned`. The point |
| 169 | + is not tidiness — a managed user who could self-mint a password would have a route around |
| 170 | + enforced SSO. |
| 171 | +- **Break-glass keeps a local credential reachable.** An environment-native owner, or an |
| 172 | + SSO-onboarded user setting an *initial* password, goes through |
| 173 | + `packages/plugins/plugin-auth/src/set-initial-password.ts#runSetInitialPassword`; the admin-side |
| 174 | + equivalent is `packages/platform-objects/src/identity/sys-user.object.ts#set_user_password`. |
| 175 | + Gaining a local credential flips the row back to `env_native` (D4's stamp), so the owner never |
| 176 | + loses self-service password management. |
| 177 | +- **An environment may never be left with zero administrators who can sign in.** |
| 178 | + `packages/plugins/plugin-auth/src/last-admin-guard.ts#registerLastAdminGuard` holds that |
| 179 | + invariant across every write shape that could take the last administrator away — a ban, a row |
| 180 | + delete, and the revocations that leave the user row untouched — and it refuses for **every** |
| 181 | + context, `isSystem` included, because the paths that actually lock an organization out are the |
| 182 | + system ones. |
| 183 | + |
| 184 | +### D6 — SSO per production environment, configured in the environment |
| 185 | + |
| 186 | +A production environment federates login to the customer's own identity provider, and that |
| 187 | +federation is **configured in the environment** by its operator — not provisioned centrally. |
| 188 | + |
| 189 | +`packages/platform-objects/src/identity/sys-sso-provider.object.ts#SysSsoProvider` is the |
| 190 | +registered-provider table, backed by `@better-auth/sso`, env-global and admin-only. Every mutation |
| 191 | +routes through the vendor's own endpoints rather than the generic data layer, so config validation |
| 192 | +and secret handling run: `packages/plugins/plugin-auth/src/register-sso-provider.ts`, with the |
| 193 | +model bridged at the adapter layer (`packages/plugins/plugin-auth/src/auth-schema-config.ts` |
| 194 | +records why the bridge sits there and not on the plugin's `schema` option). |
| 195 | + |
| 196 | +**Domain verification is opt-in** — the clause this repository's code cites as `ADR-0024 ②`. When |
| 197 | +the environment turns it on, `@better-auth/sso` mounts a DNS-TXT proof-of-ownership challenge and |
| 198 | +refuses a login through a provider whose email domain is not proven, which stops an organization |
| 199 | +admin from registering a provider for a domain they do not control. It is off by default, because |
| 200 | +turning it on changes the register-then-login flow. The surface is |
| 201 | +`packages/platform-objects/src/identity/sys-sso-provider.object.ts#request_domain_verification`, |
| 202 | +`#verify_domain` and `#domain_verified`. |
| 203 | + |
| 204 | +⚠️ **The SCIM half of "SSO + SCIM per production environment" is not restated here.** Its |
| 205 | +mechanism record is `cloud ADR-0071`, mirrored into this repository by its own card; this file |
| 206 | +records only that the same environment-side posture applies to it — the SCIM models are bridged |
| 207 | +into the environment's tables by the same adapter map D1 names, and the D5.2 guard judges a SCIM |
| 208 | +deprovision exactly as it judges an admin one. |
| 209 | + |
| 210 | +### D7 — Portability and self-host are preserved |
| 211 | + |
| 212 | +Nothing above requires the vendor's cloud. Every mechanism in D1–D6 is in this repository under |
| 213 | +Apache-2.0, and each optional piece is switched on by an environment variable in a self-hosted |
| 214 | +deployment — `OS_SSO_ENABLED` for the external-IdP relying party, |
| 215 | +`OS_SSO_DOMAIN_VERIFICATION` for D6's opt-in check — resolved in |
| 216 | +`packages/plugins/plugin-auth/src/auth-manager.ts`. A self-hosted environment therefore reaches |
| 217 | +the same identity architecture as a hosted one; what a hosted environment adds is entitlement and |
| 218 | +lifecycle, which is cloud ADR-0024's half. |
| 219 | + |
| 220 | +### D9 — Environment users live in the environment; organization membership goes through better-auth |
| 221 | + |
| 222 | +A user of an environment is a row in that environment's `sys_user`, and their membership of an |
| 223 | +organization is a row in that environment's `sys_member` — backed by better-auth's organization |
| 224 | +plugin (`packages/platform-objects/src/identity/sys-member.object.ts#SysMember`), reached through |
| 225 | +the same adapter bridge as every other identity model |
| 226 | +(`packages/plugins/plugin-auth/src/objectql-adapter.ts#AUTH_MODEL_TO_PROTOCOL`). Membership is |
| 227 | +therefore maintained by the auth stack's own endpoints, and read — never re-spelled — by the |
| 228 | +guards that judge administrative standing. |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +## What stays in `cloud` ADR-0024 |
| 233 | + |
| 234 | +These clauses are the same decision's commercial half. They are **not** recorded here, and code in |
| 235 | +this repository that means one of them must keep citing `cloud ADR-0024`: |
| 236 | + |
| 237 | +| Clause | Subject | |
| 238 | +|---|---| |
| 239 | +| D3 | cloud-as-IdP hub | |
| 240 | +| D5.1 | the cloud operator-portal membership gate | |
| 241 | +| D8 | billing | |
| 242 | +| D10 | production/development metering and population lifecycle | |
| 243 | +| V1 | the roadmap and the commercial framing | |
| 244 | + |
| 245 | +⚠️ Consequence for [#14361](https://github.com/objectstack-ai/objectstack/issues/14361): a bare |
| 246 | +`ADR-0024` citation in this tree is **not** mechanically re-pointable at this record. Some of |
| 247 | +today's citations mean a clause above — `packages/plugins/plugin-auth/src/auth-manager.ts` cites |
| 248 | +`ADR-0024 V1` for the SSO default-role provisioning — and those keep the `cloud ADR-0024` |
| 249 | +spelling. The re-pointing is per-site and semantic. |
| 250 | + |
| 251 | +## What this record does NOT settle |
| 252 | + |
| 253 | +- **Anything cloud ADR-0024 did not decide.** This file adds no clause. Where the mechanism in |
| 254 | + this tree is richer than the decision (the ADR-0092 write guard, the ADR-0095 tenant wall, the |
| 255 | + ADR-0091 validity window), that richness belongs to those records, and is cited here only as the |
| 256 | + thing D4 and D5 lean on. |
| 257 | +- **The attribution itself, against the cloud original.** See Provenance point 2: this file was |
| 258 | + written without cloud access. A difference between it and cloud ADR-0024 is this file's bug. |
| 259 | +- **The SCIM mechanism** — `cloud ADR-0071` and its own mirror record. |
| 260 | +- **Whether any individual bare `ADR-0024` citation should move.** That is #14361's per-site call, |
| 261 | + and the table above is why it cannot be a search-and-replace. |
| 262 | + |
| 263 | +## Consequences |
| 264 | + |
| 265 | +- A reader who follows an `ADR-0024` citation out of `plugin-auth` or `platform-objects/identity` |
| 266 | + now has somewhere in *this* registry to land — once #14361 re-points the citations that mean the |
| 267 | + open half. Until then the citation still resolves to `docs/adr/0024-mcp-connectors.md`, which is |
| 268 | + the defect this record is a precondition for fixing, not one it fixes by itself. |
| 269 | +- The open half of the identity architecture becomes reviewable by anyone who can read this |
| 270 | + repository, including a self-hosting customer who has no access to `cloud`. |
| 271 | +- `cloud` ADR-0024 gains a one-line pointer naming this record's number — filed as a `cloud` chore |
| 272 | + by the seat that accepts this PR, since the number is only knowable once this file merges. |
0 commit comments