@serve.zone/interfaces 32.59.0 → 32.61.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/.smartconfig.json +4 -2
- package/changelog.md +87 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/data/delegatedadmintoken.d.ts +142 -0
- package/dist_ts/data/delegatedadmintoken.js +197 -0
- package/dist_ts/data/externalregistry.d.ts +37 -1
- package/dist_ts/data/gateway.d.ts +23 -4
- package/dist_ts/data/gateway.js +1 -1
- package/dist_ts/data/index.d.ts +2 -0
- package/dist_ts/data/index.js +3 -1
- package/dist_ts/data/launchersecretowner.d.ts +60 -0
- package/dist_ts/data/launchersecretowner.js +94 -0
- package/dist_ts/data/secret.d.ts +67 -1
- package/dist_ts/data/secret.js +59 -6
- package/dist_ts/data/serviceruntime.d.ts +9 -2
- package/dist_ts/data/serviceruntime.js +2 -1
- package/dist_ts/platform/objectstorageenvironment.d.ts +12 -3
- package/dist_ts/platform/objectstorageenvironment.js +22 -6
- package/dist_ts/requests/admin.d.ts +52 -0
- package/dist_ts/requests/externalregistry.d.ts +12 -6
- package/dist_ts/requests/identity.d.ts +4 -0
- package/dist_ts/requests/service.d.ts +16 -1
- package/package.json +5 -8
- package/readme.md +76 -12
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/data/delegatedadmintoken.ts +282 -0
- package/ts/data/externalregistry.ts +60 -2
- package/ts/data/gateway.ts +24 -3
- package/ts/data/index.ts +2 -0
- package/ts/data/launchersecretowner.ts +145 -0
- package/ts/data/secret.ts +103 -7
- package/ts/data/serviceruntime.ts +9 -1
- package/ts/platform/objectstorageenvironment.ts +26 -8
- package/ts/requests/admin.ts +51 -0
- package/ts/requests/externalregistry.ts +12 -6
- package/ts/requests/identity.ts +4 -0
- package/ts/requests/service.ts +16 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readDataElements, readDataProperty, readExactDataRecord } from '../private/dataproperties.js';
|
|
2
|
+
import {
|
|
3
|
+
isReservedSecretKey, platformReservedSecretKeyPrefixes, runtimeInjectionSecretKeyPrefixes,
|
|
4
|
+
runtimeInjectionSecretKeys,
|
|
5
|
+
} from './secret.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A delegated administrator token (a "temp token") hands an agent a narrow, short-lived slice of
|
|
9
|
+
* one human administrator's authority, without the administrator's password or session.
|
|
10
|
+
*
|
|
11
|
+
* - Only a human administrator who signed in within five minutes mints one
|
|
12
|
+
* (`mintDelegatedAdminToken`), for scopes named explicitly from `delegatedAdminTokenScopes`.
|
|
13
|
+
* The plaintext token is answered exactly once; Cloudly stores only its hash.
|
|
14
|
+
* - It is exchanged with `getIdentityByToken` for a session that only the requests of its scopes
|
|
15
|
+
* (`delegatedAdminTokenScopeRequests`) admit. Every such request re-reads the token, so the
|
|
16
|
+
* session carries no validity of its own, and acts as `<administrator> via temp token <id>`.
|
|
17
|
+
* - It ends at its expiry, at most `maximumLifetimeMs` after the mint and never extended; when any
|
|
18
|
+
* administrator revokes it; or when the minting administrator's credential generation changes.
|
|
19
|
+
* - A delegated session never mints, lists or revokes tokens, and no scope reaches users, roles,
|
|
20
|
+
* human credentials, deployment tokens, authority records or organization membership.
|
|
21
|
+
*/
|
|
22
|
+
export const delegatedAdminTokenContract = Object.freeze({
|
|
23
|
+
/** The lifetime a mint that names none receives. */
|
|
24
|
+
defaultLifetimeMs: 60 * 60 * 1000,
|
|
25
|
+
/** The hard cap: a token lives at most one hour from its mint and is never extended. */
|
|
26
|
+
maximumLifetimeMs: 60 * 60 * 1000,
|
|
27
|
+
minimumLifetimeMs: 60 * 1000,
|
|
28
|
+
/** Every token is `delegatedAdminToken_<tokenId>_<secret>`, both parts 64 lowercase hex. */
|
|
29
|
+
tokenPrefix: 'delegatedAdminToken_',
|
|
30
|
+
/** Tokens one listing reports at most, newest first; a listing that leaves any out says so. */
|
|
31
|
+
maximumListedTokens: 256,
|
|
32
|
+
/** Secret key prefixes one token is granted at most. */
|
|
33
|
+
maximumSecretKeyPrefixes: 16,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a token may be minted for. Each scope admits exactly the requests
|
|
38
|
+
* `delegatedAdminTokenScopeRequests` names for it, and nothing else does.
|
|
39
|
+
*
|
|
40
|
+
* - `secrets`: the operator-managed secrets of services and SecretSets: list and read their
|
|
41
|
+
* metadata and read the ingress recipient a value is sealed to; create, rotate, retire and revoke
|
|
42
|
+
* those whose key starts with a prefix the token was granted (`secretKeyPrefixes`, see
|
|
43
|
+
* `isDelegatedSecretKey`; `changeSecretLifecycle` only with the actions
|
|
44
|
+
* `delegatedAdminTokenSecretLifecycleActions` names, never `delete`). A token creates a secret
|
|
45
|
+
* only as a launcher-environment variable, never as a file. A token minted without prefixes
|
|
46
|
+
* creates, rotates, retires and revokes nothing.
|
|
47
|
+
* - `serviceCredentials`: a service's platform credential (`SERVEZONE_PLATFORM_AUTHORIZATION`):
|
|
48
|
+
* inspect, ensure, rotate and revoke it.
|
|
49
|
+
*/
|
|
50
|
+
export const delegatedAdminTokenScopes = ['secrets', 'serviceCredentials'] as const;
|
|
51
|
+
|
|
52
|
+
export type TDelegatedAdminTokenScope = typeof delegatedAdminTokenScopes[number];
|
|
53
|
+
|
|
54
|
+
/** The exact requests each scope admits a delegated session on, by their wire method names. */
|
|
55
|
+
export const delegatedAdminTokenScopeRequests = Object.freeze({
|
|
56
|
+
secrets: Object.freeze([
|
|
57
|
+
'getSecretIngressRecipient', 'listSecrets', 'getSecretMetadata', 'createSecret', 'rotateSecret',
|
|
58
|
+
'changeSecretLifecycle',
|
|
59
|
+
] as const),
|
|
60
|
+
serviceCredentials: Object.freeze(['getServiceMachineCredential', 'mutateServiceMachineCredential'] as const),
|
|
61
|
+
}) satisfies Readonly<Record<TDelegatedAdminTokenScope, readonly string[]>>;
|
|
62
|
+
|
|
63
|
+
/** A request one scope admits, by its wire method name. */
|
|
64
|
+
export type TDelegatedAdminTokenScopeRequest<TScope extends TDelegatedAdminTokenScope = TDelegatedAdminTokenScope> =
|
|
65
|
+
(typeof delegatedAdminTokenScopeRequests)[TScope][number];
|
|
66
|
+
|
|
67
|
+
/** Whether `scopeArg` admits the request a wire method names. */
|
|
68
|
+
export const isDelegatedAdminTokenScopeRequest = <TScope extends TDelegatedAdminTokenScope>(
|
|
69
|
+
scopeArg: TScope,
|
|
70
|
+
methodArg: unknown,
|
|
71
|
+
): methodArg is TDelegatedAdminTokenScopeRequest<TScope> => (
|
|
72
|
+
(delegatedAdminTokenScopeRequests[scopeArg] as readonly unknown[]).includes(methodArg)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `changeSecretLifecycle` actions the `secrets` scope delegates, each only for a secret whose key
|
|
77
|
+
* the token's grant covers (`isDelegatedSecretKey`). Deleting a secret stays with a human
|
|
78
|
+
* administrator: a delegated session asking for it is refused like a request outside its scope.
|
|
79
|
+
*/
|
|
80
|
+
export const delegatedAdminTokenSecretLifecycleActions = Object.freeze(['retire', 'revoke'] as const);
|
|
81
|
+
|
|
82
|
+
/** One token, value-free. Neither the token nor its hash is ever part of it. */
|
|
83
|
+
export interface IDelegatedAdminTokenMetadata {
|
|
84
|
+
/** 64 lowercase hex; the part of the token that names it. */
|
|
85
|
+
tokenId: string;
|
|
86
|
+
/** The human administrator who minted the token and whom every use acts for. */
|
|
87
|
+
userId: string;
|
|
88
|
+
/** Distinct, in `delegatedAdminTokenScopes` order. */
|
|
89
|
+
scopes: TDelegatedAdminTokenScope[];
|
|
90
|
+
/**
|
|
91
|
+
* The secret key prefixes the token creates, rotates, retires and revokes secrets under, sorted
|
|
92
|
+
* and distinct; empty when it was granted none. Only a token with the `secrets` scope carries any.
|
|
93
|
+
*/
|
|
94
|
+
secretKeyPrefixes: string[];
|
|
95
|
+
createdAt: number;
|
|
96
|
+
expiresAt: number;
|
|
97
|
+
/** When an administrator revoked the token; `null` while it was never revoked. */
|
|
98
|
+
revokedAt: number | null;
|
|
99
|
+
/** The administrator who revoked it; `null` exactly when `revokedAt` is. */
|
|
100
|
+
revokedBy: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a mint states besides the administrator's identity. */
|
|
104
|
+
export interface IDelegatedAdminTokenMint {
|
|
105
|
+
scopes: TDelegatedAdminTokenScope[];
|
|
106
|
+
/** Absent: `defaultLifetimeMs`. Otherwise within `minimumLifetimeMs` and `maximumLifetimeMs`. */
|
|
107
|
+
lifetimeMs?: number;
|
|
108
|
+
/**
|
|
109
|
+
* The secret key prefixes the token may create, rotate, retire and revoke secrets under, each
|
|
110
|
+
* `isDelegatableSecretKeyPrefix`, distinct, at most `maximumSecretKeyPrefixes`; only with the
|
|
111
|
+
* `secrets` scope. Prefixes must be application-specific (`GITSYNC_`): a prefix that overlaps a
|
|
112
|
+
* reserved name is refused. Absent or empty: the token creates, rotates, retires and revokes
|
|
113
|
+
* nothing.
|
|
114
|
+
*/
|
|
115
|
+
secretKeyPrefixes?: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface IDelegatedAdminTokenMintResult {
|
|
119
|
+
/** The plaintext token, answered on this answer only; Cloudly keeps only its hash. */
|
|
120
|
+
token: string;
|
|
121
|
+
metadata: IDelegatedAdminTokenMetadata;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every refusal the token requests name, as `IDelegatedAdminTokenErrorData`.
|
|
126
|
+
*
|
|
127
|
+
* - `ADMIN_AUTHORIZATION_REQUIRED`: a mint by anyone but a human administrator who signed in
|
|
128
|
+
* within five minutes, or a listing or revocation by anyone but an administrator. A delegated
|
|
129
|
+
* session is refused here on every request.
|
|
130
|
+
* - `DELEGATED_ADMIN_TOKEN_REQUEST_INVALID`: the request does not satisfy this contract.
|
|
131
|
+
* - `DELEGATED_ADMIN_TOKEN_NOT_FOUND`: no token has that id.
|
|
132
|
+
* - `DELEGATED_ADMIN_TOKEN_INACTIVE`: the token already ended: it expired, was revoked, or its
|
|
133
|
+
* administrator is no longer a human administrator at the credential generation it was minted
|
|
134
|
+
* under.
|
|
135
|
+
*/
|
|
136
|
+
export const delegatedAdminTokenRefusals = [
|
|
137
|
+
'ADMIN_AUTHORIZATION_REQUIRED',
|
|
138
|
+
'DELEGATED_ADMIN_TOKEN_REQUEST_INVALID',
|
|
139
|
+
'DELEGATED_ADMIN_TOKEN_NOT_FOUND',
|
|
140
|
+
'DELEGATED_ADMIN_TOKEN_INACTIVE',
|
|
141
|
+
] as const;
|
|
142
|
+
|
|
143
|
+
export type TDelegatedAdminTokenRefusal = typeof delegatedAdminTokenRefusals[number];
|
|
144
|
+
|
|
145
|
+
export interface IDelegatedAdminTokenErrorData {
|
|
146
|
+
code: TDelegatedAdminTokenRefusal;
|
|
147
|
+
retryable: false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const fail = (): never => { throw new Error('Delegated admin token contract rejected.'); };
|
|
151
|
+
function requireValue(value: unknown): asserts value { if (!value) fail(); }
|
|
152
|
+
const safe = <T>(operation: () => T): T => { try { return operation(); } catch { return fail(); } };
|
|
153
|
+
/** Exactly this shape's keys, read once as data; an accessor is refused, never invoked. */
|
|
154
|
+
const object = (value: unknown, keys: readonly string[]): Record<string, unknown> => {
|
|
155
|
+
const copy = readExactDataRecord(value, keys);
|
|
156
|
+
requireValue(copy !== null);
|
|
157
|
+
return copy;
|
|
158
|
+
};
|
|
159
|
+
const identifier = (value: unknown): string => {
|
|
160
|
+
requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(value));
|
|
161
|
+
return value;
|
|
162
|
+
};
|
|
163
|
+
const timestamp = (value: unknown): number => {
|
|
164
|
+
requireValue(typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
|
|
165
|
+
return value;
|
|
166
|
+
};
|
|
167
|
+
const tokenIdPattern = /^[a-f0-9]{64}$/;
|
|
168
|
+
const tokenPattern = /^delegatedAdminToken_[a-f0-9]{64}_[a-f0-9]{64}$/;
|
|
169
|
+
|
|
170
|
+
/** Whether a value is a delegated admin token in its exact form; it proves nothing else. */
|
|
171
|
+
export const isDelegatedAdminToken = (value: unknown): value is string => (
|
|
172
|
+
typeof value === 'string' && tokenPattern.test(value)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/** Whether a value names a token: 64 lowercase hex. */
|
|
176
|
+
export const isDelegatedAdminTokenId = (value: unknown): value is string => (
|
|
177
|
+
typeof value === 'string' && tokenIdPattern.test(value)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const secretKeyPrefixPattern = /^[A-Z][A-Z0-9]*_$/;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether a prefix may be granted: `^[A-Z][A-Z0-9]*_$`, and overlapping no reserved name in
|
|
184
|
+
* either direction: it neither starts with nor is the start of an entry of
|
|
185
|
+
* `platformReservedSecretKeyPrefixes` or `runtimeInjectionSecretKeyPrefixes`, and no entry of
|
|
186
|
+
* `runtimeInjectionSecretKeys` starts with it. So a granted prefix is application-specific:
|
|
187
|
+
* `GIT_`, `NODE_`, `NPM_` and `AWS_` (`AWS_CA_BUNDLE`) are refused; `GITSYNC_` is not.
|
|
188
|
+
*/
|
|
189
|
+
export const isDelegatableSecretKeyPrefix = (value: unknown): value is string => (
|
|
190
|
+
typeof value === 'string' && value.length <= 64 && secretKeyPrefixPattern.test(value)
|
|
191
|
+
&& [...platformReservedSecretKeyPrefixes, ...runtimeInjectionSecretKeyPrefixes]
|
|
192
|
+
.every((reservedArg) => !value.startsWith(reservedArg) && !reservedArg.startsWith(value))
|
|
193
|
+
&& runtimeInjectionSecretKeys.every((reservedArg) => !reservedArg.startsWith(value))
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whether a token granted `prefixesArg` creates, rotates, retires or revokes a secret of key
|
|
198
|
+
* `keyArg`, or delivers a launcher variable of that name: the key starts with a granted prefix and
|
|
199
|
+
* is not reserved (`isReservedSecretKey`).
|
|
200
|
+
*/
|
|
201
|
+
export const isDelegatedSecretKey = (prefixesArg: readonly string[], keyArg: string): boolean => (
|
|
202
|
+
prefixesArg.some((prefixArg) => keyArg.startsWith(prefixArg)) && !isReservedSecretKey(keyArg)
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
/** Whether a lifetime is one a mint may state. */
|
|
206
|
+
export const isDelegatedAdminTokenLifetime = (value: unknown): value is number => (
|
|
207
|
+
typeof value === 'number' && Number.isSafeInteger(value)
|
|
208
|
+
&& value >= delegatedAdminTokenContract.minimumLifetimeMs
|
|
209
|
+
&& value <= delegatedAdminTokenContract.maximumLifetimeMs
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
/** Distinct known scopes, at least one, answered in `delegatedAdminTokenScopes` order. */
|
|
213
|
+
const scopes = (value: unknown, canonical: boolean): TDelegatedAdminTokenScope[] => {
|
|
214
|
+
const entries = readDataElements(value, delegatedAdminTokenScopes.length);
|
|
215
|
+
requireValue(entries !== null && entries.length > 0 && new Set(entries).size === entries.length
|
|
216
|
+
&& entries.every((entry) => delegatedAdminTokenScopes.some((scope) => scope === entry)));
|
|
217
|
+
const ordered = delegatedAdminTokenScopes.filter((scope) => entries.includes(scope));
|
|
218
|
+
requireValue(!canonical || ordered.every((scope, index) => entries[index] === scope));
|
|
219
|
+
return ordered;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Distinct grantable prefixes, at most the cap, answered sorted; `canonical` requires them sorted. */
|
|
223
|
+
const secretKeyPrefixes = (value: unknown, canonical: boolean): string[] => {
|
|
224
|
+
const entries = readDataElements(value, delegatedAdminTokenContract.maximumSecretKeyPrefixes);
|
|
225
|
+
requireValue(entries !== null && new Set(entries).size === entries.length
|
|
226
|
+
&& entries.every((entry) => isDelegatableSecretKeyPrefix(entry)));
|
|
227
|
+
const prefixes = (entries as string[]).slice().sort();
|
|
228
|
+
requireValue(!canonical || prefixes.every((prefix, index) => entries[index] === prefix));
|
|
229
|
+
return prefixes;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** Structural copy of a mint; the backend authorizes the administrator and enforces the cap. */
|
|
233
|
+
export const snapshotDelegatedAdminTokenMint = (value: unknown): IDelegatedAdminTokenMint => safe(() => {
|
|
234
|
+
const namesLifetime = readDataProperty(value, 'lifetimeMs') !== null;
|
|
235
|
+
const namesPrefixes = readDataProperty(value, 'secretKeyPrefixes') !== null;
|
|
236
|
+
const mint = object(value, ['scopes', ...(namesLifetime ? ['lifetimeMs'] : []),
|
|
237
|
+
...(namesPrefixes ? ['secretKeyPrefixes'] : [])]);
|
|
238
|
+
const copy: IDelegatedAdminTokenMint = { scopes: scopes(mint.scopes, false) };
|
|
239
|
+
if (namesLifetime) {
|
|
240
|
+
requireValue(isDelegatedAdminTokenLifetime(mint.lifetimeMs));
|
|
241
|
+
copy.lifetimeMs = mint.lifetimeMs;
|
|
242
|
+
}
|
|
243
|
+
if (namesPrefixes) {
|
|
244
|
+
const prefixes = secretKeyPrefixes(mint.secretKeyPrefixes, false);
|
|
245
|
+
requireValue(prefixes.length === 0 || copy.scopes.includes('secrets'));
|
|
246
|
+
copy.secretKeyPrefixes = prefixes;
|
|
247
|
+
}
|
|
248
|
+
return copy;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
/** Structural copy of listed or answered metadata. */
|
|
252
|
+
export const snapshotDelegatedAdminTokenMetadata = (value: unknown): IDelegatedAdminTokenMetadata => safe(() => {
|
|
253
|
+
const metadata = object(value, ['tokenId', 'userId', 'scopes', 'secretKeyPrefixes', 'createdAt', 'expiresAt',
|
|
254
|
+
'revokedAt', 'revokedBy']);
|
|
255
|
+
requireValue(isDelegatedAdminTokenId(metadata.tokenId));
|
|
256
|
+
const createdAt = timestamp(metadata.createdAt);
|
|
257
|
+
const expiresAt = timestamp(metadata.expiresAt);
|
|
258
|
+
requireValue(expiresAt > createdAt
|
|
259
|
+
&& expiresAt - createdAt <= delegatedAdminTokenContract.maximumLifetimeMs);
|
|
260
|
+
const revoked = metadata.revokedAt !== null;
|
|
261
|
+
requireValue(revoked === (metadata.revokedBy !== null));
|
|
262
|
+
const revokedAt = revoked ? timestamp(metadata.revokedAt) : null;
|
|
263
|
+
requireValue(revokedAt === null || revokedAt >= createdAt);
|
|
264
|
+
const tokenScopes = scopes(metadata.scopes, true);
|
|
265
|
+
const prefixes = secretKeyPrefixes(metadata.secretKeyPrefixes, true);
|
|
266
|
+
requireValue(prefixes.length === 0 || tokenScopes.includes('secrets'));
|
|
267
|
+
return {
|
|
268
|
+
tokenId: metadata.tokenId, userId: identifier(metadata.userId), scopes: tokenScopes,
|
|
269
|
+
secretKeyPrefixes: prefixes, createdAt, expiresAt, revokedAt,
|
|
270
|
+
revokedBy: revoked ? identifier(metadata.revokedBy) : null,
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/** Structural copy of a mint's answer: a token in its exact form and the metadata that names it. */
|
|
275
|
+
export const snapshotDelegatedAdminTokenMintResult = (value: unknown): IDelegatedAdminTokenMintResult => safe(() => {
|
|
276
|
+
const result = object(value, ['token', 'metadata']);
|
|
277
|
+
requireValue(isDelegatedAdminToken(result.token));
|
|
278
|
+
const metadata = snapshotDelegatedAdminTokenMetadata(result.metadata);
|
|
279
|
+
requireValue(result.token.slice(delegatedAdminTokenContract.tokenPrefix.length,
|
|
280
|
+
delegatedAdminTokenContract.tokenPrefix.length + 64) === metadata.tokenId && metadata.revokedAt === null);
|
|
281
|
+
return { token: result.token, metadata };
|
|
282
|
+
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* An external registry as Cloudly stores it, credential included. Never answered: every external
|
|
5
|
+
* registry response carries `IExternalRegistryPublic`.
|
|
6
|
+
*/
|
|
3
7
|
export interface IExternalRegistry {
|
|
4
8
|
id: string;
|
|
5
9
|
data: {
|
|
@@ -24,7 +28,10 @@ export interface IExternalRegistry {
|
|
|
24
28
|
username?: string;
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
|
-
* Password, access token
|
|
31
|
+
* Password, access token or API key for authentication (optional for public registries).
|
|
32
|
+
*
|
|
33
|
+
* Write-only: Cloudly stores it and uses it server-side, and never answers it. Reads carry
|
|
34
|
+
* `IExternalRegistryPublic.passwordConfigured` instead.
|
|
28
35
|
*/
|
|
29
36
|
password?: string;
|
|
30
37
|
|
|
@@ -107,4 +114,55 @@ export interface IExternalRegistry {
|
|
|
107
114
|
*/
|
|
108
115
|
updatedAt?: number;
|
|
109
116
|
};
|
|
110
|
-
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* An external registry as Cloudly answers it: the stored registry without its credential, and
|
|
121
|
+
* whether one is stored.
|
|
122
|
+
*/
|
|
123
|
+
export interface IExternalRegistryPublic {
|
|
124
|
+
id: string;
|
|
125
|
+
data: Omit<IExternalRegistry['data'], 'password'>;
|
|
126
|
+
/** Whether a password, access token or API key is stored for the registry. */
|
|
127
|
+
passwordConfigured: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The registry fields an operator writes; Cloudly owns status, errors and timestamps. */
|
|
131
|
+
export type TExternalRegistryOperatorField =
|
|
132
|
+
| 'type'
|
|
133
|
+
| 'name'
|
|
134
|
+
| 'url'
|
|
135
|
+
| 'username'
|
|
136
|
+
| 'password'
|
|
137
|
+
| 'description'
|
|
138
|
+
| 'isDefault'
|
|
139
|
+
| 'authType'
|
|
140
|
+
| 'insecure'
|
|
141
|
+
| 'namespace'
|
|
142
|
+
| 'proxy'
|
|
143
|
+
| 'config';
|
|
144
|
+
|
|
145
|
+
export type TExternalRegistryOperatorData = Pick<IExternalRegistry['data'], TExternalRegistryOperatorField>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a registry verification proved.
|
|
149
|
+
*
|
|
150
|
+
* - `authenticated`: the registry admitted the stored credentials (Basic, or a token its Bearer
|
|
151
|
+
* realm issued for them).
|
|
152
|
+
* - `anonymous`: the registry answers without authentication and none is configured.
|
|
153
|
+
* - `credentials-not-exercised`: credentials are configured, but the registry answers without
|
|
154
|
+
* authentication, so they were never checked.
|
|
155
|
+
* - `authentication-failed`: the registry, or its token realm, refused the credentials, or it
|
|
156
|
+
* requires authentication and none is configured.
|
|
157
|
+
* - `unreachable`: no usable answer (connection, TLS, timeout or an unexpected HTTP status).
|
|
158
|
+
* - `unsupported`: the registry or its configuration cannot be verified (an npm registry, OAuth2,
|
|
159
|
+
* an unknown or missing authentication challenge, a redirect, or credentials over plain HTTP
|
|
160
|
+
* without `insecure`).
|
|
161
|
+
*/
|
|
162
|
+
export type TExternalRegistryVerifyOutcome =
|
|
163
|
+
| 'authenticated'
|
|
164
|
+
| 'anonymous'
|
|
165
|
+
| 'credentials-not-exercised'
|
|
166
|
+
| 'authentication-failed'
|
|
167
|
+
| 'unreachable'
|
|
168
|
+
| 'unsupported';
|
package/ts/data/gateway.ts
CHANGED
|
@@ -20,8 +20,16 @@ export type TManagedRouteKind = 'letsencrypt-http01-forward';
|
|
|
20
20
|
/**
|
|
21
21
|
* DNS handling requested alongside a gateway route synchronization.
|
|
22
22
|
*
|
|
23
|
-
* - 'reconcile'
|
|
24
|
-
*
|
|
23
|
+
* - 'reconcile' creates, updates and deletes the exact-host A or AAAA record
|
|
24
|
+
* the route's gateway DNS ownership holds. It never adopts, replaces or
|
|
25
|
+
* deletes a record without gateway DNS ownership. The route is satisfied by
|
|
26
|
+
* manual records only when no record at the name is owned and every manual A
|
|
27
|
+
* or AAAA record there matches the desired type, value and proxied flag (TTL
|
|
28
|
+
* ignored); anything else (a differing record, any CNAME, owned and manual
|
|
29
|
+
* records together) and an operator-protected hostname is a non-retryable
|
|
30
|
+
* `record-conflict` with no mutation. When the route stops claiming the
|
|
31
|
+
* hostname while another enabled route on the gateway serves it, the owned
|
|
32
|
+
* record is released to manual ownership instead of deleted.
|
|
25
33
|
* - 'observe' reports DNS state without mutating it.
|
|
26
34
|
* - 'skip' bypasses DNS inspection and mutation and is the default when omitted.
|
|
27
35
|
*/
|
|
@@ -29,6 +37,14 @@ export type TGatewayRouteDnsMode = 'reconcile' | 'observe' | 'skip';
|
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
39
|
* Detailed DNS outcome reported by a gateway route synchronization.
|
|
40
|
+
*
|
|
41
|
+
* - `manual-satisfied`: every manual A or AAAA record at the name matches the desired type,
|
|
42
|
+
* value and proxied flag; the gateway changes and claims nothing and reports them in
|
|
43
|
+
* `records` with `managed: false`.
|
|
44
|
+
* - `managed-released`: the route no longer claims the hostname, but another enabled route on
|
|
45
|
+
* the gateway still serves it, so the gateway released its record to manual ownership instead
|
|
46
|
+
* of deleting it; the released record is reported in `records` with `managed: false`.
|
|
47
|
+
* - `managed-overwrote-manual` and `managed-orphan-deleted`: deprecated; no gateway reports them.
|
|
32
48
|
*/
|
|
33
49
|
export type TGatewayRouteDnsStatus =
|
|
34
50
|
| 'not-applicable'
|
|
@@ -37,8 +53,10 @@ export type TGatewayRouteDnsStatus =
|
|
|
37
53
|
| 'managed-updated'
|
|
38
54
|
| 'managed-overwrote-manual'
|
|
39
55
|
| 'managed-unchanged'
|
|
56
|
+
| 'manual-satisfied'
|
|
40
57
|
| 'observed-satisfied'
|
|
41
58
|
| 'managed-deleted'
|
|
59
|
+
| 'managed-released'
|
|
42
60
|
| 'managed-orphan-deleted'
|
|
43
61
|
| 'record-missing'
|
|
44
62
|
| 'target-invalid'
|
|
@@ -77,7 +95,10 @@ export interface IGatewayRouteDnsResult {
|
|
|
77
95
|
zoneName?: string;
|
|
78
96
|
nameservers?: string[];
|
|
79
97
|
records?: IGatewayRouteDnsRecord[];
|
|
80
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Prior manual records replaced by this reconciliation, preserved as evidence.
|
|
100
|
+
* @deprecated No gateway replaces manual records; it accompanied `managed-overwrote-manual`.
|
|
101
|
+
*/
|
|
81
102
|
overwrittenRecords?: IGatewayRouteDnsRecord[];
|
|
82
103
|
/** Time at which the gateway checked the DNS state, as Unix epoch milliseconds. */
|
|
83
104
|
checkedAt: number;
|
package/ts/data/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './config.js';
|
|
|
9
9
|
export * from './corestore.js';
|
|
10
10
|
export * from './corestorecontroltoken.js';
|
|
11
11
|
export * from './corestorelegacyadoption.js';
|
|
12
|
+
export * from './delegatedadmintoken.js';
|
|
12
13
|
export * from './coremail.js';
|
|
13
14
|
export * from './coremail.runtime.js';
|
|
14
15
|
export * from './deployment.js';
|
|
@@ -82,6 +83,7 @@ export * from './servicecredential.js';
|
|
|
82
83
|
export * from './serviceplatformownership.js';
|
|
83
84
|
export * from './serviceports.js';
|
|
84
85
|
export * from './serviceruntime.js';
|
|
86
|
+
export * from './launchersecretowner.js';
|
|
85
87
|
export * from './servicevolumeinitialization.js';
|
|
86
88
|
export * from './status.js';
|
|
87
89
|
export * from './taskexecution.js';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ISecretLauncherEnvironmentDelivery } from './secret.js';
|
|
2
|
+
import type { IServiceRuntimeProfile, TServiceRuntimeRefusal } from './serviceruntime.js';
|
|
3
|
+
import { hasExactKeys, isPlainRecord } from '../private/dataproperties.js';
|
|
4
|
+
import { isRuntimeIdentityNumber, runtimePolicyLimits } from '../private/runtimepolicy.js';
|
|
5
|
+
|
|
6
|
+
/** What the owner of a workload's launcher-environment secret files derives from. */
|
|
7
|
+
export interface ILauncherSecretDeliveryOwnerInput {
|
|
8
|
+
/** The runtime spec's `profile.runAs`; null runs the image's own OCI user. */
|
|
9
|
+
runAs: IServiceRuntimeProfile['runAs'];
|
|
10
|
+
/**
|
|
11
|
+
* The unresolved OCI `config.User` of the pinned digest's image config in Cloudly's canonical
|
|
12
|
+
* form (what its registry manager's `parseCanonicalImageUser` answers, never the raw registry
|
|
13
|
+
* value): `user` or `user:group`, each part a numeric identity or a name, and null for an image
|
|
14
|
+
* that states none or an empty `User`. One entry for a single-platform manifest, one per
|
|
15
|
+
* platform of a multi-platform index; never empty.
|
|
16
|
+
*/
|
|
17
|
+
imageUsers: (string | null)[];
|
|
18
|
+
/**
|
|
19
|
+
* Every launcher-environment delivery the workload receives, as its caller states it. A `0o440`
|
|
20
|
+
* delivery grants the derived group, so that group has to be one the image states numerically.
|
|
21
|
+
*/
|
|
22
|
+
launcherDeliveries: Pick<ISecretLauncherEnvironmentDelivery, 'variableName' | 'mode'>[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The numeric owner of a workload's launcher-environment secret files, or why it derives none.
|
|
27
|
+
*
|
|
28
|
+
* `not-required` is a workload without launcher-environment deliveries: no file needs an owner, so
|
|
29
|
+
* nothing about the image refuses it.
|
|
30
|
+
*
|
|
31
|
+
* `launcher-secret-ownership-unknown` is a runtime spec that runs the image's own user
|
|
32
|
+
* (`runAs: null`) while that user's numeric owner is not known: a named user other than `root`, a
|
|
33
|
+
* numeric part above the runtime identity bound, a group-readable (`0o440`) delivery whose group
|
|
34
|
+
* the image does not state numerically, or platforms of a multi-platform index that derive
|
|
35
|
+
* different owners. `variableName` names the group-readable delivery that caused it (the first by
|
|
36
|
+
* name), null for the others; `message` states the cause.
|
|
37
|
+
*/
|
|
38
|
+
export type TLauncherSecretDeliveryOwner =
|
|
39
|
+
| { status: 'derived'; uid: number; gid: number }
|
|
40
|
+
| { status: 'not-required' }
|
|
41
|
+
| {
|
|
42
|
+
status: 'refused';
|
|
43
|
+
reason: Extract<TServiceRuntimeRefusal, 'launcher-secret-ownership-unknown'>;
|
|
44
|
+
variableName: string | null;
|
|
45
|
+
message: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const imageUserPartPattern = /^[^:\u0000-\u001f\u007f]+$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One part of an image user: a numeric identity (`root` is `0`), a numeric part above the runtime
|
|
52
|
+
* identity bound, or a name only the image's root filesystem resolves.
|
|
53
|
+
*/
|
|
54
|
+
type TImageUserPart = number | 'unbounded' | 'named';
|
|
55
|
+
|
|
56
|
+
const imageUserPart = (partArg: string): TImageUserPart => {
|
|
57
|
+
if (partArg === 'root') return 0;
|
|
58
|
+
if (!/^[0-9]+$/.test(partArg)) return 'named';
|
|
59
|
+
const identity = Number(partArg);
|
|
60
|
+
return isRuntimeIdentityNumber(identity) ? identity : 'unbounded';
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** One platform's user and group; an absent group is `undefined`. A malformed image user throws. */
|
|
64
|
+
const imageUserParts = (imageUserArg: unknown): { user: TImageUserPart; group: TImageUserPart | undefined } => {
|
|
65
|
+
if (imageUserArg === null) return { user: 0, group: 0 };
|
|
66
|
+
const parts = typeof imageUserArg === 'string' ? imageUserArg.split(':') : [];
|
|
67
|
+
if (parts.length === 0 || parts.length > 2 || !parts.every((partArg) => imageUserPartPattern.test(partArg))) {
|
|
68
|
+
throw new Error('image user must be null, user or user:group');
|
|
69
|
+
}
|
|
70
|
+
return { user: imageUserPart(parts[0]), group: parts[1] === undefined ? undefined : imageUserPart(parts[1]) };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The names of the group-readable (`0o440`) deliveries, sorted; a malformed delivery throws. */
|
|
74
|
+
const groupReadableVariableNames = (deliveriesArg: unknown): string[] => {
|
|
75
|
+
if (!Array.isArray(deliveriesArg)) throw new Error('launcherDeliveries must list the launcher deliveries');
|
|
76
|
+
const names: string[] = [];
|
|
77
|
+
for (const delivery of deliveriesArg) {
|
|
78
|
+
if (!isPlainRecord(delivery) || typeof delivery.variableName !== 'string' || delivery.variableName === ''
|
|
79
|
+
|| (delivery.mode !== 0o400 && delivery.mode !== 0o440)) {
|
|
80
|
+
throw new Error('a launcher delivery must state its variableName and a 0400 or 0440 mode');
|
|
81
|
+
}
|
|
82
|
+
if (delivery.mode === 0o440) names.push(delivery.variableName);
|
|
83
|
+
}
|
|
84
|
+
return names.sort();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const refusal = (variableNameArg: string | null, messageArg: string): TLauncherSecretDeliveryOwner => ({
|
|
88
|
+
status: 'refused', reason: 'launcher-secret-ownership-unknown', variableName: variableNameArg, message: messageArg,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The owner Cloudly gives every launcher-environment secret file of a workload, which WorkloadInit
|
|
93
|
+
* reads as the workload's own process identity before it starts the image.
|
|
94
|
+
*
|
|
95
|
+
* Without launcher deliveries it is `not-required`. A `runAs` pair wins over the image. Under
|
|
96
|
+
* `runAs: null` an image without a user runs as root (`0:0`, the runtime config's own default), a
|
|
97
|
+
* numeric `user:group` is taken as stated and `root` stands for `0` in either part. A numeric user
|
|
98
|
+
* without a group, or with a named group, is `user:0` while every delivery is owner-readable
|
|
99
|
+
* (`0o400`), which grants the group nothing; a `0o440` delivery would grant it, so it refuses and
|
|
100
|
+
* names that delivery. A named user, or a numeric part above the runtime identity bound, refuses.
|
|
101
|
+
* Every platform of a multi-platform index must derive
|
|
102
|
+
* the same owner, or it refuses: a workload gets one delivery, never one per platform. Nothing is
|
|
103
|
+
* guessed. A structurally malformed input throws, whatever it would answer.
|
|
104
|
+
*/
|
|
105
|
+
export const deriveLauncherSecretDeliveryOwner = (
|
|
106
|
+
inputArg: ILauncherSecretDeliveryOwnerInput,
|
|
107
|
+
): TLauncherSecretDeliveryOwner => {
|
|
108
|
+
const { runAs, imageUsers, launcherDeliveries } = inputArg;
|
|
109
|
+
if (runAs !== null && !(isPlainRecord(runAs) && hasExactKeys(runAs, ['user', 'group'])
|
|
110
|
+
&& isRuntimeIdentityNumber(runAs.user) && isRuntimeIdentityNumber(runAs.group))) {
|
|
111
|
+
throw new Error('runAs must be null or an exact user and group');
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(imageUsers) || imageUsers.length === 0) {
|
|
114
|
+
throw new Error('imageUsers must name the user of every platform');
|
|
115
|
+
}
|
|
116
|
+
const platforms = imageUsers.map(imageUserParts);
|
|
117
|
+
const groupReadable = groupReadableVariableNames(launcherDeliveries);
|
|
118
|
+
if (launcherDeliveries.length === 0) return { status: 'not-required' };
|
|
119
|
+
if (runAs !== null) return { status: 'derived', uid: runAs.user, gid: runAs.group };
|
|
120
|
+
const userOf = (predicateArg: (platformArg: typeof platforms[number]) => boolean): string | null | undefined => (
|
|
121
|
+
imageUsers.find((_imageUserArg, indexArg) => predicateArg(platforms[indexArg])));
|
|
122
|
+
const unbounded = userOf((platformArg) => platformArg.user === 'unbounded' || platformArg.group === 'unbounded');
|
|
123
|
+
if (unbounded !== undefined) {
|
|
124
|
+
return refusal(null, `image user '${unbounded}' states an identity above `
|
|
125
|
+
+ `${runtimePolicyLimits.maximumIdentityNumber}`);
|
|
126
|
+
}
|
|
127
|
+
const namedUser = userOf((platformArg) => platformArg.user === 'named');
|
|
128
|
+
if (namedUser !== undefined) {
|
|
129
|
+
return refusal(null, `image user '${namedUser}' names a user only the image's root filesystem resolves`);
|
|
130
|
+
}
|
|
131
|
+
const ungrouped = userOf((platformArg) => typeof platformArg.group !== 'number');
|
|
132
|
+
if (ungrouped !== undefined && groupReadable.length > 0) {
|
|
133
|
+
return refusal(groupReadable[0], `launcher-environment secret ${groupReadable.join(', ')} is group-readable `
|
|
134
|
+
+ `(0440), but image user '${ungrouped}' states no numeric group`);
|
|
135
|
+
}
|
|
136
|
+
// Every part is now a bounded number, or a group that an owner-readable file does not grant.
|
|
137
|
+
const [first, ...rest] = platforms.map((platformArg) => ({
|
|
138
|
+
uid: platformArg.user as number, gid: typeof platformArg.group === 'number' ? platformArg.group : 0,
|
|
139
|
+
}));
|
|
140
|
+
if (rest.some((ownerArg) => ownerArg.uid !== first.uid || ownerArg.gid !== first.gid)) {
|
|
141
|
+
const users = imageUsers.map((userArg) => userArg === null ? 'none' : `'${userArg}'`).join(', ');
|
|
142
|
+
return refusal(null, `the platforms' image users ${users} derive different owners`);
|
|
143
|
+
}
|
|
144
|
+
return { status: 'derived', uid: first.uid, gid: first.gid };
|
|
145
|
+
};
|