@dudousxd/nestjs-catalog 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,169 @@ is a way to create one your IdP has never heard of.
124
124
  The routes mount at `accessPath`, a sibling of `path` by default — `api/catalog`
125
125
  gives `api/access`, which is the shape the React screens build.
126
126
 
127
+ ## Embed a chart in someone else's application
128
+
129
+ A saved query or a dashboard can be fetched by another application and drawn in
130
+ its own UI, without that application knowing anything about this catalog's SQL,
131
+ its schema or its console.
132
+
133
+ | Method | Path | What it does |
134
+ |---|---|---|
135
+ | `GET` | `/catalog/embed` | What this caller may embed |
136
+ | `GET` | `/catalog/embed/charts/:id` | One shared saved query, run and rendered |
137
+ | `GET` | `/catalog/embed/dashboards/:id` | A shared dashboard, every card resolved |
138
+
139
+ ### What comes back is rows, not SQL
140
+
141
+ `GET /catalog/embed/charts/:id` runs the saved query and hands back the result
142
+ already shaped for drawing:
143
+
144
+ ```jsonc
145
+ {
146
+ "id": "9a1c0e2e-…",
147
+ "title": "Vehicles by status",
148
+ "description": "Current fleet, grouped by operational status.",
149
+ "visualization": { "kind": "bar", "labelColumn": "status", "valueColumns": ["vehicles"] },
150
+ "columns": ["status", "vehicles"],
151
+ "rows": [
152
+ { "status": "Operational", "vehicles": 412 },
153
+ { "status": "In maintenance", "vehicles": 57 },
154
+ { "status": "Deadlined", "vehicles": 9 }
155
+ ],
156
+ "rowCount": 3,
157
+ "cached": true,
158
+ "generatedAt": "2026-02-11T09:15:04.221Z"
159
+ }
160
+ ```
161
+
162
+ That it is rendered rows rather than the statement behind them is the point of
163
+ the endpoint. **Handing back SQL would make every consumer a second
164
+ implementation of the console** — each one parsing this catalog's query
165
+ language, each one deciding what a `bar` means, each one drifting. A consumer
166
+ here needs a chart library and a `fetch`.
167
+
168
+ `title` and `visualization` come from the saved query itself. `layout` appears
169
+ only when the chart was reached through a dashboard, carrying that card's
170
+ `width` (1–4) and `position`; it is a hint the consumer may ignore, and it is
171
+ absent from `GET /catalog/embed/charts/:id`, which knows nothing about any
172
+ board.
173
+
174
+ `GET /catalog/embed/dashboards/:id` is the same shape one level up — `id`,
175
+ `name`, `description`, `generatedAt`, and `charts`, each entry exactly the
176
+ object above, ordered by card position. Cards are resolved **sequentially**, not
177
+ in parallel: every one is a database query, and a shared dashboard is precisely
178
+ the thing a consumer will poll on a timer. A card whose query is unshared,
179
+ missing or failing is left out rather than failing the whole board — one bad
180
+ card should not blank a page, and a consumer should therefore not assume
181
+ `charts.length` matches the card count it saw at discovery.
182
+
183
+ `GET /catalog/embed` is that discovery endpoint, so a consuming frontend can
184
+ list what it is allowed to render instead of being told the ids out of band:
185
+
186
+ ```jsonc
187
+ {
188
+ "dashboards": [{ "id": "…", "name": "Fleet readiness", "description": "…", "charts": 4 }],
189
+ "charts": [{ "id": "…", "name": "Vehicles by status", "description": "…", "kind": "bar" }]
190
+ }
191
+ ```
192
+
193
+ Two things about freshness. `cached` says the rows came from the query cache
194
+ rather than the database — the TTL is the one the query was saved with, per
195
+ query, and zero means never cached. `generatedAt` is when *this response* was
196
+ assembled, so on a cached hit it is not the age of the rows; `cached` is the
197
+ field that tells you which you are looking at. The cache key includes the
198
+ catalog's version, so a curation edit that renames a column cannot serve a
199
+ result computed under the old name.
200
+
201
+ One limit worth knowing before you build against it: rows are capped by
202
+ `maxQueryRows` (default 1000) like any other query here, and the embed payload
203
+ carries no "truncated" flag, so a chart that hit the cap looks like a complete
204
+ result.
205
+
206
+ ### Only what has been shared, and only ever explicitly
207
+
208
+ The two fetches serve a saved query or dashboard whose `shared` flag is set and
209
+ answer **403** otherwise; discovery lists only what carries the flag. An unshared
210
+ chart says so by name and tells you to mark it shared in the console, because the
211
+ fix is a decision somebody makes there rather than a configuration change.
212
+
213
+ `shared` is never inferred from the SQL. A saved query can join five relations,
214
+ so working out "which types does this touch" means parsing the statement, and a
215
+ permission that depends on a parser widens silently the first time the parser
216
+ meets a query it did not expect. Marking something shared is an act a person
217
+ performed, and it shows up in the audit trail as one.
218
+
219
+ The shipped console exposes the toggle on a saved query; the flag on a dashboard
220
+ is `shared` on the workspace store's `saveDashboard` / `updateDashboard` input.
221
+
222
+ ### `catalog:embed` is its own scope
223
+
224
+ An application that draws one chart in its own UI needs nothing else. Giving it
225
+ `catalog:read` to do that hands it every type in the catalog, and that is the
226
+ kind of over-grant nobody revisits. So the embed API has its own scope, and a
227
+ principal can hold it alone:
228
+
229
+ ```ts
230
+ new StaticKeyPrincipalResolver([
231
+ {
232
+ key: process.env.SALES_PORTAL_KEY!,
233
+ id: "sales-portal",
234
+ displayName: "Sales portal",
235
+ scopes: ["catalog:embed"], // and nothing else — no reads, no curation
236
+ },
237
+ ]);
238
+ ```
239
+
240
+ That resolver keys on the `x-catalog-key` header by default and is explicitly the
241
+ lesser option — prefer resolving a token against your IdP where there is one.
242
+ `hasScope(principal, "catalog:embed")` is how a guard asks, whichever resolver
243
+ answered; `catalog:admin` implies it, as it implies every scope.
244
+
245
+ **Declaring a scope is separate from enforcing it.** This library ships no guard,
246
+ for the same reason it ships none for anything else, so `catalog:embed` means
247
+ what the guard you pass to `guards` makes it mean: read `REQUIRED_SCOPES` off the
248
+ handler — `RequireScopes` is what sets it — and check it with `hasScope`. Nothing
249
+ under the `embed` prefix checks a scope by itself.
250
+
251
+ Note also what the embed path does *not* consult: nothing in it applies a
252
+ principal's `readTypes` or `classifications`. Those are helpers (`mayRead`,
253
+ `maySeeClassification`) for a host's guard to apply at the door, and the saved
254
+ query's SQL runs as written. The `shared` flag is the boundary — so what a query
255
+ selects is what an embedding application sees.
256
+
257
+ ### What every route asks for
258
+
259
+ Absence of a declaration is itself a declaration — it means "authenticated is
260
+ enough" — so every route on the built-in controller names a scope:
261
+
262
+ | Routes | Scope |
263
+ | --- | --- |
264
+ | the model and the rows under it: `GET /`, `graph`, `types/:name`, `objects/:name`, `objects/:name/snapshots`, `query/relations` | `catalog:read` |
265
+ | reading the workspace: `workspace/capabilities`, `saved-queries`, `saved-queries/:id`, `saved-queries/:id/run`, `saved-queries/:id/export.csv`, `dashboards`, `dashboards/:id` | `catalog:read` |
266
+ | the trail: `events`, `events/traces`, `events/traces/:id` | `catalog:read` |
267
+ | curation: `PATCH types/:name`, `PATCH types/:name/properties/:property`, `POST reset` | `catalog:curate` |
268
+ | workspace authoring that carries no SQL: `DELETE saved-queries/:id`, `POST`/`PATCH`/`DELETE dashboards` | `catalog:curate` |
269
+ | anything that chooses what SQL runs: `POST query`, `POST saved-queries`, `PATCH saved-queries/:id` | `catalog:admin` |
270
+ | `GET embed`, `embed/dashboards/:id`, `embed/charts/:id` | `catalog:embed` |
271
+
272
+ Two of those need saying out loud.
273
+
274
+ **Arbitrary SQL is `catalog:admin` because read-only is not the same as
275
+ bounded.** `catalog:read` is "read object metadata and rows" — rows of a
276
+ catalogued type, through a route that names one. `POST query` is whatever the
277
+ store's read connection can reach, and with the bundled MikroORM store that is
278
+ the catalog's own schema: `SELECT * FROM catalog_principal` returns every
279
+ principal's scopes, grants and `keyHash`. `query/relations` lists only the
280
+ catalogued types, but it is the editor's schema panel, not a restriction on the
281
+ statement. If you want analysts writing SQL without the rest of admin, give the
282
+ read connection a database role that cannot see the `catalog_*` tables and
283
+ publish your own route with your own declaration.
284
+
285
+ **Running a saved query is only `catalog:read`.** What the admin scope holds back
286
+ is choosing what SQL runs, not seeing a result. Gating execution instead would
287
+ stop an analyst opening a dashboard and would let an unprivileged caller plant a
288
+ statement for a privileged one to run.
289
+
127
290
  ## Build your own endpoints, or use ours
128
291
 
129
292
  The built-in controller is a convenience, not the interface. Pass
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.createAccessController = createAccessController;
16
16
  const common_1 = require("@nestjs/common");
17
17
  const catalog_access_1 = require("./catalog.access");
18
+ const catalog_route_auth_1 = require("./catalog.route-auth");
18
19
  const ROLES = ['viewer', 'curator', 'administrator'];
19
20
  function isRole(value) {
20
21
  return typeof value === 'string' && ROLES.some((role) => role === value);
@@ -40,6 +41,27 @@ function createAccessController(path, guards, decorators = []) {
40
41
  }
41
42
  return this.directory;
42
43
  }
44
+ /**
45
+ * Every route here is `catalog:admin`, and the screen is the reason.
46
+ *
47
+ * `listApplications` enumerates each application that can reach the catalog
48
+ * together with its scopes, its `writeTypes` and its `classifications` —
49
+ * which is to say, the map of what a stolen credential would be worth and
50
+ * which one to steal. `upsertPerson` writes into the host's directory and
51
+ * accepts `role: 'administrator'`, so an unscoped POST here is a way to
52
+ * grant yourself the scope this endpoint is protecting.
53
+ *
54
+ * Nothing weaker fits: `catalog:curate` is documented as editing labels and
55
+ * units, and reading who may write to the catalog is not a curation act.
56
+ * The console already hides this screen behind `catalog:admin`; until now
57
+ * that client-side check was the ONLY check, and a hidden tab is not an
58
+ * access control — anybody authenticated could call these three paths
59
+ * directly. `catalog.route-auth.ts` is explicit that a route declaring no
60
+ * scope means "authenticated is enough", so the absence was not an
61
+ * oversight the framework would catch. It is caught now: the completeness
62
+ * sweep in `catalog.route-scopes.integration.spec.ts` reads THIS factory
63
+ * too, which is what let these three escape it in the first place.
64
+ */
43
65
  principals() {
44
66
  return this.require().listApplications();
45
67
  }
@@ -72,12 +94,14 @@ function createAccessController(path, guards, decorators = []) {
72
94
  };
73
95
  __decorate([
74
96
  (0, common_1.Get)('principals'),
97
+ (0, catalog_route_auth_1.RequireScopes)('catalog:admin'),
75
98
  __metadata("design:type", Function),
76
99
  __metadata("design:paramtypes", []),
77
100
  __metadata("design:returntype", void 0)
78
101
  ], AccessController.prototype, "principals", null);
79
102
  __decorate([
80
103
  (0, common_1.Get)('people'),
104
+ (0, catalog_route_auth_1.RequireScopes)('catalog:admin'),
81
105
  __param(0, (0, common_1.Query)('search')),
82
106
  __param(1, (0, common_1.Query)('limit')),
83
107
  __param(2, (0, common_1.Query)('offset')),
@@ -87,6 +111,7 @@ function createAccessController(path, guards, decorators = []) {
87
111
  ], AccessController.prototype, "people", null);
88
112
  __decorate([
89
113
  (0, common_1.Post)('people'),
114
+ (0, catalog_route_auth_1.RequireScopes)('catalog:admin'),
90
115
  __param(0, (0, common_1.Body)()),
91
116
  __metadata("design:type", Function),
92
117
  __metadata("design:paramtypes", [Object]),
@@ -4,5 +4,51 @@ import { type Type } from '@nestjs/common';
4
4
  * guards both come from `forRoot`. A library that hardcodes either one forces
5
5
  * every host app to accept its idea of auth, which for an endpoint that
6
6
  * enumerates every table in the database is not a reasonable default.
7
+ *
8
+ * ---------------------------------------------------------------------------
9
+ * Every route declares what it needs, and four of the choices are not obvious.
10
+ *
11
+ * Declaring is not enforcing — see `catalog.route-auth.ts` — but an *absent*
12
+ * declaration is a declaration too: it tells a host's guard that authenticated
13
+ * is enough. So a route left bare is not "undecided", it is "open", and for a
14
+ * long time this controller said that about arbitrary SQL and about every
15
+ * curation edit.
16
+ *
17
+ * **Arbitrary SQL is `catalog:admin`, not `catalog:read`.** `catalog:read` is
18
+ * "read object metadata and rows" — rows *of a catalogued type*, through a
19
+ * route that names one. `POST query` is not bounded by the model at all: it is
20
+ * whatever the store's read connection can select, and in the shipped MikroORM
21
+ * store that connection is the catalog's own schema. `SELECT * FROM
22
+ * catalog_principal` returns every principal's scopes, grants and `keyHash` —
23
+ * the SHA-256 of its static key — which is not something a reporting principal
24
+ * should be able to fetch, and is not reachable from any other route here. That
25
+ * is squarely the population `catalog:admin` ("manage principals and grants")
26
+ * describes.
27
+ *
28
+ * **So the two saved-query write routes are `catalog:admin` too.** They accept
29
+ * a `sql` field, and `POST saved-queries` + `POST saved-queries/:id/run` is
30
+ * `POST query` in two requests. Gating one and not the others would make the
31
+ * strict declaration decoration.
32
+ *
33
+ * **Running a saved query is only `catalog:read`.** The capability being held
34
+ * back is *choosing what SQL runs*, not *seeing a result*: a saved query is an
35
+ * artefact somebody with the authoring scope vetted, and running one is reading
36
+ * a report they wrote. Gating execution instead would be worse in both
37
+ * directions — it would stop an analyst opening a dashboard, and it would let an
38
+ * unprivileged caller plant a statement and wait for a privileged one to run it.
39
+ *
40
+ * **`POST reset` is `catalog:curate`, not `catalog:admin`.** It discards exactly
41
+ * what the two `PATCH`es write, catalog-wide, and a curator can already blank
42
+ * every label one request at a time. Requiring admin would deny nothing and
43
+ * would push a routine console action into the scope that manages principals.
44
+ *
45
+ * One consequence is worth stating rather than leaving to be discovered:
46
+ * `shared` rides on the dashboard write routes, so `catalog:curate` carries the
47
+ * power to hand a board to an outside application. That is not an oversight —
48
+ * it is one field on an authoring route, and splitting it would mean a route
49
+ * whose only job is to flip a boolean. What makes it accountable is that the
50
+ * act is audited, in both directions and including by deletion; see the
51
+ * sharing block in `catalog.service.ts`.
52
+ * ---------------------------------------------------------------------------
7
53
  */
8
54
  export declare function createCatalogController(path: string, guards: Type<unknown>[], decorators?: ClassDecorator[]): Type<unknown>;