@dudousxd/nestjs-catalog 0.4.1 → 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 +163 -0
- package/dist/access.controller.js +25 -0
- package/dist/catalog.controller.d.ts +46 -0
- package/dist/catalog.controller.js +199 -23
- package/dist/catalog.environment.d.ts +65 -12
- package/dist/catalog.environment.js +14 -1
- package/dist/catalog.events.d.ts +67 -3
- package/dist/catalog.events.js +15 -2
- package/dist/catalog.principal.d.ts +49 -1
- package/dist/catalog.principal.js +92 -0
- package/dist/catalog.service.d.ts +53 -10
- package/dist/catalog.service.js +180 -15
- package/dist/catalog.types.d.ts +7 -1
- package/dist/catalog.workspace.d.ts +84 -8
- package/dist/catalog.workspace.js +23 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -2
- package/package.json +1 -1
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>;
|