diff --git a/.changeset/dev-plugin-security-stubs-and-prod-guard.md b/.changeset/dev-plugin-security-stubs-and-prod-guard.md new file mode 100644 index 0000000000..0cfd1b18a7 --- /dev/null +++ b/.changeset/dev-plugin-security-stubs-and-prod-guard.md @@ -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. diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 392bc78e4d..cc3f1210c5 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -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 diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index f756ccfcef..3f2d6f9000 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -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.)* diff --git a/packages/plugins/plugin-dev/README.md b/packages/plugins/plugin-dev/README.md index 6820322510..c6ca0254f6 100644 --- a/packages/plugins/plugin-dev/README.md +++ b/packages/plugins/plugin-dev/README.md @@ -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) diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index 5fe4a28a81..590ed09b9e 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -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)); @@ -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(); + 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, run: () => Promise) => { + const saved: Record = {}; + 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: { diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 9e466f2a3e..7f823280ec 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -16,7 +16,37 @@ const CORE_SERVICE_NAMES = [ ] as const; /** - * Security sub-services registered by the SecurityPlugin. + * Security sub-services registered by the SecurityPlugin — and, deliberately, + * by NOTHING ELSE (#4093). + * + * This plugin used to stub all three when SecurityPlugin was absent. Each fake + * inverted the decision it stood in for: `checkObjectPermission()` answered + * `true` for everything, `compileFilter()` returned `null` so no row-level + * predicate was applied, and `maskResults()` handed rows back unmasked. + * + * That is the one thing ADR-0076 D12 says a fallback may never do. Its rule, + * learned from #3891's analytics shim: **a fallback may degrade features, never + * security semantics** — and the shim only dropped the caller's RLS scoping, + * where these three answered "allowed" outright. The contract agrees from the + * other side: `packages/spec/src/contracts/security-service.ts` calls these + * three "implementation internals and deliberately NOT part of this contract", + * and specifies that access-narrowing answers **fail CLOSED** — "a consumer + * must never treat a thrown error or a deny filter as 'no restriction'". + * Fakes registered under those names by a *different* package are the exact + * opposite of failing closed. + * + * Triggering it took no exotic setup: this plugin loads SecurityPlugin through + * the same optional dynamic import as everything else, so `@objectstack/ + * plugin-security` merely not being installed swapped real RBAC/RLS/masking for + * allow-all, behind one `warn` line. + * + * The slots now stay empty, which is what production has without SecurityPlugin, + * and the consumers already handle: the enforcement paths live inside the plugin + * itself (registered hooks), and the only reader of a slot — + * `plugin-hono-server`'s `/auth/me/permissions` + `/me/apps` — resolves it + * defensively and fails open on *presentation* only, over data the read path has + * already enforced. Kept as a named list because the registration loop must + * still skip these slots and say why. */ const SECURITY_SERVICE_NAMES = [ 'security.permissions', 'security.rls', 'security.fieldMasker', @@ -222,28 +252,15 @@ function createDataStub() { }; } -/** Security sub-service stubs (PermissionEvaluator, RLSCompiler, FieldMasker) */ -function createSecurityPermissionsStub() { - return { - _serviceName: 'security.permissions', - resolvePermissionSets() { return []; }, - checkObjectPermission() { return true; }, - getFieldPermissions() { return {}; }, - }; -} -function createSecurityRLSStub() { - return { - _serviceName: 'security.rls', - compileFilter() { return null; }, - getApplicablePolicies() { return []; }, - }; -} -function createSecurityFieldMaskerStub() { - return { - _serviceName: 'security.fieldMasker', - maskResults(results: any) { return results; }, - }; -} +// [#4093] The three security sub-service stubs are GONE — see +// SECURITY_SERVICE_NAMES below for why. They were: +// +// security.permissions → checkObjectPermission() { return true; } // allow-all +// security.rls → compileFilter() { return null; } // no predicate +// security.fieldMasker → maskResults(r) { return r; } // unmasked +// +// Nothing replaces them: an empty slot is what production has when +// SecurityPlugin isn't installed, and every consumer already handles it. /** * Map of service names → contract-compliant stub factory functions. @@ -265,10 +282,6 @@ const DEV_STUB_FACTORIES: Record Record> = { 'metadata': createMetadataStub, 'data': createDataStub, 'auth': createAuthStub, - // Security sub-services - 'security.permissions': createSecurityPermissionsStub, - 'security.rls': createSecurityRLSStub, - 'security.fieldMasker': createSecurityFieldMaskerStub, }; /** @@ -312,9 +325,6 @@ const DEV_STUB_SELF_INFO: Record, info: Record): */ const NO_DEV_STUB_SERVICES = new Set(['analytics']); +/** + * Escape hatch for {@link assertNotProduction} — deliberately ungrouped and + * scary-looking per the `OS_ALLOW_{X}` convention (AGENTS.md Prime Directive #9). + */ +const ALLOW_IN_PRODUCTION_ENV = 'OS_ALLOW_DEV_PLUGIN' as const; + +/** + * [#4093] Refuse to initialize under `NODE_ENV=production`. + * + * This plugin's whole purpose is to make a local stack work without installing + * anything: it fills unclaimed service slots with fakes. Several of those + * fabricate their answers (`data` discards writes and reports success), and it + * ships as a published package with no environment check of its own — so an + * `objectstack.config.ts` that carries `new DevPlugin()` into a production + * deploy got the whole fake slate silently, with only a boot log to say so. + * + * Failing the boot is the right response rather than degrading quietly: a + * production process that reaches this line is misconfigured in a way no + * runtime behaviour can make safe, and the fakes are exactly the kind of thing + * that looks like it works. `OS_ALLOW_DEV_PLUGIN=1` overrides it for the + * deliberate cases (a staging box mimicking prod, a smoke test that pins + * `NODE_ENV`), and says at the call site that someone chose this. + */ +function assertNotProduction(): void { + if (process.env.NODE_ENV !== 'production') return; + if (process.env[ALLOW_IN_PRODUCTION_ENV] === '1') return; + throw new Error( + '@objectstack/plugin-dev refuses to initialize with NODE_ENV=production. ' + + 'It registers development fakes for every unclaimed core service slot — including ones that ' + + 'report success for work they never did — so a production process must not load it. ' + + 'Remove DevPlugin from this deployment\'s plugin list and install the real services ' + + `(the boot log names each fake it would have registered), or set ${ALLOW_IN_PRODUCTION_ENV}=1 ` + + 'if you deliberately want the dev slate under a production NODE_ENV.', + ); +} + /** * Dev Plugin Options * @@ -513,6 +559,7 @@ export class DevPlugin implements Plugin { * if a package isn't installed the service is silently skipped. */ async init(ctx: PluginContext): Promise { + assertNotProduction(); ctx.logger.info('🚀 DevPlugin initializing — auto-configuring all services for development'); const enabled = (name: string) => this.options.services?.[name] !== false; @@ -757,15 +804,23 @@ export class DevPlugin implements Plugin { } } - // Security sub-services (if SecurityPlugin wasn't loaded) + // Security sub-services get NO stub (#4093, see SECURITY_SERVICE_NAMES): + // faking an authorization decision is the one thing ADR-0076 D12 forbids a + // fallback to do, and all three former stubs decided "allowed". An empty + // slot is what production has without SecurityPlugin. Say so once, loudly, + // because "no RBAC/RLS/masking is being enforced" is worth a line in the + // boot log of a stack that expected them. if (enabled('security')) { - for (const svc of SECURITY_SERVICE_NAMES) { - try { - ctx.getService(svc); - } catch { - ctx.registerService(svc, makeStub(svc)); - stubNames.push(svc); - } + const missing = SECURITY_SERVICE_NAMES.filter((svc) => { + try { ctx.getService(svc); return false; } catch { return true; } + }); + if (missing.length > 0) { + ctx.logger.warn( + ` ⚠ No security services (${missing.join(', ')}) — SecurityPlugin is not loaded, so RBAC, ` + + 'row-level security and field masking are NOT enforced. The slots stay empty rather than ' + + 'being stubbed: a fake that answers "allowed" is worse than an absent one. Install ' + + '@objectstack/plugin-security to enforce them.', + ); } } diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 42f8be23f8..75c98e0714 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -1279,8 +1279,21 @@ export class HonoServerPlugin implements Plugin { return c.json({ authenticated: false }); } try { - const metadata: any = ctx.getService('metadata'); - const evaluator: any = ctx.getService('security.permissions'); + // [#4093] Guarded like the three lookups below, not bare: + // `getService` THROWS on an unregistered slot, and since + // plugin-dev stopped stubbing `security.permissions` (a fake + // that answered "allowed" for everything) an unclaimed slot is + // the ordinary state of a stack without SecurityPlugin. Bare, + // it landed in the outer catch — same fail-open body, but + // logged as "/auth/me/permissions failed", which reads as a + // fault on every console navigation instead of the deliberate + // `!evaluator` branch right below. + const metadata: any = (() => { + try { return ctx.getService('metadata'); } catch { return null; } + })(); + const evaluator: any = (() => { + try { return ctx.getService('security.permissions'); } catch { return null; } + })(); const bootstrap: any[] = (() => { try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } catch { return []; }