@dudousxd/nestjs-catalog 0.1.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/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/catalog.controller.d.ts +8 -0
- package/dist/catalog.controller.js +482 -0
- package/dist/catalog.decorators.d.ts +37 -0
- package/dist/catalog.decorators.js +50 -0
- package/dist/catalog.environment.d.ts +442 -0
- package/dist/catalog.environment.js +645 -0
- package/dist/catalog.events.d.ts +179 -0
- package/dist/catalog.events.js +110 -0
- package/dist/catalog.module.d.ts +5 -0
- package/dist/catalog.module.js +71 -0
- package/dist/catalog.options.d.ts +79 -0
- package/dist/catalog.options.js +4 -0
- package/dist/catalog.overlay-store.d.ts +25 -0
- package/dist/catalog.overlay-store.js +44 -0
- package/dist/catalog.overlay-store.token.d.ts +1 -0
- package/dist/catalog.overlay-store.token.js +4 -0
- package/dist/catalog.pipeline.d.ts +800 -0
- package/dist/catalog.pipeline.js +606 -0
- package/dist/catalog.principal.d.ts +209 -0
- package/dist/catalog.principal.js +245 -0
- package/dist/catalog.query-cache.d.ts +25 -0
- package/dist/catalog.query-cache.js +0 -0
- package/dist/catalog.query.d.ts +76 -0
- package/dist/catalog.query.js +64 -0
- package/dist/catalog.registry.base.d.ts +21 -0
- package/dist/catalog.registry.base.js +17 -0
- package/dist/catalog.registry.d.ts +44 -0
- package/dist/catalog.registry.js +359 -0
- package/dist/catalog.service.d.ts +115 -0
- package/dist/catalog.service.js +366 -0
- package/dist/catalog.store.d.ts +419 -0
- package/dist/catalog.store.js +175 -0
- package/dist/catalog.types.d.ts +165 -0
- package/dist/catalog.types.js +19 -0
- package/dist/catalog.workspace.d.ts +426 -0
- package/dist/catalog.workspace.js +87 -0
- package/dist/client.d.ts +86 -0
- package/dist/client.js +83 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +109 -0
- package/dist/stores/mikro-orm-read.store.d.ts +20 -0
- package/dist/stores/mikro-orm-read.store.js +120 -0
- package/dist/transform-runner.d.ts +54 -0
- package/dist/transform-runner.js +280 -0
- package/package.json +54 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is talking to the catalog.
|
|
3
|
+
*
|
|
4
|
+
* Once more than one application reads and writes the same catalog, "which app
|
|
5
|
+
* did this" stops being a logging concern and becomes part of the data. A
|
|
6
|
+
* principal is therefore recorded onto every snapshot, not just checked at the
|
|
7
|
+
* door: logs rotate, and the question "who loaded these rows" is asked months
|
|
8
|
+
* later.
|
|
9
|
+
*
|
|
10
|
+
* There are two kinds of caller and they are not the same kind of thing. An
|
|
11
|
+
* *application* acts on its own behalf — a nightly publisher has no human
|
|
12
|
+
* behind it and must never be made to invent one. A *person* acts through an
|
|
13
|
+
* application: someone signed into a console is still reaching the catalog as
|
|
14
|
+
* that console, with that console's ceiling, and the console does not become
|
|
15
|
+
* more powerful because an administrator happened to sign in. Both facts have
|
|
16
|
+
* to survive into the audit trail, because "the console renamed this column" is
|
|
17
|
+
* not an answer anybody accepts.
|
|
18
|
+
*/
|
|
19
|
+
export type CatalogScope =
|
|
20
|
+
/** Read object metadata and rows. */
|
|
21
|
+
'catalog:read'
|
|
22
|
+
/** Load snapshots for the types this principal is granted. */
|
|
23
|
+
| 'catalog:write'
|
|
24
|
+
/** Edit labels, descriptions, units — the presentation layer. */
|
|
25
|
+
| 'catalog:curate'
|
|
26
|
+
/**
|
|
27
|
+
* Fetch shared dashboards and charts through the embed API.
|
|
28
|
+
*
|
|
29
|
+
* Separate from `catalog:read` on purpose: an application that renders one
|
|
30
|
+
* chart in its own UI needs nothing else, and giving it the whole catalog to
|
|
31
|
+
* do that is the kind of over-grant nobody revisits.
|
|
32
|
+
*/
|
|
33
|
+
| 'catalog:embed'
|
|
34
|
+
/** Manage principals and grants. */
|
|
35
|
+
| 'catalog:admin';
|
|
36
|
+
/**
|
|
37
|
+
* The separator between the application half of a principal id and the person
|
|
38
|
+
* on whose behalf it acted: `catalog-console#ana@example.com`.
|
|
39
|
+
*
|
|
40
|
+
* A composite string rather than a second column, because `principalId` is
|
|
41
|
+
* already load-bearing in places this type cannot reach — it is a column on
|
|
42
|
+
* every snapshot row, an index on the audit table, and the owner key that
|
|
43
|
+
* decides which application may re-publish a type. Adding a parallel `actorId`
|
|
44
|
+
* everywhere means every one of those readers has to be found and taught about
|
|
45
|
+
* it, and the ones that are missed silently keep reporting a person's work as
|
|
46
|
+
* the console's.
|
|
47
|
+
*
|
|
48
|
+
* The convention is chosen so that nothing already written has to change:
|
|
49
|
+
* an id with no separator is exactly what it always was, an application acting
|
|
50
|
+
* alone. Old rows parse correctly because they parse as themselves.
|
|
51
|
+
*
|
|
52
|
+
* `#` specifically because it cannot appear in an OIDC client id and does not
|
|
53
|
+
* appear in a practical email address, so a round trip through
|
|
54
|
+
* {@link composePrincipalId} and {@link parsePrincipalId} is lossless. Callers
|
|
55
|
+
* that mint actor ids are expected to reject the separator rather than escape
|
|
56
|
+
* it — an escaping scheme here would be a second thing to get wrong.
|
|
57
|
+
*/
|
|
58
|
+
export declare const PRINCIPAL_ACTOR_SEPARATOR = "#";
|
|
59
|
+
/**
|
|
60
|
+
* The person behind a request, when there is one.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately thin: an id and a name to show. Everything else about a human —
|
|
63
|
+
* where their password lives, which directory they came from, what they are
|
|
64
|
+
* allowed — is the host's business, and a library that started modelling users
|
|
65
|
+
* would be a library that has opinions about your identity provider.
|
|
66
|
+
*/
|
|
67
|
+
export interface CatalogActor {
|
|
68
|
+
/**
|
|
69
|
+
* Stable login, e.g. an email address or an OIDC `sub`. It ends up inside
|
|
70
|
+
* every `principalId` this actor's session produces, so it must be stable
|
|
71
|
+
* across sessions and must not contain {@link PRINCIPAL_ACTOR_SEPARATOR}.
|
|
72
|
+
*/
|
|
73
|
+
id: string;
|
|
74
|
+
displayName?: string;
|
|
75
|
+
}
|
|
76
|
+
/** What a caller may do, independent of who the caller is. */
|
|
77
|
+
export interface CatalogGrants {
|
|
78
|
+
scopes: CatalogScope[];
|
|
79
|
+
writeTypes?: string[];
|
|
80
|
+
readTypes?: string[];
|
|
81
|
+
classifications?: string[];
|
|
82
|
+
}
|
|
83
|
+
/** A calling application, optionally acting for a person. */
|
|
84
|
+
export interface CatalogPrincipal {
|
|
85
|
+
/**
|
|
86
|
+
* Stable identifier, recorded onto every snapshot this principal writes.
|
|
87
|
+
* With OIDC client credentials this is the token's `azp` — the client id.
|
|
88
|
+
*
|
|
89
|
+
* For a delegated principal this is the composite
|
|
90
|
+
* `<applicationId>#<actor.id>`. Read it with {@link parsePrincipalId} rather
|
|
91
|
+
* than comparing it whole: an equality check against an application id is
|
|
92
|
+
* correct for machine callers and quietly false for every human one.
|
|
93
|
+
*/
|
|
94
|
+
id: string;
|
|
95
|
+
displayName?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The application half of {@link id}, always set for a delegated principal.
|
|
98
|
+
*
|
|
99
|
+
* Carried explicitly as well as encoded in the id so live code never has to
|
|
100
|
+
* re-parse a string it just built. Both are produced together by
|
|
101
|
+
* {@link delegatePrincipal}, so they cannot drift; parsing is for rows read
|
|
102
|
+
* back out of the database, where only the string survived.
|
|
103
|
+
*/
|
|
104
|
+
applicationId?: string;
|
|
105
|
+
/** The person this principal is acting for. Absent means a machine caller. */
|
|
106
|
+
actor?: CatalogActor;
|
|
107
|
+
scopes: CatalogScope[];
|
|
108
|
+
/**
|
|
109
|
+
* Object types this principal may load. `["*"]` for all.
|
|
110
|
+
*
|
|
111
|
+
* Per-type rather than a single global write scope on purpose: a shared write
|
|
112
|
+
* path where every caller can write every type means one application can
|
|
113
|
+
* quietly overwrite another's data, and the blast radius of a leaked
|
|
114
|
+
* credential is the whole catalog.
|
|
115
|
+
*/
|
|
116
|
+
writeTypes?: string[];
|
|
117
|
+
/** Object types this principal may read. `["*"]` or undefined for all. */
|
|
118
|
+
readTypes?: string[];
|
|
119
|
+
/**
|
|
120
|
+
* Classifications this principal may see. A column marked with anything
|
|
121
|
+
* outside this list is dropped from its reads.
|
|
122
|
+
*
|
|
123
|
+
* Undefined means "no classified columns", not "all of them" — the safe
|
|
124
|
+
* default for a caller nobody has thought about yet.
|
|
125
|
+
*/
|
|
126
|
+
classifications?: string[];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Turns a request into a principal.
|
|
130
|
+
*
|
|
131
|
+
* An interface rather than an implementation because the right answer depends
|
|
132
|
+
* on infrastructure the library cannot see. An app that already runs an OIDC
|
|
133
|
+
* provider should resolve the access token's `azp` and get rotation, expiry and
|
|
134
|
+
* central revocation for free; building a table of long-lived API keys beside a
|
|
135
|
+
* working identity provider is strictly worse.
|
|
136
|
+
*/
|
|
137
|
+
export interface CatalogPrincipalResolver {
|
|
138
|
+
resolve(request: unknown): Promise<CatalogPrincipal | null>;
|
|
139
|
+
}
|
|
140
|
+
export declare const CATALOG_PRINCIPAL_RESOLVER: unique symbol;
|
|
141
|
+
/**
|
|
142
|
+
* A header-keyed resolver, for local development and for callers that live
|
|
143
|
+
* outside the identity provider.
|
|
144
|
+
*
|
|
145
|
+
* Explicitly the lesser option. Keys here are long-lived, revoked only by
|
|
146
|
+
* redeploying, and as good as their storage — prefer a token-based resolver
|
|
147
|
+
* wherever there is an IdP to resolve against.
|
|
148
|
+
*/
|
|
149
|
+
export declare class StaticKeyPrincipalResolver implements CatalogPrincipalResolver {
|
|
150
|
+
private readonly header;
|
|
151
|
+
private readonly byKey;
|
|
152
|
+
constructor(principals: Array<CatalogPrincipal & {
|
|
153
|
+
key: string;
|
|
154
|
+
}>, header?: string);
|
|
155
|
+
resolve(request: unknown): Promise<CatalogPrincipal | null>;
|
|
156
|
+
}
|
|
157
|
+
export declare function hasScope(principal: CatalogPrincipal, scope: CatalogScope): boolean;
|
|
158
|
+
/** Builds the composite id. Returns the bare application id when nobody is behind it. */
|
|
159
|
+
export declare function composePrincipalId(applicationId: string, actorId?: string): string;
|
|
160
|
+
/**
|
|
161
|
+
* Splits a recorded `principalId` back into its two halves.
|
|
162
|
+
*
|
|
163
|
+
* Total, and lenient by design: every id ever written parses, including the
|
|
164
|
+
* millions written before actors existed. This is what lets a governance query
|
|
165
|
+
* keep asking "everything `flip-nestjs` did" — it matches on `applicationId`
|
|
166
|
+
* and gets both the machine's own loads and anything a person did through it.
|
|
167
|
+
*
|
|
168
|
+
* Splits on the *first* separator, so an actor id that somehow contains one
|
|
169
|
+
* still yields the right application rather than the right-hand fragment.
|
|
170
|
+
*/
|
|
171
|
+
export declare function parsePrincipalId(principalId: string): {
|
|
172
|
+
applicationId: string;
|
|
173
|
+
actorId?: string;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* `catalog:admin` written out.
|
|
177
|
+
*
|
|
178
|
+
* {@link hasScope} treats admin as implying everything, which is convenient at
|
|
179
|
+
* a gate and actively wrong when two scope lists have to be *combined*: a naive
|
|
180
|
+
* set intersection of `[catalog:admin]` with `[catalog:read]` is empty, and the
|
|
181
|
+
* person who should have kept read access is locked out instead. Expand first,
|
|
182
|
+
* intersect second.
|
|
183
|
+
*/
|
|
184
|
+
export declare function expandScopes(scopes: CatalogScope[]): CatalogScope[];
|
|
185
|
+
/**
|
|
186
|
+
* A principal for a person acting through an application.
|
|
187
|
+
*
|
|
188
|
+
* Every grant is the **intersection** of the two, never the union, and that is
|
|
189
|
+
* the whole point of this function. Signing in must not be a way to acquire
|
|
190
|
+
* what the application could not do — otherwise the console's own key stops
|
|
191
|
+
* being a ceiling and becomes decoration — and using the console must not be a
|
|
192
|
+
* way to acquire what the person was not granted. Both failures look identical
|
|
193
|
+
* from the outside: someone reads a table they were never meant to see, and
|
|
194
|
+
* the audit trail records it as perfectly authorised.
|
|
195
|
+
*
|
|
196
|
+
* The consequence worth stating out loud: raising what people can do in a
|
|
197
|
+
* console is an operator action on the console's *application* principal, not
|
|
198
|
+
* something that happens because an administrator logged in.
|
|
199
|
+
*/
|
|
200
|
+
export declare function delegatePrincipal(application: CatalogPrincipal, actor: CatalogActor, grants: CatalogGrants): CatalogPrincipal;
|
|
201
|
+
export declare function mayWrite(principal: CatalogPrincipal, typeName: string): boolean;
|
|
202
|
+
export declare function mayRead(principal: CatalogPrincipal, typeName: string): boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Whether a column is visible to this principal.
|
|
205
|
+
*
|
|
206
|
+
* Unclassified columns are visible to everyone; a classified one requires the
|
|
207
|
+
* principal to name that classification. Absence is denial.
|
|
208
|
+
*/
|
|
209
|
+
export declare function maySeeClassification(principal: CatalogPrincipal, classification: string | undefined): boolean;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Who is talking to the catalog.
|
|
4
|
+
*
|
|
5
|
+
* Once more than one application reads and writes the same catalog, "which app
|
|
6
|
+
* did this" stops being a logging concern and becomes part of the data. A
|
|
7
|
+
* principal is therefore recorded onto every snapshot, not just checked at the
|
|
8
|
+
* door: logs rotate, and the question "who loaded these rows" is asked months
|
|
9
|
+
* later.
|
|
10
|
+
*
|
|
11
|
+
* There are two kinds of caller and they are not the same kind of thing. An
|
|
12
|
+
* *application* acts on its own behalf — a nightly publisher has no human
|
|
13
|
+
* behind it and must never be made to invent one. A *person* acts through an
|
|
14
|
+
* application: someone signed into a console is still reaching the catalog as
|
|
15
|
+
* that console, with that console's ceiling, and the console does not become
|
|
16
|
+
* more powerful because an administrator happened to sign in. Both facts have
|
|
17
|
+
* to survive into the audit trail, because "the console renamed this column" is
|
|
18
|
+
* not an answer anybody accepts.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.StaticKeyPrincipalResolver = exports.CATALOG_PRINCIPAL_RESOLVER = exports.PRINCIPAL_ACTOR_SEPARATOR = void 0;
|
|
22
|
+
exports.hasScope = hasScope;
|
|
23
|
+
exports.composePrincipalId = composePrincipalId;
|
|
24
|
+
exports.parsePrincipalId = parsePrincipalId;
|
|
25
|
+
exports.expandScopes = expandScopes;
|
|
26
|
+
exports.delegatePrincipal = delegatePrincipal;
|
|
27
|
+
exports.mayWrite = mayWrite;
|
|
28
|
+
exports.mayRead = mayRead;
|
|
29
|
+
exports.maySeeClassification = maySeeClassification;
|
|
30
|
+
/** Every scope, in one place, so `catalog:admin` can be expanded exactly. */
|
|
31
|
+
const ALL_SCOPES = [
|
|
32
|
+
'catalog:read',
|
|
33
|
+
'catalog:write',
|
|
34
|
+
'catalog:curate',
|
|
35
|
+
'catalog:embed',
|
|
36
|
+
'catalog:admin',
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* The separator between the application half of a principal id and the person
|
|
40
|
+
* on whose behalf it acted: `catalog-console#ana@example.com`.
|
|
41
|
+
*
|
|
42
|
+
* A composite string rather than a second column, because `principalId` is
|
|
43
|
+
* already load-bearing in places this type cannot reach — it is a column on
|
|
44
|
+
* every snapshot row, an index on the audit table, and the owner key that
|
|
45
|
+
* decides which application may re-publish a type. Adding a parallel `actorId`
|
|
46
|
+
* everywhere means every one of those readers has to be found and taught about
|
|
47
|
+
* it, and the ones that are missed silently keep reporting a person's work as
|
|
48
|
+
* the console's.
|
|
49
|
+
*
|
|
50
|
+
* The convention is chosen so that nothing already written has to change:
|
|
51
|
+
* an id with no separator is exactly what it always was, an application acting
|
|
52
|
+
* alone. Old rows parse correctly because they parse as themselves.
|
|
53
|
+
*
|
|
54
|
+
* `#` specifically because it cannot appear in an OIDC client id and does not
|
|
55
|
+
* appear in a practical email address, so a round trip through
|
|
56
|
+
* {@link composePrincipalId} and {@link parsePrincipalId} is lossless. Callers
|
|
57
|
+
* that mint actor ids are expected to reject the separator rather than escape
|
|
58
|
+
* it — an escaping scheme here would be a second thing to get wrong.
|
|
59
|
+
*/
|
|
60
|
+
exports.PRINCIPAL_ACTOR_SEPARATOR = '#';
|
|
61
|
+
exports.CATALOG_PRINCIPAL_RESOLVER = Symbol('CATALOG_PRINCIPAL_RESOLVER');
|
|
62
|
+
/**
|
|
63
|
+
* A header-keyed resolver, for local development and for callers that live
|
|
64
|
+
* outside the identity provider.
|
|
65
|
+
*
|
|
66
|
+
* Explicitly the lesser option. Keys here are long-lived, revoked only by
|
|
67
|
+
* redeploying, and as good as their storage — prefer a token-based resolver
|
|
68
|
+
* wherever there is an IdP to resolve against.
|
|
69
|
+
*/
|
|
70
|
+
class StaticKeyPrincipalResolver {
|
|
71
|
+
header;
|
|
72
|
+
byKey;
|
|
73
|
+
constructor(principals, header = 'x-catalog-key') {
|
|
74
|
+
this.header = header;
|
|
75
|
+
this.byKey = new Map(principals.map(({ key, ...principal }) => [key, principal]));
|
|
76
|
+
}
|
|
77
|
+
async resolve(request) {
|
|
78
|
+
if (!request || typeof request !== 'object')
|
|
79
|
+
return null;
|
|
80
|
+
const headers = Reflect.get(request, 'headers');
|
|
81
|
+
if (!headers || typeof headers !== 'object')
|
|
82
|
+
return null;
|
|
83
|
+
const presented = Reflect.get(headers, this.header);
|
|
84
|
+
if (typeof presented !== 'string')
|
|
85
|
+
return null;
|
|
86
|
+
return this.byKey.get(presented) ?? null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
exports.StaticKeyPrincipalResolver = StaticKeyPrincipalResolver;
|
|
90
|
+
function hasScope(principal, scope) {
|
|
91
|
+
return principal.scopes.includes(scope) || principal.scopes.includes('catalog:admin');
|
|
92
|
+
}
|
|
93
|
+
/** Builds the composite id. Returns the bare application id when nobody is behind it. */
|
|
94
|
+
function composePrincipalId(applicationId, actorId) {
|
|
95
|
+
if (!actorId)
|
|
96
|
+
return applicationId;
|
|
97
|
+
return `${applicationId}${exports.PRINCIPAL_ACTOR_SEPARATOR}${actorId}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Splits a recorded `principalId` back into its two halves.
|
|
101
|
+
*
|
|
102
|
+
* Total, and lenient by design: every id ever written parses, including the
|
|
103
|
+
* millions written before actors existed. This is what lets a governance query
|
|
104
|
+
* keep asking "everything `flip-nestjs` did" — it matches on `applicationId`
|
|
105
|
+
* and gets both the machine's own loads and anything a person did through it.
|
|
106
|
+
*
|
|
107
|
+
* Splits on the *first* separator, so an actor id that somehow contains one
|
|
108
|
+
* still yields the right application rather than the right-hand fragment.
|
|
109
|
+
*/
|
|
110
|
+
function parsePrincipalId(principalId) {
|
|
111
|
+
const at = principalId.indexOf(exports.PRINCIPAL_ACTOR_SEPARATOR);
|
|
112
|
+
if (at < 0)
|
|
113
|
+
return { applicationId: principalId };
|
|
114
|
+
const actorId = principalId.slice(at + exports.PRINCIPAL_ACTOR_SEPARATOR.length);
|
|
115
|
+
return {
|
|
116
|
+
applicationId: principalId.slice(0, at),
|
|
117
|
+
// A trailing separator with nothing after it is a malformed id, not an
|
|
118
|
+
// actor named "". Treating it as a machine caller is the safer reading:
|
|
119
|
+
// it under-claims attribution rather than inventing a person.
|
|
120
|
+
actorId: actorId.length > 0 ? actorId : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `catalog:admin` written out.
|
|
125
|
+
*
|
|
126
|
+
* {@link hasScope} treats admin as implying everything, which is convenient at
|
|
127
|
+
* a gate and actively wrong when two scope lists have to be *combined*: a naive
|
|
128
|
+
* set intersection of `[catalog:admin]` with `[catalog:read]` is empty, and the
|
|
129
|
+
* person who should have kept read access is locked out instead. Expand first,
|
|
130
|
+
* intersect second.
|
|
131
|
+
*/
|
|
132
|
+
function expandScopes(scopes) {
|
|
133
|
+
if (scopes.includes('catalog:admin'))
|
|
134
|
+
return [...ALL_SCOPES];
|
|
135
|
+
return [...scopes];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A principal for a person acting through an application.
|
|
139
|
+
*
|
|
140
|
+
* Every grant is the **intersection** of the two, never the union, and that is
|
|
141
|
+
* the whole point of this function. Signing in must not be a way to acquire
|
|
142
|
+
* what the application could not do — otherwise the console's own key stops
|
|
143
|
+
* being a ceiling and becomes decoration — and using the console must not be a
|
|
144
|
+
* way to acquire what the person was not granted. Both failures look identical
|
|
145
|
+
* from the outside: someone reads a table they were never meant to see, and
|
|
146
|
+
* the audit trail records it as perfectly authorised.
|
|
147
|
+
*
|
|
148
|
+
* The consequence worth stating out loud: raising what people can do in a
|
|
149
|
+
* console is an operator action on the console's *application* principal, not
|
|
150
|
+
* something that happens because an administrator logged in.
|
|
151
|
+
*/
|
|
152
|
+
function delegatePrincipal(application, actor, grants) {
|
|
153
|
+
if (actor.id.includes(exports.PRINCIPAL_ACTOR_SEPARATOR)) {
|
|
154
|
+
throw new Error(`Actor id "${actor.id}" contains ${exports.PRINCIPAL_ACTOR_SEPARATOR}, which would make its principal id ambiguous.`);
|
|
155
|
+
}
|
|
156
|
+
const applicationId = application.applicationId ?? application.id;
|
|
157
|
+
const appScopes = expandScopes(application.scopes);
|
|
158
|
+
const actorScopes = expandScopes(grants.scopes);
|
|
159
|
+
return {
|
|
160
|
+
id: composePrincipalId(applicationId, actor.id),
|
|
161
|
+
applicationId,
|
|
162
|
+
actor,
|
|
163
|
+
displayName: actor.displayName
|
|
164
|
+
? `${actor.displayName} via ${application.displayName ?? applicationId}`
|
|
165
|
+
: applicationId,
|
|
166
|
+
scopes: appScopes.filter((scope) => actorScopes.includes(scope)),
|
|
167
|
+
// Absent means "nothing" on both sides — an unlisted type is a denied
|
|
168
|
+
// write — so `["*"]` is the only way either side says "all of them".
|
|
169
|
+
writeTypes: intersectAllowList(application.writeTypes, grants.writeTypes, 'none'),
|
|
170
|
+
// Absent means "everything" here, matching `mayRead`. Preserving that
|
|
171
|
+
// through the intersection is what keeps a plain reader from having to
|
|
172
|
+
// enumerate every type that will ever exist.
|
|
173
|
+
readTypes: intersectAllowList(application.readTypes, grants.readTypes, 'all'),
|
|
174
|
+
// Classifications get a plain set intersection with no wildcard, because
|
|
175
|
+
// `maySeeClassification` does not honour `"*"` — it asks for the exact
|
|
176
|
+
// label. Emitting `["*"]` here would produce a principal that looks
|
|
177
|
+
// all-seeing and can in fact see nothing classified at all.
|
|
178
|
+
classifications: (application.classifications ?? []).filter((label) => (grants.classifications ?? []).includes(label)),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Intersects two allow-lists that both use `"*"` for "all", where an absent
|
|
183
|
+
* list means `whenAbsent`.
|
|
184
|
+
*
|
|
185
|
+
* Returns `undefined` for "all" only when absence already means all on that
|
|
186
|
+
* axis, so a read intersection stays in the compact `undefined` form that
|
|
187
|
+
* `mayRead` understands rather than being expanded into a list that goes stale
|
|
188
|
+
* the moment a new type is published.
|
|
189
|
+
*/
|
|
190
|
+
function intersectAllowList(a, b, whenAbsent) {
|
|
191
|
+
const left = normaliseAllowList(a, whenAbsent);
|
|
192
|
+
const right = normaliseAllowList(b, whenAbsent);
|
|
193
|
+
// "Nothing" on either side settles it before anything else is considered:
|
|
194
|
+
// intersecting with an empty set is empty whatever the other side says.
|
|
195
|
+
if (left === 'none' || right === 'none')
|
|
196
|
+
return [];
|
|
197
|
+
// Each remaining test compares against the *same* variable it narrows.
|
|
198
|
+
// Deciding "exactly one side is all" with a compound condition reads fine and
|
|
199
|
+
// does not narrow — the compiler cannot carry `right !== "all"` out of an
|
|
200
|
+
// earlier `left === "all" && right === "all"`, so the branch below it still
|
|
201
|
+
// has `"all"` in its type and the function stops compiling.
|
|
202
|
+
if (left === 'all') {
|
|
203
|
+
if (right === 'all')
|
|
204
|
+
return whenAbsent === 'all' ? undefined : ['*'];
|
|
205
|
+
return right;
|
|
206
|
+
}
|
|
207
|
+
if (right === 'all')
|
|
208
|
+
return left;
|
|
209
|
+
return left.filter((name) => right.includes(name));
|
|
210
|
+
}
|
|
211
|
+
function normaliseAllowList(list, whenAbsent) {
|
|
212
|
+
if (list === undefined)
|
|
213
|
+
return whenAbsent;
|
|
214
|
+
if (list.includes('*'))
|
|
215
|
+
return 'all';
|
|
216
|
+
if (list.length === 0)
|
|
217
|
+
return 'none';
|
|
218
|
+
return list;
|
|
219
|
+
}
|
|
220
|
+
function matches(list, typeName) {
|
|
221
|
+
if (!list)
|
|
222
|
+
return false;
|
|
223
|
+
return list.includes('*') || list.includes(typeName);
|
|
224
|
+
}
|
|
225
|
+
function mayWrite(principal, typeName) {
|
|
226
|
+
return hasScope(principal, 'catalog:write') && matches(principal.writeTypes, typeName);
|
|
227
|
+
}
|
|
228
|
+
function mayRead(principal, typeName) {
|
|
229
|
+
if (!hasScope(principal, 'catalog:read'))
|
|
230
|
+
return false;
|
|
231
|
+
// Undefined readTypes means every type, which is the useful default for a
|
|
232
|
+
// read-only consumer. Write grants get no such default — see `writeTypes`.
|
|
233
|
+
return principal.readTypes === undefined || matches(principal.readTypes, typeName);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Whether a column is visible to this principal.
|
|
237
|
+
*
|
|
238
|
+
* Unclassified columns are visible to everyone; a classified one requires the
|
|
239
|
+
* principal to name that classification. Absence is denial.
|
|
240
|
+
*/
|
|
241
|
+
function maySeeClassification(principal, classification) {
|
|
242
|
+
if (!classification)
|
|
243
|
+
return true;
|
|
244
|
+
return principal.classifications?.includes(classification) ?? false;
|
|
245
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CatalogQueryResult } from './catalog.query';
|
|
2
|
+
/**
|
|
3
|
+
* A small in-process cache for query results.
|
|
4
|
+
*
|
|
5
|
+
* In-process on purpose. A shared cache would need invalidation on every
|
|
6
|
+
* commit from every publisher, across every pod, and getting that wrong means
|
|
7
|
+
* showing someone last week's numbers with this week's confidence. A per-pod
|
|
8
|
+
* cache with a short TTL is a worse cache and a much better failure mode: the
|
|
9
|
+
* staleness is bounded by a number the query's author chose.
|
|
10
|
+
*
|
|
11
|
+
* Keyed on the SQL *and* the catalog version, so a curation edit that renames a
|
|
12
|
+
* column cannot serve a result computed under the old name.
|
|
13
|
+
*/
|
|
14
|
+
export declare class QueryCache {
|
|
15
|
+
private readonly maxEntries;
|
|
16
|
+
private readonly entries;
|
|
17
|
+
constructor(maxEntries?: number);
|
|
18
|
+
static key(sql: string, catalogVersion: number): string;
|
|
19
|
+
get(key: string): CatalogQueryResult | undefined;
|
|
20
|
+
set(key: string, result: CatalogQueryResult, ttlSeconds: number): void;
|
|
21
|
+
clear(): void;
|
|
22
|
+
get size(): number;
|
|
23
|
+
}
|
|
24
|
+
/** CSV, for the export button. */
|
|
25
|
+
export declare function toCsv(result: CatalogQueryResult): string;
|
|
Binary file
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ad-hoc SQL over the catalogued data.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `CatalogReadStore` because not every store can offer it: a
|
|
5
|
+
* read-through view of someone else's tables has no business handing out a SQL
|
|
6
|
+
* console over them, and a store fronting an API has no SQL to run.
|
|
7
|
+
*/
|
|
8
|
+
export interface CatalogQueryRequest {
|
|
9
|
+
sql: string;
|
|
10
|
+
/** Hard cap on rows returned. The service clamps this. */
|
|
11
|
+
maxRows?: number;
|
|
12
|
+
/**
|
|
13
|
+
* How long the statement may run, in milliseconds — **best effort**.
|
|
14
|
+
*
|
|
15
|
+
* Unlike `maxRows`, which the store enforces itself by wrapping the statement,
|
|
16
|
+
* this is handed to the engine and the engine decides what to do with it. What
|
|
17
|
+
* that buys varies more than it looks:
|
|
18
|
+
*
|
|
19
|
+
* - MySQL's `MAX_EXECUTION_TIME` applies to read-only SELECTs and is a hint
|
|
20
|
+
* the optimiser checks between stages, so a statement can overrun it.
|
|
21
|
+
* - ClickHouse's `max_execution_time` is likewise checked between blocks.
|
|
22
|
+
* - An engine with no statement-level timeout at all can only honour this by
|
|
23
|
+
* abandoning the client's side of the connection, which stops the caller
|
|
24
|
+
* waiting and does not stop the query.
|
|
25
|
+
*
|
|
26
|
+
* So a caller may not treat this as a guarantee that resources are released
|
|
27
|
+
* when it elapses. The honest reading is "the store will ask, and will stop
|
|
28
|
+
* waiting"; a deployment that needs a hard bound sets one on the database, in
|
|
29
|
+
* the role the catalog's read connection uses.
|
|
30
|
+
*/
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface CatalogQueryResult {
|
|
34
|
+
/** True when this came from the cache rather than the database. */
|
|
35
|
+
cached?: boolean;
|
|
36
|
+
columns: string[];
|
|
37
|
+
rows: Array<Record<string, unknown>>;
|
|
38
|
+
rowCount: number;
|
|
39
|
+
/** True when the cap cut the result short, so the UI can say so. */
|
|
40
|
+
truncated: boolean;
|
|
41
|
+
elapsedMs: number;
|
|
42
|
+
}
|
|
43
|
+
/** One queryable relation, for the editor's schema panel and autocomplete. */
|
|
44
|
+
export interface CatalogQueryRelation {
|
|
45
|
+
/** What to write in a FROM clause. */
|
|
46
|
+
name: string;
|
|
47
|
+
kind: 'current' | 'history';
|
|
48
|
+
objectType: string;
|
|
49
|
+
description: string;
|
|
50
|
+
columns: Array<{
|
|
51
|
+
name: string;
|
|
52
|
+
type: string;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
55
|
+
export interface CatalogQueryStore {
|
|
56
|
+
/**
|
|
57
|
+
* Run a read-only statement.
|
|
58
|
+
*
|
|
59
|
+
* Implementations are expected to enforce read-only at the *database*, not by
|
|
60
|
+
* inspecting the string: a keyword denylist is a guess about a parser, and
|
|
61
|
+
* the parser always wins eventually.
|
|
62
|
+
*/
|
|
63
|
+
runQuery(request: CatalogQueryRequest): Promise<CatalogQueryResult>;
|
|
64
|
+
/** What a query may select from. */
|
|
65
|
+
queryRelations(): Promise<CatalogQueryRelation[]>;
|
|
66
|
+
}
|
|
67
|
+
export declare function isQueryStore(store: unknown): store is CatalogQueryStore;
|
|
68
|
+
/**
|
|
69
|
+
* A cheap sanity check on the shape of a statement.
|
|
70
|
+
*
|
|
71
|
+
* Explicitly NOT the security boundary — that is the read-only transaction the
|
|
72
|
+
* store opens. This exists to turn "you typed an UPDATE" into a clear message
|
|
73
|
+
* instead of a database error, and to refuse the multi-statement form outright
|
|
74
|
+
* since nothing legitimate here needs it.
|
|
75
|
+
*/
|
|
76
|
+
export declare function assertReadOnlyShape(sql: string): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Ad-hoc SQL over the catalogued data.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `CatalogReadStore` because not every store can offer it: a
|
|
6
|
+
* read-through view of someone else's tables has no business handing out a SQL
|
|
7
|
+
* console over them, and a store fronting an API has no SQL to run.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.isQueryStore = isQueryStore;
|
|
11
|
+
exports.assertReadOnlyShape = assertReadOnlyShape;
|
|
12
|
+
function isQueryStore(store) {
|
|
13
|
+
return (typeof store === 'object' &&
|
|
14
|
+
store !== null &&
|
|
15
|
+
typeof Reflect.get(store, 'runQuery') === 'function');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A cheap sanity check on the shape of a statement.
|
|
19
|
+
*
|
|
20
|
+
* Explicitly NOT the security boundary — that is the read-only transaction the
|
|
21
|
+
* store opens. This exists to turn "you typed an UPDATE" into a clear message
|
|
22
|
+
* instead of a database error, and to refuse the multi-statement form outright
|
|
23
|
+
* since nothing legitimate here needs it.
|
|
24
|
+
*/
|
|
25
|
+
function assertReadOnlyShape(sql) {
|
|
26
|
+
const trimmed = sql.trim().replace(/;\s*$/, '');
|
|
27
|
+
if (trimmed.length === 0) {
|
|
28
|
+
throw new Error('The query is empty.');
|
|
29
|
+
}
|
|
30
|
+
if (trimmed.includes(';')) {
|
|
31
|
+
throw new Error('Only one statement at a time. Remove the semicolon in the middle.');
|
|
32
|
+
}
|
|
33
|
+
// Leading comments have to come off before the keyword test. People paste
|
|
34
|
+
// annotated SQL and they write notes above the statement — rejecting that as
|
|
35
|
+
// "not a SELECT" is both wrong and baffling, since the SELECT is right there.
|
|
36
|
+
const statement = stripLeadingComments(trimmed);
|
|
37
|
+
if (statement.length === 0) {
|
|
38
|
+
throw new Error('That is all comments — there is no statement to run.');
|
|
39
|
+
}
|
|
40
|
+
if (!/^(select|with)\b/i.test(statement)) {
|
|
41
|
+
throw new Error('Only SELECT (or WITH … SELECT) can run here. The catalog is read-only from this screen.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Strips `-- line` and `/* block *\/` comments from the front of a statement. */
|
|
45
|
+
function stripLeadingComments(sql) {
|
|
46
|
+
let rest = sql.trimStart();
|
|
47
|
+
for (;;) {
|
|
48
|
+
if (rest.startsWith('--') || rest.startsWith('#')) {
|
|
49
|
+
const newline = rest.indexOf('\n');
|
|
50
|
+
if (newline === -1)
|
|
51
|
+
return '';
|
|
52
|
+
rest = rest.slice(newline + 1).trimStart();
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (rest.startsWith('/*')) {
|
|
56
|
+
const close = rest.indexOf('*/');
|
|
57
|
+
if (close === -1)
|
|
58
|
+
return '';
|
|
59
|
+
rest = rest.slice(close + 2).trimStart();
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return rest;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CatalogGraph, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
|
|
2
|
+
/**
|
|
3
|
+
* What the catalog knows about your types, however it came to know it.
|
|
4
|
+
*
|
|
5
|
+
* An abstract class rather than an interface so it doubles as the DI token.
|
|
6
|
+
*
|
|
7
|
+
* Two implementations are expected and they are genuinely different: one
|
|
8
|
+
* *derives* the model from an ORM in the application that owns the tables, and
|
|
9
|
+
* one *stores* it because the model arrived over the wire from somewhere else.
|
|
10
|
+
* A warehouse has no entity classes to reflect over — the type definitions are
|
|
11
|
+
* data it was handed. Everything above this line works the same either way.
|
|
12
|
+
*/
|
|
13
|
+
export declare abstract class CatalogRegistry {
|
|
14
|
+
abstract getSnapshot(): CatalogSnapshot;
|
|
15
|
+
abstract getType(name: string): CatalogObjectTypeDef | undefined;
|
|
16
|
+
abstract getGraph(): CatalogGraph;
|
|
17
|
+
/** Presentation-only edits. Never a schema change. */
|
|
18
|
+
abstract patchType(typeName: string, patch: Partial<CatalogOverlay['types'][string]>): Promise<CatalogObjectTypeDef | undefined>;
|
|
19
|
+
abstract patchProperty(typeName: string, propertyName: string, patch: NonNullable<CatalogOverlay['types'][string]['properties']>[string]): Promise<CatalogObjectTypeDef | undefined>;
|
|
20
|
+
abstract resetOverlay(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CatalogRegistry = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* What the catalog knows about your types, however it came to know it.
|
|
6
|
+
*
|
|
7
|
+
* An abstract class rather than an interface so it doubles as the DI token.
|
|
8
|
+
*
|
|
9
|
+
* Two implementations are expected and they are genuinely different: one
|
|
10
|
+
* *derives* the model from an ORM in the application that owns the tables, and
|
|
11
|
+
* one *stores* it because the model arrived over the wire from somewhere else.
|
|
12
|
+
* A warehouse has no entity classes to reflect over — the type definitions are
|
|
13
|
+
* data it was handed. Everything above this line works the same either way.
|
|
14
|
+
*/
|
|
15
|
+
class CatalogRegistry {
|
|
16
|
+
}
|
|
17
|
+
exports.CatalogRegistry = CatalogRegistry;
|