Skip to content

Commit 7fe0b91

Browse files
os-zhuangclaude
andauthored
feat(rest): enforce object enable.apiEnabled / apiMethods (ADR-0049 #1889) (#1937)
The metadata-liveness audit found object `enable.apiEnabled`/`apiMethods` parsed but NOT enforced by REST — an object could not be hidden from the automatic API despite the flag, a false sense of security. This wires them into the REST data surface (the external API boundary only — internal objectql/hooks/flows unaffected): - new RestServer.enforceApiAccess() loads object metadata and, per request: - enable.apiEnabled === false → 404 OBJECT_API_DISABLED (existence not revealed) - enable.apiMethods (non-empty whitelist) → unlisted op → 405 OBJECT_API_METHOD_NOT_ALLOWED - called across all 12 data entry points: list/get/create/query/update/delete/ import/export/batch/createMany/updateMany/deleteMany. - default-allow: no `enable` block, or apiEnabled unset/true + no apiMethods → no change (no regression). Unknown objects fall through to the normal 404. A metadata-read failure does not block (the data call needs the same metadata). 8 new tests (apiEnabled 404 on read+write, apiMethods 405/allow, no-regression default, unknown-object pass-through, bulk path). Full rest suite green (110). Closes the apiEnabled gap from object liveness; the gate now classifies enable.apiEnabled/apiMethods live (evidence: rest-server.ts). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 51b1c3c commit 7fe0b91

4 files changed

Lines changed: 190 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
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.

packages/rest/src/rest-server.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,67 @@ export class RestServer {
736736
return true;
737737
}
738738

739+
/**
740+
* Enforce object-level API exposure (ObjectSchema `enable.apiEnabled` /
741+
* `enable.apiMethods`) on the REST data surface — the *external* API boundary
742+
* only. Internal callers (hooks, flows, raw objectql) are unaffected, which is
743+
* the point: `apiEnabled` controls automatic API exposure, not data access.
744+
*
745+
* - `enable.apiEnabled === false` → object hidden from the API (404, so its
746+
* existence isn't revealed).
747+
* - `enable.apiMethods` (non-empty whitelist) → unlisted operations rejected (405).
748+
*
749+
* Default-allow: objects with no `enable` block (or `apiEnabled` unset/true and
750+
* no `apiMethods` whitelist) behave exactly as before — no regression. Unknown
751+
* objects fall through to the normal 404 path. A metadata-read failure does not
752+
* block (the data call itself needs the same metadata and will surface the
753+
* error). Returns `true` when the request was blocked (response already sent).
754+
*
755+
* See ADR-0049 (#1889): shipping a non-enforcing `apiEnabled` is false security.
756+
*/
757+
private async enforceApiAccess(
758+
req: any,
759+
res: any,
760+
p: ObjectStackProtocol,
761+
environmentId: string | undefined,
762+
operation: string,
763+
): Promise<boolean> {
764+
const objectName = req?.params?.object;
765+
if (!objectName) return false;
766+
let enable: any;
767+
try {
768+
const r: any = await (p as any).getMetaItems?.({
769+
type: 'object',
770+
...(environmentId ? { environmentId } : {}),
771+
});
772+
const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
773+
const obj = items.find((o: any) => o?.name === objectName);
774+
if (!obj) return false; // unknown object → let the data path 404
775+
enable = obj.enable;
776+
} catch {
777+
return false; // metadata unavailable → don't block (data call needs it too)
778+
}
779+
if (!enable) return false;
780+
if (enable.apiEnabled === false) {
781+
res.status(404).json({
782+
error: `Object '${objectName}' is not exposed via the API`,
783+
code: 'OBJECT_API_DISABLED',
784+
object: objectName,
785+
});
786+
return true;
787+
}
788+
if (Array.isArray(enable.apiMethods) && enable.apiMethods.length > 0 && !enable.apiMethods.includes(operation)) {
789+
res.status(405).json({
790+
error: `API operation '${operation}' is not allowed on object '${objectName}'`,
791+
code: 'OBJECT_API_METHOD_NOT_ALLOWED',
792+
object: objectName,
793+
allowed: enable.apiMethods,
794+
});
795+
return true;
796+
}
797+
return false;
798+
}
799+
739800
/**
740801
* Resolve the request's execution context (RBAC/RLS/FLS) by looking up
741802
* the better-auth session via the project's `auth` service. Returns
@@ -2646,6 +2707,7 @@ export class RestServer {
26462707
const p = await this.resolveProtocol(environmentId, req);
26472708
const context = await this.resolveExecCtx(environmentId, req);
26482709
if (this.enforceAuth(req, res, context)) return;
2710+
if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return;
26492711
const result = await p.findData({
26502712
object: req.params.object,
26512713
query: req.query,
@@ -2682,6 +2744,7 @@ export class RestServer {
26822744
const { select, expand } = req.query || {};
26832745
const context = await this.resolveExecCtx(environmentId, req);
26842746
if (this.enforceAuth(req, res, context)) return;
2747+
if (await this.enforceApiAccess(req, res, p, environmentId, 'get')) return;
26852748
const result = await p.getData({
26862749
object: req.params.object,
26872750
id: req.params.id,
@@ -2715,6 +2778,7 @@ export class RestServer {
27152778
const p = await this.resolveProtocol(environmentId, req);
27162779
const context = await this.resolveExecCtx(environmentId, req);
27172780
if (this.enforceAuth(req, res, context)) return;
2781+
if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return;
27182782
const result = await p.createData({
27192783
object: req.params.object,
27202784
data: req.body,
@@ -2749,6 +2813,7 @@ export class RestServer {
27492813
const p = await this.resolveProtocol(environmentId, req);
27502814
const context = await this.resolveExecCtx(environmentId, req);
27512815
if (this.enforceAuth(req, res, context)) return;
2816+
if (await this.enforceApiAccess(req, res, p, environmentId, 'list')) return;
27522817
const result = await p.findData({
27532818
object: req.params.object,
27542819
query: req.body || {},
@@ -2798,6 +2863,7 @@ export class RestServer {
27982863
const { expectedVersion: _drop, ...rest } = data as any;
27992864
data = rest;
28002865
}
2866+
if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return;
28012867
const result = await p.updateData({
28022868
object: req.params.object,
28032869
id: req.params.id,
@@ -2840,6 +2906,7 @@ export class RestServer {
28402906
? (req.query as any).expectedVersion
28412907
: undefined;
28422908
const expectedVersion = queryVersion ?? ifMatchHeader;
2909+
if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return;
28432910
const result = await p.deleteData({
28442911
object: req.params.object,
28452912
id: req.params.id,
@@ -2932,6 +2999,7 @@ export class RestServer {
29322999
res.status(400).json({ code: 'INVALID_REQUEST', error: 'object is required' });
29333000
return;
29343001
}
3002+
if (await this.enforceApiAccess(req, res, p, environmentId, 'import')) return;
29353003
const body = req.body ?? {};
29363004
const dryRun = body.dryRun === true;
29373005
const mapping: Record<string, string> = body.mapping ?? {};
@@ -3037,6 +3105,7 @@ export class RestServer {
30373105
res.status(400).json({ code: 'INVALID_REQUEST', error: 'object is required' });
30383106
return;
30393107
}
3108+
if (await this.enforceApiAccess(req, res, p, environmentId, 'export')) return;
30403109
const q = req.query ?? {};
30413110
const format = (String(q.format ?? 'csv')).toLowerCase() === 'json' ? 'json' : 'csv';
30423111
const HARD_CAP = 50_000;
@@ -4750,6 +4819,7 @@ export class RestServer {
47504819
const p = await this.resolveProtocol(environmentId, req);
47514820
const context = await this.resolveExecCtx(environmentId, req);
47524821
if (this.enforceAuth(req, res, context)) return;
4822+
if (await this.enforceApiAccess(req, res, p, environmentId, 'bulk')) return;
47534823
const result = await p.batchData!({
47544824
object: req.params.object,
47554825
request: req.body,
@@ -4780,6 +4850,7 @@ export class RestServer {
47804850
const p = await this.resolveProtocol(environmentId, req);
47814851
const context = await this.resolveExecCtx(environmentId, req);
47824852
if (this.enforceAuth(req, res, context)) return;
4853+
if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return;
47834854
const result = await p.createManyData!({
47844855
object: req.params.object,
47854856
records: req.body || [],
@@ -4810,6 +4881,7 @@ export class RestServer {
48104881
const p = await this.resolveProtocol(environmentId, req);
48114882
const context = await this.resolveExecCtx(environmentId, req);
48124883
if (this.enforceAuth(req, res, context)) return;
4884+
if (await this.enforceApiAccess(req, res, p, environmentId, 'update')) return;
48134885
const result = await p.updateManyData!({
48144886
object: req.params.object,
48154887
...req.body,
@@ -4840,6 +4912,7 @@ export class RestServer {
48404912
const p = await this.resolveProtocol(environmentId, req);
48414913
const context = await this.resolveExecCtx(environmentId, req);
48424914
if (this.enforceAuth(req, res, context)) return;
4915+
if (await this.enforceApiAccess(req, res, p, environmentId, 'delete')) return;
48434916
const result = await p.deleteManyData!({
48444917
object: req.params.object,
48454918
...req.body,

packages/rest/src/rest.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,3 +1799,101 @@ describe('filterAppForUser — ADR-0045 hidden-app gate', () => {
17991799
).toBe('production_management');
18001800
});
18011801
});
1802+
1803+
// ---------------------------------------------------------------------------
1804+
// Object API exposure — enable.apiEnabled / enable.apiMethods (ADR-0049 #1889)
1805+
// ---------------------------------------------------------------------------
1806+
1807+
describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => {
1808+
function makeRes() {
1809+
const res: any = { statusCode: 200, body: undefined };
1810+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
1811+
res.json = vi.fn((b: any) => { res.body = b; return res; });
1812+
res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn();
1813+
return res;
1814+
}
1815+
// Build a server with one object whose `enable` block is under test.
1816+
function setup(enable: any | undefined) {
1817+
const server = createMockServer();
1818+
const protocol = createMockProtocol();
1819+
protocol.batchData = vi.fn().mockResolvedValue({});
1820+
protocol.createManyData = vi.fn().mockResolvedValue([]);
1821+
protocol.updateManyData = vi.fn().mockResolvedValue([]);
1822+
protocol.deleteManyData = vi.fn().mockResolvedValue([]);
1823+
protocol.getMetaItems = vi.fn().mockResolvedValue(
1824+
enable === undefined
1825+
? [{ name: 'widget' }]
1826+
: [{ name: 'widget', enable }],
1827+
);
1828+
const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);
1829+
rest.registerRoutes();
1830+
return { rest, protocol };
1831+
}
1832+
async function invoke(rest: any, method: string, path: string, params: any, body?: any) {
1833+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
1834+
if (!route) throw new Error(`route not found: ${method} ${path}`);
1835+
const res = makeRes();
1836+
await route.handler({ method, params, query: {}, body: body ?? {} }, res);
1837+
return res;
1838+
}
1839+
const LIST = '/api/v1/data/:object';
1840+
const BY_ID = '/api/v1/data/:object/:id';
1841+
1842+
it('apiEnabled:false → 404 on list, and the data engine is never called', async () => {
1843+
const { rest, protocol } = setup({ apiEnabled: false });
1844+
const res = await invoke(rest, 'GET', LIST, { object: 'widget' });
1845+
expect(res.statusCode).toBe(404);
1846+
expect(res.body.code).toBe('OBJECT_API_DISABLED');
1847+
expect(protocol.findData).not.toHaveBeenCalled();
1848+
});
1849+
1850+
it('apiEnabled:false → 404 on create (write blocked too)', async () => {
1851+
const { rest, protocol } = setup({ apiEnabled: false });
1852+
const res = await invoke(rest, 'POST', LIST, { object: 'widget' }, { a: 1 });
1853+
expect(res.statusCode).toBe(404);
1854+
expect(protocol.createData).not.toHaveBeenCalled();
1855+
});
1856+
1857+
it('apiMethods whitelist → disallowed op returns 405', async () => {
1858+
const { rest, protocol } = setup({ apiMethods: ['get', 'list'] });
1859+
const res = await invoke(rest, 'POST', LIST, { object: 'widget' }, { a: 1 });
1860+
expect(res.statusCode).toBe(405);
1861+
expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED');
1862+
expect(res.body.allowed).toEqual(['get', 'list']);
1863+
expect(protocol.createData).not.toHaveBeenCalled();
1864+
});
1865+
1866+
it('apiMethods whitelist → allowed op passes through to the engine', async () => {
1867+
const { rest, protocol } = setup({ apiMethods: ['get', 'list'] });
1868+
const res = await invoke(rest, 'GET', LIST, { object: 'widget' });
1869+
expect(res.statusCode).toBe(200);
1870+
expect(protocol.findData).toHaveBeenCalledTimes(1);
1871+
});
1872+
1873+
it('default object (no enable block) is unaffected — no regression', async () => {
1874+
const { rest, protocol } = setup(undefined);
1875+
const res = await invoke(rest, 'GET', LIST, { object: 'widget' });
1876+
expect(res.statusCode).toBe(200);
1877+
expect(protocol.findData).toHaveBeenCalledTimes(1);
1878+
});
1879+
1880+
it('apiEnabled:true explicit → passes', async () => {
1881+
const { rest, protocol } = setup({ apiEnabled: true });
1882+
await invoke(rest, 'GET', BY_ID, { object: 'widget', id: '1' });
1883+
expect(protocol.getData).toHaveBeenCalledTimes(1);
1884+
});
1885+
1886+
it('unknown object (not in metadata) is not blocked by the guard', async () => {
1887+
const { rest, protocol } = setup({ apiEnabled: false }); // only 'widget' is hidden
1888+
const res = await invoke(rest, 'GET', LIST, { object: 'other' });
1889+
expect(res.statusCode).toBe(200);
1890+
expect(protocol.findData).toHaveBeenCalledTimes(1);
1891+
});
1892+
1893+
it('apiEnabled:false also blocks bulk createMany', async () => {
1894+
const { rest, protocol } = setup({ apiEnabled: false });
1895+
const res = await invoke(rest, 'POST', '/api/v1/data/:object/createMany', { object: 'widget' }, []);
1896+
expect(res.statusCode).toBe(404);
1897+
expect(protocol.createManyData).not.toHaveBeenCalled();
1898+
});
1899+
});

packages/spec/liveness/object.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,20 @@
3232
"crossTenantAccess": { "status": "dead", "evidence": "inert" }
3333
}
3434
},
35-
"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)." },
35+
"enable": {
36+
"children": {
37+
"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." },
38+
"apiMethods": { "status": "live", "evidence": "packages/rest/src/rest-server.ts", "note": "enforced by enforceApiAccess — operations outside the whitelist → 405." },
39+
"trackHistory": { "status": "dead", "evidence": "no behavior-changing reader (database-loader.trackHistory is a separate ctor option)" },
40+
"searchable": { "status": "dead", "evidence": "no behavior-changing reader" },
41+
"files": { "status": "dead", "evidence": "no behavior-changing reader" },
42+
"feeds": { "status": "dead", "evidence": "no behavior-changing reader" },
43+
"activities": { "status": "dead", "evidence": "no behavior-changing reader" },
44+
"trash": { "status": "dead", "evidence": "no behavior-changing reader" },
45+
"mru": { "status": "dead", "evidence": "no behavior-changing reader" },
46+
"clone": { "status": "dead", "evidence": "no behavior-changing reader" }
47+
}
48+
},
3649
"versioning": { "status": "dead", "evidence": "aspirational; no runtime" },
3750
"partitioning": { "status": "dead", "evidence": "aspirational; no runtime" },
3851
"cdc": { "status": "dead", "evidence": "aspirational; no runtime" },

0 commit comments

Comments
 (0)