Surfaced by the self-review of PR #2562 (the ADR-0056 D2 requireAuth default flip). The flip makes REST /data/* deny-anonymous by default, but sibling HTTP surfaces never consult requireAuth / enforceAuth, so the platform's anonymous posture is inconsistent by surface rather than uniform. An attacker doesn't care which door is locked — until every entry point honors the same posture, the D2 flip's security promise is not actually delivered. None of these gaps was introduced by #2562 (they are pre-existing), but the flip makes them newly load-bearing.
Status of the three original surfaces
| # |
Surface |
Status |
| 1 |
Metadata /meta/* — RestServer.registerMetadataEndpoints |
✅ Fixed — the route registrar is now wrapped for the duration of metadata registration (packages/rest/src/rest-server.ts registerMetadataEndpoints), so every present and future /meta route inherits enforceAuth. Keep as the reference pattern for Phase 1. |
| 2 |
Dispatcher GraphQL — packages/runtime/src/dispatcher-plugin.ts (~570) mounts POST {prefix}/graphql → dispatcher.handleGraphQL(...) with no auth check; the security middleware falls open for an anonymous context (no roles/permissions/userId → next()) |
❌ Open — an anonymous GraphQL query reads object data that the sibling /data/* 401 denies |
| 3 |
Raw hono CRUD — HonoServerPlugin.registerStandardEndpoints (defaults on, packages/plugins/plugin-hono-server/src/hono-plugin.ts ~179) registers /api/v1/data/:object routes that delegate straight to ObjectQL and never consult requireAuth |
❌ Open — routes are only shadowed when the REST plugin registers the same paths first, so secure-by-default currently depends on plugin registration order. A load-order change silently reopens the hole and no test fails. |
Root cause
All gaps share one shape: requireAuth is enforced at exactly one gate (RestServer.enforceAuth) while other HTTP entry points reach ObjectQL without passing through it. The /meta fix proves the diagnosis — per-route hand-added gates always miss one; the gate must be a property of the registration/request pipeline. But that fix is itself still per-plugin: three surfaces, three different fix shapes, is exactly the anti-pattern this issue exists to end.
Plan — two phases, don't big-bang the refactor
Phase 1 — close the holes now (small patches)
- Gate dispatcher
POST /graphql behind the same requireAuth decision, reusing the /meta wrapper pattern. This is behavior-changing for deployments relying on anonymous GraphQL (e.g. public dashboards) — reuse the D2 warn-then-enforce migration path (release note + one-release warning), not a hard cut.
- Gate the raw-hono standard data endpoints. Additionally evaluate whether
registerStandardEndpoints should default off when the REST plugin is present — removing a duplicate surface is cheaper than gating it, and it eliminates the registration-order dependency outright.
- Dogfood proofs per surface: anonymous
POST /graphql and anonymous raw-hono /data both 401 under the default posture; each remains reachable when the deployment explicitly opts out (requireAuth: false).
- Conformance-ledger rows (ADR-0060,
packages/verify/src/conformance.ts) for all three surfaces — including a row documenting the already-landed /meta gate — each enforced with its runtime enforcement site and proof. No new mechanism needed; the ledger exists for exactly this.
Phase 2 — converge on a single mechanism (follow-on refactor)
- Lift the anonymous-deny decision into a shared middleware / request-pipeline property that every HTTP surface mounts, with an explicit, documented exemption list (auth routes, health checks,
publicSharing-opted-in resources, share-link tokens).
- Migrate the
/meta wrapper and the Phase 1 patches into it, so there is one gate with one exemption list instead of N per-plugin copies.
- Wire the conformance ledger's
discover() ratchet to enumerate HTTP entry points from source, so a new ungated surface fails CI as unclassified instead of shipping open.
Acceptance
- Anonymous
POST /graphql and anonymous raw-hono /data 401 under the default posture; explicit opt-out keeps them reachable. (Anonymous /meta already proven.)
- No surface's security depends on plugin registration order.
- Conformance rows for all three surfaces, plus the ratchet so new HTTP entry points cannot silently ship ungated.
- GraphQL gating ships via warn-then-enforce with a release note.
Context: #2561 (authorization gap map), #2562 (the flip), ADR-0056 D2, ADR-0060 (conformance ledger).
Surfaced by the self-review of PR #2562 (the ADR-0056 D2
requireAuthdefault flip). The flip makes REST/data/*deny-anonymous by default, but sibling HTTP surfaces never consultrequireAuth/enforceAuth, so the platform's anonymous posture is inconsistent by surface rather than uniform. An attacker doesn't care which door is locked — until every entry point honors the same posture, the D2 flip's security promise is not actually delivered. None of these gaps was introduced by #2562 (they are pre-existing), but the flip makes them newly load-bearing.Status of the three original surfaces
/meta/*—RestServer.registerMetadataEndpointspackages/rest/src/rest-server.tsregisterMetadataEndpoints), so every present and future/metaroute inheritsenforceAuth. Keep as the reference pattern for Phase 1.packages/runtime/src/dispatcher-plugin.ts(~570) mountsPOST {prefix}/graphql→dispatcher.handleGraphQL(...)with no auth check; the security middleware falls open for an anonymous context (no roles/permissions/userId →next())/data/*401 deniesHonoServerPlugin.registerStandardEndpoints(defaults on,packages/plugins/plugin-hono-server/src/hono-plugin.ts~179) registers/api/v1/data/:objectroutes that delegate straight to ObjectQL and never consultrequireAuthRoot cause
All gaps share one shape:
requireAuthis enforced at exactly one gate (RestServer.enforceAuth) while other HTTP entry points reach ObjectQL without passing through it. The/metafix proves the diagnosis — per-route hand-added gates always miss one; the gate must be a property of the registration/request pipeline. But that fix is itself still per-plugin: three surfaces, three different fix shapes, is exactly the anti-pattern this issue exists to end.Plan — two phases, don't big-bang the refactor
Phase 1 — close the holes now (small patches)
POST /graphqlbehind the samerequireAuthdecision, reusing the/metawrapper pattern. This is behavior-changing for deployments relying on anonymous GraphQL (e.g. public dashboards) — reuse the D2 warn-then-enforce migration path (release note + one-release warning), not a hard cut.registerStandardEndpointsshould default off when the REST plugin is present — removing a duplicate surface is cheaper than gating it, and it eliminates the registration-order dependency outright.POST /graphqland anonymous raw-hono/databoth 401 under the default posture; each remains reachable when the deployment explicitly opts out (requireAuth: false).packages/verify/src/conformance.ts) for all three surfaces — including a row documenting the already-landed/metagate — eachenforcedwith its runtime enforcement site and proof. No new mechanism needed; the ledger exists for exactly this.Phase 2 — converge on a single mechanism (follow-on refactor)
publicSharing-opted-in resources, share-link tokens)./metawrapper and the Phase 1 patches into it, so there is one gate with one exemption list instead of N per-plugin copies.discover()ratchet to enumerate HTTP entry points from source, so a new ungated surface fails CI as unclassified instead of shipping open.Acceptance
POST /graphqland anonymous raw-hono/data401 under the default posture; explicit opt-out keeps them reachable. (Anonymous/metaalready proven.)Context: #2561 (authorization gap map), #2562 (the flip), ADR-0056 D2, ADR-0060 (conformance ledger).