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
37 changes: 37 additions & 0 deletions .changeset/authz-2567-anonymous-posture-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/runtime': minor
'@objectstack/plugin-hono-server': minor
---

fix(security): enforce the anonymous-deny posture uniformly across HTTP surfaces (#2567)

The ADR-0056 D2 `requireAuth` flip made REST `/data/*` deny-anonymous by
default, but three sibling surfaces reached ObjectQL without passing through the
gate — so the platform's anonymous posture was **inconsistent by surface**: an
anonymous caller denied on `/data` could read the same object data through a
different door. This closes the remaining two gaps (the `/meta` gate had already
landed) and pins every surface with a conformance row.

- **Dispatcher GraphQL** (`runtime/http-dispatcher.ts`, `dispatcher-plugin.ts`):
`POST /graphql` reached `kernel.graphql`, whose security middleware falls
**open** for an anonymous context. `handleGraphQL` now applies the same
`requireAuth` gate as `/data` and `/meta`, resolving identity for the direct
route that does not flow through `dispatch()`. The dispatcher's `requireAuth`
default is aligned with the REST plugin's (`?? true`) so a bare host no longer
denies anonymous `/data` while serving the same rows over `/graphql`; an
explicit `requireAuth: false` opt-out is honoured and logs a boot warning.

- **Raw-hono standard `/data` routes** (`plugin-hono-server/hono-plugin.ts`):
these delegate straight to ObjectQL and were only *shadowed* when the REST
plugin registered the same paths first — so secure-by-default depended on
plugin registration order. Each route now consults `requireAuth` (secure by
default, mirroring `rest-server.ts`), making the deny decision a property of
this entry point too. Order no longer affects the anonymous posture.

**Behaviour change:** on a `requireAuth` deployment (the secure default),
anonymous `POST /graphql` and anonymous raw-hono `/data` now return 401.
Deployments that intentionally serve these surfaces publicly set
`requireAuth: false` (a boot warning is logged). Proven end-to-end on the
platform default in `showcase-anonymous-deny-surfaces.dogfood.test.ts`, with
handler-level regression coverage in `http-dispatcher.requireauth.test.ts` and
`hono-anonymous-deny.test.ts`, and pinned by three new authz-conformance rows.
12 changes: 10 additions & 2 deletions packages/client/src/client.hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ describe('ObjectStackClient (with Hono Server)', () => {
kernel.use(new ObjectQLPlugin());

// 2. Setup Hono Plugin
const honoPlugin = new HonoServerPlugin({
// This suite exercises the CLIENT's data operations over the raw-hono
// standard endpoints; it wires no auth service. Opt out of the
// secure-by-default anonymous-deny posture (#2567) so the anonymous
// data CRUD under test stays reachable — the same explicit
// `requireAuth: false` a deployment that intentionally serves data
// publicly would set. The gate itself is proven in
// plugin-hono-server/hono-anonymous-deny.test.ts.
const honoPlugin = new HonoServerPlugin({
port: 0,
registerStandardEndpoints: true
registerStandardEndpoints: true,
restConfig: { api: { requireAuth: false } } as any,
});
kernel.use(honoPlugin);

Expand Down
15 changes: 15 additions & 0 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
note: 'An organization_admin holds the superuser bit via its `*` wildcard, so it used to also get the Layer 0 exemption and read/write EVERY tenant\'s rows on private tenant objects. The exemption now requires a platform-exclusive capability (manage_metadata/manage_platform_settings/studio.access/manage_users), which org_admin deliberately lacks — a SECURITY NARROWING: org admin is walled to its own org, a true platform admin still crosses, the better-auth carve-out is untouched. Unit-proven in plugin-security/authz-matrix-gate.test.ts ([Finding 2 / #2937] Layer 0 cross-tenant exemption requires the platform posture).' },
{ id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced',
enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' },
// ── #2567 — the anonymous-deny posture is UNIFORM across HTTP surfaces, not
// just REST `/data`. Each sibling surface that reaches ObjectQL now consults
// the same `requireAuth` gate; these rows pin every entry point so a new
// ungated surface (or a silent regression) fails CI, not review.
{ id: 'anonymous-deny-meta', summary: 'anonymous-deny on the metadata endpoints (#2567 surface 1)', state: 'enforced',
enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all',
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts' },
{ id: 'anonymous-deny-graphql', summary: 'anonymous-deny on the dispatcher GraphQL endpoint (#2567 surface 2)', state: 'enforced',
enforcement: 'runtime/http-dispatcher.ts handleGraphQL (requireAuth gate, resolves identity for the direct /graphql route) + runtime/dispatcher-plugin.ts requireAuth default(true), mirroring rest-server.ts',
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
note: 'GraphQL reaches the same object data as /data through kernel.graphql, whose security middleware falls OPEN for an anonymous context. Unit-proven in runtime/http-dispatcher.requireauth.test.ts (GraphQL block); e2e on the platform default in the surfaces proof.' },
{ id: 'anonymous-deny-hono-data', summary: 'anonymous-deny on the raw-hono standard /data routes (#2567 surface 3)', state: 'enforced',
enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)',
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.' },
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },

Expand Down
7 changes: 6 additions & 1 deletion packages/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => {
it('is a sound conformance ledger (ADR-0060 checkLedger)', () => {
const problems = checkLedger(AUTHZ_CONFORMANCE, {
proofRoot: HERE, // proofs are dogfood test files alongside this one
highRisk: ['owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile'],
highRisk: [
'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile',
// #2567 — every anonymous-deny HTTP surface is high-risk: it guards the
// same object data as REST `/data` through a sibling entry point.
'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data',
],
});
expect(problems, problems.join('\n')).toEqual([]);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #2567 — anonymous posture must be UNIFORM across HTTP surfaces, not just the
// REST `/data` routes proven by showcase-anonymous-deny.dogfood.test.ts. Before
// this fix, on a `requireAuth` deployment `/data/*` denied anonymous callers
// while three sibling surfaces reached ObjectQL without the gate:
// - the metadata endpoints (`/meta`)
// - the dispatcher GraphQL endpoint (`/graphql`)
// - the raw-hono standard `/data` routes (order-dependent shadowing)
//
// This proof boots the real showcase HTTP stack ON THE PLATFORM DEFAULT (the
// verify harness passes no `requireAuth` override, so the flipped secure default
// is what a fresh production deployment gets) and asserts every surface denies
// an anonymous caller with 401 while an authenticated member is unaffected.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack from '@objectstack/example-showcase';
import { bootStack, type VerifyStack } from '@objectstack/verify';

const OBJ = '/data/showcase_private_note';

describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => {
let stack: VerifyStack;
let memberToken: string;

beforeAll(async () => {
stack = await bootStack(showcaseStack); // platform default (deny anonymous)
await stack.signIn();
memberToken = await stack.signUp('surfaces-member@verify.test');
}, 60_000);

afterAll(async () => { await stack?.stop(); });

// ── /meta ──────────────────────────────────────────────────────────────
it('anonymous GET /meta is denied (401)', async () => {
const r = await stack.api('/meta', { method: 'GET' });
expect(r.status, 'anonymous metadata read must be 401').toBe(401);
});

it('an authenticated member is NOT denied on /meta (deny targets anonymity)', async () => {
const r = await stack.apiAs(memberToken, 'GET', '/meta');
expect(r.status, 'authenticated metadata read must clear the auth gate').not.toBe(401);
});

// ── /graphql ─────────────────────────────────────────────────────────────
it('anonymous POST /graphql is denied (401)', async () => {
const r = await stack.api('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __typename }' }),
});
expect(r.status, 'anonymous GraphQL query must be 401').toBe(401);
});

it('an authenticated member clears the /graphql gate (not 401)', async () => {
// Past the gate the query may 200 or 501 (depending on whether a GraphQL
// service is wired) — the point is it is NOT the anonymous 401.
const r = await stack.apiAs(memberToken, 'POST', '/graphql', { query: '{ __typename }' });
expect(r.status, 'authenticated GraphQL must clear the auth gate').not.toBe(401);
});

// ── /data (surface-level; raw-hono handler proven in plugin-hono-server) ──
it('anonymous READ of the data surface is denied (401)', async () => {
const r = await stack.api(OBJ, { method: 'GET' });
expect(r.status, 'anonymous data read must be 401').toBe(401);
});

it('an authenticated member is allowed on the data surface', async () => {
const r = await stack.apiAs(memberToken, 'GET', OBJ);
expect(r.status).toBe(200);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #2567 — the raw-hono standard `/data` endpoints must honour the same
// secure-by-default (`requireAuth`) anonymous-deny posture as the REST `/data`
// routes. Before the gate, these routes delegated straight to ObjectQL and were
// only *shadowed* when the REST plugin registered the same paths first — so the
// anonymous posture depended on plugin registration order. These tests drive the
// real routes on a real Hono app (no REST plugin in the picture) to prove the
// gate stands on its own.

import { describe, it, expect } from 'vitest';
import { HonoServerPlugin } from './hono-plugin';

/**
* Boot a plugin and register ONLY the standard CRUD routes on its real Hono
* app, then hand back the app so tests can drive HTTP requests directly. No
* listening socket, no CORS/static — we exercise the data routes in isolation.
*/
function bootStandardEndpoints(opts: {
restConfig?: { api?: { requireAuth?: boolean } };
services: Record<string, unknown>;
}) {
const plugin = new HonoServerPlugin({ port: 0, restConfig: opts.restConfig as any });
const ctx: any = {
logger: { info() {}, debug() {}, warn() {}, error() {} },
getKernel: () => ({ getService: (n: string) => opts.services[n] }),
registerService: () => {},
hook: () => {},
getService: (n: string) => {
const s = opts.services[n];
if (s === undefined) throw new Error(`no service: ${n}`);
return s;
},
};
(plugin as any).registerDiscoveryAndCrudEndpoints(ctx);
return (plugin as any).server.getRawApp();
}

// ObjectQL stub — every read returns empty, every write echoes an id. Enough
// for the routes to reach a 200 once the gate has been cleared.
const objectql = {
find: async () => [],
insert: async () => ({ id: 'new-id' }),
};

// Auth stub — resolves a session ONLY when the request carries `x-test-user`,
// so the same boot serves both anonymous and authenticated requests.
const auth = {
api: {
getSession: async ({ headers }: { headers: Headers }) => {
const uid = headers?.get?.('x-test-user');
return uid ? { user: { id: uid }, session: {} } : null;
},
},
};

const REQ = 'http://localhost/api/v1/data/thing';

describe('raw-hono /data — anonymous-deny gate (#2567)', () => {
it('secure-by-default (no restConfig): anonymous LIST is 401', async () => {
const app = bootStandardEndpoints({ services: { objectql, auth } });
const res = await app.request(REQ, { method: 'GET' });
expect(res.status).toBe(401);
});

it('secure-by-default: anonymous GET-by-id is 401', async () => {
const app = bootStandardEndpoints({ services: { objectql, auth } });
const res = await app.request(`${REQ}/abc`, { method: 'GET' });
expect(res.status).toBe(401);
});

it('secure-by-default: anonymous CREATE is 401', async () => {
const app = bootStandardEndpoints({ services: { objectql, auth } });
const res = await app.request(REQ, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'x' }),
});
expect(res.status).toBe(401);
});

it('an AUTHENTICATED caller is served (deny targets anonymity, not the route)', async () => {
const app = bootStandardEndpoints({ services: { objectql, auth } });
const res = await app.request(REQ, { method: 'GET', headers: { 'x-test-user': 'u1' } });
expect(res.status).toBe(200);
});

it('explicit opt-out (requireAuth:false) keeps the surface anonymously reachable', async () => {
const app = bootStandardEndpoints({
restConfig: { api: { requireAuth: false } },
services: { objectql, auth },
});
const res = await app.request(REQ, { method: 'GET' });
expect(res.status).toBe(200);
});
});
46 changes: 46 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,46 @@ export class HonoServerPlugin implements Plugin {

ctx.logger.info('Registered discovery endpoints', { prefix });

// ── Anonymous-deny gate (ADR-0056 D2, #2567) ──────────────────────────
// These raw `/data/:object` routes delegate straight to ObjectQL. They
// are only *shadowed* by the REST plugin's gated `/data` routes when
// that plugin registers the same paths FIRST — so before this gate the
// platform's anonymous posture depended on plugin registration order: a
// load-order change silently reopened anonymous data access with no test
// failing. Gating here makes the deny decision a property of THIS entry
// point too, so security no longer depends on who registered first.
//
// Secure-by-default: `requireAuth` mirrors `rest-server.ts`'s `?? true`
// (ADR-0056 D2). A deployment that intentionally serves data publicly
// sets `restConfig.api.requireAuth = false` (a boot warning is logged, as
// in the REST plugin). No-op in that case — the previously-public surface
// is unchanged. An authenticated / system caller always passes.
//
// `requireAuth` is not in the typed `api` shape (rest-server.ts reads it
// via the same `as any` cast), so widen locally.
const requireAuth =
(this.options.restConfig?.api as { requireAuth?: boolean } | undefined)?.requireAuth ?? true;
if (!requireAuth) {
ctx.logger.warn(
'Hono standard /data endpoints: requireAuth is OFF — anonymous callers can read/write object data. ' +
'This is a deliberate opt-out; set restConfig.requireAuth=true to deny anonymous access (ADR-0056 D2, #2567).',
);
}
// Returns a 401 Response when the caller is anonymous under the deny
// posture, else null (caller proceeds). `isSystem` is never set on
// inbound HTTP (internal-only), so it cannot be forged to bypass this.
const denyAnonymous = (c: any, execCtx: any): Response | null => {
if (!requireAuth) return null;
if (execCtx?.userId || execCtx?.isSystem) return null;
return c.json(
{
error: 'unauthenticated',
message: 'Authentication is required to access this endpoint.',
},
401,
);
};

// Basic CRUD data endpoints — delegate to ObjectQL service directly
const getObjectQL = () => ctx.getService<IDataEngine>('objectql');

Expand Down Expand Up @@ -675,6 +715,8 @@ export class HonoServerPlugin implements Plugin {
const object = c.req.param('object');
const data = await c.req.json().catch(() => ({}));
const execCtx = await resolveCtx(c);
const denied = denyAnonymous(c, execCtx);
if (denied) return denied;
try {
const res = await ql.insert(object, data, { context: execCtx } as any);
const record = { ...data, ...res };
Expand All @@ -694,6 +736,8 @@ export class HonoServerPlugin implements Plugin {
const object = c.req.param('object');
const id = c.req.param('id');
const execCtx = await resolveCtx(c);
const denied = denyAnonymous(c, execCtx);
if (denied) return denied;
try {
let all = await ql.find(object, { context: execCtx } as any);
if (!all) all = [];
Expand All @@ -713,6 +757,8 @@ export class HonoServerPlugin implements Plugin {
if (!ql) return c.json({ error: 'Data service not available' }, 503);
const object = c.req.param('object');
const execCtx = await resolveCtx(c);
const denied = denyAnonymous(c, execCtx);
if (denied) return denied;
try {
let all = await ql.find(object, { context: execCtx } as any);
if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value;
Expand Down
Loading