Skip to content

Commit 131b937

Browse files
fix(plugin-auth, runtime): four principal-less producers take the explicit system opt-in (#21939)
Fixes #21912 Clause-②: no This PR is a slice of #21908, the closure of the security middleware's principal-less hand-off (ADR-0096). It covers the identity and runtime producers. #21908 stays open for the deny, which lands last. ## What moved Each producer below reached the data engine with no principal and no `isSystem`. That is the hand-off, and it is not an authorization. Each one now takes the explicit system opt-in that already exists. ⛔ No new elevation API, no change to what any door authorizes, no accept-set change. | Row | Position (function) | Engine calls | Route taken | |---|---|---|---| | 17 | `plugin-auth` `auth-plugin.ts`, the platform-admin OAuth client toggle route (`/admin/oauth2/toggle-disabled`) | `findOne` + `update` `sys_oauth_application` | `withSystemContext`, the wrapper better-auth's adapter already writes these rows through | | 18 | `plugin-auth` `scim-connection-service.ts` `verifyScimBearerToken` | `findOne` `sys_scim_connection_credential` | `isSystem: true` in the read's trailing options | | 19 | `plugin-auth` `auth-manager.ts` `organizationHooks.beforeUpdateOrganization` (the slug guard) | `findOne` `sys_organization`, `find` `sys_environment` | `withSystemContext` | | 21 | `runtime` `http-dispatcher.ts` `enforceProjectMembership` | `find` `sys_environment_member` | `isSystem: true` as the read's query context | **Row 20 moves nothing.** I read every site, and each one already runs with the opt-in: - `adopt-membership.ts` `adoptExistingMembership`: its only caller hands it the adapter's `withSystemContext` engine. - `membership-ended-session.ts` `endSessionClaimsForEndedMembership`: all four calls pass `{ context: SYSTEM_CTX }` (`isSystem: true`) as the trailing options, and the engine honours that argument on reads and writes. - The `auth-manager.ts` insert helper (`settleSelfRegistrationGrant`, with `findPermissionSetRows`): it reads and writes through `withSystemReadContext`, the deprecated alias of `withSystemContext`. The `auth-manager.ts` edit (row 19) was made after #21872 landed, on a merge of `origin/main` that contains it. ## Measured: no gate fires on any moved call today An `isSystem` context short-circuits the gates the hand-off still runs before `next()`: package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-administration. A move is neutral only if none of them fires on the producer's calls. - **Static.** Each gate is keyed to objects and verbs that none of these calls touch. The first four guard writes to `sys_permission_set`, `sys_position`, `sys_capability` and `sys_position_permission_set`. Engine-owned needs a `userId`. Delegated-administration guards writes to the RBAC link tables, `sys_permission_set` and `sys_member`. Rows 18, 19 and 21 are reads. Row 17 writes `sys_oauth_application`, which none of the gates names. - **Instrumented.** I added a local, uncommitted probe in `security-plugin.ts`. It recorded each principal-less, non-system context that reached the hand-off, with its stack, and each gate refusal of such a context. Over every run below it recorded **0 gate refusals**. Every call of the card's functions reached the hand-off, so no gate had stopped it. Per function, before → after (records at the hand-off): | Function | dogfood subset | dev boot | runtime harness | |---|---|---|---| | toggle route (row 17) | 5 → 0 (`findOne` 3, `update` 2) | 5 → 0 | — | | `verifyScimBearerToken` (row 18) | 0 → 0 | 1 → 0 | — | | `beforeUpdateOrganization` (row 19) | 0 → 0 | 1 → 0 (`sys_organization` `findOne`) | — | | `enforceProjectMembership` (row 21) | — | — | 2 → 0 | | row 20 functions | 0 → 0 | 0 → 0 | 0 → 0 | | all records | 1601 → 1596 | 300 → 293 | 39 → 37 | Before = the base tree with the probe. After = the change with the probe: the dev boot and the harness on the final tree (`9878b925`), and the dogfood subset on the pre-merge commit `4239dd47`, whose row 17 code is the same. The boot's background ticks (the outbox claims) make the totals differ by a few records between runs. The per-function counts are the reading. - **Dogfood subset.** Seven files that reach the card's functions: the two platform-admin route sweeps, the organization-update door, the two SCIM-enabled suites, org-admin reach and membership attribution. 57 tests passed both times. Only row 17 appears in the dogfood suite. This subset reproduces the full-suite census of the measure-first round (`6003676228`) for these rows exactly. - **Dev boot.** `pnpm dev -- --fresh` on showcase with SCIM enabled, driven as the seeded admin. It registers an OAuth client, toggles it twice, toggles a missing id, sends a SCIM request with an unknown bearer, and changes the default organization's slug. The answers were identical before and after: register 201, toggles 200 / 200 / 404, SCIM 401, slug update 200. - **Runtime harness.** A scratch file, deleted afterwards, booted a real engine with `SecurityPlugin` and called `enforceProjectMembership` for a member and a non-member. No open-source composition reaches row 21: no `KernelResolver` sets `environmentId`, and `sys_environment_member` is a cloud control-plane object. The answers were `null` and 403 both times. - **Restored.** The probe was reverted (`security-plugin.ts` blob `5b4ab280` equals HEAD), `plugin-security` was rebuilt, and `ablation-dist-preflight --absent` confirms the marker is gone from `dist/`. The positive control: 4 hits in `dist/` while the probe was live. **One difference that is not a gate (row 17).** Under the hand-off, the engine's static read-only strip ran on the toggle's `update` and dropped the `updated_at` the route supplies, with a WARN. Under `isSystem` the strip does not run. I compared the stored rows: on the SQL driver, both paths store `disabled` and an `updated_at` equal to the driver's own stamp. The only change is that the WARN line no longer appears on each toggle. ## Pins (one per package) and ablations - `plugin-auth/src/principal-less-producers-system-context.test.ts`: a real engine with a context-recording middleware. The toggle route's `findOne` and `update` and the SCIM probe's `findOne` are `isSystem`. The route still answers 200 and flips the stored flag, and still answers 404 `RESOURCE_NOT_FOUND`. The verifier still resolves a known bearer to its connection, and still answers `null` for an unknown one. - `plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts`: both slug-guard reads are `isSystem`, and the guard still refuses with `FORBIDDEN` / 403 while an active environment exists. **On an engine that refuses a principal-less, non-system context, the guard still refuses.** The pre-existing catches are pinned as they stand: a read that throws ends the hook without refusing. - `runtime/src/http-dispatcher.membership-system-context.test.ts`: the membership read is `isSystem`, a member passes, and a non-member gets 403 `PROJECT_MEMBERSHIP_REQUIRED`. **On an engine that refuses a principal-less, non-system context, the non-member is still refused.** The pre-existing fail-open catch is pinned as it stands: a read that throws lets the request through. - **Ablations.** Each went through `scripts/ablation-replace.mjs`: the anchor hit once, the mutation was verified on disk, and the restore was proven (blob equals HEAD, `git diff HEAD` empty). Each pin imports its subject from `src`, so no build sat between the mutation and the run. - A, row 17, `withSystemContext` dropped: 2 red. - B, row 18, trailing context dropped: 2 red. - C, row 21, query context dropped (re-run on `9878b925`): 3 red, including the refusing-engine non-member case. - D, row 19, `withSystemContext` dropped: 2 red, including the refusing-engine case. ## Tests and gates (at `9878b925`) - New pins, on `9878b925`: plugin-auth 2 files, 9/9 passed. runtime 1 file, 5/5 passed. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` on `9878b925`: 330 files, 4654 passed, 19 skipped. `pnpm --filter @objectstack/runtime run typecheck`: exit 0. - plugin-auth on `60c5f22c`: the suite (126 files, 2607 passed, 10 skipped) and `run typecheck` (exit 0). The only commit since, `9878b925`, touches `runtime` and the changeset, and plugin-auth imports neither. - **The runtime suite caught a spelling of mine.** The membership read first carried its context as a trailing third argument. Eight existing assertions read the read's two arguments: `toHaveBeenCalledWith` in `http-dispatcher.test.ts` and `http-dispatcher.membership-skip-boundary.test.ts`. They turned red. The context now rides inside the query instead. The opt-in is the same and so is the engine's reading (ObjectQL merges the two), and both suites pass unedited. - `eslint --no-inline-config` over the 7 changed `.ts` files: 7 files, 0 errors, 0 warnings. These 7 are the whole population whose lint verdict this diff can move. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so no untouched file's verdict can change. The repo-wide `pnpm lint` is CI's. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `9878b925` derived 98 commands. All 98 ran and exited 0. The `--ran` reconciliation over the exit-coded record reads: "98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)". The same 98 also ran green on `60c5f22c`. - The branch sits 3 commits behind `origin/main` (`9dce6353`). Those commits touch `content/docs/permissions/sso.mdx` and a `rest` test, none of this diff's files. #21902 is merged into `main` and contained in this branch. ## Acceptance notes - **The fail-open catches on rows 19 and 21 are unchanged.** They are pre-existing, and row 21's is documented as deferred. This PR removes the path by which a principal-less deny would trip them: both reads are now `isSystem`. A read that throws for any other reason still skips the slug guard (row 19) or opens the membership gate (row 21). Both behaviours are pinned as they stand, so the seat can sequence them before the deny. - **Row 19 in the open-source composition.** `sys_environment` is not registered there, so the environment read throws before it reaches the engine middleware. The catch then ends the hook, and the slug guard never refuses in an open-source deployment. It acts only where the object exists. Measured on the dev boot: the slug change answered 200 and recorded no environment read at the hand-off. - **NOT MEASURED: a cloud composition.** An `isSystem` read also bypasses any host read hook keyed on the caller, such as a control-plane org-scope hook. I measured the six named gates only, and only in-repo. - `mintScimConnectionCredential` inserts without the opt-in. It has no runtime caller (tests only) and is not exported from the package entry, so nothing produces through it today. Noted, not changed. - The census page (`content/docs/permissions/system-context.mdx`) is current. `--fix` moved its held declaration count from 25 to 26, for the new trailing-options type on the SCIM probe. It asked for no anchors. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e33ee7 commit 131b937

9 files changed

Lines changed: 593 additions & 8 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Four producers that reached the data engine with no principal and no `isSystem` now carry the explicit system opt-in. Each is already authorized by its own door, so nothing it answers changes.
7+
8+
Clause-②: no
9+
10+
- **`@objectstack/plugin-auth` — the platform-admin OAuth client toggle route** (`POST /api/v1/auth/admin/oauth2/toggle-disabled`). Its `sys_oauth_application` read and write go through `withSystemContext`, the wrapper better-auth's adapter already writes those rows through. The platform-admin judge still runs first. The answers (`200`, `404 RESOURCE_NOT_FOUND`, the refusals) and the stored row are unchanged. One log line goes away: the engine's read-only `updated_at` warning on every toggle. The value it warned about was discarded before and the driver still stamps the column.
11+
- **`@objectstack/plugin-auth` — `verifyScimBearerToken`.** The credential probe passes `isSystem: true` in the read's trailing options. It runs before any caller is known, and the digest equality is still all it matches. An unknown, inactive or expired bearer is still `null` (`401`).
12+
- **`@objectstack/plugin-auth` — the organization slug guard** (`organizationHooks.beforeUpdateOrganization`). Its `sys_organization` and `sys_environment` reads go through `withSystemContext`. The organization id stays in the `where`. A slug change while an active environment references the organization is still refused (`FORBIDDEN`), and any other change is still allowed. The catches around both reads are unchanged: a read that throws still ends the hook without refusing.
13+
- **`@objectstack/runtime` — the dispatcher's environment-membership gate.** The `sys_environment_member` read carries `isSystem: true` as its query context. The caller's user id stays in the `where`. A member still passes and a non-member is still refused with `403 PROJECT_MEMBERSHIP_REQUIRED`. The catch around the read is unchanged: a read that throws still lets the request through.
14+
15+
Why: the security middleware hands a context with no principal and no `isSystem` straight through (ADR-0096). That hand-through is not an authorization. A caller that is the platform acting for itself says so explicitly. ⛔ No new elevation API, no door's authorization moves, and no accept set changes.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ still holds equal to the census on every pull request:
353353
| — in tests | 1013 | — |
354354
| — in non-test sources | 798 | — |
355355
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
356-
| — parsed as a declaration | 25 | ✅ |
356+
| — parsed as a declaration | 26 | ✅ |
357357
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
358358
| — parsed as a property **read** | 120 | ✅ |
359359
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The organization slug guard (`organizationHooks.beforeUpdateOrganization`)
5+
* reads `sys_organization` and `sys_environment` through `withSystemContext`,
6+
* so both reads carry the explicit system opt-in (`isSystem: true`).
7+
*
8+
* The hook IS the guard: the organization id is the `where`, not the reader.
9+
* Before, both reads reached the engine with no principal and no opt-in — the
10+
* security middleware's principal-less hand-off (ADR-0096), which is not an
11+
* authorization, and which a deny of principal-less contexts would refuse.
12+
*
13+
* Three facts are pinned here:
14+
*
15+
* 1. both reads carry the opt-in, and the guard's answers (refuse a slug
16+
* change while an active environment references the organization; allow
17+
* it otherwise) are unchanged;
18+
* 2. the guard KEEPS refusing on an engine that refuses a principal-less,
19+
* non-system context — its reads are not one, so the catch below never
20+
* turns the refusal into a skipped guard;
21+
* 3. the catches around the two reads are unchanged: a read that throws ends
22+
* the hook without refusing, as it did before. That is pre-existing
23+
* behaviour, pinned as it stands, not endorsed here.
24+
*/
25+
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import { AuthManager } from './auth-manager';
28+
29+
vi.mock('better-auth', () => ({
30+
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
31+
}));
32+
vi.mock('better-auth/plugins/organization', () => ({
33+
organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })),
34+
}));
35+
vi.mock('better-auth/plugins/two-factor', () => ({
36+
twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })),
37+
}));
38+
vi.mock('better-auth/plugins/magic-link', () => ({
39+
magicLink: vi.fn((_opts?: any) => ({ id: 'magic-link' })),
40+
}));
41+
vi.mock('better-auth/plugins/custom-session', () => ({
42+
customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })),
43+
}));
44+
vi.mock('better-auth/plugins/haveibeenpwned', () => ({
45+
haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })),
46+
}));
47+
48+
import { betterAuth } from 'better-auth';
49+
50+
type Query = { where?: Record<string, unknown>; context?: Record<string, unknown> } | undefined;
51+
type Options = { context?: Record<string, unknown> } | undefined;
52+
53+
/** The context the engine would act under: the query's, overridden by the trailing options' (ObjectQL's rule). */
54+
const effectiveContext = (q: Query, o?: Options): Record<string, unknown> => ({ ...(q?.context ?? {}), ...(o?.context ?? {}) });
55+
56+
function isPrincipalLessNonSystem(ctx: Record<string, unknown>): boolean {
57+
const positions = (ctx.positions as unknown[] | undefined) ?? [];
58+
const permissions = (ctx.permissions as unknown[] | undefined) ?? [];
59+
return positions.length === 0 && permissions.length === 0 && !ctx.userId && ctx.isSystem !== true;
60+
}
61+
62+
const ORG = { id: 'org-42', slug: 'acme-old' };
63+
const ENVS = [
64+
{ id: 'e1', status: 'active' },
65+
{ id: 'e2', status: 'archived' },
66+
];
67+
68+
const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
69+
beforeEach(() => {
70+
vi.clearAllMocks();
71+
// The MCP surface is default-ON and would append jwt + oauth-provider; this
72+
// file needs only the organization plugin's hooks.
73+
process.env.OS_MCP_SERVER_ENABLED = 'false';
74+
});
75+
afterEach(() => {
76+
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
77+
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
78+
});
79+
80+
/** Build the manager over `dataEngine` and hand back the slug guard better-auth would call. */
81+
async function slugGuard(dataEngine: unknown) {
82+
let captured: any;
83+
(betterAuth as any).mockImplementation((config: any) => {
84+
captured = config;
85+
return { handler: vi.fn(), api: {} };
86+
});
87+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
88+
try {
89+
const manager = new AuthManager({
90+
secret: 'test-secret-at-least-32-chars-long',
91+
baseUrl: 'http://localhost:3000',
92+
plugins: { organization: true },
93+
dataEngine: dataEngine as any,
94+
});
95+
await manager.getAuthInstance();
96+
} finally {
97+
warnSpy.mockRestore();
98+
}
99+
const orgPlugin = captured.plugins.find((p: any) => p.id === 'organization');
100+
const guard = orgPlugin._opts.organizationHooks.beforeUpdateOrganization as (arg: unknown) => Promise<unknown>;
101+
expect(typeof guard).toBe('function');
102+
return (slug: string) => guard({ organization: { slug }, member: { organizationId: ORG.id } });
103+
}
104+
105+
/** The guard's refusal, asserted on its envelope: better-auth's `FORBIDDEN` / 403, naming the active environments. */
106+
async function expectSlugRefusal(attempt: Promise<unknown>) {
107+
const err = (await attempt.then(
108+
() => undefined,
109+
(e: unknown) => e,
110+
)) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown } } | undefined;
111+
expect(err, 'the slug change was not refused').toBeTruthy();
112+
expect(err?.statusCode).toBe(403);
113+
expect(err?.status).toBe('FORBIDDEN');
114+
expect(String(err?.body?.message)).toMatch(/active.*environment/i);
115+
}
116+
117+
describe('organization slug guard — both reads carry the system opt-in', () => {
118+
it('refuses a slug change while an active environment references the org, through two isSystem reads', async () => {
119+
const engine = {
120+
findOne: vi.fn(async (_object: string, _q?: Query, _o?: Options) => ORG),
121+
find: vi.fn(async (_object: string, _q?: Query, _o?: Options) => ENVS),
122+
};
123+
const update = await slugGuard(engine);
124+
125+
await expectSlugRefusal(update('acme-new'));
126+
127+
expect(engine.findOne).toHaveBeenCalledTimes(1);
128+
const [orgObject, orgQuery, orgOptions] = engine.findOne.mock.calls[0];
129+
expect(orgObject).toBe('sys_organization');
130+
expect(orgQuery?.where).toEqual({ id: ORG.id });
131+
expect(effectiveContext(orgQuery, orgOptions).isSystem, 'the organization read reached the engine without the system opt-in').toBe(true);
132+
133+
expect(engine.find).toHaveBeenCalledTimes(1);
134+
const [envObject, envQuery, envOptions] = engine.find.mock.calls[0];
135+
expect(envObject).toBe('sys_environment');
136+
expect(envQuery?.where).toEqual({ organization_id: ORG.id });
137+
expect(effectiveContext(envQuery, envOptions).isSystem, 'the environment read reached the engine without the system opt-in').toBe(true);
138+
});
139+
140+
it('allows the change when no active environment references the org', async () => {
141+
const engine = {
142+
findOne: vi.fn(async () => ORG),
143+
find: vi.fn(async () => [{ id: 'e2', status: 'archived' }]),
144+
};
145+
const update = await slugGuard(engine);
146+
await expect(update('acme-new')).resolves.toBeUndefined();
147+
});
148+
});
149+
150+
describe('organization slug guard — on an engine that refuses a principal-less, non-system context', () => {
151+
it('still refuses the slug change — the guard is not skipped by the refusal', async () => {
152+
const refuse = (q?: Query, o?: Options) => {
153+
if (isPrincipalLessNonSystem(effectiveContext(q, o))) {
154+
throw Object.assign(new Error('[Security] Access denied: principal-less context'), {
155+
code: 'PERMISSION_DENIED',
156+
status: 403,
157+
});
158+
}
159+
};
160+
const engine = {
161+
findOne: vi.fn(async (_object: string, q?: Query, o?: Options) => {
162+
refuse(q, o);
163+
return ORG;
164+
}),
165+
find: vi.fn(async (_object: string, q?: Query, o?: Options) => {
166+
refuse(q, o);
167+
return ENVS;
168+
}),
169+
};
170+
const update = await slugGuard(engine);
171+
await expectSlugRefusal(update('acme-new'));
172+
});
173+
});
174+
175+
describe('organization slug guard — the catches around the reads are unchanged (pre-existing)', () => {
176+
it('an organization read that throws ends the hook without refusing, as before', async () => {
177+
const engine = {
178+
findOne: vi.fn(async () => {
179+
throw new Error('store unavailable');
180+
}),
181+
find: vi.fn(async () => ENVS),
182+
};
183+
const update = await slugGuard(engine);
184+
await expect(update('acme-new')).resolves.toBeUndefined();
185+
expect(engine.find).not.toHaveBeenCalled();
186+
});
187+
188+
it('an environment read that throws ends the hook without refusing, as before', async () => {
189+
const engine = {
190+
findOne: vi.fn(async () => ORG),
191+
find: vi.fn(async () => {
192+
throw new Error('object sys_environment is not registered');
193+
}),
194+
};
195+
const update = await slugGuard(engine);
196+
await expect(update('acme-new')).resolves.toBeUndefined();
197+
});
198+
});

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
} from '@objectstack/spec';
5858
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
5959
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
60-
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
60+
import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js';
6161
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
6262
import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js';
6363
import {
@@ -3334,8 +3334,14 @@ export class AuthManager {
33343334
const orgId = member?.organizationId;
33353335
if (!newSlug || !orgId) return;
33363336

3337-
const dataEngine = this.config.dataEngine as any;
3338-
if (!dataEngine) return;
3337+
// Both reads run as the platform (`withSystemContext`): this hook
3338+
// IS the slug guard — the organization id is the `where`, not the
3339+
// reader — so neither read reaches the engine with no principal
3340+
// and no opt-in (the security middleware's principal-less
3341+
// hand-off, ADR-0096). The catches below are unchanged.
3342+
const rawEngine = this.config.dataEngine;
3343+
if (!rawEngine) return;
3344+
const dataEngine = withSystemContext(rawEngine) as any;
33393345

33403346
let currentSlug: string | undefined;
33413347
try {

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
import { isDefaultOrganizationBootstrapTrigger } from './ensure-default-organization.js';
4545
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
4646
import { runAttributedToUser } from './auth-actor-attribution.js';
47+
import { withSystemContext } from './objectql-adapter.js';
4748
import type { AuthEventAuditSurface } from './auth-session-audit.js';
4849
import { createTenancyService, type TenancyService } from './tenancy-service.js';
4950
import {
@@ -2379,10 +2380,18 @@ export class AuthPlugin implements Plugin {
23792380
// better-auth's own endpoint invocation context. This is the same
23802381
// physical row the better-auth runtime reads at introspect / token
23812382
// / authorize time, so the toggle is fully honoured.
2382-
const dataEngine: any = this.authManager!.getDataEngine();
2383-
if (!dataEngine) {
2383+
//
2384+
// And through the same WRAPPER: `withSystemContext`, so the read and
2385+
// the write both carry the explicit system opt-in (`isSystem: true`).
2386+
// The platform-admin judge above is this route's authorization; the
2387+
// raw engine would have handed the security middleware a context with
2388+
// no principal and no opt-in — the principal-less hand-off (ADR-0096),
2389+
// which is not an authorization at all.
2390+
const rawEngine = this.authManager!.getDataEngine();
2391+
if (!rawEngine) {
23842392
return c.json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'Data engine unavailable' } }, 503);
23852393
}
2394+
const dataEngine: any = withSystemContext(rawEngine);
23862395

23872396
const existing = await dataEngine.findOne('sys_oauth_application', {
23882397
where: { client_id: clientId },

0 commit comments

Comments
 (0)