diff --git a/.changeset/enforce-object-api-flags.md b/.changeset/enforce-object-api-flags.md new file mode 100644 index 0000000000..ef2b940fa5 --- /dev/null +++ b/.changeset/enforce-object-api-flags.md @@ -0,0 +1,5 @@ +--- +"@objectstack/rest": minor +--- + +feat(rest): enforce object-level API exposure (`enable.apiEnabled` / `enable.apiMethods`) on the REST data surface (ADR-0049 #1889). Previously these flags were parsed but unenforced — an object could not be hidden from the automatic API, a false sense of security. Now: `apiEnabled: false` → the object's `/api/v1/data/{object}` routes return 404 (existence not revealed); a non-empty `apiMethods` whitelist → operations outside it return 405. Enforced across list/get/create/query/update/delete/import/export/batch/createMany/updateMany/deleteMany. Default-allow (objects with no `enable` block, or `apiEnabled` unset/true and no `apiMethods`) behave exactly as before — no regression. This is the *external* API boundary only; internal callers (hooks, flows, objectql) are unaffected. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index eb515d68dc..ebc72d2ee0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -736,6 +736,67 @@ export class RestServer { return true; } + /** + * Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` / + * `enable.apiMethods`) on the REST data surface — the *external* API boundary + * only. Internal callers (hooks, flows, raw objectql) are unaffected, which is + * the point: `apiEnabled` controls automatic API exposure, not data access. + * + * - `enable.apiEnabled === false` → object hidden from the API (404, so its + * existence isn't revealed). + * - `enable.apiMethods` (non-empty whitelist) → unlisted operations rejected (405). + * + * Default-allow: objects with no `enable` block (or `apiEnabled` unset/true and + * no `apiMethods` whitelist) behave exactly as before — no regression. Unknown + * objects fall through to the normal 404 path. A metadata-read failure does not + * block (the data call itself needs the same metadata and will surface the + * error). Returns `true` when the request was blocked (response already sent). + * + * See ADR-0049 (#1889): shipping a non-enforcing `apiEnabled` is false security. + */ + private async enforceApiAccess( + req: any, + res: any, + p: ObjectStackProtocol, + environmentId: string | undefined, + operation: string, + ): Promise { + const objectName = req?.params?.object; + if (!objectName) return false; + let enable: any; + try { + const r: any = await (p as any).getMetaItems?.({ + type: 'object', + ...(environmentId ? { environmentId } : {}), + }); + const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; + const obj = items.find((o: any) => o?.name === objectName); + if (!obj) return false; // unknown object → let the data path 404 + enable = obj.enable; + } catch { + return false; // metadata unavailable → don't block (data call needs it too) + } + if (!enable) return false; + if (enable.apiEnabled === false) { + res.status(404).json({ + error: `Object '${objectName}' is not exposed via the API`, + code: 'OBJECT_API_DISABLED', + object: objectName, + }); + return true; + } + if (Array.isArray(enable.apiMethods) && enable.apiMethods.length > 0 && !enable.apiMethods.includes(operation)) { + res.status(405).json({ + error: `API operation '${operation}' is not allowed on object '${objectName}'`, + code: 'OBJECT_API_METHOD_NOT_ALLOWED', + object: objectName, + allowed: enable.apiMethods, + }); + return true; + } + return false; + } + /** * Resolve the request's execution context (RBAC/RLS/FLS) by looking up * the better-auth session via the project's `auth` service. Returns @@ -2646,6 +2707,7 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return; const result = await p.findData({ object: req.params.object, query: req.query, @@ -2682,6 +2744,7 @@ export class RestServer { const { select, expand } = req.query || {}; const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'get')) return; const result = await p.getData({ object: req.params.object, id: req.params.id, @@ -2715,6 +2778,7 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return; const result = await p.createData({ object: req.params.object, data: req.body, @@ -2749,6 +2813,7 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return; const result = await p.findData({ object: req.params.object, query: req.body || {}, @@ -2798,6 +2863,7 @@ export class RestServer { const { expectedVersion: _drop, ...rest } = data as any; data = rest; } + if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return; const result = await p.updateData({ object: req.params.object, id: req.params.id, @@ -2840,6 +2906,7 @@ export class RestServer { ? (req.query as any).expectedVersion : undefined; const expectedVersion = queryVersion ?? ifMatchHeader; + if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return; const result = await p.deleteData({ object: req.params.object, id: req.params.id, @@ -2932,6 +2999,7 @@ export class RestServer { res.status(400).json({ code: 'INVALID_REQUEST', error: 'object is required' }); return; } + if (await this.enforceApiAccess(req, res, p, environmentId, 'import')) return; const body = req.body ?? {}; const dryRun = body.dryRun === true; const mapping: Record = body.mapping ?? {}; @@ -3037,6 +3105,7 @@ export class RestServer { res.status(400).json({ code: 'INVALID_REQUEST', error: 'object is required' }); return; } + if (await this.enforceApiAccess(req, res, p, environmentId, 'export')) return; const q = req.query ?? {}; const format = (String(q.format ?? 'csv')).toLowerCase() === 'json' ? 'json' : 'csv'; const HARD_CAP = 50_000; @@ -4750,6 +4819,7 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'bulk')) return; const result = await p.batchData!({ object: req.params.object, request: req.body, @@ -4780,6 +4850,7 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return; const result = await p.createManyData!({ object: req.params.object, records: req.body || [], @@ -4810,6 +4881,7 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return; const result = await p.updateManyData!({ object: req.params.object, ...req.body, @@ -4840,6 +4912,7 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return; const result = await p.deleteManyData!({ object: req.params.object, ...req.body, diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 1e8f4fdc55..fb4dafcfb5 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1799,3 +1799,101 @@ describe('filterAppForUser — ADR-0045 hidden-app gate', () => { ).toBe('production_management'); }); }); + +// --------------------------------------------------------------------------- +// Object API exposure — enable.apiEnabled / enable.apiMethods (ADR-0049 #1889) +// --------------------------------------------------------------------------- + +describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => { + function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; + } + // Build a server with one object whose `enable` block is under test. + function setup(enable: any | undefined) { + const server = createMockServer(); + const protocol = createMockProtocol(); + protocol.batchData = vi.fn().mockResolvedValue({}); + protocol.createManyData = vi.fn().mockResolvedValue([]); + protocol.updateManyData = vi.fn().mockResolvedValue([]); + protocol.deleteManyData = vi.fn().mockResolvedValue([]); + protocol.getMetaItems = vi.fn().mockResolvedValue( + enable === undefined + ? [{ name: 'widget' }] + : [{ name: 'widget', enable }], + ); + const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + rest.registerRoutes(); + return { rest, protocol }; + } + async function invoke(rest: any, method: string, path: string, params: any, body?: any) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`route not found: ${method} ${path}`); + const res = makeRes(); + await route.handler({ method, params, query: {}, body: body ?? {} }, res); + return res; + } + const LIST = '/api/v1/data/:object'; + const BY_ID = '/api/v1/data/:object/:id'; + + it('apiEnabled:false → 404 on list, and the data engine is never called', async () => { + const { rest, protocol } = setup({ apiEnabled: false }); + const res = await invoke(rest, 'GET', LIST, { object: 'widget' }); + expect(res.statusCode).toBe(404); + expect(res.body.code).toBe('OBJECT_API_DISABLED'); + expect(protocol.findData).not.toHaveBeenCalled(); + }); + + it('apiEnabled:false → 404 on create (write blocked too)', async () => { + const { rest, protocol } = setup({ apiEnabled: false }); + const res = await invoke(rest, 'POST', LIST, { object: 'widget' }, { a: 1 }); + expect(res.statusCode).toBe(404); + expect(protocol.createData).not.toHaveBeenCalled(); + }); + + it('apiMethods whitelist → disallowed op returns 405', async () => { + const { rest, protocol } = setup({ apiMethods: ['get', 'list'] }); + const res = await invoke(rest, 'POST', LIST, { object: 'widget' }, { a: 1 }); + expect(res.statusCode).toBe(405); + expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(res.body.allowed).toEqual(['get', 'list']); + expect(protocol.createData).not.toHaveBeenCalled(); + }); + + it('apiMethods whitelist → allowed op passes through to the engine', async () => { + const { rest, protocol } = setup({ apiMethods: ['get', 'list'] }); + const res = await invoke(rest, 'GET', LIST, { object: 'widget' }); + expect(res.statusCode).toBe(200); + expect(protocol.findData).toHaveBeenCalledTimes(1); + }); + + it('default object (no enable block) is unaffected — no regression', async () => { + const { rest, protocol } = setup(undefined); + const res = await invoke(rest, 'GET', LIST, { object: 'widget' }); + expect(res.statusCode).toBe(200); + expect(protocol.findData).toHaveBeenCalledTimes(1); + }); + + it('apiEnabled:true explicit → passes', async () => { + const { rest, protocol } = setup({ apiEnabled: true }); + await invoke(rest, 'GET', BY_ID, { object: 'widget', id: '1' }); + expect(protocol.getData).toHaveBeenCalledTimes(1); + }); + + it('unknown object (not in metadata) is not blocked by the guard', async () => { + const { rest, protocol } = setup({ apiEnabled: false }); // only 'widget' is hidden + const res = await invoke(rest, 'GET', LIST, { object: 'other' }); + expect(res.statusCode).toBe(200); + expect(protocol.findData).toHaveBeenCalledTimes(1); + }); + + it('apiEnabled:false also blocks bulk createMany', async () => { + const { rest, protocol } = setup({ apiEnabled: false }); + const res = await invoke(rest, 'POST', '/api/v1/data/:object/createMany', { object: 'widget' }, []); + expect(res.statusCode).toBe(404); + expect(protocol.createManyData).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 9ca49c99ce..cea730a9ff 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -32,7 +32,20 @@ "crossTenantAccess": { "status": "dead", "evidence": "inert" } } }, - "enable": { "status": "dead", "evidence": "ObjectCapabilities block (trackHistory/searchable/apiEnabled/apiMethods/files/feeds/activities/trash/mru/clone) — all 10 flags have zero behavior-changing readers", "note": "apiEnabled/apiMethods NOT enforced by REST — false sense of security (ADR-0049-class)." }, + "enable": { + "children": { + "apiEnabled": { "status": "live", "evidence": "packages/rest/src/rest-server.ts", "note": "enforced by enforceApiAccess on the REST data surface (ADR-0049 #1889) — apiEnabled:false → 404." }, + "apiMethods": { "status": "live", "evidence": "packages/rest/src/rest-server.ts", "note": "enforced by enforceApiAccess — operations outside the whitelist → 405." }, + "trackHistory": { "status": "dead", "evidence": "no behavior-changing reader (database-loader.trackHistory is a separate ctor option)" }, + "searchable": { "status": "dead", "evidence": "no behavior-changing reader" }, + "files": { "status": "dead", "evidence": "no behavior-changing reader" }, + "feeds": { "status": "dead", "evidence": "no behavior-changing reader" }, + "activities": { "status": "dead", "evidence": "no behavior-changing reader" }, + "trash": { "status": "dead", "evidence": "no behavior-changing reader" }, + "mru": { "status": "dead", "evidence": "no behavior-changing reader" }, + "clone": { "status": "dead", "evidence": "no behavior-changing reader" } + } + }, "versioning": { "status": "dead", "evidence": "aspirational; no runtime" }, "partitioning": { "status": "dead", "evidence": "aspirational; no runtime" }, "cdc": { "status": "dead", "evidence": "aspirational; no runtime" },