Skip to content

Commit 3335bd3

Browse files
os-warrenclaude
andauthored
docs(adr): ADR-0135 mirrors the open half of cloud ADR-0024 — identity & access architecture (#16478)
* docs(adr): ADR-0132 mirrors the open half of cloud ADR-0024 — identity & access architecture Records, in this repository, the mechanism half of `objectstack-ai/cloud` ADR-0024 (Accepted there 2026-06-25) whose code lives here: per-environment identity (D1), the two user populations (D2), the managed-vs-env-native source-of-truth marking (D4), identity-from-the-IdP with authorization decided in the environment (D5) and the SSO-era local user-management surface (D5.2), per-environment SSO with opt-in domain verification (D6), preserved portability and self-host (D7), and env-local users with better-auth-backed organization membership (D9). Every clause carries a symbol anchor into this tree. The commercial half — D3, D5.1, D8, D10 and the V1 roadmap — stays in cloud and is still cited as `cloud ADR-0024`; a table in the record says so, because the re-pointing of today's bare citations is per-site and semantic, not a search-and-replace. No decision is added or widened. Files are not moved and numbers are not reassigned, per the maintainer ruling of 2026-09-02. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(adr): renumber the cloud ADR-0024 mirror 0132 → 0135 (0132 is claimed by an open PR) `0132` is free on `main` but claimed on the branch of an open PR — #16215 adds `docs/adr/0132-multi-organization-runtime-is-open-core.md`. Verified here by a full `git diff --name-only origin/main...<pr-head> -- docs/adr/` over all 27 open PRs, not by titles: 0132 is #16215's, 0133 is #16267's, 0134 is #16476's, and 0135 is unclaimed. File renamed with `git mv`, slug unchanged. The record's one self-citation (its title line) moves with it; the `ADR-0105` and `ADR-0108` references are landed records and are untouched. No line-number anchors were introduced — the file still carries none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aefab7b commit 3335bd3

1 file changed

Lines changed: 272 additions & 0 deletions

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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

Comments
 (0)