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
15 changes: 15 additions & 0 deletions .changeset/24031-exposure-gate-one-object-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/rest": patch
---

fix(rest): the API-exposure check on every `/data` request reads only the object it checks, not the whole object list

Clause-②: no

- **Faster `/data` requests.** Before each `/data` read or write, the REST layer checks the object's `enable.apiEnabled` and `enable.apiMethods`. To read that one object's flags, the check used to load every object's metadata and run a full schema diagnostic on each one. It now reads only the object named in the URL. Measured on the showcase app on a shared machine: 33 `GET /api/v1/data/showcase_account` calls took 647 ms instead of 2421 ms (-73 %), and the metadata read behind the check fell from 56-61 ms to about 4.5 ms per request.
- **Same answers.** Code-registered objects, objects stored in the environment and objects that another package extends get the same `404 OBJECT_API_DISABLED` and `405 OBJECT_API_METHOD_NOT_ALLOWED` as before. An unknown object is still answered by the data path's `404 OBJECT_NOT_FOUND`. When the metadata store cannot be read, the check still lets the request through and logs its `api-exposure gate` warning. Nothing is cached, so a metadata change applies from the next request.
- **One refusal changes, for an object whose package is disabled.** A denied operation on such an object now gets the exposure answer instead of the disabled-package one:
- an operation outside `apiMethods`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `405 OBJECT_API_METHOD_NOT_ALLOWED`;
- any operation on an object with `apiEnabled: false`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `404 OBJECT_API_DISABLED`.

An allowed operation still answers `404 OBJECT_PACKAGE_DISABLED`. No accepted request is now refused, and no refused request is now accepted.
57 changes: 54 additions & 3 deletions packages/rest/src/meta-item-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ export interface MetaReadGateListSource {
* an empty doc corpus is "every doc unclaimed, so `org`" — both GRANT.
*/
listMetaItems(type: 'book' | 'doc' | 'object'): Promise<unknown> | undefined;
/**
* [#24031] One `getMetaItem` BY-NAME read of the object `name`, env-scoped
* the way {@link listMetaItems} is — absent when this transport's protocol
* has no by-name read, and then {@link loadObjectItem} asks the list as
* before. Answers the read's envelope (`{ item }`). A THROW is a fault,
* ⛔ never "no such object".
*/
getObjectItem?(name: string): Promise<unknown>;
}

/** Everything {@link createMetaItemReadGate} reads, supplied by the transport. */
Expand Down Expand Up @@ -402,9 +410,9 @@ export type NavDocAudienceGate = (entry: any) => boolean;
* Load the object metadata items for the current protocol/environment,
* coerced to a plain array. Returns `[]` when metadata is unavailable so
* callers fail OPEN (the data call itself needs the same metadata and will
* surface any real error). Shared by `enforceApiAccess` (one object), the
* cross-object batch route (all ops, fetched once) and the nav-servability
* gate.
* surface any real error). Shared by the cross-object batch route (all ops,
* fetched once) and the nav-servability gate; the single-object exposure gate
* reads its one object by name ({@link loadObjectItem}).
*/
export async function loadObjectItems(source: MetaReadGateListSource): Promise<any[]> {
try {
Expand All @@ -431,6 +439,49 @@ export async function loadObjectItems(source: MetaReadGateListSource): Promise<a
}
}

/**
* [#24031] The ONE object the API-exposure gate judges, read by name — or
* `undefined` when no object is behind the name (no declared exposure policy,
* so the gate passes the request on, #3770).
*
* The gate used to ask {@link loadObjectItems} and search the list for the
* name. The list read serves every object decorated, and the decoration is a
* full schema diagnostic of each one, so every `/data` request paid a
* diagnostic pass over the whole registry to read one object's `enable`. The
* by-name read decorates only the object it serves.
*
* The answers are the list's:
*
* - The item counts only under its own name, the match the list search made,
* so a name the by-name read resolves to some other object is still "no
* object here".
* - A THROWN read fails OPEN and logs, as {@link loadObjectItems} does and
* for the reason it records (#3545); "no object" is an answer, not a fault,
* and logs nothing.
* - A source with no by-name read (`getObjectItem` absent) asks the list, as
* before.
*
* ⛔ Nothing here is cached: each request reads the object's current
* document, so a metadata change is honoured on the next request.
*/
export async function loadObjectItem(source: MetaReadGateListSource, name: string): Promise<any | undefined> {
if (typeof source.getObjectItem !== 'function') {
return (await loadObjectItems(source)).find((o: any) => o?.name === name);
}
try {
const envelope: any = await source.getObjectItem(name);
const item = envelope?.item;
return item !== null && typeof item === 'object' && item.name === name ? item : undefined;
} catch (err) {
logWarn(
'[REST] api-exposure gate: object metadata read failed — failing open ' +
'(auth + CRUD/FLS/RLS still enforce on the data call)',
(err as Error)?.message ?? err,
);
return undefined;
}
}

/** Whether any of these books carries a `{ permissionSet }` audience. */
export function anyPermissionSetAudience(books: readonly any[]): boolean {
return books.some(
Expand Down
9 changes: 8 additions & 1 deletion packages/rest/src/rest-bulk-path-object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ function setup() {
{ name: 'open' },
{ name: 'locked', enable: { apiEnabled: false } },
]),
getMetaItem: vi.fn().mockResolvedValue({}),
// [#24031] The gate reads its one object BY NAME; this by-name read
// answers the object the list above holds, as the real protocol's two
// reads agree.
getMetaItem: vi.fn(async (req: any) => ({
type: req?.type,
name: req?.name,
item: (await protocol.getMetaItems({ type: req?.type })).find((o: any) => o?.name === req?.name),
})),
findData: vi.fn().mockResolvedValue([]),
updateManyData,
deleteManyData,
Expand Down
7 changes: 5 additions & 2 deletions packages/rest/src/rest-export-permission-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,12 @@ describe('export route — user-level export axis (#3544)', () => {
// export (405), this axis decides whether the CALLER may use it (403). An
// object that exposes no export must answer 405 whatever the user holds.
const findData = vi.fn().mockResolvedValue({ object: 'task', records: ROWS });
// [#24031] The gate reads its one object BY NAME; the by-name read answers
// the object the list holds, as the real protocol's two reads agree.
const exposedForGetOnly = { ...TASK, enable: { apiMethods: ['get'] } };
const protocol: any = {
getMetaItems: vi.fn().mockResolvedValue({ items: [{ ...TASK, enable: { apiMethods: ['get'] } }] }),
getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK }),
getMetaItems: vi.fn().mockResolvedValue({ items: [exposedForGetOnly] }),
getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: exposedForGetOnly }),
findData,
};
const rest = new RestServer(
Expand Down
Loading
Loading