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
5 changes: 5 additions & 0 deletions .changeset/enforce-object-api-flags.md
Original file line number Diff line number Diff line change
@@ -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.
73 changes: 73 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 || {},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> = body.mapping ?? {};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 || [],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
15 changes: 14 additions & 1 deletion packages/spec/liveness/object.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading