Skip to content

Commit 9c1ca6d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-7823-session-token-internal
2 parents e2d139e + 03b5f81 commit 9c1ca6d

51 files changed

Lines changed: 6926 additions & 137 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): impersonation actually takes effect for bearer clients — rotate the caller's token, and let `stop-impersonating` recover the admin via bearer (#8243)
6+
7+
`POST /api/v1/auth/admin/impersonate-user` answered **HTTP 200 and did nothing**
8+
for every bearer-authenticated client — the console after every normal sign-in,
9+
and every deployment where cookies are blocked, which is the exact context
10+
better-auth's `bearer()` plugin exists for.
11+
12+
Two correct pieces of better-auth collided. `bearer()` authenticates a request by
13+
**overwriting the request's session cookie** with the bearer token. The admin
14+
plugin's impersonation route does the opposite: it mints the impersonation
15+
session and hands it over **as a cookie**, parking the admin's own session token
16+
in a signed `admin_session` cookie for the way back. A browser composes those
17+
two; a bearer client cannot. The client kept replaying its unchanged
18+
`Authorization: Bearer` header, that header kept being converted back into the
19+
**admin's** session, and the impersonation cookie was never read.
20+
21+
Nothing reported this. The endpoint returned success, an impersonation session
22+
row existed, and every subsequent request — including every write, since the
23+
framework's data routes resolve identity through the same seam — was attributed
24+
to the **admin** rather than the impersonated user.
25+
26+
**Impersonation now rotates the caller's credential.** When the caller
27+
authenticated with a bearer, the token it holds is invalidated as part of
28+
impersonating: a rotated admin session is minted, the caller is handed it as a
29+
recovery credential, and the original admin session is deleted. Afterwards the
30+
only token that resolves is the impersonated one better-auth already emits on
31+
`set-auth-token`. A client that adopts the rotation is the impersonated
32+
principal; a client that ignores it gets a loud 401 on its next request.
33+
"Impersonation succeeded but did not take effect" is no longer expressible.
34+
35+
Refusing bearer-authenticated impersonation was considered and rejected: it
36+
would leave cookie-blocked deployments unable to impersonate at all.
37+
38+
**The exit path ships with it.** `POST /admin/stop-impersonating` resolved the
39+
admin through the `admin_session` **cookie alone**, so it was dead in precisely
40+
the deployments this fix is about. The recovery credential is now emitted on a
41+
`set-admin-session-token` response header (exposed via
42+
`Access-Control-Expose-Headers`, alongside `set-auth-token`) and accepted back on
43+
an `x-admin-session-token` request header. Clients that already work through
44+
cookies need no change: a real `admin_session` cookie still wins, and the vendor
45+
route's own checks all still run — this adds a lane, it does not open one.
46+
47+
For API clients, the flow is the same one `set-auth-token` already asks for:
48+
read both headers off the impersonation response, send `Authorization: Bearer`
49+
with the new token, and send the recovery credential back on
50+
`x-admin-session-token` when leaving impersonation.
51+
52+
Unaffected: cookie-authenticated impersonation, which is unchanged byte for
53+
byte — a browser caller has no stale credential in hand to invalidate.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
fix(mcp): the stdio record resource honours the ADR-0049 `apiEnabled` / `apiMethods` exposure declaration (#8266)
6+
7+
An object that declares `enable.apiEnabled: false` — or narrows `enable.apiMethods`
8+
so a single-record read is outside the whitelist — was refused by the `get_record`
9+
tool and **still readable** through the ADR-0101 record resource
10+
(`objectstack://objects/{objectName}/records/{recordId}`): same transport, same key,
11+
same declaration, two answers.
12+
13+
**This is a surface-area declaration leak, not an authorization bypass.** The gate is
14+
a surface-area control by `api-exposure.ts`'s own ADR note, and this read passed the
15+
ObjectQL security middleware (CRUD / FLS / RLS) — under the key's `ExecutionContext`
16+
— before this change and after it. What was leaking is the author's *exposure
17+
declaration*, not the data guard.
18+
19+
Why the resource was missed when the tool was fixed: the six object-CRUD verbs all
20+
flow through `createStdioDataBridge`, which has been gated since #8083. The record
21+
resource does not use that bridge — its reader is a separate closure built inline in
22+
the plugin that calls `ql.find` directly and is handed to `bridgeResources`. Gating
23+
the bridge therefore never reached it. That reader now applies the same gate, taking
24+
its decision from the same single source of truth every other enforcement point
25+
delegates to (the spec's `resolveEffectiveApiMethods` / `isApiOperationAllowed`), so
26+
the three-state whitelist and the derived verbs resolve identically on all surfaces.
27+
28+
The gated action is `get`, matching what the HTTP path sends for a single-record
29+
read. Refusals carry the same machine codes the REST surface answers with —
30+
`OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` — and reach an MCP client
31+
as the resource's `{ "error": ... }` body, which is how that resource has always
32+
reported a failed read. The behaviours matched to the HTTP path in #8083 are matched
33+
here for the same reasons: a system context bypasses, and unresolvable metadata fails
34+
open to the schema defaults.
35+
36+
Unaffected: the object schema and object list resources stay ungated, because the
37+
HTTP bridge answers both straight off the metadata service — refusing a *schema* read
38+
here would be a fresh divergence pointing the other way. The remaining known
39+
divergences between the two MCP bridges (the protocol layer's ingress `readonly`
40+
strip, its existence probes, its spec-shaped receipts and `expand` / `select`) are
41+
unchanged and still filed as follow-up work.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): canonicalise `sys_member.role` at the write, so an org admin can no longer remove an owner (#8317)
6+
7+
**Security — authorization inversion.** A membership stored with a non-canonical
8+
role — `Owner`, `' owner'`, `OWNER` — was an **owner** to every ObjectStack-side
9+
check and a **plain member** to better-auth.
10+
11+
better-auth `1.7.0-rc.2` reads that column with a raw `role.split(",")`, with no
12+
`trim()` and no `toLowerCase()`, in three branches of
13+
`dist/plugins/organization/routes/crud-members.mjs`: `removeMember`'s "only an
14+
owner may remove an owner", `updateMemberRole`'s creator protection, and
15+
`organization/leave`'s last-owner count. ObjectStack's own readers all trim and
16+
lower-case (the #5942 grade ladder, `mapMembershipRole`). So on such a row the
17+
vendor never entered its owner branch at all and fell through to
18+
`hasPermission({ member: ['delete'] })` — **which an org admin passes**. An org
19+
admin could remove, demote, or count out an owner that every ObjectStack check
20+
treated as an owner.
21+
22+
Not reachable through the ordinary invite/accept path (better-auth's own writes
23+
are canonical). Reachable through anything else that writes the column: an
24+
operator SQL fix-up, a data import, a SCIM group mapping, a script.
25+
26+
**The fix normalises at the write**, so the disagreement is unrepresentable
27+
rather than adjudicated per reader:
28+
29+
- ObjectQL `beforeInsert` / `beforeUpdate` hooks on `sys_member` canonicalise
30+
`role` on every write path, in every context (system and better-auth adapter
31+
writes included — those are the paths this exists for). They run at priority
32+
5, ahead of the ADR-0092 identity write guard and the ADR-0024 D5.2
33+
break-glass guard, so both judge the value's normal form.
34+
- A **one-off convergent pass runs at boot** and canonicalises rows that already
35+
exist. It is idempotent, safe to re-run, and logs a census of every distinct
36+
non-canonical spelling it found with row counts.
37+
38+
Canonicalisation is per token: a token that is a membership role (ADR-0108's
39+
closed vocabulary) is trimmed and lower-cased; any other token is preserved
40+
verbatim apart from trimming, because `mapMembershipRole` passes an unknown
41+
value through with its case and it becomes a position name a permission set may
42+
be bound to. A value carrying no known role at all is left completely untouched
43+
and only reported — it cannot produce the inversion.
44+
45+
No API, schema or configuration change: `sys_member.role`'s option list is
46+
unchanged, and canonicalisation never moves a membership's grade, so no
47+
membership gains or loses authority as a result of this fix.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Stop putting caught driver text on the batch verbs' response payloads (#8333).
6+
7+
`publishPackageDrafts`, the publish side effects and their materializer, the seed
8+
apply, `duplicatePackage`, `revertCommit`, `rollbackToPackageCommit` and
9+
`migrateStoredMetadata` each reported a failure by copying the caught error's
10+
sentence onto a field of the response — `failed[].error`, `materializeApplied`'s
11+
`failures[].error`, `seedApplied.error`, `rows[].reason`. Those are DATA, not
12+
messages, so no HTTP boundary's 5xx message withhold ever reached them: a
13+
`sys_metadata` outage shipped `SQLITE_ERROR: no such table: sys_metadata` to the
14+
client on an otherwise successful-looking response.
15+
16+
All eight now follow the rule #8136 installed for the uninstall cluster: a caught
17+
sentence is quoted back only when that error **declared itself a client-facing
18+
refusal** (a 4xx `status` in the ADR-0112 envelope). Anything else gets a stable
19+
sentence and the original goes to the server log, which these sites did not
20+
previously write at all.
21+
22+
Authoring feedback is unaffected, and that was measured before anything changed
23+
rather than assumed. Every authored refusal reaching these collectors already
24+
declares 4xx, so it is still quoted verbatim — a failed package publish still
25+
tells the author which field of which draft is wrong, with its `code` and
26+
structured `issues` intact.
27+
28+
One producer needed declaring rather than converting: `applySeedBodies` parsed
29+
the seed request with `SeedLoaderRequestSchema.parse()`, so a malformed seed body
30+
surfaced as a raw `ZodError` that declared nothing. It is now a `safeParse` that
31+
raises a real `422 INVALID_METADATA` envelope, so `seedApplied.error` carries the
32+
same curated, path-pointing summary every other authoring surface produces
33+
instead of a multi-line dump of zod internals.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/rest": patch
4+
"@objectstack/lint": patch
5+
---
6+
7+
fix(rest,lint,spec): prune nav entries whose destination object cannot serve, and refuse them at authoring time (#7912)
8+
9+
A `type: 'object'` navigation entry pointing at an object that **cannot answer a
10+
list** was served to the client in the `/meta` payload anyway. The user saw a
11+
menu item that could not work, and the console rendered the failure as a generic
12+
empty state — so it read as *"you have no records"* rather than *"this page
13+
cannot work"*.
14+
15+
Two independent conditions make a destination unservable, and **neither was
16+
expressible on a nav entry**:
17+
18+
- `enable.apiEnabled: false` → the list answers `OBJECT_API_DISABLED` (404);
19+
- an `enable.apiMethods` whitelist without `list` → `OBJECT_API_METHOD_NOT_ALLOWED` (405).
20+
21+
Both are pure functions of the object's own `enable` block — no user, no
22+
permissions, no request context — so the destination is dead for **every**
23+
persona, platform administrator included. That is why a `requiredPermissions`
24+
gate could never prune such an entry: the two are independent conditions, and no
25+
combination of permissions on the *entry* rescues an entry whose *object* is
26+
API-disabled. One shipped that way for a year and read as correct to reviewers,
27+
its in-code comment claiming a non-admin "403s server-side" — which implies an
28+
admin could list. None could.
29+
30+
**The fact is now derived, not declared.** `filterAppForUser` consults the
31+
destination's `enable` block for every `type: 'object'` entry and drops the ones
32+
that cannot serve, on both the app-list and the by-name `/meta` routes and
33+
inside `children` and `areas[]` alike. No new authorable key was minted: the
34+
platform already knows this, on the object, in one place.
35+
36+
**And the prune is never silent.** A prune the author cannot see is the same
37+
failure one layer over, so it is refused at authoring time: `os validate` /
38+
`os build` / `os lint` now **fail** with `nav-object-unservable`, naming the
39+
entry, the object, the offending `enable` key path and which of the two
40+
conditions fired. The serving side logs the same facts for an entry that reaches
41+
a running deployment anyway.
42+
43+
The single two-step order these consumers share — `apiEnabled` first and
44+
independently, the whitelist second — is now declared once as
45+
`apiExposureDenialReason` / `canServeApiOperation` in `@objectstack/spec/data`,
46+
beside the `resolveEffectiveApiMethods` / `isApiOperationAllowed` primitives it
47+
composes. The REST data gate, the nav prune and the authoring rule all read that
48+
one export instead of re-spelling the order.
49+
50+
**Deliberately unchanged:**
51+
52+
- `requiresObject` keeps its client-only evaluation. It asks whether an object
53+
is *registered*; this gate asks whether a registered object's `enable` block
54+
lets it answer. An entry whose object this layer cannot find is **served**,
55+
not pruned.
56+
- `visible` (CEL) is still client-side only.
57+
- Fail-open throughout: unreadable object metadata prunes nothing, so a cold
58+
start or a metadata outage cannot empty a healthy deployment's sidebar.
59+
- Objects an authoring stack does not itself declare are not judged by the lint
60+
rule — their `enable` block is not visible from there.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": patch
4+
"@objectstack/driver-turso": patch
5+
"@objectstack/plugin-security": patch
6+
"@objectstack/service-analytics": patch
7+
---
8+
9+
feat(spec): filter-subtree provenance — the cross-field refusal names an author's own columns again, without re-disclosing policy (#8220, A of the #7929 ruling)
10+
11+
#8198 (B of the 2026-08-12 #7929 ruling) made the SQL family's cross-field
12+
`{ $field }` refusal withhold its operands from **every** caller, because the
13+
predicate reached the driver as a bare `FilterCondition`: an administrator's
14+
CEL sharing/permission rule and the author's own filter were indistinguishable
15+
there. The accepted, named cost was the author's diagnostic. This change is A
16+
— the sanctioned follow-up that pays it back behind a real mark instead of a
17+
guess.
18+
19+
**The mark** (`@objectstack/spec/data`, `filter-subtree-provenance.ts`) is a
20+
spec-declared symbol on a filter subtree: `markFilterSubtreeProvenance(subtree,
21+
'author' | 'policy')`, read positionally by
22+
`resolveFilterSubtreeProvenance(root, node)` (innermost mark on the ancestor
23+
chain wins; located by object identity, never structural equality). It rides
24+
the `where` tree by reference across the `DriverQuery` boundary — no new slot,
25+
documented on `DriverQuery` itself — and is dropped by exactly the operations
26+
(serialize, copy, rewrite) after which no attestation could be trusted.
27+
28+
**Set at both read-scope merge boundaries**: `plugin-security`'s CRUD RLS
29+
injection marks every injected scope `'policy'` and the caller's verbatim
30+
predicate `'author'` — the latter only under the identity vouch
31+
`ast.where === options.where`, so a tree a sibling middleware already rewrote
32+
is vouched for nobody. `service-analytics`' `ObjectQLStrategy.withReadScope`
33+
marks its scope `'policy'` and the strategy-built user filter `'author'` (and
34+
`resolveFkAttr`'s scope arm `'policy'`).
35+
36+
**Consumed by the SQL family** (`driver-sql`, `driver-turso`'s
37+
`RemoteTransport`; `driver-sqlite-wasm` inherits): a refusal raised from a
38+
subtree positively marked `'author'` carries its full diagnostic on the wire
39+
again — both columns, the operator, the list index, the boundary reason —
40+
same identity (`INVALID_FILTER` / 400).
41+
42+
**⚠️ The fail direction is closed, and it is the design**: unmarked or
43+
ambiguous — no mark anywhere, a mark lost to serialization, a node
44+
unreachable from the query's own `where`, conflicting aliased marks —
45+
withholds exactly like `'policy'`. The mark is permission to reveal, never a
46+
requirement to prove secrecy; a driver-side guess at provenance is the shape
47+
the #7929 triage rejected.
48+
49+
**Two B-era pins were REWRITTEN deliberately, not weakened.** First,
50+
`service-analytics`' `cross-field-engine-fallback.test.ts` pinned B's blanket
51+
redaction on refusals of the caller's OWN `where` (no scope in play) — under A
52+
that caller is the vouched author, so those cases now assert the corpus's
53+
`diagnosticIncludes` fragments are back on the wire, while the
54+
policy-injected-scope case gains the explicit non-disclosure assertions as its
55+
fail-closed pair. Second, the sharper one:
56+
`packages/runtime/src/cross-field-refusal-operand-withhold.test.ts` pinned
57+
author-written and policy-injected refusals **byte-identical** — the strongest
58+
available statement of "the driver cannot tell them apart", and explicitly the
59+
assertion A was chartered to supersede. Its successor pins the three-way split
60+
#8220's "Done means" names: policy-injected withholds (unchanged), the vouched
61+
author's filter names its columns again (the messages now differ, by design),
62+
and an unmarked predicate still withholds **byte-identical to the policy
63+
case** — B's surviving half. Reading that diff as a regression is exactly what
64+
the old pin's comment warned against; the file header carries the full
65+
account.
66+
67+
Unaffected: the REST boundary's 5xx-only withhold (#5367/#5667) and every
68+
refusal outside the cross-field family.

0 commit comments

Comments
 (0)