Skip to content
15 changes: 15 additions & 0 deletions .changeset/21912-principal-less-producers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/runtime': patch
---

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.

Clause-②: no

- **`@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.
- **`@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`).
- **`@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.
- **`@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.

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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ still holds equal to the census on every pull request:
| — in tests | 1013 | — |
| — in non-test sources | 798 | — |
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 25 | ✅ |
| — parsed as a declaration | 26 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 120 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The organization slug guard (`organizationHooks.beforeUpdateOrganization`)
* reads `sys_organization` and `sys_environment` through `withSystemContext`,
* so both reads carry the explicit system opt-in (`isSystem: true`).
*
* The hook IS the guard: the organization id is the `where`, not the reader.
* Before, both reads reached the engine with no principal and no opt-in — the
* security middleware's principal-less hand-off (ADR-0096), which is not an
* authorization, and which a deny of principal-less contexts would refuse.
*
* Three facts are pinned here:
*
* 1. both reads carry the opt-in, and the guard's answers (refuse a slug
* change while an active environment references the organization; allow
* it otherwise) are unchanged;
* 2. the guard KEEPS refusing on an engine that refuses a principal-less,
* non-system context — its reads are not one, so the catch below never
* turns the refusal into a skipped guard;
* 3. the catches around the two reads are unchanged: a read that throws ends
* the hook without refusing, as it did before. That is pre-existing
* behaviour, pinned as it stands, not endorsed here.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager } from './auth-manager';

vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
}));
vi.mock('better-auth/plugins/organization', () => ({
organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })),
}));
vi.mock('better-auth/plugins/two-factor', () => ({
twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })),
}));
vi.mock('better-auth/plugins/magic-link', () => ({
magicLink: vi.fn((_opts?: any) => ({ id: 'magic-link' })),
}));
vi.mock('better-auth/plugins/custom-session', () => ({
customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })),
}));
vi.mock('better-auth/plugins/haveibeenpwned', () => ({
haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })),
}));

import { betterAuth } from 'better-auth';

type Query = { where?: Record<string, unknown>; context?: Record<string, unknown> } | undefined;
type Options = { context?: Record<string, unknown> } | undefined;

/** The context the engine would act under: the query's, overridden by the trailing options' (ObjectQL's rule). */
const effectiveContext = (q: Query, o?: Options): Record<string, unknown> => ({ ...(q?.context ?? {}), ...(o?.context ?? {}) });

function isPrincipalLessNonSystem(ctx: Record<string, unknown>): boolean {
const positions = (ctx.positions as unknown[] | undefined) ?? [];
const permissions = (ctx.permissions as unknown[] | undefined) ?? [];
return positions.length === 0 && permissions.length === 0 && !ctx.userId && ctx.isSystem !== true;
}

const ORG = { id: 'org-42', slug: 'acme-old' };
const ENVS = [
{ id: 'e1', status: 'active' },
{ id: 'e2', status: 'archived' },
];

const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED;
beforeEach(() => {
vi.clearAllMocks();
// The MCP surface is default-ON and would append jwt + oauth-provider; this
// file needs only the organization plugin's hooks.
process.env.OS_MCP_SERVER_ENABLED = 'false';
});
afterEach(() => {
if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv;
});

/** Build the manager over `dataEngine` and hand back the slug guard better-auth would call. */
async function slugGuard(dataEngine: unknown) {
let captured: any;
(betterAuth as any).mockImplementation((config: any) => {
captured = config;
return { handler: vi.fn(), api: {} };
});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const manager = new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
plugins: { organization: true },
dataEngine: dataEngine as any,
});
await manager.getAuthInstance();
} finally {
warnSpy.mockRestore();
}
const orgPlugin = captured.plugins.find((p: any) => p.id === 'organization');
const guard = orgPlugin._opts.organizationHooks.beforeUpdateOrganization as (arg: unknown) => Promise<unknown>;
expect(typeof guard).toBe('function');
return (slug: string) => guard({ organization: { slug }, member: { organizationId: ORG.id } });
}

/** The guard's refusal, asserted on its envelope: better-auth's `FORBIDDEN` / 403, naming the active environments. */
async function expectSlugRefusal(attempt: Promise<unknown>) {
const err = (await attempt.then(
() => undefined,
(e: unknown) => e,
)) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown } } | undefined;
expect(err, 'the slug change was not refused').toBeTruthy();
expect(err?.statusCode).toBe(403);
expect(err?.status).toBe('FORBIDDEN');
expect(String(err?.body?.message)).toMatch(/active.*environment/i);
}

describe('organization slug guard — both reads carry the system opt-in', () => {
it('refuses a slug change while an active environment references the org, through two isSystem reads', async () => {
const engine = {
findOne: vi.fn(async (_object: string, _q?: Query, _o?: Options) => ORG),
find: vi.fn(async (_object: string, _q?: Query, _o?: Options) => ENVS),
};
const update = await slugGuard(engine);

await expectSlugRefusal(update('acme-new'));

expect(engine.findOne).toHaveBeenCalledTimes(1);
const [orgObject, orgQuery, orgOptions] = engine.findOne.mock.calls[0];
expect(orgObject).toBe('sys_organization');
expect(orgQuery?.where).toEqual({ id: ORG.id });
expect(effectiveContext(orgQuery, orgOptions).isSystem, 'the organization read reached the engine without the system opt-in').toBe(true);

expect(engine.find).toHaveBeenCalledTimes(1);
const [envObject, envQuery, envOptions] = engine.find.mock.calls[0];
expect(envObject).toBe('sys_environment');
expect(envQuery?.where).toEqual({ organization_id: ORG.id });
expect(effectiveContext(envQuery, envOptions).isSystem, 'the environment read reached the engine without the system opt-in').toBe(true);
});

it('allows the change when no active environment references the org', async () => {
const engine = {
findOne: vi.fn(async () => ORG),
find: vi.fn(async () => [{ id: 'e2', status: 'archived' }]),
};
const update = await slugGuard(engine);
await expect(update('acme-new')).resolves.toBeUndefined();
});
});

describe('organization slug guard — on an engine that refuses a principal-less, non-system context', () => {
it('still refuses the slug change — the guard is not skipped by the refusal', async () => {
const refuse = (q?: Query, o?: Options) => {
if (isPrincipalLessNonSystem(effectiveContext(q, o))) {
throw Object.assign(new Error('[Security] Access denied: principal-less context'), {
code: 'PERMISSION_DENIED',
status: 403,
});
}
};
const engine = {
findOne: vi.fn(async (_object: string, q?: Query, o?: Options) => {
refuse(q, o);
return ORG;
}),
find: vi.fn(async (_object: string, q?: Query, o?: Options) => {
refuse(q, o);
return ENVS;
}),
};
const update = await slugGuard(engine);
await expectSlugRefusal(update('acme-new'));
});
});

describe('organization slug guard — the catches around the reads are unchanged (pre-existing)', () => {
it('an organization read that throws ends the hook without refusing, as before', async () => {
const engine = {
findOne: vi.fn(async () => {
throw new Error('store unavailable');
}),
find: vi.fn(async () => ENVS),
};
const update = await slugGuard(engine);
await expect(update('acme-new')).resolves.toBeUndefined();
expect(engine.find).not.toHaveBeenCalled();
});

it('an environment read that throws ends the hook without refusing, as before', async () => {
const engine = {
findOne: vi.fn(async () => ORG),
find: vi.fn(async () => {
throw new Error('object sys_environment is not registered');
}),
};
const update = await slugGuard(engine);
await expect(update('acme-new')).resolves.toBeUndefined();
});
});
12 changes: 9 additions & 3 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import {
} from '@objectstack/spec';
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js';
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js';
import {
Expand Down Expand Up @@ -3334,8 +3334,14 @@ export class AuthManager {
const orgId = member?.organizationId;
if (!newSlug || !orgId) return;

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

let currentSlug: string | undefined;
try {
Expand Down
13 changes: 11 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { isDefaultOrganizationBootstrapTrigger } from './ensure-default-organization.js';
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
import { runAttributedToUser } from './auth-actor-attribution.js';
import { withSystemContext } from './objectql-adapter.js';
import type { AuthEventAuditSurface } from './auth-session-audit.js';
import { createTenancyService, type TenancyService } from './tenancy-service.js';
import {
Expand Down Expand Up @@ -2379,10 +2380,18 @@ export class AuthPlugin implements Plugin {
// better-auth's own endpoint invocation context. This is the same
// physical row the better-auth runtime reads at introspect / token
// / authorize time, so the toggle is fully honoured.
const dataEngine: any = this.authManager!.getDataEngine();
if (!dataEngine) {
//
// And through the same WRAPPER: `withSystemContext`, so the read and
// the write both carry the explicit system opt-in (`isSystem: true`).
// The platform-admin judge above is this route's authorization; the
// raw engine would have handed the security middleware a context with
// no principal and no opt-in — the principal-less hand-off (ADR-0096),
// which is not an authorization at all.
const rawEngine = this.authManager!.getDataEngine();
if (!rawEngine) {
return c.json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'Data engine unavailable' } }, 503);
}
const dataEngine: any = withSystemContext(rawEngine);

const existing = await dataEngine.findOne('sys_oauth_application', {
where: { client_id: clientId },
Expand Down
Loading
Loading