@dudousxd/nestjs-catalog 0.10.0 → 0.11.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/dist/catalog.pipeline.d.ts +82 -2
- package/dist/catalog.pipeline.js +60 -2
- package/dist/catalog.secrets.d.ts +283 -0
- package/dist/catalog.secrets.js +209 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +9 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +12 -2
- package/dist/transform-runner.d.ts +22 -5
- package/dist/transform-runner.js +126 -9
- package/package.json +1 -1
|
@@ -442,13 +442,48 @@ export interface WorkflowGraph {
|
|
|
442
442
|
* an audit. The limitation is real and worth stating plainly — an edited graph
|
|
443
443
|
* cannot be reconstructed from an old run, only identified as different.
|
|
444
444
|
*/
|
|
445
|
+
/**
|
|
446
|
+
* Whether this graph is still being drawn, or is something somebody declared
|
|
447
|
+
* finished.
|
|
448
|
+
*
|
|
449
|
+
* The distinction exists because validation used to be the gate on *saving*, and
|
|
450
|
+
* that made an unfinished graph unstorable: `saveWorkflow` refused anything
|
|
451
|
+
* `validateWorkflow` had an issue with, so a canvas with one node on it could
|
|
452
|
+
* not be written down at all and closing the tab lost it. Worse, it made the
|
|
453
|
+
* canvas lie about ordinary work — clicking "+ Sink" produces a node that is
|
|
454
|
+
* unreachable from any source and names no type, both true and both useless one
|
|
455
|
+
* second after the click, because a just-added node is unwired by construction.
|
|
456
|
+
*
|
|
457
|
+
* So the gate moved rather than loosened. Validation is now the gate on
|
|
458
|
+
* publishing, and the same `validateWorkflow` still decides — a draft is not a
|
|
459
|
+
* graph that skipped the rules, it is a graph nobody has claimed is finished
|
|
460
|
+
* yet. Everything that consumes a workflow asks for `ready`: a connector may
|
|
461
|
+
* only point at one, and a promotion may only carry one. What crosses an
|
|
462
|
+
* environment should be something a person declared done.
|
|
463
|
+
*/
|
|
464
|
+
export declare const WORKFLOW_STATUSES: readonly ["draft", "ready"];
|
|
465
|
+
export type WorkflowStatus = (typeof WORKFLOW_STATUSES)[number];
|
|
466
|
+
/** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
|
|
467
|
+
export declare function isWorkflowStatus(value: unknown): value is WorkflowStatus;
|
|
445
468
|
export interface CatalogWorkflow {
|
|
446
469
|
id: string;
|
|
447
470
|
name: string;
|
|
448
471
|
description?: string;
|
|
449
472
|
nodes: WorkflowNode[];
|
|
450
473
|
edges: WorkflowEdge[];
|
|
451
|
-
/**
|
|
474
|
+
/** See {@link WORKFLOW_STATUSES}. A graph is `draft` until somebody publishes it. */
|
|
475
|
+
status: WorkflowStatus;
|
|
476
|
+
/**
|
|
477
|
+
* Bumped whenever the graph's behaviour changes. Never on a rename or a move.
|
|
478
|
+
*
|
|
479
|
+
* Bumped on a **draft** edit too, which looks like exactly the inflation this
|
|
480
|
+
* rule exists to prevent and is not. The counter's job is to make
|
|
481
|
+
* {@link ConnectorRun.workflowVersion} answer "which shape ran": freezing it
|
|
482
|
+
* while a graph is drafted would let a run recorded at v4 and a later run also
|
|
483
|
+
* at v4 mean two different graphs, which is the one thing that field must
|
|
484
|
+
* never do. Drafting therefore inflates a number nobody reads — cheap — rather
|
|
485
|
+
* than making a number somebody does read ambiguous.
|
|
486
|
+
*/
|
|
452
487
|
version: number;
|
|
453
488
|
/** Fingerprint of the graph at this version. See {@link workflowGraphHash}. */
|
|
454
489
|
graphHash: string;
|
|
@@ -660,7 +695,24 @@ export interface CatalogWorkflowStore {
|
|
|
660
695
|
listWorkflows(): Promise<CatalogWorkflow[]>;
|
|
661
696
|
getWorkflow(id: string): Promise<CatalogWorkflow | undefined>;
|
|
662
697
|
/**
|
|
663
|
-
* Validates
|
|
698
|
+
* Writes. Validates only what it must.
|
|
699
|
+
*
|
|
700
|
+
* A **draft** is written without validating, which is the whole of the change
|
|
701
|
+
* and the reason {@link WORKFLOW_STATUSES} exists: a graph you have not
|
|
702
|
+
* finished has to be storable, or closing the tab loses it. A **ready**
|
|
703
|
+
* workflow is still validated on every save, because it is the one that runs.
|
|
704
|
+
*
|
|
705
|
+
* `status` is not an input. A save cannot promote a draft to ready — that is
|
|
706
|
+
* {@link publishWorkflow}, which exists so there is one place that validates
|
|
707
|
+
* and one place that can explain why it refused. A save of an already-ready
|
|
708
|
+
* workflow keeps it ready, and **refuses an edit that would make it invalid**
|
|
709
|
+
* rather than quietly demoting it to draft. Demotion was the other option and
|
|
710
|
+
* it is the one that loses a running pipeline silently: a connector may only
|
|
711
|
+
* point at a ready graph, so a save that dropped the status would disable a
|
|
712
|
+
* scheduled load with nothing said to anybody. Refusing puts the error in
|
|
713
|
+
* front of the person who is editing, at the moment they edit. To park a
|
|
714
|
+
* broken idea on a live graph, {@link unpublishWorkflow} it first and be told
|
|
715
|
+
* which connectors that stops.
|
|
664
716
|
*
|
|
665
717
|
* `version`, `graphHash` and `targetType` are not inputs: the first two are
|
|
666
718
|
* derived from the graph and the third from the sink, and accepting them from
|
|
@@ -670,6 +722,34 @@ export interface CatalogWorkflowStore {
|
|
|
670
722
|
id?: string;
|
|
671
723
|
description?: string;
|
|
672
724
|
}, createdBy: string): Promise<CatalogWorkflow>;
|
|
725
|
+
/**
|
|
726
|
+
* Declare a graph finished: validate it, and make it `ready`.
|
|
727
|
+
*
|
|
728
|
+
* A transition rather than a field on save, and the argument is that this is
|
|
729
|
+
* the only shape with somewhere to put the refusal. "Ready" is a claim that
|
|
730
|
+
* has to be checked, and a check that fails owes an explanation naming the
|
|
731
|
+
* nodes — `validateWorkflow` produces exactly that, and a boolean field on a
|
|
732
|
+
* save request has nowhere to return it that is not an error on an operation
|
|
733
|
+
* the caller thought was about something else. It also makes the audit
|
|
734
|
+
* question answerable: publishing is an act with an actor, and a field set in
|
|
735
|
+
* passing during an autosave is not.
|
|
736
|
+
*
|
|
737
|
+
* Idempotent on an already-ready graph, because the honest answer to "publish
|
|
738
|
+
* this thing that is published" is the graph, not an error.
|
|
739
|
+
*/
|
|
740
|
+
publishWorkflow(id: string, publishedBy: string): Promise<CatalogWorkflow>;
|
|
741
|
+
/**
|
|
742
|
+
* Take a graph back to `draft`.
|
|
743
|
+
*
|
|
744
|
+
* **Refuses while any connector still runs it**, exactly as
|
|
745
|
+
* {@link deleteWorkflow} does and for the same reason: a connector may only
|
|
746
|
+
* point at a ready graph, so unpublishing one out from under a schedule breaks
|
|
747
|
+
* a load that was working, and the operator needs to know *which* connectors
|
|
748
|
+
* to point elsewhere first. Refusing here rather than cascading is deliberate —
|
|
749
|
+
* disabling somebody's connectors as a side effect of an edit to something
|
|
750
|
+
* else is precisely the silent action this status exists to prevent.
|
|
751
|
+
*/
|
|
752
|
+
unpublishWorkflow(id: string, unpublishedBy: string): Promise<CatalogWorkflow>;
|
|
673
753
|
/** Refuses while any connector still runs it. */
|
|
674
754
|
deleteWorkflow(id: string): Promise<boolean>;
|
|
675
755
|
/** Which connectors run it. Named, so a refusal can say. */
|
package/dist/catalog.pipeline.js
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* systems each believing they decide when a load runs.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.TRANSFORM_RUNNER = exports.TRANSFORM_LANGUAGES = exports.CONNECTOR_KINDS = void 0;
|
|
12
|
+
exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.TRANSFORM_RUNNER = exports.TRANSFORM_LANGUAGES = exports.CONNECTOR_KINDS = void 0;
|
|
13
13
|
exports.isConnectorKind = isConnectorKind;
|
|
14
14
|
exports.isTransformLanguage = isTransformLanguage;
|
|
15
15
|
exports.isWorkflowNodeKind = isWorkflowNodeKind;
|
|
16
|
+
exports.isWorkflowStatus = isWorkflowStatus;
|
|
16
17
|
exports.isWorkflowExecutionMode = isWorkflowExecutionMode;
|
|
17
18
|
exports.validateWorkflow = validateWorkflow;
|
|
18
19
|
exports.workflowRunOrder = workflowRunOrder;
|
|
@@ -136,6 +137,57 @@ function isWorkflowNodeKind(value) {
|
|
|
136
137
|
* separator would let one node read another's rows.
|
|
137
138
|
*/
|
|
138
139
|
exports.WORKFLOW_NODE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
140
|
+
/**
|
|
141
|
+
* An authored graph of steps ending in one commit.
|
|
142
|
+
*
|
|
143
|
+
* Versioned the way {@link CatalogTransform} is, and for the same question: a
|
|
144
|
+
* load that produced surprising numbers is investigated afterwards, and "what
|
|
145
|
+
* ran" is the first thing asked. For a single-transform connector that means
|
|
146
|
+
* the code; for a workflow it means the code *and* the wiring, so both the
|
|
147
|
+
* graph version and the per-node transform versions are recorded on the run.
|
|
148
|
+
*
|
|
149
|
+
* Like a transform, only the latest shape is kept. Storing every past graph was
|
|
150
|
+
* the alternative and was rejected for consistency: transforms already answer
|
|
151
|
+
* "which code ran" with a number and no history, and a model where the graph is
|
|
152
|
+
* fully recoverable but the code inside it is not would give false confidence in
|
|
153
|
+
* an audit. The limitation is real and worth stating plainly — an edited graph
|
|
154
|
+
* cannot be reconstructed from an old run, only identified as different.
|
|
155
|
+
*/
|
|
156
|
+
/**
|
|
157
|
+
* Whether this graph is still being drawn, or is something somebody declared
|
|
158
|
+
* finished.
|
|
159
|
+
*
|
|
160
|
+
* The distinction exists because validation used to be the gate on *saving*, and
|
|
161
|
+
* that made an unfinished graph unstorable: `saveWorkflow` refused anything
|
|
162
|
+
* `validateWorkflow` had an issue with, so a canvas with one node on it could
|
|
163
|
+
* not be written down at all and closing the tab lost it. Worse, it made the
|
|
164
|
+
* canvas lie about ordinary work — clicking "+ Sink" produces a node that is
|
|
165
|
+
* unreachable from any source and names no type, both true and both useless one
|
|
166
|
+
* second after the click, because a just-added node is unwired by construction.
|
|
167
|
+
*
|
|
168
|
+
* So the gate moved rather than loosened. Validation is now the gate on
|
|
169
|
+
* publishing, and the same `validateWorkflow` still decides — a draft is not a
|
|
170
|
+
* graph that skipped the rules, it is a graph nobody has claimed is finished
|
|
171
|
+
* yet. Everything that consumes a workflow asks for `ready`: a connector may
|
|
172
|
+
* only point at one, and a promotion may only carry one. What crosses an
|
|
173
|
+
* environment should be something a person declared done.
|
|
174
|
+
*/
|
|
175
|
+
exports.WORKFLOW_STATUSES = [
|
|
176
|
+
/**
|
|
177
|
+
* Being drawn. Saves without validating, and cannot run, be scheduled, or be
|
|
178
|
+
* promoted. An incomplete node here is the normal state rather than an alarm.
|
|
179
|
+
*/
|
|
180
|
+
'draft',
|
|
181
|
+
/**
|
|
182
|
+
* Declared finished, and validated at the moment it was declared. This is the
|
|
183
|
+
* only status a connector may point at and the only one a promotion carries.
|
|
184
|
+
*/
|
|
185
|
+
'ready',
|
|
186
|
+
];
|
|
187
|
+
/** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
|
|
188
|
+
function isWorkflowStatus(value) {
|
|
189
|
+
return exports.WORKFLOW_STATUSES.some((status) => status === value);
|
|
190
|
+
}
|
|
139
191
|
/**
|
|
140
192
|
* How a workflow run is executed here.
|
|
141
193
|
*
|
|
@@ -634,7 +686,13 @@ function isWorkflowEdge(value) {
|
|
|
634
686
|
function supportsWorkflows(store) {
|
|
635
687
|
return (typeof store.listWorkflows === 'function' &&
|
|
636
688
|
typeof store.getWorkflow === 'function' &&
|
|
637
|
-
typeof store.saveWorkflow === 'function'
|
|
689
|
+
typeof store.saveWorkflow === 'function' &&
|
|
690
|
+
// Asked for by name like the rest, rather than assumed to come with
|
|
691
|
+
// `saveWorkflow`. Promotion publishes what it saves, so a store that has the
|
|
692
|
+
// save and not the transition would narrow cleanly here and then fail one
|
|
693
|
+
// call later, in the middle of an apply that has already written types and
|
|
694
|
+
// transforms into the target.
|
|
695
|
+
typeof store.publishWorkflow === 'function');
|
|
638
696
|
}
|
|
639
697
|
function supportsWorkflowStages(store) {
|
|
640
698
|
return typeof store.writeStage === 'function' && typeof store.readStage === 'function';
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypting the credential that has to rest in `catalog_connection.config`.
|
|
3
|
+
*
|
|
4
|
+
* `config-secrets.ts` in the pipeline package closed the HTTP half of this: a
|
|
5
|
+
* connection URL is a password with an address attached, `config` is served by
|
|
6
|
+
* `GET pipeline/connections` under `catalog:read`, and the redaction stopped it
|
|
7
|
+
* travelling in a response. `MySqlPipelineStore` closed the write half: a
|
|
8
|
+
* password-bearing URL that is not already stored is refused, with a message
|
|
9
|
+
* naming `secretEnvVar`.
|
|
10
|
+
*
|
|
11
|
+
* Neither touches the thing this file is about. A redaction protects a reader
|
|
12
|
+
* coming through the API. It does nothing at all for a database dump, a read
|
|
13
|
+
* replica, a nightly backup, or anybody holding `SELECT` on the RDS instance —
|
|
14
|
+
* and for those, `config` is still a list of every password the catalog knows.
|
|
15
|
+
* `allowInlineCredentials` makes that population larger on purpose, which is
|
|
16
|
+
* precisely why encryption becomes worth building at the same time as the flag
|
|
17
|
+
* that needs it.
|
|
18
|
+
*
|
|
19
|
+
* ## A seam, not an implementation
|
|
20
|
+
*
|
|
21
|
+
* There is no cipher in this file and there must not be one. A library that
|
|
22
|
+
* shipped AES with a key read from an environment variable would have moved the
|
|
23
|
+
* problem from one column to one variable, and would have to answer for key
|
|
24
|
+
* rotation, per-environment separation, and an audit trail — all of which the
|
|
25
|
+
* host's KMS, Vault, or HSM already answers for, and none of which this package
|
|
26
|
+
* can answer for on its behalf. What is here is the shape of the question, so a
|
|
27
|
+
* provider can be written against it: {@link CatalogSecretVault}.
|
|
28
|
+
*
|
|
29
|
+
* The library never inspects a ciphertext, never chooses a key, and never
|
|
30
|
+
* decides an algorithm. It decides *which values* are secrets, *when* they are
|
|
31
|
+
* sealed, and *what happens when the vault cannot be reached* — three questions
|
|
32
|
+
* a provider should not have to have an opinion about.
|
|
33
|
+
*
|
|
34
|
+
* ## What the default does
|
|
35
|
+
*
|
|
36
|
+
* Refuses. {@link RefusingSecretVault} throws on `seal`, naming the token to
|
|
37
|
+
* bind. A default that quietly stored plaintext would be the worst shape
|
|
38
|
+
* available here: a host would turn `encryptCredentials` on, see saves succeed,
|
|
39
|
+
* and have exactly the same column contents as before with a docblock now
|
|
40
|
+
* claiming otherwise. Every other refusing default in this codebase exists for
|
|
41
|
+
* the same reason — `CATALOG_LOAD_EXPECTATIONS` unbound means incremental loads
|
|
42
|
+
* are refused rather than silently unpoliced.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* What a vault hands back. Stored verbatim; opaque to this library.
|
|
46
|
+
*
|
|
47
|
+
* Three fields and no fourth, because everything a rotation needs is here and
|
|
48
|
+
* nothing a reader could misuse is: the ciphertext says nothing without the
|
|
49
|
+
* vault, and the vault says nothing without the key. It is a plain JSON object
|
|
50
|
+
* so that it can sit in the same `json` column the plaintext sat in — no
|
|
51
|
+
* migration, no second table, and a column that may hold either form for as
|
|
52
|
+
* long as it takes a deployment to move over. {@link isSealedSecret} is what
|
|
53
|
+
* tells the two apart on the way out.
|
|
54
|
+
*/
|
|
55
|
+
export interface SealedSecret {
|
|
56
|
+
/** Which vault sealed it — `CatalogSecretVault.name`. */
|
|
57
|
+
vault: string;
|
|
58
|
+
/** Which key, in whatever form that vault names keys. */
|
|
59
|
+
keyId: string;
|
|
60
|
+
/**
|
|
61
|
+
* An opaque string the vault chose. **Not base64, and never decoded here.**
|
|
62
|
+
*
|
|
63
|
+
* This said "base64" first, and both provider implementations proved it
|
|
64
|
+
* wrong from opposite directions: HashiCorp Transit returns
|
|
65
|
+
* `vault:v1:<base64>`, which has two colons and is not decodable as base64 at
|
|
66
|
+
* all. The provider stores that verbatim and is right to — the `v1` is the
|
|
67
|
+
* row's only self-description of which key version sealed it, and
|
|
68
|
+
* `transit/rewrap` takes exactly that string back.
|
|
69
|
+
*
|
|
70
|
+
* The claim was harmless while nothing acted on it, which is what makes it
|
|
71
|
+
* worth correcting now rather than later: the day something takes it
|
|
72
|
+
* literally — a base64 `CHECK` constraint, a JSON-schema `contentEncoding`, a
|
|
73
|
+
* `Buffer.from(value, 'base64')` round trip — it breaks **silently**, because
|
|
74
|
+
* `Buffer.from` does not throw on input it cannot read. It returns different
|
|
75
|
+
* bytes.
|
|
76
|
+
*
|
|
77
|
+
* So the only rule is: non-empty, and given back to the vault exactly as it
|
|
78
|
+
* came. {@link isSealedSecret} checks that it is a non-empty string and must
|
|
79
|
+
* never be tightened past that — any narrower test encodes one vault's format
|
|
80
|
+
* as the contract and silently rejects the next one's rows.
|
|
81
|
+
*/
|
|
82
|
+
ciphertext: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Where a secret is being used, so a vault can scope or audit by it.
|
|
86
|
+
*
|
|
87
|
+
* Passed to `open` as well as to `seal`, and that is the interesting half: a
|
|
88
|
+
* KMS provider can put this in an encryption context so a ciphertext sealed for
|
|
89
|
+
* `connection/abc/url` cannot be replayed as `connector/xyz/url`, and a Vault
|
|
90
|
+
* provider can write an audit line naming the row somebody's credential was
|
|
91
|
+
* read for. Neither is possible if the seam only carries bytes.
|
|
92
|
+
*/
|
|
93
|
+
export interface SecretContext {
|
|
94
|
+
/** `connection` or `connector`. */
|
|
95
|
+
kind: string;
|
|
96
|
+
/**
|
|
97
|
+
* The row's id. Present and stable whenever `MySqlPipelineStore` is the
|
|
98
|
+
* caller, including on a first save.
|
|
99
|
+
*
|
|
100
|
+
* This said "absent on a first save", and both provider implementations
|
|
101
|
+
* independently left `id` out of their encryption context because of it —
|
|
102
|
+
* correctly, given what it said. A context that is absent on create and
|
|
103
|
+
* present on update seals under one context and opens under another, and the
|
|
104
|
+
* failure lands at the first connector run rather than at the save.
|
|
105
|
+
*
|
|
106
|
+
* So the store was changed rather than the providers: it mints the row's id
|
|
107
|
+
* *before* sealing instead of inside `em.create`, which costs one hoisted
|
|
108
|
+
* `randomUUID()` and makes the binding property real. A provider may now put
|
|
109
|
+
* this in an encryption context and get what the seam always claimed —
|
|
110
|
+
* `connection/abc/url` is not replayable as `connection/xyz/url`.
|
|
111
|
+
*
|
|
112
|
+
* Two things this does NOT make true, both worth stating before somebody
|
|
113
|
+
* relies on them:
|
|
114
|
+
*
|
|
115
|
+
* - It is still optional in the type, because a host calling a vault
|
|
116
|
+
* directly is not obliged to have a row. A provider that binds it must
|
|
117
|
+
* decide what an absent `id` means for its own callers.
|
|
118
|
+
* - **Rows sealed before this existed were sealed without it.** A provider
|
|
119
|
+
* that starts binding `id` on a deployment that already has sealed rows
|
|
120
|
+
* strands every one of them. Binding it is safe on a new deployment, and
|
|
121
|
+
* on an existing one only behind a re-seal.
|
|
122
|
+
*/
|
|
123
|
+
id?: string;
|
|
124
|
+
/** The config key being sealed — `url`, `password`. */
|
|
125
|
+
field: string;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The seam a host binds to make credentials unreadable in the catalog's own
|
|
129
|
+
* database.
|
|
130
|
+
*
|
|
131
|
+
* Both methods are async and both are expected to fail sometimes: they are
|
|
132
|
+
* network calls to something outside this process. What each failure *means*
|
|
133
|
+
* is not symmetric, and the store treats them differently — see
|
|
134
|
+
* {@link SecretSealFailedError} and {@link SecretOpenFailedError}.
|
|
135
|
+
*/
|
|
136
|
+
export interface CatalogSecretVault {
|
|
137
|
+
/**
|
|
138
|
+
* Stable, stored on every SealedSecret so a rotation can find its own.
|
|
139
|
+
*
|
|
140
|
+
* Stable across deployments and releases, not merely across a process: it is
|
|
141
|
+
* written into rows. Renaming it strands every row already sealed under the
|
|
142
|
+
* old name, which the store refuses loudly rather than guessing at — a bound
|
|
143
|
+
* vault whose name does not match the row's is not asked to try.
|
|
144
|
+
*/
|
|
145
|
+
readonly name: string;
|
|
146
|
+
seal(plaintext: string, context: SecretContext): Promise<SealedSecret>;
|
|
147
|
+
open(sealed: SealedSecret, context: SecretContext): Promise<string>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The token a host binds one vault — or several — to.
|
|
151
|
+
*
|
|
152
|
+
* **Several is the supported shape, and it is what makes rotation possible.**
|
|
153
|
+
* Bind `CatalogSecretVault[]` and the store seals with the *first* and opens
|
|
154
|
+
* with whichever one's `name` matches the row. Moving from one vault to another
|
|
155
|
+
* is then: bind `[next, current]`, let saves reseal under `next`, and drop
|
|
156
|
+
* `current` when nothing is sealed under it any more. With a single vault that
|
|
157
|
+
* transition has no middle — the moment `next` is bound, every row sealed by
|
|
158
|
+
* `current` is unreadable — and a library that made rotation require an outage
|
|
159
|
+
* would be a library whose keys never got rotated.
|
|
160
|
+
*
|
|
161
|
+
* `@Optional()` where it is injected, so a host that binds nothing still boots.
|
|
162
|
+
* It then gets {@link RefusingSecretVault}, which costs nothing until something
|
|
163
|
+
* actually asks for a seal.
|
|
164
|
+
*/
|
|
165
|
+
export declare const CATALOG_SECRET_VAULT: unique symbol;
|
|
166
|
+
/**
|
|
167
|
+
* Whether a value that came back out of a JSON column is a sealed secret.
|
|
168
|
+
*
|
|
169
|
+
* A runtime guard and not a cast, because this is a `json` column: MikroORM
|
|
170
|
+
* hands back whatever is in it, and what is in it may have been written by a
|
|
171
|
+
* different release, by a hand-run `UPDATE`, or by a host's own seeder. The
|
|
172
|
+
* same argument `WorkflowRow.nodes` makes for being typed `unknown[]`.
|
|
173
|
+
*
|
|
174
|
+
* Deliberately strict about emptiness. A `{ vault: '', keyId: '', ciphertext:
|
|
175
|
+
* '' }` is not a sealed secret and must not be treated as one — treating it as
|
|
176
|
+
* one sends it to a vault that will refuse it, and the operator is told their
|
|
177
|
+
* vault is broken when the row is. An empty ciphertext falls through as an
|
|
178
|
+
* ordinary config value instead, which is what it is.
|
|
179
|
+
*
|
|
180
|
+
* Deliberately loose about everything else, and that is the harder half to
|
|
181
|
+
* hold. `ciphertext` is tested for "non-empty string" and must never be tested
|
|
182
|
+
* for more — not base64, not a length, not a prefix. Two vaults already
|
|
183
|
+
* disagree about its shape: KMS hands back base64, Transit hands back
|
|
184
|
+
* `vault:v1:<base64>`. Any tighter check would encode one of them as the
|
|
185
|
+
* contract and start silently refusing the other's rows, which surfaces as
|
|
186
|
+
* "this credential is not encrypted" for a value that is.
|
|
187
|
+
*
|
|
188
|
+
* What it cannot distinguish is a config value a host deliberately authored as
|
|
189
|
+
* an object with exactly these three non-empty string fields. Nothing prevents
|
|
190
|
+
* that and nothing sensible produces it; saying so is better than adding a
|
|
191
|
+
* marker field that would then have to be defended against forgery it also
|
|
192
|
+
* could not detect.
|
|
193
|
+
*/
|
|
194
|
+
export declare function isSealedSecret(value: unknown): value is SealedSecret;
|
|
195
|
+
/** The name {@link RefusingSecretVault} answers to. No row can carry it: it never seals. */
|
|
196
|
+
export declare const UNCONFIGURED_VAULT = "unconfigured";
|
|
197
|
+
/**
|
|
198
|
+
* The default: a vault that will not pretend.
|
|
199
|
+
*
|
|
200
|
+
* Bound whenever `CATALOG_SECRET_VAULT` is not, which is every host that has
|
|
201
|
+
* not thought about this. It costs nothing until `encryptCredentials` is turned
|
|
202
|
+
* on — the store only reaches a vault when there is something to seal or
|
|
203
|
+
* something sealed to open — so the refusal lands on the deployment that asked
|
|
204
|
+
* for encryption and did not say with what, and lands at the first save rather
|
|
205
|
+
* than at the first run.
|
|
206
|
+
*
|
|
207
|
+
* The alternative shapes were both considered and are both worse. A default
|
|
208
|
+
* that stored plaintext would make `encryptCredentials: true` a no-op with a
|
|
209
|
+
* reassuring name. A default that base64-encoded would be worse still: it looks
|
|
210
|
+
* sealed, `isSealedSecret` would agree, and a dump would be trivially reversed
|
|
211
|
+
* by anybody who noticed.
|
|
212
|
+
*/
|
|
213
|
+
export declare class RefusingSecretVault implements CatalogSecretVault {
|
|
214
|
+
readonly name = "unconfigured";
|
|
215
|
+
seal(_plaintext: string, context: SecretContext): Promise<SealedSecret>;
|
|
216
|
+
open(sealed: SealedSecret, context: SecretContext): Promise<string>;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Nothing is bound, or nothing bound answers to the name a row carries.
|
|
220
|
+
*
|
|
221
|
+
* `retryable = false`, and that field is not decoration. A connector run is
|
|
222
|
+
* dispatched as a durable step, and the dispatch boundary serialises a throw
|
|
223
|
+
* into `{message, code, retryable}` — `retryable` is the only field the engine
|
|
224
|
+
* reads on the way back in, and its predicate is `error?.retryable !== false`.
|
|
225
|
+
* A missing token will be exactly as missing on the third attempt as on the
|
|
226
|
+
* first, so this must not burn fifteen minutes of exponential backoff before
|
|
227
|
+
* saying so. `connector-run.steps.ts` documents that mechanism at length and
|
|
228
|
+
* this is the same use of it.
|
|
229
|
+
*/
|
|
230
|
+
export declare class SecretVaultNotConfiguredError extends Error {
|
|
231
|
+
/** The field the dispatch boundary serialises and the engine acts on. */
|
|
232
|
+
readonly retryable = false;
|
|
233
|
+
readonly code = "secret_vault_not_configured";
|
|
234
|
+
constructor(message: string);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A save could not seal a credential, so the save did not happen.
|
|
238
|
+
*
|
|
239
|
+
* Which is the correct outcome and worth being explicit about: the fallback a
|
|
240
|
+
* reasonable person reaches for — write it in plaintext and log a warning — is
|
|
241
|
+
* the one thing this feature exists to make impossible. A deployment that
|
|
242
|
+
* turned `encryptCredentials` on and then, during a vault outage, quietly wrote
|
|
243
|
+
* three passwords in the clear would have no way to find out which three.
|
|
244
|
+
*
|
|
245
|
+
* Deliberately not a `BadRequestException`. The caller's URL is fine; the vault
|
|
246
|
+
* is down. A 400 sends somebody to edit a field that is correct.
|
|
247
|
+
*/
|
|
248
|
+
export declare class SecretSealFailedError extends Error {
|
|
249
|
+
readonly code = "secret_seal_failed";
|
|
250
|
+
constructor(message: string, options?: {
|
|
251
|
+
cause?: unknown;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* A read could not open a sealed credential.
|
|
256
|
+
*
|
|
257
|
+
* **This is the one whose class matters**, because a connector run is a durable
|
|
258
|
+
* step and the step decides whether to retry from the error it catches. Two
|
|
259
|
+
* things had to be got right here and both are load-bearing:
|
|
260
|
+
*
|
|
261
|
+
* - It is **not** a `NotFoundException` or a `BadRequestException`.
|
|
262
|
+
* `ConnectorRunSteps.runConnector` catches exactly those two around the
|
|
263
|
+
* runner and converts them to `UnavailableConnectorError`, which carries
|
|
264
|
+
* `retryable = false`. A vault that timed out would then be filed under
|
|
265
|
+
* `connector_unavailable` and never retried — and an operator filtering a
|
|
266
|
+
* failed-run list on that code would go looking for a connector somebody
|
|
267
|
+
* deleted. That is precisely the confusion `UnmetLoadExpectationError` was
|
|
268
|
+
* split into its own class to avoid.
|
|
269
|
+
* - `retryable` is **true by default**. A vault being briefly unreachable is
|
|
270
|
+
* the same kind of failure as a source being briefly unreachable, which is
|
|
271
|
+
* exactly what that step's `retries: 3, backoff exp 60s…900s` policy exists
|
|
272
|
+
* for. It is set false only when waiting provably cannot help: nothing is
|
|
273
|
+
* bound, or nothing bound answers to the name the row carries.
|
|
274
|
+
*/
|
|
275
|
+
export declare class SecretOpenFailedError extends Error {
|
|
276
|
+
/** The field the dispatch boundary serialises and the engine acts on. */
|
|
277
|
+
readonly retryable: boolean;
|
|
278
|
+
readonly code = "secret_open_failed";
|
|
279
|
+
constructor(message: string, options: {
|
|
280
|
+
retryable: boolean;
|
|
281
|
+
cause?: unknown;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Encrypting the credential that has to rest in `catalog_connection.config`.
|
|
4
|
+
*
|
|
5
|
+
* `config-secrets.ts` in the pipeline package closed the HTTP half of this: a
|
|
6
|
+
* connection URL is a password with an address attached, `config` is served by
|
|
7
|
+
* `GET pipeline/connections` under `catalog:read`, and the redaction stopped it
|
|
8
|
+
* travelling in a response. `MySqlPipelineStore` closed the write half: a
|
|
9
|
+
* password-bearing URL that is not already stored is refused, with a message
|
|
10
|
+
* naming `secretEnvVar`.
|
|
11
|
+
*
|
|
12
|
+
* Neither touches the thing this file is about. A redaction protects a reader
|
|
13
|
+
* coming through the API. It does nothing at all for a database dump, a read
|
|
14
|
+
* replica, a nightly backup, or anybody holding `SELECT` on the RDS instance —
|
|
15
|
+
* and for those, `config` is still a list of every password the catalog knows.
|
|
16
|
+
* `allowInlineCredentials` makes that population larger on purpose, which is
|
|
17
|
+
* precisely why encryption becomes worth building at the same time as the flag
|
|
18
|
+
* that needs it.
|
|
19
|
+
*
|
|
20
|
+
* ## A seam, not an implementation
|
|
21
|
+
*
|
|
22
|
+
* There is no cipher in this file and there must not be one. A library that
|
|
23
|
+
* shipped AES with a key read from an environment variable would have moved the
|
|
24
|
+
* problem from one column to one variable, and would have to answer for key
|
|
25
|
+
* rotation, per-environment separation, and an audit trail — all of which the
|
|
26
|
+
* host's KMS, Vault, or HSM already answers for, and none of which this package
|
|
27
|
+
* can answer for on its behalf. What is here is the shape of the question, so a
|
|
28
|
+
* provider can be written against it: {@link CatalogSecretVault}.
|
|
29
|
+
*
|
|
30
|
+
* The library never inspects a ciphertext, never chooses a key, and never
|
|
31
|
+
* decides an algorithm. It decides *which values* are secrets, *when* they are
|
|
32
|
+
* sealed, and *what happens when the vault cannot be reached* — three questions
|
|
33
|
+
* a provider should not have to have an opinion about.
|
|
34
|
+
*
|
|
35
|
+
* ## What the default does
|
|
36
|
+
*
|
|
37
|
+
* Refuses. {@link RefusingSecretVault} throws on `seal`, naming the token to
|
|
38
|
+
* bind. A default that quietly stored plaintext would be the worst shape
|
|
39
|
+
* available here: a host would turn `encryptCredentials` on, see saves succeed,
|
|
40
|
+
* and have exactly the same column contents as before with a docblock now
|
|
41
|
+
* claiming otherwise. Every other refusing default in this codebase exists for
|
|
42
|
+
* the same reason — `CATALOG_LOAD_EXPECTATIONS` unbound means incremental loads
|
|
43
|
+
* are refused rather than silently unpoliced.
|
|
44
|
+
*/
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.SecretOpenFailedError = exports.SecretSealFailedError = exports.SecretVaultNotConfiguredError = exports.RefusingSecretVault = exports.UNCONFIGURED_VAULT = exports.CATALOG_SECRET_VAULT = void 0;
|
|
47
|
+
exports.isSealedSecret = isSealedSecret;
|
|
48
|
+
/**
|
|
49
|
+
* The token a host binds one vault — or several — to.
|
|
50
|
+
*
|
|
51
|
+
* **Several is the supported shape, and it is what makes rotation possible.**
|
|
52
|
+
* Bind `CatalogSecretVault[]` and the store seals with the *first* and opens
|
|
53
|
+
* with whichever one's `name` matches the row. Moving from one vault to another
|
|
54
|
+
* is then: bind `[next, current]`, let saves reseal under `next`, and drop
|
|
55
|
+
* `current` when nothing is sealed under it any more. With a single vault that
|
|
56
|
+
* transition has no middle — the moment `next` is bound, every row sealed by
|
|
57
|
+
* `current` is unreadable — and a library that made rotation require an outage
|
|
58
|
+
* would be a library whose keys never got rotated.
|
|
59
|
+
*
|
|
60
|
+
* `@Optional()` where it is injected, so a host that binds nothing still boots.
|
|
61
|
+
* It then gets {@link RefusingSecretVault}, which costs nothing until something
|
|
62
|
+
* actually asks for a seal.
|
|
63
|
+
*/
|
|
64
|
+
exports.CATALOG_SECRET_VAULT = Symbol('CATALOG_SECRET_VAULT');
|
|
65
|
+
/**
|
|
66
|
+
* Whether a value that came back out of a JSON column is a sealed secret.
|
|
67
|
+
*
|
|
68
|
+
* A runtime guard and not a cast, because this is a `json` column: MikroORM
|
|
69
|
+
* hands back whatever is in it, and what is in it may have been written by a
|
|
70
|
+
* different release, by a hand-run `UPDATE`, or by a host's own seeder. The
|
|
71
|
+
* same argument `WorkflowRow.nodes` makes for being typed `unknown[]`.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately strict about emptiness. A `{ vault: '', keyId: '', ciphertext:
|
|
74
|
+
* '' }` is not a sealed secret and must not be treated as one — treating it as
|
|
75
|
+
* one sends it to a vault that will refuse it, and the operator is told their
|
|
76
|
+
* vault is broken when the row is. An empty ciphertext falls through as an
|
|
77
|
+
* ordinary config value instead, which is what it is.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately loose about everything else, and that is the harder half to
|
|
80
|
+
* hold. `ciphertext` is tested for "non-empty string" and must never be tested
|
|
81
|
+
* for more — not base64, not a length, not a prefix. Two vaults already
|
|
82
|
+
* disagree about its shape: KMS hands back base64, Transit hands back
|
|
83
|
+
* `vault:v1:<base64>`. Any tighter check would encode one of them as the
|
|
84
|
+
* contract and start silently refusing the other's rows, which surfaces as
|
|
85
|
+
* "this credential is not encrypted" for a value that is.
|
|
86
|
+
*
|
|
87
|
+
* What it cannot distinguish is a config value a host deliberately authored as
|
|
88
|
+
* an object with exactly these three non-empty string fields. Nothing prevents
|
|
89
|
+
* that and nothing sensible produces it; saying so is better than adding a
|
|
90
|
+
* marker field that would then have to be defended against forgery it also
|
|
91
|
+
* could not detect.
|
|
92
|
+
*/
|
|
93
|
+
function isSealedSecret(value) {
|
|
94
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
95
|
+
return false;
|
|
96
|
+
const vault = Reflect.get(value, 'vault');
|
|
97
|
+
const keyId = Reflect.get(value, 'keyId');
|
|
98
|
+
const ciphertext = Reflect.get(value, 'ciphertext');
|
|
99
|
+
return (typeof vault === 'string' &&
|
|
100
|
+
vault.length > 0 &&
|
|
101
|
+
typeof keyId === 'string' &&
|
|
102
|
+
typeof ciphertext === 'string' &&
|
|
103
|
+
ciphertext.length > 0);
|
|
104
|
+
}
|
|
105
|
+
/** The name {@link RefusingSecretVault} answers to. No row can carry it: it never seals. */
|
|
106
|
+
exports.UNCONFIGURED_VAULT = 'unconfigured';
|
|
107
|
+
/**
|
|
108
|
+
* The default: a vault that will not pretend.
|
|
109
|
+
*
|
|
110
|
+
* Bound whenever `CATALOG_SECRET_VAULT` is not, which is every host that has
|
|
111
|
+
* not thought about this. It costs nothing until `encryptCredentials` is turned
|
|
112
|
+
* on — the store only reaches a vault when there is something to seal or
|
|
113
|
+
* something sealed to open — so the refusal lands on the deployment that asked
|
|
114
|
+
* for encryption and did not say with what, and lands at the first save rather
|
|
115
|
+
* than at the first run.
|
|
116
|
+
*
|
|
117
|
+
* The alternative shapes were both considered and are both worse. A default
|
|
118
|
+
* that stored plaintext would make `encryptCredentials: true` a no-op with a
|
|
119
|
+
* reassuring name. A default that base64-encoded would be worse still: it looks
|
|
120
|
+
* sealed, `isSealedSecret` would agree, and a dump would be trivially reversed
|
|
121
|
+
* by anybody who noticed.
|
|
122
|
+
*/
|
|
123
|
+
class RefusingSecretVault {
|
|
124
|
+
name = exports.UNCONFIGURED_VAULT;
|
|
125
|
+
seal(_plaintext, context) {
|
|
126
|
+
return Promise.reject(new SecretVaultNotConfiguredError(`encryptCredentials is on, so ${context.kind}.config.${context.field} has to be sealed, and no vault is bound to seal it with. Bind CATALOG_SECRET_VAULT to a CatalogSecretVault — an AWS KMS or HashiCorp Vault provider, or your own — or turn encryptCredentials off and decide what allowInlineCredentials should be instead.`));
|
|
127
|
+
}
|
|
128
|
+
open(sealed, context) {
|
|
129
|
+
// Reachable only through a row this instance did not write, since `seal`
|
|
130
|
+
// never returns — so the useful thing to say is the name the row is asking
|
|
131
|
+
// for, which is the one the operator has to go and bind.
|
|
132
|
+
return Promise.reject(new SecretVaultNotConfiguredError(`${context.kind}.config.${context.field} was sealed by the "${sealed.vault}" vault and no vault is bound to open it. Bind CATALOG_SECRET_VAULT to that provider; nothing else can read this value.`));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
exports.RefusingSecretVault = RefusingSecretVault;
|
|
136
|
+
/**
|
|
137
|
+
* Nothing is bound, or nothing bound answers to the name a row carries.
|
|
138
|
+
*
|
|
139
|
+
* `retryable = false`, and that field is not decoration. A connector run is
|
|
140
|
+
* dispatched as a durable step, and the dispatch boundary serialises a throw
|
|
141
|
+
* into `{message, code, retryable}` — `retryable` is the only field the engine
|
|
142
|
+
* reads on the way back in, and its predicate is `error?.retryable !== false`.
|
|
143
|
+
* A missing token will be exactly as missing on the third attempt as on the
|
|
144
|
+
* first, so this must not burn fifteen minutes of exponential backoff before
|
|
145
|
+
* saying so. `connector-run.steps.ts` documents that mechanism at length and
|
|
146
|
+
* this is the same use of it.
|
|
147
|
+
*/
|
|
148
|
+
class SecretVaultNotConfiguredError extends Error {
|
|
149
|
+
/** The field the dispatch boundary serialises and the engine acts on. */
|
|
150
|
+
retryable = false;
|
|
151
|
+
code = 'secret_vault_not_configured';
|
|
152
|
+
constructor(message) {
|
|
153
|
+
super(message);
|
|
154
|
+
this.name = 'SecretVaultNotConfiguredError';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
exports.SecretVaultNotConfiguredError = SecretVaultNotConfiguredError;
|
|
158
|
+
/**
|
|
159
|
+
* A save could not seal a credential, so the save did not happen.
|
|
160
|
+
*
|
|
161
|
+
* Which is the correct outcome and worth being explicit about: the fallback a
|
|
162
|
+
* reasonable person reaches for — write it in plaintext and log a warning — is
|
|
163
|
+
* the one thing this feature exists to make impossible. A deployment that
|
|
164
|
+
* turned `encryptCredentials` on and then, during a vault outage, quietly wrote
|
|
165
|
+
* three passwords in the clear would have no way to find out which three.
|
|
166
|
+
*
|
|
167
|
+
* Deliberately not a `BadRequestException`. The caller's URL is fine; the vault
|
|
168
|
+
* is down. A 400 sends somebody to edit a field that is correct.
|
|
169
|
+
*/
|
|
170
|
+
class SecretSealFailedError extends Error {
|
|
171
|
+
code = 'secret_seal_failed';
|
|
172
|
+
constructor(message, options) {
|
|
173
|
+
super(message, options);
|
|
174
|
+
this.name = 'SecretSealFailedError';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
exports.SecretSealFailedError = SecretSealFailedError;
|
|
178
|
+
/**
|
|
179
|
+
* A read could not open a sealed credential.
|
|
180
|
+
*
|
|
181
|
+
* **This is the one whose class matters**, because a connector run is a durable
|
|
182
|
+
* step and the step decides whether to retry from the error it catches. Two
|
|
183
|
+
* things had to be got right here and both are load-bearing:
|
|
184
|
+
*
|
|
185
|
+
* - It is **not** a `NotFoundException` or a `BadRequestException`.
|
|
186
|
+
* `ConnectorRunSteps.runConnector` catches exactly those two around the
|
|
187
|
+
* runner and converts them to `UnavailableConnectorError`, which carries
|
|
188
|
+
* `retryable = false`. A vault that timed out would then be filed under
|
|
189
|
+
* `connector_unavailable` and never retried — and an operator filtering a
|
|
190
|
+
* failed-run list on that code would go looking for a connector somebody
|
|
191
|
+
* deleted. That is precisely the confusion `UnmetLoadExpectationError` was
|
|
192
|
+
* split into its own class to avoid.
|
|
193
|
+
* - `retryable` is **true by default**. A vault being briefly unreachable is
|
|
194
|
+
* the same kind of failure as a source being briefly unreachable, which is
|
|
195
|
+
* exactly what that step's `retries: 3, backoff exp 60s…900s` policy exists
|
|
196
|
+
* for. It is set false only when waiting provably cannot help: nothing is
|
|
197
|
+
* bound, or nothing bound answers to the name the row carries.
|
|
198
|
+
*/
|
|
199
|
+
class SecretOpenFailedError extends Error {
|
|
200
|
+
/** The field the dispatch boundary serialises and the engine acts on. */
|
|
201
|
+
retryable;
|
|
202
|
+
code = 'secret_open_failed';
|
|
203
|
+
constructor(message, options) {
|
|
204
|
+
super(message, { cause: options.cause });
|
|
205
|
+
this.name = 'SecretOpenFailedError';
|
|
206
|
+
this.retryable = options.retryable;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
exports.SecretOpenFailedError = SecretOpenFailedError;
|
package/dist/client.d.ts
CHANGED
|
@@ -93,5 +93,6 @@ export { CONNECTOR_KINDS, isConnectorKind, isTransformLanguage, isWorkflowEdge,
|
|
|
93
93
|
* `WORKFLOW_NODE_ID_PATTERN` and `WORKFLOW_NODE_KINDS` are what a palette and an
|
|
94
94
|
* id field should be built from rather than from a second copy that drifts.
|
|
95
95
|
*/
|
|
96
|
-
export { validateWorkflow, WORKFLOW_EXECUTION_MODES, WORKFLOW_ISSUE_CODES, WORKFLOW_NODE_ID_PATTERN, WORKFLOW_NODE_KINDS, workflowGraphHash, workflowRunOrder, isWorkflowExecutionMode, isWorkflowNodeKind, } from './catalog.pipeline';
|
|
96
|
+
export { validateWorkflow, WORKFLOW_EXECUTION_MODES, WORKFLOW_ISSUE_CODES, WORKFLOW_NODE_ID_PATTERN, WORKFLOW_NODE_KINDS, WORKFLOW_STATUSES, workflowGraphHash, workflowRunOrder, isWorkflowExecutionMode, isWorkflowNodeKind, isWorkflowStatus, } from './catalog.pipeline';
|
|
97
|
+
export type { WorkflowStatus } from './catalog.pipeline';
|
|
97
98
|
export type { CatalogTrace, CatalogTraceList, CatalogTraceOutcome, CatalogTraceSpan, TraceQuery, } from './catalog.workspace';
|
package/dist/client.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* types are.
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.isWorkflowNodeKind = exports.isWorkflowExecutionMode = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.isWorkflowNode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.catalogRoutes = void 0;
|
|
14
|
+
exports.isWorkflowStatus = exports.isWorkflowNodeKind = exports.isWorkflowExecutionMode = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.isWorkflowNode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.catalogRoutes = void 0;
|
|
15
15
|
/**
|
|
16
16
|
* Builds the paths the catalog controller serves, relative to wherever it was
|
|
17
17
|
* mounted. Kept as string builders rather than a fetch wrapper so the host
|
|
@@ -87,7 +87,15 @@ Object.defineProperty(exports, "WORKFLOW_EXECUTION_MODES", { enumerable: true, g
|
|
|
87
87
|
Object.defineProperty(exports, "WORKFLOW_ISSUE_CODES", { enumerable: true, get: function () { return catalog_pipeline_2.WORKFLOW_ISSUE_CODES; } });
|
|
88
88
|
Object.defineProperty(exports, "WORKFLOW_NODE_ID_PATTERN", { enumerable: true, get: function () { return catalog_pipeline_2.WORKFLOW_NODE_ID_PATTERN; } });
|
|
89
89
|
Object.defineProperty(exports, "WORKFLOW_NODE_KINDS", { enumerable: true, get: function () { return catalog_pipeline_2.WORKFLOW_NODE_KINDS; } });
|
|
90
|
+
// The draft/ready pair, for the same reason as the list above: a canvas that
|
|
91
|
+
// cannot see it restates it, and the copy is what drifts. Without this the
|
|
92
|
+
// editor could not tell a graph it is allowed to store from one the server
|
|
93
|
+
// would refuse — so it told everybody the second, which is wrong for every
|
|
94
|
+
// draft and is exactly the kind of confident-and-false sentence this codebase
|
|
95
|
+
// keeps removing.
|
|
96
|
+
Object.defineProperty(exports, "WORKFLOW_STATUSES", { enumerable: true, get: function () { return catalog_pipeline_2.WORKFLOW_STATUSES; } });
|
|
90
97
|
Object.defineProperty(exports, "workflowGraphHash", { enumerable: true, get: function () { return catalog_pipeline_2.workflowGraphHash; } });
|
|
91
98
|
Object.defineProperty(exports, "workflowRunOrder", { enumerable: true, get: function () { return catalog_pipeline_2.workflowRunOrder; } });
|
|
92
99
|
Object.defineProperty(exports, "isWorkflowExecutionMode", { enumerable: true, get: function () { return catalog_pipeline_2.isWorkflowExecutionMode; } });
|
|
93
100
|
Object.defineProperty(exports, "isWorkflowNodeKind", { enumerable: true, get: function () { return catalog_pipeline_2.isWorkflowNodeKind; } });
|
|
101
|
+
Object.defineProperty(exports, "isWorkflowStatus", { enumerable: true, get: function () { return catalog_pipeline_2.isWorkflowStatus; } });
|
package/dist/index.d.ts
CHANGED
|
@@ -3,11 +3,12 @@ export { CATALOG_EVENT_PHASE, CATALOG_EVENT_PHASE_FALLBACK, CATALOG_EVENTS, CATA
|
|
|
3
3
|
export { CatalogModule } from './catalog.module';
|
|
4
4
|
export { assertReadOnlyShape, type CatalogQueryRelation, type CatalogQueryRequest, type CatalogQueryResult, type CatalogQueryStore, isQueryStore, } from './catalog.query';
|
|
5
5
|
export { CATALOG_OPTIONS, type CatalogModuleOptions } from './catalog.options';
|
|
6
|
+
export * from './catalog.secrets';
|
|
6
7
|
export { type CatalogOverlayStore, FileCatalogOverlayStore, InMemoryCatalogOverlayStore, } from './catalog.overlay-store';
|
|
7
8
|
export { CATALOG_OVERLAY_STORE } from './catalog.overlay-store.token';
|
|
8
9
|
export { MikroOrmCatalogRegistry } from './catalog.registry';
|
|
9
10
|
export { CatalogRegistry } from './catalog.registry.base';
|
|
10
|
-
export { CATALOG_PIPELINE_STORE, type CatalogConnection, type CatalogConnector, type ConnectionCheck, CONNECTOR_KINDS, type CatalogPipelineStore, type CatalogStageStore, type CatalogTransform, type CatalogWorkflow, type CatalogWorkflowCapabilities, type CatalogWorkflowStore, type ConnectorKind, type ConnectorRun, isConnectorKind, isPipelineStore, isTransformLanguage, isWorkflowEdge, isWorkflowExecutionMode, isWorkflowNode, isWorkflowNodeKind, supportsWorkflows, supportsWorkflowStages, TRANSFORM_RUNNER, TRANSFORM_LANGUAGES, type TransformLanguage, type TransformResult, type TransformRunner, validateWorkflow, WORKFLOW_EXECUTION_MODES, WORKFLOW_ISSUE_CODES, WORKFLOW_NODE_ID_PATTERN, WORKFLOW_NODE_KINDS, type WorkflowEdge, type WorkflowExecutionMode, type WorkflowGraph, workflowGraphHash, type WorkflowIssueCode, type WorkflowNode, type WorkflowNodeKind, type WorkflowNodeOutcome, type WorkflowNodeStepInput, type WorkflowNodeStepOutput, workflowRunOrder, type WorkflowSinkNode, type WorkflowSourceNode, type WorkflowStageRef, type WorkflowTransformNode, type WorkflowValidationIssue, } from './catalog.pipeline';
|
|
11
|
+
export { CATALOG_PIPELINE_STORE, type CatalogConnection, type CatalogConnector, type ConnectionCheck, CONNECTOR_KINDS, type CatalogPipelineStore, type CatalogStageStore, type CatalogTransform, type CatalogWorkflow, type CatalogWorkflowCapabilities, type CatalogWorkflowStore, type ConnectorKind, type ConnectorRun, isConnectorKind, isPipelineStore, isTransformLanguage, isWorkflowEdge, isWorkflowExecutionMode, isWorkflowNode, isWorkflowNodeKind, isWorkflowStatus, supportsWorkflows, supportsWorkflowStages, TRANSFORM_RUNNER, TRANSFORM_LANGUAGES, type TransformLanguage, type TransformResult, type TransformRunner, validateWorkflow, WORKFLOW_EXECUTION_MODES, WORKFLOW_ISSUE_CODES, WORKFLOW_NODE_ID_PATTERN, WORKFLOW_NODE_KINDS, WORKFLOW_STATUSES, type WorkflowEdge, type WorkflowExecutionMode, type WorkflowGraph, workflowGraphHash, type WorkflowIssueCode, type WorkflowNode, type WorkflowNodeKind, type WorkflowNodeOutcome, type WorkflowNodeStepInput, type WorkflowNodeStepOutput, workflowRunOrder, type WorkflowSinkNode, type WorkflowSourceNode, type WorkflowStageRef, type WorkflowStatus, type WorkflowTransformNode, type WorkflowValidationIssue, } from './catalog.pipeline';
|
|
11
12
|
export * from './catalog.environment';
|
|
12
13
|
export { QueryCache, toCsv } from './catalog.query-cache';
|
|
13
14
|
export { SubprocessTransformRunner, type TransformRunnerOptions, } from './transform-runner';
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.
|
|
18
|
-
exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.supportsCarryForward = exports.isWriteStore = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = exports.traceOutcomeFilter = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = exports.CATALOG_TRACE_OUTCOMES = exports.visibleToPrincipal = void 0;
|
|
17
|
+
exports.emptySearch = exports.bestMatch = exports.MAX_SEARCH_LIMIT = exports.DEFAULT_SEARCH_LIMIT = exports.CatalogService = exports.SubprocessTransformRunner = exports.toCsv = exports.QueryCache = exports.workflowRunOrder = exports.workflowGraphHash = exports.WORKFLOW_STATUSES = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.validateWorkflow = exports.TRANSFORM_LANGUAGES = exports.TRANSFORM_RUNNER = exports.supportsWorkflowStages = exports.supportsWorkflows = exports.isWorkflowStatus = exports.isWorkflowNodeKind = exports.isWorkflowNode = exports.isWorkflowExecutionMode = exports.isWorkflowEdge = exports.isTransformLanguage = exports.isPipelineStore = exports.isConnectorKind = exports.CONNECTOR_KINDS = exports.CATALOG_PIPELINE_STORE = exports.CatalogRegistry = exports.MikroOrmCatalogRegistry = exports.CATALOG_OVERLAY_STORE = exports.InMemoryCatalogOverlayStore = exports.FileCatalogOverlayStore = exports.CATALOG_OPTIONS = exports.isQueryStore = exports.assertReadOnlyShape = exports.CatalogModule = exports.emitCatalog = exports.curationActor = exports.channelNameFor = exports.catalogEventPhase = exports.UNATTRIBUTED_PRINCIPAL_ID = exports.CATALOG_LIB = exports.CATALOG_EVENTS = exports.CATALOG_EVENT_PHASE_FALLBACK = exports.CATALOG_EVENT_PHASE = exports.CatalogType = exports.CatalogProperty = void 0;
|
|
18
|
+
exports.RequireScopes = exports.RequireHuman = exports.REQUIRES_HUMAN = exports.REQUIRED_SCOPES = exports.MikroOrmReadStore = exports.supportsCarryForward = exports.isWriteStore = exports.isReservedColumn = exports.isCatalogStoreCapabilities = exports.findColumnCollisions = exports.CatalogColumnCollisionError = exports.CATALOG_STORE = exports.CATALOG_SNAPSHOT_MODES = exports.CATALOG_RESERVED_COLUMNS = exports.assertNoColumnCollisions = exports.StaticKeyPrincipalResolver = exports.readableObjectPage = exports.mayWrite = exports.mayRead = exports.maySeeClassification = exports.PRINCIPAL_ACTOR_SEPARATOR = exports.parsePrincipalId = exports.hasScope = exports.expandScopes = exports.delegatePrincipal = exports.composePrincipalId = exports.CATALOG_PRINCIPAL_RESOLVER = exports.traceOutcomeFilter = exports.isWorkspaceStore = exports.isTraceStore = exports.isCatalogTraceOutcome = exports.embeddedVisualization = exports.CATALOG_WORKSPACE_STORE = exports.CATALOG_TRACE_STORE = exports.CATALOG_TRACE_OUTCOMES = exports.visibleToPrincipal = exports.searchCatalog = exports.maySearch = void 0;
|
|
19
19
|
var catalog_decorators_1 = require("./catalog.decorators");
|
|
20
20
|
Object.defineProperty(exports, "CatalogProperty", { enumerable: true, get: function () { return catalog_decorators_1.CatalogProperty; } });
|
|
21
21
|
Object.defineProperty(exports, "CatalogType", { enumerable: true, get: function () { return catalog_decorators_1.CatalogType; } });
|
|
@@ -36,6 +36,14 @@ Object.defineProperty(exports, "assertReadOnlyShape", { enumerable: true, get: f
|
|
|
36
36
|
Object.defineProperty(exports, "isQueryStore", { enumerable: true, get: function () { return catalog_query_1.isQueryStore; } });
|
|
37
37
|
var catalog_options_1 = require("./catalog.options");
|
|
38
38
|
Object.defineProperty(exports, "CATALOG_OPTIONS", { enumerable: true, get: function () { return catalog_options_1.CATALOG_OPTIONS; } });
|
|
39
|
+
// Everything, deliberately, and here more than anywhere: this is a seam two
|
|
40
|
+
// separate provider packages are being written against. A barrel that shipped
|
|
41
|
+
// `CATALOG_SECRET_VAULT` and `CatalogSecretVault` but not `SealedSecret` or
|
|
42
|
+
// `SecretContext` — the return type and the argument of the two methods a
|
|
43
|
+
// provider implements — would be the exact gap `index.barrel.spec.ts` was
|
|
44
|
+
// written after, reproduced on the one surface where a third party compiles
|
|
45
|
+
// against it.
|
|
46
|
+
__exportStar(require("./catalog.secrets"), exports);
|
|
39
47
|
var catalog_overlay_store_1 = require("./catalog.overlay-store");
|
|
40
48
|
Object.defineProperty(exports, "FileCatalogOverlayStore", { enumerable: true, get: function () { return catalog_overlay_store_1.FileCatalogOverlayStore; } });
|
|
41
49
|
Object.defineProperty(exports, "InMemoryCatalogOverlayStore", { enumerable: true, get: function () { return catalog_overlay_store_1.InMemoryCatalogOverlayStore; } });
|
|
@@ -55,6 +63,7 @@ Object.defineProperty(exports, "isWorkflowEdge", { enumerable: true, get: functi
|
|
|
55
63
|
Object.defineProperty(exports, "isWorkflowExecutionMode", { enumerable: true, get: function () { return catalog_pipeline_1.isWorkflowExecutionMode; } });
|
|
56
64
|
Object.defineProperty(exports, "isWorkflowNode", { enumerable: true, get: function () { return catalog_pipeline_1.isWorkflowNode; } });
|
|
57
65
|
Object.defineProperty(exports, "isWorkflowNodeKind", { enumerable: true, get: function () { return catalog_pipeline_1.isWorkflowNodeKind; } });
|
|
66
|
+
Object.defineProperty(exports, "isWorkflowStatus", { enumerable: true, get: function () { return catalog_pipeline_1.isWorkflowStatus; } });
|
|
58
67
|
Object.defineProperty(exports, "supportsWorkflows", { enumerable: true, get: function () { return catalog_pipeline_1.supportsWorkflows; } });
|
|
59
68
|
Object.defineProperty(exports, "supportsWorkflowStages", { enumerable: true, get: function () { return catalog_pipeline_1.supportsWorkflowStages; } });
|
|
60
69
|
Object.defineProperty(exports, "TRANSFORM_RUNNER", { enumerable: true, get: function () { return catalog_pipeline_1.TRANSFORM_RUNNER; } });
|
|
@@ -64,6 +73,7 @@ Object.defineProperty(exports, "WORKFLOW_EXECUTION_MODES", { enumerable: true, g
|
|
|
64
73
|
Object.defineProperty(exports, "WORKFLOW_ISSUE_CODES", { enumerable: true, get: function () { return catalog_pipeline_1.WORKFLOW_ISSUE_CODES; } });
|
|
65
74
|
Object.defineProperty(exports, "WORKFLOW_NODE_ID_PATTERN", { enumerable: true, get: function () { return catalog_pipeline_1.WORKFLOW_NODE_ID_PATTERN; } });
|
|
66
75
|
Object.defineProperty(exports, "WORKFLOW_NODE_KINDS", { enumerable: true, get: function () { return catalog_pipeline_1.WORKFLOW_NODE_KINDS; } });
|
|
76
|
+
Object.defineProperty(exports, "WORKFLOW_STATUSES", { enumerable: true, get: function () { return catalog_pipeline_1.WORKFLOW_STATUSES; } });
|
|
67
77
|
Object.defineProperty(exports, "workflowGraphHash", { enumerable: true, get: function () { return catalog_pipeline_1.workflowGraphHash; } });
|
|
68
78
|
Object.defineProperty(exports, "workflowRunOrder", { enumerable: true, get: function () { return catalog_pipeline_1.workflowRunOrder; } });
|
|
69
79
|
// The environment surface: which catalog database a call is served from, and
|
|
@@ -15,11 +15,28 @@ export interface TransformRunnerOptions {
|
|
|
15
15
|
/**
|
|
16
16
|
* Runs a transform in a child process, with a clock on it.
|
|
17
17
|
*
|
|
18
|
-
* **This is not a security boundary
|
|
19
|
-
* runaway allocation, a stray
|
|
20
|
-
* the child gets a timeout and
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* **This is not a security boundary, and the trimmed environment is not one
|
|
19
|
+
* either.** It stops accidents — an infinite loop, a runaway allocation, a stray
|
|
20
|
+
* read of `process.env.DATABASE_PASSWORD` — because the child gets a timeout and
|
|
21
|
+
* an environment of `{PATH, NODE_ENV}`. It does not stop code written to escape
|
|
22
|
+
* it, and it is worth being exact about how thin the allowlist is rather than
|
|
23
|
+
* leaving a reader to assume it holds:
|
|
24
|
+
*
|
|
25
|
+
* - the child inherits nothing of the parent's environment **through `env`**,
|
|
26
|
+
* and reads all of it anyway from `/proc/<ppid>/environ`, which is readable
|
|
27
|
+
* because parent and child run as the same uid;
|
|
28
|
+
* - it runs in a working directory of this runner's choosing but on the host's
|
|
29
|
+
* filesystem, so a service account token under
|
|
30
|
+
* `/var/run/secrets/kubernetes.io/serviceaccount/` is an absolute path away;
|
|
31
|
+
* - it can open sockets, as whatever user the service runs as.
|
|
32
|
+
*
|
|
33
|
+
* So the allowlist is a guard rail against the accident, and the reachability of
|
|
34
|
+
* everything it names is a property of the process boundary, not a leak to be
|
|
35
|
+
* patched. **Running a transform is running code in this pod.** Who is allowed
|
|
36
|
+
* to is therefore an authorisation question and not a sandboxing one, and it is
|
|
37
|
+
* answered at the HTTP surface — see the "Running a transform is running code"
|
|
38
|
+
* section of `@dudousxd/nestjs-catalog-pipeline`'s README, which is where a host
|
|
39
|
+
* can actually read it, and `pipeline.controller.ts` for the checks themselves.
|
|
23
40
|
*
|
|
24
41
|
* That is a deliberate trade for the case this is built for, where transforms
|
|
25
42
|
* are written by the same people who already have database access. A catalog
|
package/dist/transform-runner.js
CHANGED
|
@@ -13,10 +13,50 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
exports.SubprocessTransformRunner = void 0;
|
|
14
14
|
const node_child_process_1 = require("node:child_process");
|
|
15
15
|
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_os_1 = require("node:os");
|
|
16
17
|
const node_path_1 = require("node:path");
|
|
17
18
|
const common_1 = require("@nestjs/common");
|
|
18
19
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
20
|
const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* How much of the child's stderr is held, and why it is a different number from
|
|
23
|
+
* {@link MAX_OUTPUT_BYTES} with a different consequence.
|
|
24
|
+
*
|
|
25
|
+
* It had no bound at all, which is the one shape a capture must never have when
|
|
26
|
+
* the thing filling it is user code: `stderr += chunk` ran for the whole timeout
|
|
27
|
+
* window, so a transform whose only line is a loop writing to fd 2 grew the
|
|
28
|
+
* **parent's** heap — not the child's — at whatever rate the pipe would carry,
|
|
29
|
+
* and took the pod out with it. The timeout is no answer to that: thirty seconds
|
|
30
|
+
* of an unthrottled writer is gigabytes, and the process that dies is the one
|
|
31
|
+
* serving every other request.
|
|
32
|
+
*
|
|
33
|
+
* Bounded rather than killed, which is the opposite of what stdout overflow
|
|
34
|
+
* does, and the asymmetry is the point. Stdout *is* the result channel — past
|
|
35
|
+
* {@link MAX_OUTPUT_BYTES} there is no readable JSON line at the end of it and
|
|
36
|
+
* the run has already failed, so killing costs nothing. Stderr is only ever the
|
|
37
|
+
* diagnostic: a transform that writes a great deal to it and then returns a
|
|
38
|
+
* perfectly good array of rows is a working transform, and killing it would turn
|
|
39
|
+
* a noisy dependency's warnings into a failed load.
|
|
40
|
+
*
|
|
41
|
+
* The **head** is kept, because the head is what is read. Both places that
|
|
42
|
+
* consume this take `stderr.slice(0, 500)` — the first line of a traceback, the
|
|
43
|
+
* import error, the thing that says what went wrong — so dropping the tail
|
|
44
|
+
* discards exactly the part nobody was going to see. 64 KiB is far more than any
|
|
45
|
+
* of those and small enough that the ceiling is not itself a memory decision.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_CAPTURED_STDERR_BYTES = 64 * 1024;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the child is put in its own process group, so that stopping it stops
|
|
50
|
+
* what it started.
|
|
51
|
+
*
|
|
52
|
+
* POSIX only, because the mechanism is POSIX: `detached` there makes the child a
|
|
53
|
+
* process-group leader and `process.kill(-pid)` signals the whole group, which
|
|
54
|
+
* is the only way to reach a grandchild. On Windows `detached` means something
|
|
55
|
+
* else entirely (a new console) and a negative pid is not a group, so the
|
|
56
|
+
* platform gets the single-process kill it always had rather than a call that
|
|
57
|
+
* would throw on every timeout.
|
|
58
|
+
*/
|
|
59
|
+
const KILL_PROCESS_GROUP = process.platform !== 'win32';
|
|
20
60
|
/**
|
|
21
61
|
* How much of what a transform logged is carried back, on both axes.
|
|
22
62
|
*
|
|
@@ -73,11 +113,28 @@ const REPORTED_PACKAGES = ['pandas', 'numpy', 'pyarrow', 'requests'];
|
|
|
73
113
|
/**
|
|
74
114
|
* Runs a transform in a child process, with a clock on it.
|
|
75
115
|
*
|
|
76
|
-
* **This is not a security boundary
|
|
77
|
-
* runaway allocation, a stray
|
|
78
|
-
* the child gets a timeout and
|
|
79
|
-
*
|
|
80
|
-
*
|
|
116
|
+
* **This is not a security boundary, and the trimmed environment is not one
|
|
117
|
+
* either.** It stops accidents — an infinite loop, a runaway allocation, a stray
|
|
118
|
+
* read of `process.env.DATABASE_PASSWORD` — because the child gets a timeout and
|
|
119
|
+
* an environment of `{PATH, NODE_ENV}`. It does not stop code written to escape
|
|
120
|
+
* it, and it is worth being exact about how thin the allowlist is rather than
|
|
121
|
+
* leaving a reader to assume it holds:
|
|
122
|
+
*
|
|
123
|
+
* - the child inherits nothing of the parent's environment **through `env`**,
|
|
124
|
+
* and reads all of it anyway from `/proc/<ppid>/environ`, which is readable
|
|
125
|
+
* because parent and child run as the same uid;
|
|
126
|
+
* - it runs in a working directory of this runner's choosing but on the host's
|
|
127
|
+
* filesystem, so a service account token under
|
|
128
|
+
* `/var/run/secrets/kubernetes.io/serviceaccount/` is an absolute path away;
|
|
129
|
+
* - it can open sockets, as whatever user the service runs as.
|
|
130
|
+
*
|
|
131
|
+
* So the allowlist is a guard rail against the accident, and the reachability of
|
|
132
|
+
* everything it names is a property of the process boundary, not a leak to be
|
|
133
|
+
* patched. **Running a transform is running code in this pod.** Who is allowed
|
|
134
|
+
* to is therefore an authorisation question and not a sandboxing one, and it is
|
|
135
|
+
* answered at the HTTP surface — see the "Running a transform is running code"
|
|
136
|
+
* section of `@dudousxd/nestjs-catalog-pipeline`'s README, which is where a host
|
|
137
|
+
* can actually read it, and `pipeline.controller.ts` for the checks themselves.
|
|
81
138
|
*
|
|
82
139
|
* That is a deliberate trade for the case this is built for, where transforms
|
|
83
140
|
* are written by the same people who already have database access. A catalog
|
|
@@ -179,7 +236,21 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
|
|
|
179
236
|
const child = (0, node_child_process_1.spawn)(command, args, {
|
|
180
237
|
// An empty environment, not the parent's. A transform has no business
|
|
181
238
|
// reading the database password, and inheriting env is how it would.
|
|
239
|
+
// Read the class docblock before treating this as containment: the same
|
|
240
|
+
// values are a `/proc/<ppid>/environ` read away, and this is a guard
|
|
241
|
+
// rail against the accidental read rather than a boundary.
|
|
182
242
|
env: { PATH: process.env.PATH ?? '', NODE_ENV: 'production' },
|
|
243
|
+
// Not the parent's, which is a running service's directory and holds
|
|
244
|
+
// the `.env` the allowlist above exists to withhold — a transform whose
|
|
245
|
+
// first line is `readFileSync(".env")` was reading the host application's
|
|
246
|
+
// configuration by relative path. A temporary directory keeps the file
|
|
247
|
+
// writes a transform may legitimately want working while making the one
|
|
248
|
+
// path it can name without knowing anything about the deployment
|
|
249
|
+
// uninteresting. Absolute paths are unaffected, and cannot be.
|
|
250
|
+
cwd: (0, node_os_1.tmpdir)(),
|
|
251
|
+
// Its own process group, so the timeout below can reach a grandchild.
|
|
252
|
+
// See {@link KILL_PROCESS_GROUP}.
|
|
253
|
+
detached: KILL_PROCESS_GROUP,
|
|
183
254
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
184
255
|
});
|
|
185
256
|
let stdout = '';
|
|
@@ -189,16 +260,22 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
|
|
|
189
260
|
if (settled)
|
|
190
261
|
return;
|
|
191
262
|
settled = true;
|
|
192
|
-
child
|
|
263
|
+
stop(child);
|
|
193
264
|
reject(new Error(`The transform ran for longer than ${timeoutMs}ms and was stopped.`));
|
|
194
265
|
}, timeoutMs);
|
|
195
266
|
child.stdout.on('data', (chunk) => {
|
|
196
267
|
stdout += chunk.toString();
|
|
197
268
|
if (stdout.length > MAX_OUTPUT_BYTES)
|
|
198
|
-
child
|
|
269
|
+
stop(child);
|
|
199
270
|
});
|
|
200
271
|
child.stderr.on('data', (chunk) => {
|
|
201
|
-
|
|
272
|
+
// Appended only while there is room, rather than appended and trimmed:
|
|
273
|
+
// trimming after the fact still materialises the whole chunk into the
|
|
274
|
+
// parent's heap, which is the thing being bounded. See
|
|
275
|
+
// {@link MAX_CAPTURED_STDERR_BYTES} for why this bounds rather than kills.
|
|
276
|
+
if (stderr.length >= MAX_CAPTURED_STDERR_BYTES)
|
|
277
|
+
return;
|
|
278
|
+
stderr += chunk.toString().slice(0, MAX_CAPTURED_STDERR_BYTES - stderr.length);
|
|
202
279
|
});
|
|
203
280
|
child.on('error', (error) => {
|
|
204
281
|
if (settled)
|
|
@@ -228,7 +305,12 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
|
|
|
228
305
|
return this.pythonPath;
|
|
229
306
|
const venv = this.options.pythonVenv ?? process.env.CATALOG_PYTHON_VENV;
|
|
230
307
|
if (venv) {
|
|
231
|
-
|
|
308
|
+
// Absolute, and it has to be: a child now runs in a temporary directory
|
|
309
|
+
// rather than the parent's, so a relative `CATALOG_PYTHON_VENV` — which
|
|
310
|
+
// `existsSync` here resolves against the *parent's* cwd — would pass this
|
|
311
|
+
// check and then fail to spawn. Resolved once, at the point the two cwds
|
|
312
|
+
// are still the same.
|
|
313
|
+
const candidate = (0, node_path_1.resolve)((0, node_path_1.join)(venv, 'bin', 'python'));
|
|
232
314
|
if ((0, node_fs_1.existsSync)(candidate)) {
|
|
233
315
|
this.pythonPath = candidate;
|
|
234
316
|
this.logger.log(`Python transforms run in the venv at ${venv}`);
|
|
@@ -256,6 +338,41 @@ exports.SubprocessTransformRunner = SubprocessTransformRunner = SubprocessTransf
|
|
|
256
338
|
(0, common_1.Injectable)(),
|
|
257
339
|
__metadata("design:paramtypes", [Object])
|
|
258
340
|
], SubprocessTransformRunner);
|
|
341
|
+
/**
|
|
342
|
+
* Stop the transform, and everything the transform started.
|
|
343
|
+
*
|
|
344
|
+
* `child.kill()` signals one pid. A transform that double-forks — two lines of
|
|
345
|
+
* `child_process.spawn` with `detached` and an `unref` — leaves a grandchild
|
|
346
|
+
* that the direct child's death says nothing about, so the timeout expired, the
|
|
347
|
+
* caller was told the run had been stopped, and the work carried on
|
|
348
|
+
* indefinitely. That is not a hypothetical: it is the standard way a timeout on
|
|
349
|
+
* a process is escaped, and a bound that a caller can opt out of is not a bound.
|
|
350
|
+
*
|
|
351
|
+
* So the child leads its own process group and the negative pid signals the
|
|
352
|
+
* group, which is every descendant that has not deliberately left it. Leaving
|
|
353
|
+
* one is possible (`setsid` again) and there is no answer to that short of a
|
|
354
|
+
* cgroup or a container — the same place the class docblock's honesty about the
|
|
355
|
+
* boundary ends up, for the same reason.
|
|
356
|
+
*
|
|
357
|
+
* Falls through to the single-process kill whenever the group kill cannot be the
|
|
358
|
+
* one that happens: on Windows, where a negative pid is not a group, and on an
|
|
359
|
+
* `ESRCH` where the group is already gone and the direct kill is a harmless
|
|
360
|
+
* no-op. A throw here would replace a timeout error — which says something true
|
|
361
|
+
* and useful — with an unhandled one that says nothing.
|
|
362
|
+
*/
|
|
363
|
+
function stop(child) {
|
|
364
|
+
const pid = child.pid;
|
|
365
|
+
if (KILL_PROCESS_GROUP && pid !== undefined) {
|
|
366
|
+
try {
|
|
367
|
+
process.kill(-pid, 'SIGKILL');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Already gone, or never grouped. The direct kill below covers both.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
child.kill('SIGKILL');
|
|
375
|
+
}
|
|
259
376
|
/**
|
|
260
377
|
* The traceback, plus the tail of what the code printed on its way to it.
|
|
261
378
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-catalog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Davide Carvalho",
|