Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/dev-plugin-security-stubs-and-prod-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-dev': minor
'@objectstack/plugin-hono-server': patch
---

Retire the three `security.*` dev stubs, and refuse to load `plugin-dev` under `NODE_ENV=production` (#4093).

**The security stubs are gone.** When `@objectstack/plugin-security` was not installed, `plugin-dev` filled its three slots with fakes that inverted the decision each stood in for: `security.permissions.checkObjectPermission()` returned `true` for everything, `security.rls.compileFilter()` returned `null` so no row-level predicate was applied, and `security.fieldMasker.maskResults()` returned rows unmasked. ADR-0076 D12's rule — learned from the analytics shim it retired in #3891 — is that a fallback may degrade features, **never security semantics**; `packages/spec/src/contracts/security-service.ts` says the same from the other side (these three are plugin-security's internals, and access-narrowing answers must fail CLOSED). Since `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, the package merely being absent was enough to swap real RBAC/RLS/masking for allow-all behind a single `warn` line.

The slots now stay empty — which is what production has without SecurityPlugin, and what every consumer already handles — and the boot log states plainly that RBAC, row-level security and field masking are not being enforced.

**`plugin-dev` now refuses to initialize under `NODE_ENV=production`.** It is a published package that registers development fakes for every unclaimed core service slot, including ones that report success for work they never did, and it had no environment check of its own: an `objectstack.config.ts` carrying `new DevPlugin()` into a production deploy got the whole fake slate with only a boot log to say so. `init()` now throws there. Set `OS_ALLOW_DEV_PLUGIN=1` if you deliberately want the dev slate under a production `NODE_ENV` (a staging box mimicking prod, a smoke test that pins the variable).

FROM → TO: a stack that relied on the dev security stubs was not being protected by them — it was being told everything was allowed. Install `@objectstack/plugin-security` to enforce RBAC/RLS/masking, or accept the empty slots (unchanged behaviour on every path that already handled an absent SecurityPlugin). A production process that loaded `plugin-dev` must now either drop it and install the real services, or opt in explicitly with `OS_ALLOW_DEV_PLUGIN=1`.

Also: `plugin-hono-server`'s `/auth/me/permissions` resolves `security.permissions` and `metadata` through the same guarded lookup its three sibling lookups already used. An unregistered slot makes `getService` throw, which previously landed in the outer catch — the same fail-open response body, but logged as "/auth/me/permissions failed" on every console navigation instead of taking the deliberate `!evaluator` branch.
6 changes: 3 additions & 3 deletions content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,10 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern

### @objectstack/plugin-dev

**Developer Tools Plugin** — Development-time utilities.
**Local Development Plugin** — one-line local stack: wires ObjectQL, the in-memory driver, auth, security, the HTTP server, REST and the dispatcher, then fills any still-unclaimed core service slot with an in-memory dev implementation.

- **Features**: Metadata validation, schema introspection, debugging tools
- **When to use**: Development and debugging
- **Features**: Composes the real plugins above; registers dev implementations for unclaimed slots, each declaring what kind of fake it is (`__serviceInfo` — `degraded` when it really does the work in memory, `stub` when it fabricates). Slots that would fabricate an answer over HTTP get no implementation at all: `analytics` and the three `security.*` handles stay empty on purpose.
- **When to use**: Local development only. **`init()` throws under `NODE_ENV=production`** — set `OS_ALLOW_DEV_PLUGIN=1` only if you deliberately want the dev slate under a production `NODE_ENV`.
- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-dev/README.md)

### @objectstack/plugin-approvals
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0076-objectql-core-tiering.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Decision: each capability plugin registers its routes as a **normalized handler*
**Root cause of agents being misled.** Several plugins register stub / dev / fallback services under canonical names, and the discovery builder reports *any* present service as fully real: `runtime/http-dispatcher.ts`'s `svcAvailable` hardcodes `{ enabled: true, status: 'available', handlerReady: true }` for every registered service — it **ignores stub markers** (its own comment even says "handlerReady:false … may be served by a stub", but the code never computes it). So `discovery.services.*` claims capabilities that are only stubbed, and consumers (AI agents, the console) trust them. A dev AI stub advertised this way has already confused an agent.

**Inventory of current fakes / mis-reports:**
- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* *(Update #4058 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)*
- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* *(Update #4058 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)* *(Update #4093: three of those `stub` entries are **gone**, not relabelled — `security.permissions` / `security.rls` / `security.fieldMasker`. Classifying them honestly was necessary but not sufficient, for the same reason honest labelling was not enough for the analytics shim (#3891): the label was accurate while the behaviour still inverted the decision it stood in for — `checkObjectPermission()` returned `true` for everything, `compileFilter()` returned `null` so no row predicate applied, `maskResults()` returned rows unmasked. That is this decision's own line — a fallback may degrade features, never security semantics — and `spec/src/contracts/security-service.ts` states it from the other side: these three are plugin-security's internals, and access-narrowing answers must fail CLOSED. `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, so the package merely being absent swapped real RBAC/RLS/masking for allow-all behind one `warn`. The slots now stay empty (what production has without SecurityPlugin), the boot log states that nothing is enforcing them, and `plugin-dev` additionally refuses to initialize at all under `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN=1` to override) — it is a published package that had no environment check of its own. Whether the remaining fabricating stubs (`data`, `auth`, `ui`) should occupy slots at all is still #4093; `auth` in particular is blocked on #4113, since the dispatcher's own `/auth` mock fallback — not the dev stub — is what actually answers `/auth/*` when the slot is empty.)*
- `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available).
- `http-dispatcher.ts` `svcAvailable` — the hardcode above.
- *(Added by #4058: the **kernel's own** fallbacks — `createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`, auto-registered by ObjectKernel — were missing from this inventory and were the worst case in it: they carried `_fallback: true`, a marker **no** reader recognized (not even `readServiceSelfInfo`), so both discovery builders reported them as fully `available`. They now self-describe as `degraded` with `handlerReady: false` where no HTTP surface exists (`cache` / `queue` / `job`). The lone duck-typed consumer of `_fallback`/`_dev` — an i18n diagnostic in `app-plugin.ts` — reads `readServiceSelfInfo` instead.)*
Expand Down
18 changes: 15 additions & 3 deletions packages/plugins/plugin-dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,25 @@ plugins: [
| REST API | `@objectstack/rest` | Auto-generated CRUD + metadata endpoints |
| Dispatcher | `@objectstack/runtime` | Auth routes, GraphQL, analytics, packages, storage |

### ⛔ Local development only

`DevPlugin.init()` **throws under `NODE_ENV=production`**. It fills unclaimed service slots with fakes — some of which report success for work they never did — so a production process must not load it. Remove it from that deployment's plugin list and install the real services. `OS_ALLOW_DEV_PLUGIN=1` overrides the refusal if you deliberately want the dev slate under a production `NODE_ENV` (a staging box mimicking prod, a smoke test that pins the variable).

### Dev stubs (in-memory / no-op)

Any core kernel service not provided by a real plugin is automatically registered as a dev stub. This ensures the **full kernel service map** is populated and features like UI permissions, automation, etc. don't crash:
Most core kernel services not provided by a real plugin are registered as a dev stub, so the kernel service map is populated and callers get correct return types instead of `undefined`. Each one declares what kind of fake it is (`__serviceInfo`, ADR-0076 D12) — consumers, the dispatcher included, gate on that:

| Class | Slots | Meaning |
|:---|:---|:---|
| `degraded` | `cache`, `queue`, `job`, `file-storage`, `search`, `realtime`, `i18n`, `workflow`, `metadata` | Really does the work, in memory only. Served normally over HTTP. |
| `stub` | `data`, `auth`, plus `ui` (placeholder with no implementation) | Fabricates its answer. Reported as a stub in discovery, and every dispatcher-owned domain answers it exactly as it answers an empty slot. |

**Never stubbed** — these slots stay empty on purpose, which is what production has when the real plugin isn't installed:

`cache`, `queue`, `job`, `file-storage`, `search`, `automation`, `graphql`, `analytics`, `realtime`, `notification`, `ai`, `i18n`, `ui`, `workflow`, `security.permissions`, `security.rls`, `security.fieldMasker`
- `analytics` (#4000). Install `@objectstack/service-analytics` (it runs an InMemory strategy).
- `security.permissions`, `security.rls`, `security.fieldMasker` (#4093). The former stubs answered "allowed" for every permission check, compiled no row-level filter, and returned rows unmasked — inverting the decisions they stood in for. ADR-0076 D12's rule is that a fallback may degrade features, **never security semantics**. Without `@objectstack/plugin-security` nothing enforces RBAC, RLS or field masking, and the boot log says so rather than a fake quietly saying yes.

All services are **optional** — if a peer package isn't installed, it is silently skipped and a stub takes its place.
All services are **optional** — if a peer package isn't installed it is skipped, and for the slots above a stub takes its place.

## API Endpoints (when all services enabled)

Expand Down
104 changes: 102 additions & 2 deletions packages/plugins/plugin-dev/src/dev-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,9 @@ describe('DevPlugin', () => {
// Really do the work, just less of it — their answers are true answers.
const DEGRADED = ['cache', 'queue', 'job', 'file-storage', 'search', 'realtime', 'i18n', 'workflow', 'metadata'];
// Fabricate the answer — must never be mistaken for a capability.
const STUB = ['automation', 'notification', 'ai', 'data', 'auth',
'security.permissions', 'security.rls', 'security.fieldMasker'];
// (`security.*` are no longer in this list because they are no longer
// registered at all — see the dedicated test below, #4093.)
const STUB = ['automation', 'notification', 'ai', 'data', 'auth'];

for (const name of DEGRADED) {
const info = readServiceSelfInfo(registeredServices.get(name));
Expand Down Expand Up @@ -289,6 +290,105 @@ describe('DevPlugin', () => {
expect(registeredServices.has('analytics')).toBe(false);
});

// [#4093] The one thing a fallback may never fake. ADR-0076 D12, from #3891:
// "a fallback may degrade features, never security semantics" — and these
// three did not merely degrade, they inverted. `checkObjectPermission()`
// answered `true` for everything, `compileFilter()` returned `null` so no
// row-level predicate applied, `maskResults()` returned rows unmasked. The
// contract calls them plugin-security's internals and requires
// access-narrowing answers to fail CLOSED; a fake registered by another
// package under those names is the opposite. The slots stay empty.
it('registers NO security sub-service stub, and says so loudly', async () => {
const registered = new Map<string, any>();
const ctx: any = {
logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
getService: vi.fn().mockImplementation((name: string) => {
if (registered.has(name)) return registered.get(name);
throw new Error('not found');
}),
getServices: vi.fn().mockReturnValue(new Map()),
registerService: vi.fn().mockImplementation((n: string, s: any) => { registered.set(n, s); }),
hook: vi.fn(),
trigger: vi.fn(),
getKernel: vi.fn(),
};

// `security` left ENABLED — plugin-security is mocked as missing at the top
// of this file, so this is exactly the boot that used to get allow-all.
await new DevPlugin({
seedAdminUser: false,
services: { objectql: false, driver: false, setup: false, server: false, rest: false, dispatcher: false },
}).init(ctx);

for (const slot of ['security.permissions', 'security.rls', 'security.fieldMasker']) {
expect(registered.has(slot), `${slot} must NOT be registered`).toBe(false);
}

// "Nothing is enforcing RBAC/RLS/masking" is not a silent condition.
const warned = ctx.logger.warn.mock.calls.find(
(call: any[]) => typeof call[0] === 'string' && call[0].includes('No security services'),
);
expect(warned, 'must warn that security is unenforced').toBeDefined();
expect(warned[0]).toContain('plugin-security');
});

// [#4093] The package is published, has no environment check of its own, and
// registers fakes that report success for work they never did. A production
// process that loads it is misconfigured in a way no runtime behaviour can
// make safe, so refuse the boot instead of degrading quietly.
describe('production guard', () => {
const makeCtx = () => ({
logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
getService: vi.fn().mockImplementation(() => { throw new Error('not found'); }),
getServices: vi.fn().mockReturnValue(new Map()),
registerService: vi.fn(),
hook: vi.fn(),
trigger: vi.fn(),
getKernel: vi.fn(),
}) as any;

// Restored per-case: these tests mutate process.env deliberately.
const withEnv = async (env: Record<string, string | undefined>, run: () => Promise<void>) => {
const saved: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(env)) {
saved[k] = process.env[k];
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
try { await run(); } finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
}
};

it('refuses to initialize under NODE_ENV=production', async () => {
await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: undefined }, async () => {
const ctx = makeCtx();
await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).rejects.toThrow(/NODE_ENV=production/);
// And nothing was registered before the refusal.
expect(ctx.registerService).not.toHaveBeenCalled();
});
});

it('names the escape hatch in the refusal, and honours it', async () => {
await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: undefined }, async () => {
await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx()))
.rejects.toThrow(/OS_ALLOW_DEV_PLUGIN=1/);
});
await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: '1' }, async () => {
await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx())).resolves.not.toThrow();
});
});

it('stays out of the way for every other NODE_ENV', async () => {
for (const value of ['development', 'test', undefined]) {
await withEnv({ NODE_ENV: value, OS_ALLOW_DEV_PLUGIN: undefined }, async () => {
await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx())).resolves.not.toThrow();
});
}
});
});

it('should skip disabled services', async () => {
const ctx: any = {
logger: {
Expand Down
Loading
Loading