@dudousxd/nestjs-catalog 0.5.0 → 0.7.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.decorators.d.ts +17 -0
- package/dist/catalog.decorators.js +17 -0
- package/dist/catalog.environment.d.ts +104 -12
- package/dist/catalog.environment.js +64 -1
- package/dist/catalog.events.d.ts +67 -3
- package/dist/catalog.events.js +15 -2
- package/dist/catalog.pipeline.d.ts +19 -0
- package/dist/catalog.principal.d.ts +49 -1
- package/dist/catalog.principal.js +92 -0
- package/dist/catalog.registry.js +117 -28
- package/dist/catalog.service.d.ts +53 -10
- package/dist/catalog.service.js +180 -15
- package/dist/catalog.types.d.ts +114 -3
- package/dist/catalog.workspace.d.ts +68 -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
|
@@ -94,13 +94,24 @@ export interface CatalogEnvironment {
|
|
|
94
94
|
*/
|
|
95
95
|
rank: number;
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
* promotion.
|
|
97
|
+
* A declaration that this environment is one where changes are supposed to
|
|
98
|
+
* arrive as a reviewed promotion. True for production.
|
|
99
99
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
100
|
+
* **Advisory. Nothing in this library refuses anything because of it.** No
|
|
101
|
+
* guard, no store and no plan reads this field; the only code that does is the
|
|
102
|
+
* console, which paints the environment switcher amber so that "am I about to
|
|
103
|
+
* do this to production" is answerable at a glance. That is worth having and
|
|
104
|
+
* it is worth not overstating — a flag documented as an enforcement point,
|
|
105
|
+
* enforced nowhere, is worse than no flag, because it invites a host to
|
|
106
|
+
* believe the refusal already exists.
|
|
107
|
+
*
|
|
108
|
+
* Kept rather than removed because the enforcement it describes cannot live
|
|
109
|
+
* here. The library has no apply endpoint and no opinion about who may approve
|
|
110
|
+
* what — see {@link CatalogPromotionPlan.fingerprint} for the same division —
|
|
111
|
+
* so the refusal belongs to whatever host exposes promotion over HTTP. This
|
|
112
|
+
* field is how that host learns which environments to demand
|
|
113
|
+
* {@link CatalogPromotionApproval} for, without every deployment restating its
|
|
114
|
+
* own list of environment names in a guard.
|
|
104
115
|
*/
|
|
105
116
|
protected: boolean;
|
|
106
117
|
}
|
|
@@ -173,17 +184,40 @@ export declare function catalogDatabaseNameFor(base: string, environmentId: Cata
|
|
|
173
184
|
* It stops being enough the moment anyone asks a governance question across
|
|
174
185
|
* environments — "everything this person did this week" has to be answerable
|
|
175
186
|
* without the reader having to remember which of three lists they are looking
|
|
176
|
-
* at
|
|
187
|
+
* at.
|
|
177
188
|
*
|
|
178
189
|
* Stamped on read rather than stored in a column on purpose: a stored column
|
|
179
190
|
* can be wrong, because nothing in the database stops a row in the production
|
|
180
191
|
* table saying `environment: "dev"`. A value derived from which connection the
|
|
181
192
|
* row was read through cannot be.
|
|
193
|
+
*
|
|
194
|
+
* Which is also why the stamping does not happen here, and cannot. This package
|
|
195
|
+
* knows what an environment *is*; it has no connection and no scope, so it can
|
|
196
|
+
* only supply the shape and the function. The stamp is applied by the one reader
|
|
197
|
+
* that resolved an environment in order to do the read at all —
|
|
198
|
+
* `RoutingWorkspaceStore.listEvents` in the MikroORM store package, which widens
|
|
199
|
+
* its return type to this intersection. A read that went straight to a store,
|
|
200
|
+
* with no environment resolved anywhere above it, is *not* stamped, and that is
|
|
201
|
+
* the honest answer rather than a gap: nothing in that call knew which world it
|
|
202
|
+
* was reading, so anything written into the field would be a guess.
|
|
182
203
|
*/
|
|
183
204
|
export interface EnvironmentStampedAuditEvent {
|
|
184
205
|
environment: CatalogEnvironmentId;
|
|
185
206
|
}
|
|
186
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* Stamps a batch of events with the environment they were read from.
|
|
209
|
+
*
|
|
210
|
+
* A separate function taking the id as an argument rather than a method
|
|
211
|
+
* somewhere, so the caller has to have the environment in hand to call it. There
|
|
212
|
+
* is no default and nothing to look up — the id comes from the same resolution
|
|
213
|
+
* that chose the connection, which is what makes the stamp a fact about the read
|
|
214
|
+
* rather than a label somebody attached afterwards.
|
|
215
|
+
*
|
|
216
|
+
* Generic over the event rather than typed to `CatalogAuditEvent`, which lives
|
|
217
|
+
* in `catalog.workspace.ts` and would make this file depend on the workspace
|
|
218
|
+
* vocabulary to say something true of any row. Nothing here needs to know more
|
|
219
|
+
* about an event than that it is an object.
|
|
220
|
+
*/
|
|
187
221
|
export declare function stampEnvironment<T>(environment: CatalogEnvironmentId, events: readonly T[]): Array<T & EnvironmentStampedAuditEvent>;
|
|
188
222
|
/** What a promotion is allowed to carry. */
|
|
189
223
|
export declare const PROMOTABLE_KINDS: readonly ["objectType", "transform", "workflow", "connector"];
|
|
@@ -257,6 +291,45 @@ export interface PromotableObjectType {
|
|
|
257
291
|
unit?: string;
|
|
258
292
|
classification?: string;
|
|
259
293
|
}>;
|
|
294
|
+
/**
|
|
295
|
+
* The links the type declares, in the shape they are stored in.
|
|
296
|
+
*
|
|
297
|
+
* Absent from this interface until it was noticed that a promoted type arrived
|
|
298
|
+
* complete in every visible way — right properties, right table, right owner —
|
|
299
|
+
* and sat in the target's graph as an island. Nothing errored, nothing was
|
|
300
|
+
* reported, and the plan the operator approved said "nothing to promote" for a
|
|
301
|
+
* release whose only content was a link.
|
|
302
|
+
*
|
|
303
|
+
* Spelled out here rather than imported from the store package, which is the
|
|
304
|
+
* same reason `properties` is: the dependency runs the other way, and this
|
|
305
|
+
* side is the one both a MikroORM environment and anything else that ever
|
|
306
|
+
* holds a catalog have to satisfy. `kind` is a bare `string` for the reason the
|
|
307
|
+
* stored row gives — what comes back out of a JSON column is whatever some
|
|
308
|
+
* earlier version of some publisher put in, and it is narrowed where it is
|
|
309
|
+
* read, not asserted here.
|
|
310
|
+
*
|
|
311
|
+
* Optional, and read as `[]` wherever it is used. An environment whose rows
|
|
312
|
+
* predate the relations column holds `NULL` there, and requiring the field
|
|
313
|
+
* would force every hand-built promotable set to name something it has nothing
|
|
314
|
+
* to say about. "Absent" and "empty" are deliberately the same statement here,
|
|
315
|
+
* unlike on the publish wire: this shape is built by `readPromotable` in this
|
|
316
|
+
* process rather than arriving from a client of unknown vintage, and the plan
|
|
317
|
+
* an operator approves has to describe exactly what the apply will do. See
|
|
318
|
+
* `promoteType`.
|
|
319
|
+
*/
|
|
320
|
+
relations?: Array<{
|
|
321
|
+
name: string;
|
|
322
|
+
displayName: string;
|
|
323
|
+
description?: string;
|
|
324
|
+
kind: string;
|
|
325
|
+
targetType: string;
|
|
326
|
+
localKey?: string;
|
|
327
|
+
nullable: boolean;
|
|
328
|
+
hidden: boolean;
|
|
329
|
+
position: number;
|
|
330
|
+
owner: boolean;
|
|
331
|
+
inverseName?: string;
|
|
332
|
+
}>;
|
|
260
333
|
}
|
|
261
334
|
export interface PromotableTransform {
|
|
262
335
|
id: string;
|
|
@@ -375,9 +448,22 @@ export interface CatalogPromotionPlan {
|
|
|
375
448
|
* The point of a preview is that somebody read it. Without a fingerprint the
|
|
376
449
|
* apply call is a fresh promotion that happens to have been preceded by a
|
|
377
450
|
* preview, and anything that changed in between — a colleague editing the
|
|
378
|
-
* transform, a connector deleted — goes in unreviewed.
|
|
379
|
-
*
|
|
380
|
-
*
|
|
451
|
+
* transform, a connector deleted — goes in unreviewed.
|
|
452
|
+
*
|
|
453
|
+
* **The check is the host's, and this library does not perform it.** There is
|
|
454
|
+
* no apply endpoint here: `applyPromotion` is exported for a host to call, and
|
|
455
|
+
* it does not recompute the plan or compare anything to this value. The
|
|
456
|
+
* expectation, stated the same way where the apply lives, is that the caller
|
|
457
|
+
* re-runs {@link planPromotion} immediately before applying and confirms the
|
|
458
|
+
* fingerprint still matches what the reviewer approved. It lives with the
|
|
459
|
+
* caller because it is a policy decision about who may approve what, and
|
|
460
|
+
* `applyPromotion` is the mechanism rather than the policy.
|
|
461
|
+
*
|
|
462
|
+
* So this field is what makes that check *possible* — a stable name for one
|
|
463
|
+
* reviewed set of changes, computed from the effect and not the clock — and
|
|
464
|
+
* not, on its own, the check. A host that carries the value from preview to
|
|
465
|
+
* apply and never compares it has a promotion nobody approved, and nothing in
|
|
466
|
+
* this package will say so.
|
|
381
467
|
*/
|
|
382
468
|
fingerprint: string;
|
|
383
469
|
}
|
|
@@ -394,7 +480,13 @@ export interface CatalogPromotionPlan {
|
|
|
394
480
|
* which environment the row belongs to.
|
|
395
481
|
*/
|
|
396
482
|
export declare const PROMOTION_AUDIT_EVENT = "promotion.applied";
|
|
397
|
-
/**
|
|
483
|
+
/**
|
|
484
|
+
* What a host's apply call asks for, to prove which plan was approved.
|
|
485
|
+
*
|
|
486
|
+
* A shape rather than a check — see {@link CatalogPromotionPlan.fingerprint} for
|
|
487
|
+
* why the comparison lives with the caller. Written down here so that every host
|
|
488
|
+
* demands the same two things and an audit row means the same thing in each.
|
|
489
|
+
*/
|
|
398
490
|
export interface CatalogPromotionApproval {
|
|
399
491
|
fingerprint: string;
|
|
400
492
|
/** Free text the operator typed. Recorded in the audit trail, never parsed. */
|
|
@@ -216,7 +216,20 @@ function durableKeyspaceFor(environmentId, prefix = 'catalog') {
|
|
|
216
216
|
function catalogDatabaseNameFor(base, environmentId) {
|
|
217
217
|
return `${base}_${assertEnvironmentId(environmentId)}`;
|
|
218
218
|
}
|
|
219
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Stamps a batch of events with the environment they were read from.
|
|
221
|
+
*
|
|
222
|
+
* A separate function taking the id as an argument rather than a method
|
|
223
|
+
* somewhere, so the caller has to have the environment in hand to call it. There
|
|
224
|
+
* is no default and nothing to look up — the id comes from the same resolution
|
|
225
|
+
* that chose the connection, which is what makes the stamp a fact about the read
|
|
226
|
+
* rather than a label somebody attached afterwards.
|
|
227
|
+
*
|
|
228
|
+
* Generic over the event rather than typed to `CatalogAuditEvent`, which lives
|
|
229
|
+
* in `catalog.workspace.ts` and would make this file depend on the workspace
|
|
230
|
+
* vocabulary to say something true of any row. Nothing here needs to know more
|
|
231
|
+
* about an event than that it is an object.
|
|
232
|
+
*/
|
|
220
233
|
function stampEnvironment(environment, events) {
|
|
221
234
|
return events.map((event) => ({ ...event, environment }));
|
|
222
235
|
}
|
|
@@ -719,5 +732,55 @@ function diffObjectType(existing, incoming) {
|
|
|
719
732
|
to: gone,
|
|
720
733
|
});
|
|
721
734
|
}
|
|
735
|
+
diffs.push(...diffRelations(existing, incoming));
|
|
736
|
+
return diffs;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The links, compared the same way the properties above are — and named
|
|
740
|
+
* differently on purpose.
|
|
741
|
+
*
|
|
742
|
+
* **Why this exists at all.** Without it a promotion whose only difference is a
|
|
743
|
+
* link reports `unchanged`, so the plan says there is nothing to promote and the
|
|
744
|
+
* apply, driven by that plan, does nothing. The change is real, the operator is
|
|
745
|
+
* shown an empty diff, and the link stays behind in dev. A plan is what somebody
|
|
746
|
+
* approves; a change invisible in the plan is a change nobody approved.
|
|
747
|
+
*
|
|
748
|
+
* **`relations.removed`, not `relations.absentFromSource`.** The properties
|
|
749
|
+
* above borrow the softer word because nothing acts on them: `ensureType` is
|
|
750
|
+
* additive, so a column that vanished from the source keeps its data in the
|
|
751
|
+
* target. A link that vanished really is deleted there — `promoteType` assigns
|
|
752
|
+
* the source's links rather than merging them, because a link holds no data and
|
|
753
|
+
* keeping one the source deliberately dropped means the target asserts a join
|
|
754
|
+
* the schema no longer has. The field name has to say which of those two a
|
|
755
|
+
* reviewer is looking at.
|
|
756
|
+
*
|
|
757
|
+
* **`to` repeats the removed names rather than being empty.** The fingerprint an
|
|
758
|
+
* approval is compared against hashes each field's `to` value; an empty one
|
|
759
|
+
* would make "drops the link to Base" and "drops the link to Depot" hash
|
|
760
|
+
* identically, so a plan approved for one would be applicable as the other.
|
|
761
|
+
*/
|
|
762
|
+
function diffRelations(existing, incoming) {
|
|
763
|
+
const diffs = [];
|
|
764
|
+
// `?? []` on both sides, so an environment predating the relations column
|
|
765
|
+
// compares as holding none rather than as differing from everything.
|
|
766
|
+
const before = new Map((existing?.relations ?? []).map((relation) => [relation.name, relation]));
|
|
767
|
+
const after = new Map((incoming.relations ?? []).map((relation) => [relation.name, relation]));
|
|
768
|
+
const added = [...after.keys()].filter((name) => !before.has(name));
|
|
769
|
+
const changed = [...after.entries()]
|
|
770
|
+
.filter(([name, relation]) => {
|
|
771
|
+
const previous = before.get(name);
|
|
772
|
+
return previous !== undefined && stable(previous) !== stable(relation);
|
|
773
|
+
})
|
|
774
|
+
.map(([name]) => name);
|
|
775
|
+
const removed = [...before.keys()].filter((name) => !after.has(name));
|
|
776
|
+
if (added.length) {
|
|
777
|
+
diffs.push({ field: 'relations.added', from: [], to: added });
|
|
778
|
+
}
|
|
779
|
+
if (changed.length) {
|
|
780
|
+
diffs.push({ field: 'relations.changed', from: changed, to: changed });
|
|
781
|
+
}
|
|
782
|
+
if (removed.length) {
|
|
783
|
+
diffs.push({ field: 'relations.removed', from: removed, to: removed });
|
|
784
|
+
}
|
|
722
785
|
return diffs;
|
|
723
786
|
}
|
package/dist/catalog.events.d.ts
CHANGED
|
@@ -14,15 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export declare const CATALOG_LIB = "catalog";
|
|
16
16
|
/** Every event name this package emits. Exported so a watcher can claim them. */
|
|
17
|
-
export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed"];
|
|
17
|
+
export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
|
|
18
18
|
export type CatalogEvent = (typeof CATALOG_EVENTS)[number];
|
|
19
19
|
/**
|
|
20
20
|
* Where each event sits in the life of one load.
|
|
21
21
|
*
|
|
22
22
|
* This exists because a timestamp is not enough to order a trace. A fast load
|
|
23
23
|
* emits its whole story inside a single tick of whatever clock the recorder
|
|
24
|
-
* writes — the bundled MySQL store keeps
|
|
25
|
-
*
|
|
24
|
+
* writes — the bundled MySQL store keeps milliseconds, and a commit of a few
|
|
25
|
+
* hundred rows lands well inside one — so sorting by time alone leaves the
|
|
26
|
+
* events in insertion order, and insertion order is arbitrary.
|
|
26
27
|
* The observed result is a trace that reads `finished → written → committed →
|
|
27
28
|
* started`: causally impossible, rendered with total confidence. Sorting by
|
|
28
29
|
* time *and then* by this rank puts a same-tick story back in the only order it
|
|
@@ -142,6 +143,69 @@ export interface CatalogEventPayloads {
|
|
|
142
143
|
nodeCount: number;
|
|
143
144
|
changedBy: string;
|
|
144
145
|
};
|
|
146
|
+
/**
|
|
147
|
+
* A saved query started or stopped being fetchable through the embed API.
|
|
148
|
+
*
|
|
149
|
+
* The single act that hands an outside application data from this catalog, and
|
|
150
|
+
* for a long time the only one that left no trace: `SavedQuery.shared` is the
|
|
151
|
+
* whole embed boundary, it is set by a person, and nothing recorded that they
|
|
152
|
+
* had set it.
|
|
153
|
+
*
|
|
154
|
+
* One event for both directions rather than a `query.unshared` beside it,
|
|
155
|
+
* following `connector.run.finished`: anybody watching for shares has to watch
|
|
156
|
+
* for revocations too — a trail that records only the grants cannot answer
|
|
157
|
+
* "was this still shared last Tuesday", which is the question an incident
|
|
158
|
+
* actually asks. The direction is in {@link shared}.
|
|
159
|
+
*
|
|
160
|
+
* Emitted on the transition only. A save that leaves the flag where it was is
|
|
161
|
+
* not a sharing decision, and a trail that logged every keystroke on a query
|
|
162
|
+
* would be a trail nobody reads.
|
|
163
|
+
*
|
|
164
|
+
* **Deleting a shared query is one of those transitions**, and it emits here
|
|
165
|
+
* rather than under a name of its own. The question this event answers is
|
|
166
|
+
* "when did this stop being reachable from outside", and anybody asking it
|
|
167
|
+
* filters on `query.shared` and reads the last entry. A separate
|
|
168
|
+
* `query.deleted` would leave that filter reporting `shared: true` forever
|
|
169
|
+
* for something nobody can fetch — the revocation would be in a channel the
|
|
170
|
+
* asker did not subscribe to, which is the same absence this event exists to
|
|
171
|
+
* remove. {@link deleted} says which kind of ending it was.
|
|
172
|
+
*/
|
|
173
|
+
'query.shared': {
|
|
174
|
+
savedQueryId: string;
|
|
175
|
+
/** As it read at the moment of the change, so the trail is legible alone. */
|
|
176
|
+
name: string;
|
|
177
|
+
/** True when access was granted, false when it was taken back. */
|
|
178
|
+
shared: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Who decided. The host-resolved principal where there is one, and named
|
|
181
|
+
* `principalId` because that is the field a recorder lifts into the audit
|
|
182
|
+
* table's indexed column — spell it anything else and the entry lands
|
|
183
|
+
* attributed to nobody.
|
|
184
|
+
*/
|
|
185
|
+
principalId: string;
|
|
186
|
+
/**
|
|
187
|
+
* Set only when the revocation was a deletion. Absent means the thing still
|
|
188
|
+
* exists and was merely un-shared.
|
|
189
|
+
*
|
|
190
|
+
* Typed as `true` rather than `boolean` so the flag cannot be written the
|
|
191
|
+
* other way round: "not a deletion" is the absence of this key, matching
|
|
192
|
+
* how the rest of these payloads treat a fact that did not happen.
|
|
193
|
+
*
|
|
194
|
+
* Worth carrying rather than leaving to be looked up, because after a
|
|
195
|
+
* deletion there is nothing left to look up. An entry that said only
|
|
196
|
+
* `shared: false` would send its reader to a row that is gone, and the
|
|
197
|
+
* empty result reads as a broken trail rather than as the answer.
|
|
198
|
+
*/
|
|
199
|
+
deleted?: true;
|
|
200
|
+
};
|
|
201
|
+
/** The same decision, about a whole board. See {@link CatalogEventPayloads['query.shared']}. */
|
|
202
|
+
'dashboard.shared': {
|
|
203
|
+
dashboardId: string;
|
|
204
|
+
name: string;
|
|
205
|
+
shared: boolean;
|
|
206
|
+
principalId: string;
|
|
207
|
+
deleted?: true;
|
|
208
|
+
};
|
|
145
209
|
}
|
|
146
210
|
/**
|
|
147
211
|
* Teach the diagnostics channel what this library puts on it.
|
package/dist/catalog.events.js
CHANGED
|
@@ -31,14 +31,17 @@ exports.CATALOG_EVENTS = [
|
|
|
31
31
|
'connector.run.finished',
|
|
32
32
|
'transform.changed',
|
|
33
33
|
'workflow.changed',
|
|
34
|
+
'query.shared',
|
|
35
|
+
'dashboard.shared',
|
|
34
36
|
];
|
|
35
37
|
/**
|
|
36
38
|
* Where each event sits in the life of one load.
|
|
37
39
|
*
|
|
38
40
|
* This exists because a timestamp is not enough to order a trace. A fast load
|
|
39
41
|
* emits its whole story inside a single tick of whatever clock the recorder
|
|
40
|
-
* writes — the bundled MySQL store keeps
|
|
41
|
-
*
|
|
42
|
+
* writes — the bundled MySQL store keeps milliseconds, and a commit of a few
|
|
43
|
+
* hundred rows lands well inside one — so sorting by time alone leaves the
|
|
44
|
+
* events in insertion order, and insertion order is arbitrary.
|
|
42
45
|
* The observed result is a trace that reads `finished → written → committed →
|
|
43
46
|
* started`: causally impossible, rendered with total confidence. Sorting by
|
|
44
47
|
* time *and then* by this rank puts a same-tick story back in the only order it
|
|
@@ -64,6 +67,16 @@ exports.CATALOG_EVENT_PHASE = {
|
|
|
64
67
|
// be complete is exactly the mechanism that will fail the build the day a new
|
|
65
68
|
// event is added and nobody thinks about where it belongs.
|
|
66
69
|
'type.curated': 2,
|
|
70
|
+
// Sharing carries no snapshot id either, for the same reason curation does
|
|
71
|
+
// not: it is a standalone act on a saved query or a board, not a step of any
|
|
72
|
+
// load. So these ranks are never consulted, and they are written out for the
|
|
73
|
+
// reason the note above gives — the `Record` has to be complete, and that is
|
|
74
|
+
// what makes the build fail the day an event is added and nobody thinks about
|
|
75
|
+
// where it belongs. Ranked beside curation because they are the same kind of
|
|
76
|
+
// thing: a person deciding something about the catalog rather than data moving
|
|
77
|
+
// through it.
|
|
78
|
+
'query.shared': 2,
|
|
79
|
+
'dashboard.shared': 2,
|
|
67
80
|
'schema.changed': 3,
|
|
68
81
|
'snapshot.written': 4,
|
|
69
82
|
'snapshot.committed': 5,
|
|
@@ -84,6 +84,25 @@ export interface CatalogConnector {
|
|
|
84
84
|
* view atomically. `incremental` reads only what changed since the last run
|
|
85
85
|
* and carries the rest forward, which is cheaper but needs the source to
|
|
86
86
|
* offer a watermark and the type to have a primary key to merge on.
|
|
87
|
+
*
|
|
88
|
+
* **`incremental` is blind to deletes, and that is structural rather than a
|
|
89
|
+
* gap in any particular fetcher.** A run asks its source for what changed
|
|
90
|
+
* since a watermark; a row physically removed from the source never changes
|
|
91
|
+
* again, so it is never returned again, so the carry-forward copies it into
|
|
92
|
+
* every subsequent snapshot indefinitely. Nothing goes wrong at any single
|
|
93
|
+
* step — the catalog simply never finds out, and every count and dashboard
|
|
94
|
+
* built on the type is quietly wrong from then on.
|
|
95
|
+
*
|
|
96
|
+
* Because that failure is silent, the pipeline **refuses an incremental load
|
|
97
|
+
* of a type for which no reconciliation strategy has been declared**: a full
|
|
98
|
+
* read on an interval, a source that soft-deletes where the watermark can see
|
|
99
|
+
* it, or an explicit "stale rows are acceptable here, because …". The
|
|
100
|
+
* declaration is per object type and lives in the host's
|
|
101
|
+
* `CATALOG_LOAD_EXPECTATIONS` (see `load-expectations.ts` in
|
|
102
|
+
* `@dudousxd/nestjs-catalog-pipeline`), because it is a statement about the
|
|
103
|
+
* data rather than about the connector reading it — the same type loaded by a
|
|
104
|
+
* workflow sink or by an application POSTing to the publish API has exactly
|
|
105
|
+
* the same problem.
|
|
87
106
|
*/
|
|
88
107
|
mode?: 'full' | 'incremental';
|
|
89
108
|
/**
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* to survive into the audit trail, because "the console renamed this column" is
|
|
17
17
|
* not an answer anybody accepts.
|
|
18
18
|
*/
|
|
19
|
+
import type { CatalogObjectPage } from './catalog.types';
|
|
19
20
|
export type CatalogScope =
|
|
20
21
|
/** Read object metadata and rows. */
|
|
21
22
|
'catalog:read'
|
|
@@ -118,7 +119,9 @@ export interface CatalogPrincipal {
|
|
|
118
119
|
readTypes?: string[];
|
|
119
120
|
/**
|
|
120
121
|
* Classifications this principal may see. A column marked with anything
|
|
121
|
-
* outside this list is dropped from its reads
|
|
122
|
+
* outside this list is to be dropped from its reads — *by whoever serves
|
|
123
|
+
* them*, which is not this library. See the note above {@link mayWrite} on
|
|
124
|
+
* who enforces, and {@link readableObjectPage} for the drop itself.
|
|
122
125
|
*
|
|
123
126
|
* Undefined means "no classified columns", not "all of them" — the safe
|
|
124
127
|
* default for a caller nobody has thought about yet.
|
|
@@ -198,12 +201,57 @@ export declare function expandScopes(scopes: CatalogScope[]): CatalogScope[];
|
|
|
198
201
|
* something that happens because an administrator logged in.
|
|
199
202
|
*/
|
|
200
203
|
export declare function delegatePrincipal(application: CatalogPrincipal, actor: CatalogActor, grants: CatalogGrants): CatalogPrincipal;
|
|
204
|
+
/** Enforced by the library, because the publish API is handed a principal. */
|
|
201
205
|
export declare function mayWrite(principal: CatalogPrincipal, typeName: string): boolean;
|
|
206
|
+
/**
|
|
207
|
+
* Whether this principal may read a type at all.
|
|
208
|
+
*
|
|
209
|
+
* A predicate for a host to apply, not something the catalog's own read routes
|
|
210
|
+
* consult — see the note above. `readableObjectPage` is the ready-made
|
|
211
|
+
* application of it.
|
|
212
|
+
*/
|
|
202
213
|
export declare function mayRead(principal: CatalogPrincipal, typeName: string): boolean;
|
|
203
214
|
/**
|
|
204
215
|
* Whether a column is visible to this principal.
|
|
205
216
|
*
|
|
206
217
|
* Unclassified columns are visible to everyone; a classified one requires the
|
|
207
218
|
* principal to name that classification. Absence is denial.
|
|
219
|
+
*
|
|
220
|
+
* The same caveat as {@link mayRead}: answering the question is all this does.
|
|
221
|
+
* No read path in this library asks it.
|
|
208
222
|
*/
|
|
209
223
|
export declare function maySeeClassification(principal: CatalogPrincipal, classification: string | undefined): boolean;
|
|
224
|
+
/**
|
|
225
|
+
* A page of objects as this principal is allowed to see it: the page, minus
|
|
226
|
+
* every column carrying a classification they do not hold — and `null` when
|
|
227
|
+
* they may not read the type at all.
|
|
228
|
+
*
|
|
229
|
+
* This exists so that "who applies `mayRead` and `maySeeClassification`?" has an
|
|
230
|
+
* answer a host can call rather than a rule a host has to re-derive. Wrap the
|
|
231
|
+
* service:
|
|
232
|
+
*
|
|
233
|
+
* ```ts
|
|
234
|
+
* const page = await this.catalog.readObjects(name, query);
|
|
235
|
+
* const visible = readableObjectPage(principal, page);
|
|
236
|
+
* if (!visible) throw new ForbiddenException(`${principal.id} may not read ${name}.`);
|
|
237
|
+
* return visible;
|
|
238
|
+
* ```
|
|
239
|
+
*
|
|
240
|
+
* `null` rather than a thrown exception because the status code is the host's
|
|
241
|
+
* decision: a 403 confirms to the caller that the type exists, and a deployment
|
|
242
|
+
* that considers the type list itself sensitive would rather answer 404. `null`
|
|
243
|
+
* rather than an empty page because a page with no columns is what a type whose
|
|
244
|
+
* every column is hidden legitimately returns, and a denial must not be
|
|
245
|
+
* mistakable for one.
|
|
246
|
+
*
|
|
247
|
+
* Hidden values are **deleted** from each row rather than blanked. A key present
|
|
248
|
+
* with `null` asserts that the column exists and happens to be empty, which for
|
|
249
|
+
* a classified column is itself a disclosure.
|
|
250
|
+
*
|
|
251
|
+
* What it does not do: rows can carry keys no column declares, because
|
|
252
|
+
* `readObjects` always fetches the primary key so a UI has a row identity even
|
|
253
|
+
* when that key is hidden. Those survive. This removes what a classification
|
|
254
|
+
* hides; it is not a whitelist, and a classified column that is also part of a
|
|
255
|
+
* hidden primary key is outside what a page alone can express.
|
|
256
|
+
*/
|
|
257
|
+
export declare function readableObjectPage(principal: CatalogPrincipal, page: CatalogObjectPage): CatalogObjectPage | null;
|
|
@@ -27,6 +27,7 @@ exports.delegatePrincipal = delegatePrincipal;
|
|
|
27
27
|
exports.mayWrite = mayWrite;
|
|
28
28
|
exports.mayRead = mayRead;
|
|
29
29
|
exports.maySeeClassification = maySeeClassification;
|
|
30
|
+
exports.readableObjectPage = readableObjectPage;
|
|
30
31
|
/** Every scope, in one place, so `catalog:admin` can be expanded exactly. */
|
|
31
32
|
const ALL_SCOPES = [
|
|
32
33
|
'catalog:read',
|
|
@@ -222,9 +223,45 @@ function matches(list, typeName) {
|
|
|
222
223
|
return false;
|
|
223
224
|
return list.includes('*') || list.includes(typeName);
|
|
224
225
|
}
|
|
226
|
+
// -----------------------------------------------------------------------------
|
|
227
|
+
// The grant predicates, and who is expected to call them.
|
|
228
|
+
//
|
|
229
|
+
// **This library declares and evaluates; the host enforces.** The same split
|
|
230
|
+
// `catalog.route-auth.ts` states for scopes holds for grants: nothing in this
|
|
231
|
+
// package ever turns a request into a principal. `CatalogPrincipalResolver` is
|
|
232
|
+
// an interface with one bundled development implementation, no guard ships here,
|
|
233
|
+
// and the `request.principal` that `publish.controller.ts` reads is put there by
|
|
234
|
+
// a guard the host wrote — the error it throws even names a class this repo does
|
|
235
|
+
// not contain.
|
|
236
|
+
//
|
|
237
|
+
// The asymmetry between write and read is real and is not the bug it looks like.
|
|
238
|
+
// `mayWrite` is called inside the library, in `publish.service.ts`, for exactly
|
|
239
|
+
// one reason: the publish API takes a `CatalogPrincipal` as an argument, so it
|
|
240
|
+
// has one in hand. The read path does not. `CatalogService.readObjects` has no
|
|
241
|
+
// principal parameter, `GET /catalog/objects/:name` has none to give it, and so
|
|
242
|
+
// `mayRead` and `maySeeClassification` are called nowhere in this repository at
|
|
243
|
+
// runtime. That is the design: a library that filtered reads would need a
|
|
244
|
+
// principal on every read, which means shipping a guard, which means having an
|
|
245
|
+
// opinion about the host's identity provider.
|
|
246
|
+
//
|
|
247
|
+
// What was wrong was not the mechanism but the prose around it. A host reading
|
|
248
|
+
// "classifications this principal may see" is entitled to assume something drops
|
|
249
|
+
// the columns, and nothing does. So, stated once, here: **the read path applies
|
|
250
|
+
// no grants and no classification filtering, and a host that wants either calls
|
|
251
|
+
// `readableObjectPage` itself.** Anyone about to "wire this up" should change
|
|
252
|
+
// that sentence rather than quietly making it false.
|
|
253
|
+
// -----------------------------------------------------------------------------
|
|
254
|
+
/** Enforced by the library, because the publish API is handed a principal. */
|
|
225
255
|
function mayWrite(principal, typeName) {
|
|
226
256
|
return hasScope(principal, 'catalog:write') && matches(principal.writeTypes, typeName);
|
|
227
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether this principal may read a type at all.
|
|
260
|
+
*
|
|
261
|
+
* A predicate for a host to apply, not something the catalog's own read routes
|
|
262
|
+
* consult — see the note above. `readableObjectPage` is the ready-made
|
|
263
|
+
* application of it.
|
|
264
|
+
*/
|
|
228
265
|
function mayRead(principal, typeName) {
|
|
229
266
|
if (!hasScope(principal, 'catalog:read'))
|
|
230
267
|
return false;
|
|
@@ -237,9 +274,64 @@ function mayRead(principal, typeName) {
|
|
|
237
274
|
*
|
|
238
275
|
* Unclassified columns are visible to everyone; a classified one requires the
|
|
239
276
|
* principal to name that classification. Absence is denial.
|
|
277
|
+
*
|
|
278
|
+
* The same caveat as {@link mayRead}: answering the question is all this does.
|
|
279
|
+
* No read path in this library asks it.
|
|
240
280
|
*/
|
|
241
281
|
function maySeeClassification(principal, classification) {
|
|
242
282
|
if (!classification)
|
|
243
283
|
return true;
|
|
244
284
|
return principal.classifications?.includes(classification) ?? false;
|
|
245
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* A page of objects as this principal is allowed to see it: the page, minus
|
|
288
|
+
* every column carrying a classification they do not hold — and `null` when
|
|
289
|
+
* they may not read the type at all.
|
|
290
|
+
*
|
|
291
|
+
* This exists so that "who applies `mayRead` and `maySeeClassification`?" has an
|
|
292
|
+
* answer a host can call rather than a rule a host has to re-derive. Wrap the
|
|
293
|
+
* service:
|
|
294
|
+
*
|
|
295
|
+
* ```ts
|
|
296
|
+
* const page = await this.catalog.readObjects(name, query);
|
|
297
|
+
* const visible = readableObjectPage(principal, page);
|
|
298
|
+
* if (!visible) throw new ForbiddenException(`${principal.id} may not read ${name}.`);
|
|
299
|
+
* return visible;
|
|
300
|
+
* ```
|
|
301
|
+
*
|
|
302
|
+
* `null` rather than a thrown exception because the status code is the host's
|
|
303
|
+
* decision: a 403 confirms to the caller that the type exists, and a deployment
|
|
304
|
+
* that considers the type list itself sensitive would rather answer 404. `null`
|
|
305
|
+
* rather than an empty page because a page with no columns is what a type whose
|
|
306
|
+
* every column is hidden legitimately returns, and a denial must not be
|
|
307
|
+
* mistakable for one.
|
|
308
|
+
*
|
|
309
|
+
* Hidden values are **deleted** from each row rather than blanked. A key present
|
|
310
|
+
* with `null` asserts that the column exists and happens to be empty, which for
|
|
311
|
+
* a classified column is itself a disclosure.
|
|
312
|
+
*
|
|
313
|
+
* What it does not do: rows can carry keys no column declares, because
|
|
314
|
+
* `readObjects` always fetches the primary key so a UI has a row identity even
|
|
315
|
+
* when that key is hidden. Those survive. This removes what a classification
|
|
316
|
+
* hides; it is not a whitelist, and a classified column that is also part of a
|
|
317
|
+
* hidden primary key is outside what a page alone can express.
|
|
318
|
+
*/
|
|
319
|
+
function readableObjectPage(principal, page) {
|
|
320
|
+
if (!mayRead(principal, page.type))
|
|
321
|
+
return null;
|
|
322
|
+
const hidden = page.columns
|
|
323
|
+
.filter((column) => !maySeeClassification(principal, column.classification))
|
|
324
|
+
.map((column) => column.name);
|
|
325
|
+
if (hidden.length === 0)
|
|
326
|
+
return page;
|
|
327
|
+
return {
|
|
328
|
+
...page,
|
|
329
|
+
columns: page.columns.filter((column) => !hidden.includes(column.name)),
|
|
330
|
+
rows: page.rows.map((row) => {
|
|
331
|
+
const visible = { ...row };
|
|
332
|
+
for (const name of hidden)
|
|
333
|
+
delete visible[name];
|
|
334
|
+
return visible;
|
|
335
|
+
}),
|
|
336
|
+
};
|
|
337
|
+
}
|