@sequenceholdings/artifact-studio 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -6
- package/VERSION-PIN.md +56 -1
- package/dist/gateway/admission.d.ts +23 -1
- package/dist/gateway/admission.js +30 -13
- package/dist/gateway/control.d.ts +40 -0
- package/dist/gateway/control.js +76 -7
- package/dist/gateway/hosted-proxy.d.ts +11 -8
- package/dist/gateway/hosted-proxy.js +99 -91
- package/dist/gateway/ingress-admission.d.ts +77 -0
- package/dist/gateway/ingress-admission.js +150 -0
- package/dist/gateway/ingress-audit.d.ts +46 -0
- package/dist/gateway/ingress-audit.js +84 -0
- package/dist/gateway/ingress-context.d.ts +57 -0
- package/dist/gateway/ingress-context.js +57 -0
- package/dist/gateway/ingress-development.d.ts +12 -0
- package/dist/gateway/ingress-development.js +23 -0
- package/dist/gateway/ingress-handler.d.ts +44 -0
- package/dist/gateway/ingress-handler.js +320 -0
- package/dist/gateway/ingress-pass.d.ts +83 -0
- package/dist/gateway/ingress-pass.js +143 -0
- package/dist/gateway/ingress-response.d.ts +74 -0
- package/dist/gateway/ingress-response.js +98 -0
- package/dist/gateway/ingress-runtime.d.ts +86 -0
- package/dist/gateway/ingress-runtime.js +126 -0
- package/dist/gateway/ingress-snapshot.d.ts +54 -0
- package/dist/gateway/ingress-snapshot.js +63 -0
- package/dist/gateway/ingress-stages.d.ts +60 -0
- package/dist/gateway/ingress-stages.js +387 -0
- package/dist/gateway/ingress.d.ts +259 -0
- package/dist/gateway/ingress.js +204 -0
- package/dist/gateway/lattice-invoke.js +2 -0
- package/dist/gateway/lattice-upstream.d.ts +3 -4
- package/dist/gateway/lattice-upstream.js +11 -9
- package/dist/gateway/runtime-transport.d.ts +17 -0
- package/dist/gateway/runtime-transport.js +26 -0
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.js +21 -0
- package/dist/gateway/session-response.d.ts +15 -0
- package/dist/gateway/session-response.js +40 -0
- package/dist/gateway/usage.d.ts +8 -1
- package/dist/gateway-main.js +32 -8
- package/dist/manifest-next-ingress.d.ts +5 -2
- package/dist/manifest-next-ingress.js +27 -2
- package/dist/manifest-next.d.ts +20 -6
- package/dist/manifest-next.js +13 -5
- package/dist/manifest.d.ts +7 -2
- package/dist/next-project.d.ts +4 -0
- package/dist/sdk.d.ts +12 -3
- package/dist/server/broker-contract.d.ts +62 -0
- package/dist/server/broker-contract.js +43 -1
- package/dist/server/client.js +38 -13
- package/dist/server/external-context.d.ts +8 -1
- package/dist/server/external-context.js +7 -2
- package/dist/server/internal/v1-context.js +17 -1
- package/dist/server/internal/verify-any.d.ts +11 -2
- package/dist/server/internal/verify-any.js +11 -3
- package/dist/server/process-run-sharing.d.ts +86 -0
- package/dist/server/process-run-sharing.js +29 -0
- package/dist/server/process-runs-page.d.ts +39 -0
- package/dist/server/process-runs-page.js +10 -0
- package/dist/server/processes.d.ts +8 -9
- package/dist/server/processes.js +4 -0
- package/dist/server/request-context.js +10 -3
- package/dist/server/runtime-config.d.ts +10 -1
- package/dist/server/runtime-config.js +11 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/tunnel/broker.d.ts +18 -0
- package/dist/tunnel/broker.js +175 -17
- package/dist/tunnel/client.d.ts +13 -0
- package/dist/tunnel/client.js +40 -2
- package/dist/tunnel/external.d.ts +51 -0
- package/dist/tunnel/external.js +10 -0
- package/dist/tunnel/index.d.ts +1 -1
- package/dist/tunnel/protocol.d.ts +20 -2
- package/dist/tunnel/protocol.js +42 -6
- package/package.json +4 -4
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway's local copy of every enabled ingress host (design §(e) "Host snapshot"). It is
|
|
3
|
+
* pulled from `control/ingress-snapshot` on a timer and never on request, so a request for a
|
|
4
|
+
* random hostname costs a map lookup and can never amplify into an Atlas call.
|
|
5
|
+
*/
|
|
6
|
+
export const INGRESS_SNAPSHOT_INTERVAL_MS = 15_000;
|
|
7
|
+
/** Older than this (Atlas unreachable for eight pulls), every ingress request answers 503. */
|
|
8
|
+
export const INGRESS_SNAPSHOT_STALE_AFTER_MS = 120_000;
|
|
9
|
+
export class IngressSnapshotCache {
|
|
10
|
+
options;
|
|
11
|
+
hosts = new Map();
|
|
12
|
+
snapshot;
|
|
13
|
+
loadedAt;
|
|
14
|
+
pending;
|
|
15
|
+
timer;
|
|
16
|
+
intervalMs;
|
|
17
|
+
staleAfterMs;
|
|
18
|
+
now;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.intervalMs = options.intervalMs ?? INGRESS_SNAPSHOT_INTERVAL_MS;
|
|
22
|
+
this.staleAfterMs = options.staleAfterMs ?? INGRESS_SNAPSHOT_STALE_AFTER_MS;
|
|
23
|
+
this.now = options.now ?? Date.now;
|
|
24
|
+
}
|
|
25
|
+
/** Pulls now and then every interval; the timer never keeps the process alive. */
|
|
26
|
+
start() {
|
|
27
|
+
if (this.timer)
|
|
28
|
+
return;
|
|
29
|
+
void this.refresh();
|
|
30
|
+
this.timer = setInterval(() => { void this.refresh(); }, this.intervalMs);
|
|
31
|
+
this.timer.unref();
|
|
32
|
+
}
|
|
33
|
+
stop() {
|
|
34
|
+
clearInterval(this.timer);
|
|
35
|
+
this.timer = undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Single-flight: concurrent callers share one pull. Never rejects. */
|
|
38
|
+
refresh() {
|
|
39
|
+
this.pending ??= this.options.load().then((snapshot) => {
|
|
40
|
+
this.snapshot = snapshot;
|
|
41
|
+
this.hosts = new Map(snapshot.hosts.map((entry) => [entry.host, entry]));
|
|
42
|
+
this.loadedAt = this.now();
|
|
43
|
+
}, () => {
|
|
44
|
+
this.options.onError?.({ kind: 'ingress_snapshot_failed' });
|
|
45
|
+
}).finally(() => { this.pending = undefined; });
|
|
46
|
+
return this.pending;
|
|
47
|
+
}
|
|
48
|
+
/** `host` must already be the canonical raw Host. A local map lookup; never a network call. */
|
|
49
|
+
lookup(host) {
|
|
50
|
+
const entry = this.hosts.get(host);
|
|
51
|
+
if (this.loadedAt === undefined || this.now() - this.loadedAt > this.staleAfterMs)
|
|
52
|
+
return { state: 'stale', entry };
|
|
53
|
+
return entry ? { state: 'fresh', entry } : { state: 'unknown' };
|
|
54
|
+
}
|
|
55
|
+
/** The non-secret per-provider summary the exchange precheck reads; undefined until a pull succeeds. */
|
|
56
|
+
get providers() {
|
|
57
|
+
return this.snapshot?.providers;
|
|
58
|
+
}
|
|
59
|
+
/** The last pulled snapshot's policy epoch; a newer one on a grant asks for a pull now. */
|
|
60
|
+
get policyEpoch() {
|
|
61
|
+
return this.snapshot?.policyEpoch;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IngressSnapshot } from '../server/ingress-control-contract.js';
|
|
2
|
+
import { type ArtifactRequestAdmission } from './admission.js';
|
|
3
|
+
import { type ArtifactGatewayControl } from './control.js';
|
|
4
|
+
import { type IngressAdmission } from './ingress-admission.js';
|
|
5
|
+
import { type IngressPassKeyring } from './ingress-pass.js';
|
|
6
|
+
import type { IngressSnapshotCache } from './ingress-snapshot.js';
|
|
7
|
+
import { type IngressHandlers } from './ingress.js';
|
|
8
|
+
/**
|
|
9
|
+
* The authenticated half of the external ingress (design "First call" steps 3-5, "Steady state"
|
|
10
|
+
* steps 4-8, §(d), §(e) "Rate limiting and admission"): pass exchange, logout, and for API routes
|
|
11
|
+
* local pass verification, admission, grant, freshness, concurrency and the signed v2 context.
|
|
12
|
+
* Order matters: nothing a forged credential presents reaches Redis or Atlas, and garbage is only
|
|
13
|
+
* ever counted, never audited (design §(g) "Gateway denials").
|
|
14
|
+
*/
|
|
15
|
+
/** Exchange request bodies above this are refused unread past the limit, before admission. */
|
|
16
|
+
export declare const INGRESS_EXCHANGE_MAX_BODY_BYTES = 1024;
|
|
17
|
+
/** Routes with a body cap at or below this have the body buffered before a concurrency permit. */
|
|
18
|
+
export declare const INGRESS_BUFFERED_BODY_MAX_BYTES: number;
|
|
19
|
+
export interface IngressStagesOptions {
|
|
20
|
+
control: Pick<ArtifactGatewayControl, 'ingressExchange' | 'ingressGrant' | 'ingressRevoke'>;
|
|
21
|
+
snapshot: Pick<IngressSnapshotCache, 'providers' | 'policyEpoch' | 'refresh'>;
|
|
22
|
+
keyring: IngressPassKeyring;
|
|
23
|
+
/** The gateway's external context key; never the browser one. */
|
|
24
|
+
externalContext: {
|
|
25
|
+
privateKey: string;
|
|
26
|
+
keyId: string;
|
|
27
|
+
};
|
|
28
|
+
/** Hosted request concurrency (ACQUIRE-v2). */
|
|
29
|
+
admission: Pick<ArtifactRequestAdmission, 'acquire'>;
|
|
30
|
+
ingressAdmission: Pick<IngressAdmission, 'preflight' | 'exchange' | 'logout' | 'request' | 'withPool'>;
|
|
31
|
+
/** The gateway's APP_ENV: stamped into every pass and required of every pass it verifies. */
|
|
32
|
+
appEnv: string;
|
|
33
|
+
/** Epoch milliseconds. */
|
|
34
|
+
now?: () => number;
|
|
35
|
+
/** Reuse of a read grant (default 5 s, the authorization cache TTL). */
|
|
36
|
+
grantCacheTtlMs?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function subjectHashOf(subject: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* The structural precheck before any Redis or Atlas call (design "First call" step 3): three
|
|
41
|
+
* base64url segments, an `alg` among the provider's, a `kid`, and the provider's `iss`. No crypto
|
|
42
|
+
* and no JWKS: Atlas verifies the token. Returns the token's `exp`, which Atlas verified with it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function precheckIdentityToken({ token, provider }: {
|
|
45
|
+
token: string;
|
|
46
|
+
provider: IngressSnapshot['providers'][string];
|
|
47
|
+
}): {
|
|
48
|
+
exp: number | null;
|
|
49
|
+
} | null;
|
|
50
|
+
export declare function createIngressStages(options: IngressStagesOptions): Required<Pick<IngressHandlers, 'preflight' | 'exchange' | 'logout' | 'admit'>>;
|
|
51
|
+
/**
|
|
52
|
+
* The usage actor: the context's `ext|<provider>|<subject>` when Atlas's actor grammar accepts it
|
|
53
|
+
* (no whitespace or control characters, ≤ 255), else the subject's hash, so one odd subject can
|
|
54
|
+
* never fail a whole usage batch.
|
|
55
|
+
*/
|
|
56
|
+
export declare function usageActor({ provider, subject, subjectHash }: {
|
|
57
|
+
provider: string;
|
|
58
|
+
subject: string;
|
|
59
|
+
subjectHash: string;
|
|
60
|
+
}): string;
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { artifactNextIngressIdSchema } from '../manifest-next-ingress.js';
|
|
4
|
+
import { INGRESS_ROUTE_METHODS } from '../server/ingress-routes.js';
|
|
5
|
+
import { ArtifactAdmissionError } from './admission.js';
|
|
6
|
+
import { PositiveDecisionCache } from './authorization-cache.js';
|
|
7
|
+
import { IngressControlError } from './control.js';
|
|
8
|
+
import { ingressClientBucket } from './ingress-admission.js';
|
|
9
|
+
import { externalSubject, observeUserAgent, signExternalGatewayContext } from './ingress-context.js';
|
|
10
|
+
import { IngressPassTooLargeError, mintPass, verifyPass } from './ingress-pass.js';
|
|
11
|
+
import { INGRESS_RESPONSE_SECURITY_HEADERS } from './ingress-response.js';
|
|
12
|
+
import { ingressCorsHeaders, IngressError, } from './ingress.js';
|
|
13
|
+
/**
|
|
14
|
+
* The authenticated half of the external ingress (design "First call" steps 3-5, "Steady state"
|
|
15
|
+
* steps 4-8, §(d), §(e) "Rate limiting and admission"): pass exchange, logout, and for API routes
|
|
16
|
+
* local pass verification, admission, grant, freshness, concurrency and the signed v2 context.
|
|
17
|
+
* Order matters: nothing a forged credential presents reaches Redis or Atlas, and garbage is only
|
|
18
|
+
* ever counted, never audited (design §(g) "Gateway denials").
|
|
19
|
+
*/
|
|
20
|
+
/** Exchange request bodies above this are refused unread past the limit, before admission. */
|
|
21
|
+
export const INGRESS_EXCHANGE_MAX_BODY_BYTES = 1024;
|
|
22
|
+
/** Routes with a body cap at or below this have the body buffered before a concurrency permit. */
|
|
23
|
+
export const INGRESS_BUFFERED_BODY_MAX_BYTES = 1024 * 1024;
|
|
24
|
+
/** A grant-triggered snapshot pull happens at most this often. */
|
|
25
|
+
const POLICY_REFRESH_MIN_INTERVAL_MS = 1_000;
|
|
26
|
+
/** Only GET reads reuse a grant; HEAD never matches a declared route (INGRESS_ROUTE_METHODS). */
|
|
27
|
+
const READ_METHODS = new Set(['GET']);
|
|
28
|
+
/** Pass refusals whose MAC held: an authentic pass refused by a decision, audited. The rest is garbage. */
|
|
29
|
+
const AUTHENTIC_PASS_REFUSALS = new Set(['expired', 'not_yet_valid', 'wrong_origin', 'wrong_environment']);
|
|
30
|
+
const exchangeBodySchema = z.strictObject({ ingress: artifactNextIngressIdSchema });
|
|
31
|
+
export function subjectHashOf(subject) {
|
|
32
|
+
return createHash('sha256').update(subject).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A refused pass (design "Steady state" step 4): always counted; audited only when its MAC held,
|
|
36
|
+
* so a forged pass costs a local HMAC and a counter increment, never an Atlas audit write.
|
|
37
|
+
*/
|
|
38
|
+
function passRefused({ reason, authentic, detail = {} }) {
|
|
39
|
+
return new IngressError(401, 'invalid_token', { metric: 'ingress_pass_rejected', ...(authentic ? { audit: { reason, ...detail } } : {}) });
|
|
40
|
+
}
|
|
41
|
+
/** An identity token refused by the structural precheck: garbage, counted and never audited. */
|
|
42
|
+
function tokenRefused() {
|
|
43
|
+
return new IngressError(401, 'invalid_token', { metric: 'ingress_token_rejected' });
|
|
44
|
+
}
|
|
45
|
+
/** The single `Authorization: Bearer <credential>` value, or null. The handler already refused repeats and oversize. */
|
|
46
|
+
function bearer(request) {
|
|
47
|
+
const value = request.headers.authorization;
|
|
48
|
+
const match = typeof value === 'string' ? /^Bearer ([A-Za-z0-9._~+/=-]+)$/i.exec(value) : null;
|
|
49
|
+
return match ? match[1] : null;
|
|
50
|
+
}
|
|
51
|
+
function decodeJsonSegment(segment) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const jwsHeaderSchema = z.object({ alg: z.string(), kid: z.string().min(1).max(256) });
|
|
60
|
+
const jwsPayloadSchema = z.object({ iss: z.string(), exp: z.number().int().positive().optional() });
|
|
61
|
+
/**
|
|
62
|
+
* The structural precheck before any Redis or Atlas call (design "First call" step 3): three
|
|
63
|
+
* base64url segments, an `alg` among the provider's, a `kid`, and the provider's `iss`. No crypto
|
|
64
|
+
* and no JWKS: Atlas verifies the token. Returns the token's `exp`, which Atlas verified with it.
|
|
65
|
+
*/
|
|
66
|
+
export function precheckIdentityToken({ token, provider }) {
|
|
67
|
+
const parts = token.split('.');
|
|
68
|
+
if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)))
|
|
69
|
+
return null;
|
|
70
|
+
const header = jwsHeaderSchema.safeParse(decodeJsonSegment(parts[0]));
|
|
71
|
+
const payload = jwsPayloadSchema.safeParse(decodeJsonSegment(parts[1]));
|
|
72
|
+
if (!header.success || !payload.success)
|
|
73
|
+
return null;
|
|
74
|
+
if (!provider.algorithms.includes(header.data.alg) || payload.data.iss !== provider.issuer)
|
|
75
|
+
return null;
|
|
76
|
+
return { exp: payload.data.exp ?? null };
|
|
77
|
+
}
|
|
78
|
+
/** The body, read up to `maxBytes`; beyond it 413 with nothing more read. */
|
|
79
|
+
async function readBounded({ request, maxBytes }) {
|
|
80
|
+
const declared = Number(request.headers['content-length']);
|
|
81
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
82
|
+
throw new IngressError(413, 'payload_too_large');
|
|
83
|
+
const chunks = [];
|
|
84
|
+
let length = 0;
|
|
85
|
+
for await (const chunk of request) {
|
|
86
|
+
const bytes = Buffer.from(chunk);
|
|
87
|
+
length += bytes.length;
|
|
88
|
+
if (length > maxBytes)
|
|
89
|
+
throw new IngressError(413, 'payload_too_large');
|
|
90
|
+
chunks.push(bytes);
|
|
91
|
+
}
|
|
92
|
+
return Buffer.concat(chunks);
|
|
93
|
+
}
|
|
94
|
+
/** A refusal an Atlas control route returned, as the caller sees it; never audited by the gateway. */
|
|
95
|
+
function fromControl(error) {
|
|
96
|
+
if (error instanceof IngressControlError && error.code !== null) {
|
|
97
|
+
return new IngressError(error.status, error.code, error.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: error.retryAfterSeconds });
|
|
98
|
+
}
|
|
99
|
+
if (error instanceof ArtifactAdmissionError)
|
|
100
|
+
return fromAdmission(error);
|
|
101
|
+
// A bare failure, a timeout or an unparseable answer: an outage the caller may retry.
|
|
102
|
+
return new IngressError(503, 'temporarily_unavailable', error instanceof IngressControlError && error.retryAfterSeconds !== undefined ? { retryAfterSeconds: error.retryAfterSeconds } : {});
|
|
103
|
+
}
|
|
104
|
+
/** A rate limit or a Redis outage: a metric, never an audit event (a flood or an outage would become one per request). */
|
|
105
|
+
function fromAdmission(error) {
|
|
106
|
+
return error.status === 429
|
|
107
|
+
? new IngressError(429, 'rate_limited', { retryAfterSeconds: error.retryAfterSeconds ?? 1, metric: 'admission_rejected' })
|
|
108
|
+
: new IngressError(503, 'temporarily_unavailable', { retryAfterSeconds: error.retryAfterSeconds ?? 2, metric: 'admission_rejected' });
|
|
109
|
+
}
|
|
110
|
+
async function admitted(work) {
|
|
111
|
+
try {
|
|
112
|
+
return await work();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error instanceof ArtifactAdmissionError)
|
|
116
|
+
throw fromAdmission(error);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** A buffered body as the stream the proxy forwards. */
|
|
121
|
+
async function* replay(body) {
|
|
122
|
+
if (body.length > 0)
|
|
123
|
+
yield body;
|
|
124
|
+
}
|
|
125
|
+
function writeJson({ response, status, body, corsOrigin }) {
|
|
126
|
+
response.writeHead(status, {
|
|
127
|
+
...INGRESS_RESPONSE_SECURITY_HEADERS,
|
|
128
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
129
|
+
...(corsOrigin ? ingressCorsHeaders(corsOrigin) : {}),
|
|
130
|
+
});
|
|
131
|
+
response.end(body === undefined ? undefined : JSON.stringify(body));
|
|
132
|
+
}
|
|
133
|
+
/** The caller's origin for CORS on a success, or a 403 when this ingress does not allow it. */
|
|
134
|
+
function ingressOrigin({ origin, ingress }) {
|
|
135
|
+
if (origin === null)
|
|
136
|
+
return null;
|
|
137
|
+
if (!ingress.allowedOrigins.includes(origin))
|
|
138
|
+
throw new IngressError(403, 'forbidden', { audit: { reason: 'origin_not_allowed', ingress: ingress.id } });
|
|
139
|
+
return origin;
|
|
140
|
+
}
|
|
141
|
+
function grantCacheKey(parts) {
|
|
142
|
+
return createHash('sha256').update(JSON.stringify(['ingress-grant-v1', ...parts])).digest('hex');
|
|
143
|
+
}
|
|
144
|
+
export function createIngressStages(options) {
|
|
145
|
+
const now = options.now ?? Date.now;
|
|
146
|
+
const seconds = () => Math.floor(now() / 1000);
|
|
147
|
+
const grants = new PositiveDecisionCache({ ttlMs: options.grantCacheTtlMs, now });
|
|
148
|
+
let lastPolicyRefresh = Number.NEGATIVE_INFINITY;
|
|
149
|
+
/** Local pass verification (design "Steady state" step 4): no Redis, no Atlas; failure is 401, uncharged. */
|
|
150
|
+
function verifiedPass({ request, publicOrigin, entry }) {
|
|
151
|
+
const token = bearer(request);
|
|
152
|
+
if (!token)
|
|
153
|
+
throw passRefused({ reason: 'pass_missing', authentic: false });
|
|
154
|
+
const verification = verifyPass({ keyring: options.keyring, token, now: seconds(), origin: publicOrigin, env: options.appEnv });
|
|
155
|
+
if (!verification.ok)
|
|
156
|
+
throw passRefused({ reason: `pass_${verification.reason}`, authentic: AUTHENTIC_PASS_REFUSALS.has(verification.reason) });
|
|
157
|
+
const { payload } = verification;
|
|
158
|
+
const ingress = entry.ingresses.find((candidate) => candidate.id === payload.ingress && candidate.provider === payload.provider);
|
|
159
|
+
if (!ingress || payload.projectId !== entry.projectId)
|
|
160
|
+
throw passRefused({ reason: 'pass_wrong_ingress', authentic: true, detail: { passId: payload.jti } });
|
|
161
|
+
return { pass: payload, kid: verification.kid, ingress };
|
|
162
|
+
}
|
|
163
|
+
async function preflight({ clientIp }) {
|
|
164
|
+
await admitted(() => options.ingressAdmission.preflight({ clientIp }));
|
|
165
|
+
}
|
|
166
|
+
async function exchange(input) {
|
|
167
|
+
const { request, response, host, publicOrigin, entry, clientIp, origin, requestId } = input;
|
|
168
|
+
// Read, bounded, before admission (design §(e) "Limits").
|
|
169
|
+
if (request.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
|
|
170
|
+
throw new IngressError(400, 'bad_request');
|
|
171
|
+
const raw = await readBounded({ request, maxBytes: INGRESS_EXCHANGE_MAX_BODY_BYTES });
|
|
172
|
+
let parsedBody;
|
|
173
|
+
try {
|
|
174
|
+
parsedBody = JSON.parse(raw.toString('utf8'));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new IngressError(400, 'bad_request');
|
|
178
|
+
}
|
|
179
|
+
const body = exchangeBodySchema.safeParse(parsedBody);
|
|
180
|
+
if (!body.success)
|
|
181
|
+
throw new IngressError(400, 'bad_request');
|
|
182
|
+
const ingress = entry.ingresses.find((candidate) => candidate.id === body.data.ingress);
|
|
183
|
+
if (!ingress)
|
|
184
|
+
throw new IngressError(404, 'not_found');
|
|
185
|
+
const corsOrigin = ingressOrigin({ origin, ingress });
|
|
186
|
+
const token = bearer(request);
|
|
187
|
+
if (!token)
|
|
188
|
+
throw tokenRefused();
|
|
189
|
+
const provider = options.snapshot.providers?.[ingress.provider];
|
|
190
|
+
if (!provider)
|
|
191
|
+
throw new IngressError(503, 'temporarily_unavailable');
|
|
192
|
+
const precheck = precheckIdentityToken({ token, provider });
|
|
193
|
+
if (!precheck)
|
|
194
|
+
throw tokenRefused();
|
|
195
|
+
const release = await admitted(() => options.ingressAdmission.exchange({ clientIp, host }));
|
|
196
|
+
let verified;
|
|
197
|
+
try {
|
|
198
|
+
verified = await options.control.ingressExchange({
|
|
199
|
+
host, ingress: ingress.id, origin, token, clientIp, userAgent: observeUserAgent(request.headers['user-agent']).userAgent, requestId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
throw fromControl(error);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
release();
|
|
207
|
+
}
|
|
208
|
+
const { principal } = verified;
|
|
209
|
+
// The context's `sub` is `ext|<provider>|<subject>`, at most 255 characters (design §(f)).
|
|
210
|
+
const denied = (reason) => new IngressError(403, 'exchange_denied', { audit: { reason, ingress: ingress.id, subjectHash: subjectHashOf(principal.subject) } });
|
|
211
|
+
if (externalSubject({ provider: ingress.provider, subject: principal.subject }).length > 255)
|
|
212
|
+
throw denied('subject_too_long');
|
|
213
|
+
const iat = seconds();
|
|
214
|
+
const exp = Math.min(precheck.exp ?? Number.POSITIVE_INFINITY, iat + verified.passTtlSeconds);
|
|
215
|
+
if (exp <= iat)
|
|
216
|
+
throw new IngressError(401, 'invalid_token', { audit: { reason: 'token_expired', ingress: ingress.id } });
|
|
217
|
+
let pass;
|
|
218
|
+
try {
|
|
219
|
+
pass = mintPass({ keyring: options.keyring, payload: {
|
|
220
|
+
v: 1, typ: 'artifact-ingress-pass', env: options.appEnv, origin: publicOrigin, projectId: entry.projectId,
|
|
221
|
+
ingress: ingress.id, provider: ingress.provider, bep: verified.bindingEpoch, ...principal,
|
|
222
|
+
exchangeIp: clientIp, jti: randomUUID(), iat, exp,
|
|
223
|
+
} });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
// A principal near the schema maxima never fits: a decided refusal, not an outage to retry.
|
|
227
|
+
if (error instanceof IngressPassTooLargeError)
|
|
228
|
+
throw denied('pass_too_large');
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
// `expiresAt` is epoch milliseconds, as the BAP's other token clocks are.
|
|
232
|
+
writeJson({ response, status: 200, body: { token: pass, expiresAt: exp * 1000 }, corsOrigin });
|
|
233
|
+
}
|
|
234
|
+
async function logout(input) {
|
|
235
|
+
const { response, host, clientIp, origin } = input;
|
|
236
|
+
const { pass, ingress } = verifiedPass(input);
|
|
237
|
+
const corsOrigin = ingressOrigin({ origin, ingress });
|
|
238
|
+
const subjectHash = subjectHashOf(pass.subject);
|
|
239
|
+
const release = await admitted(() => options.ingressAdmission.logout({ provider: pass.provider, subjectHash, clientIp }));
|
|
240
|
+
try {
|
|
241
|
+
await options.control.ingressRevoke({ host, ingress: ingress.id, provider: pass.provider, subject: pass.subject, passJti: pass.jti, tokenIssuedAt: pass.tokenIssuedAt });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
throw fromControl(error);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
release();
|
|
248
|
+
}
|
|
249
|
+
writeJson({ response, status: 204, corsOrigin });
|
|
250
|
+
}
|
|
251
|
+
/** A grant for this exact (route, method), cached ≤ 5 s for reads only, under an `ingress-grant` permit. */
|
|
252
|
+
async function grantFor({ input, pass, subjectHash }) {
|
|
253
|
+
const method = input.request.method ?? 'GET';
|
|
254
|
+
const body = {
|
|
255
|
+
host: input.host, ingress: input.ingress.id, provider: input.ingress.provider, subjectHash, tokenIssuedAt: pass.tokenIssuedAt,
|
|
256
|
+
passJti: pass.jti, method: z.enum(INGRESS_ROUTE_METHODS).parse(method), routeId: input.match.routeId,
|
|
257
|
+
};
|
|
258
|
+
// The snapshot's target is part of the key: after an `artifact dev` restart a refreshed
|
|
259
|
+
// snapshot names the new session, so a read grant cached for the old one must miss.
|
|
260
|
+
const { target } = input.entry;
|
|
261
|
+
const lookup = {
|
|
262
|
+
key: grantCacheKey([body.host, body.ingress, subjectHash, body.passJti, body.routeId, body.method, target.kind === 'development' ? `development:${target.sessionId}` : 'published']),
|
|
263
|
+
load: () => options.ingressAdmission.withPool('ingress-grant', () => options.control.ingressGrant(body)),
|
|
264
|
+
notAfter: (grant) => grant.expiresAt,
|
|
265
|
+
};
|
|
266
|
+
try {
|
|
267
|
+
// Mutations always ask Atlas: revocation, a disabled binding or a new release stop them at once.
|
|
268
|
+
return READ_METHODS.has(method) ? await grants.get(lookup) : await grants.refresh(lookup);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
throw fromControl(error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Pulls the snapshot now, at most once a second. A cache-buster only, never a reason to deny. */
|
|
275
|
+
function refreshSnapshot() {
|
|
276
|
+
if (now() - lastPolicyRefresh < POLICY_REFRESH_MIN_INTERVAL_MS)
|
|
277
|
+
return;
|
|
278
|
+
lastPolicyRefresh = now();
|
|
279
|
+
void options.snapshot.refresh();
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The grant's release, or 503 when it does not match the snapshot entry it was asked for: a
|
|
283
|
+
* published grant only for a published target, and a development grant only on a development
|
|
284
|
+
* gateway (its own APP_ENV, never a value from the request or Atlas), for the snapshot's own
|
|
285
|
+
* development session (design §(j.3) steps 3-4). Never proxied over either session id alone:
|
|
286
|
+
* after an `artifact dev` restart inside one poll the two differ, so the request is refused and
|
|
287
|
+
* the snapshot pulled at once.
|
|
288
|
+
*/
|
|
289
|
+
function releaseOf({ grant, entry }) {
|
|
290
|
+
const unavailable = () => { refreshSnapshot(); return new IngressError(503, 'temporarily_unavailable'); };
|
|
291
|
+
if (grant.mode === 'published') {
|
|
292
|
+
if (entry.target.kind !== 'published')
|
|
293
|
+
throw unavailable();
|
|
294
|
+
return { mode: 'published', deploymentId: grant.deploymentId };
|
|
295
|
+
}
|
|
296
|
+
if (options.appEnv !== 'development')
|
|
297
|
+
throw new IngressError(503, 'temporarily_unavailable');
|
|
298
|
+
if (entry.target.kind !== 'development' || entry.target.sessionId !== grant.devSessionId)
|
|
299
|
+
throw unavailable();
|
|
300
|
+
return { mode: 'development', devSessionId: grant.devSessionId };
|
|
301
|
+
}
|
|
302
|
+
/** Route freshness (design §(h)): `fresh_auth_seconds` against the pass's sign-in time, else a step-up 401. */
|
|
303
|
+
function requireFreshAuth({ pass, freshness, detail }) {
|
|
304
|
+
if (freshness > 0 && (pass.authTime === null || seconds() - pass.authTime > freshness)) {
|
|
305
|
+
throw new IngressError(401, 'insufficient_user_authentication', {
|
|
306
|
+
wwwAuthenticate: `Bearer error="insufficient_user_authentication", max_age=${freshness}`,
|
|
307
|
+
audit: { reason: 'step_up_required', ...detail },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** A grant for this request that a context may still be signed with, and its release, else 503 before signing. */
|
|
312
|
+
async function usableGrant({ input, pass, detail }) {
|
|
313
|
+
const grant = await grantFor({ input, pass, subjectHash: detail.subjectHash });
|
|
314
|
+
if (grant.projectId !== input.entry.projectId || grant.expiresAt <= now())
|
|
315
|
+
throw new IngressError(503, 'temporarily_unavailable');
|
|
316
|
+
if (grant.policyEpoch > (options.snapshot.policyEpoch ?? 0))
|
|
317
|
+
refreshSnapshot();
|
|
318
|
+
const release = releaseOf({ grant, entry: input.entry });
|
|
319
|
+
// A deployment that does not trust the gateway's current kid would refuse the context: 503 before signing.
|
|
320
|
+
if (!grant.trustedExternalKids.includes(options.externalContext.keyId))
|
|
321
|
+
throw new IngressError(503, 'temporarily_unavailable', { audit: { reason: 'untrusted_external_kid', ...detail } });
|
|
322
|
+
return { grant, release };
|
|
323
|
+
}
|
|
324
|
+
async function admit(input) {
|
|
325
|
+
const { request, entry, ingress, match, clientIp, origin, requestId, signedPath } = input;
|
|
326
|
+
const { pass, kid: passKid, ingress: passIngress } = verifiedPass(input);
|
|
327
|
+
// A pass is good for its own ingress's routes only, whatever other ingress the host carries.
|
|
328
|
+
if (passIngress.id !== ingress.id)
|
|
329
|
+
throw passRefused({ reason: 'pass_wrong_ingress', authentic: true, detail: { ingress: ingress.id, passId: pass.jti } });
|
|
330
|
+
const subjectHash = subjectHashOf(pass.subject);
|
|
331
|
+
const detail = { ingress: ingress.id, subjectHash, passId: pass.jti };
|
|
332
|
+
const freshness = match.route.fresh_auth_seconds ?? 0;
|
|
333
|
+
// Freshness needs only the verified pass: refused early, before any Redis count or Atlas grant.
|
|
334
|
+
requireFreshAuth({ pass, freshness, detail });
|
|
335
|
+
await admitted(() => options.ingressAdmission.request({ provider: pass.provider, subjectHash, projectId: entry.projectId, ingressId: ingress.id }));
|
|
336
|
+
let { grant, release } = await usableGrant({ input, pass, detail });
|
|
337
|
+
const maxBodyBytes = match.route.max_body_bytes;
|
|
338
|
+
let body;
|
|
339
|
+
if (maxBodyBytes <= INGRESS_BUFFERED_BODY_MAX_BYTES) {
|
|
340
|
+
body = replay(await readBounded({ request, maxBytes: maxBodyBytes }));
|
|
341
|
+
// A slow upload can outlive the pass, the route's freshness window and the grant: decide all
|
|
342
|
+
// three again before signing, so nothing is forwarded on an authorization that has lapsed.
|
|
343
|
+
if (pass.exp <= seconds())
|
|
344
|
+
throw passRefused({ reason: 'pass_expired', authentic: true, detail: { ingress: ingress.id, passId: pass.jti } });
|
|
345
|
+
requireFreshAuth({ pass, freshness, detail });
|
|
346
|
+
if (grant.expiresAt <= now())
|
|
347
|
+
({ grant, release } = await usableGrant({ input, pass, detail }));
|
|
348
|
+
}
|
|
349
|
+
// A development session takes the deployment's place in the concurrency keys.
|
|
350
|
+
const releaseId = release.mode === 'published' ? release.deploymentId : release.devSessionId;
|
|
351
|
+
const lease = await admitted(() => options.admission.acquire({ environment: grant.environment, projectId: grant.projectId, deploymentId: releaseId, resources: grant.resources }, { ingress: { id: ingress.id, subjectHash, clientBucket: ingressClientBucket(clientIp) } }));
|
|
352
|
+
let requestContext;
|
|
353
|
+
try {
|
|
354
|
+
requestContext = signExternalGatewayContext({
|
|
355
|
+
privateKey: options.externalContext.privateKey, keyId: options.externalContext.keyId,
|
|
356
|
+
projectId: grant.projectId, environment: grant.environment, release, requestId,
|
|
357
|
+
method: request.method ?? 'GET', path: signedPath, serviceAccountId: grant.serviceAccountId, pass, passKid,
|
|
358
|
+
client: { ip: clientIp, ...observeUserAgent(request.headers['user-agent']), origin }, now: seconds(),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
void lease.release().catch(() => undefined);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
const attribution = { actorId: usageActor({ provider: pass.provider, subject: pass.subject, subjectHash }), ingressId: ingress.id, projectId: grant.projectId, environment: grant.environment };
|
|
366
|
+
return {
|
|
367
|
+
target: grant.mode === 'published'
|
|
368
|
+
? { upstreamUrl: grant.upstreamUrl, upstreamAudience: grant.upstreamAudience, projectId: grant.projectId, deploymentId: grant.deploymentId }
|
|
369
|
+
: { kind: 'development', projectId: grant.projectId, environment: grant.environment, sessionId: grant.devSessionId },
|
|
370
|
+
requestContext,
|
|
371
|
+
timeoutMs: lease.timeoutMs,
|
|
372
|
+
bind: (response, abort) => { lease.bind(response, abort); },
|
|
373
|
+
...(body ? { body } : {}),
|
|
374
|
+
usage: release.mode === 'published' ? { ...attribution, deploymentId: release.deploymentId } : { ...attribution, devSessionId: release.devSessionId },
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return { preflight, exchange, logout, admit };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The usage actor: the context's `ext|<provider>|<subject>` when Atlas's actor grammar accepts it
|
|
381
|
+
* (no whitespace or control characters, ≤ 255), else the subject's hash, so one odd subject can
|
|
382
|
+
* never fail a whole usage batch.
|
|
383
|
+
*/
|
|
384
|
+
export function usageActor({ provider, subject, subjectHash }) {
|
|
385
|
+
const actor = externalSubject({ provider, subject });
|
|
386
|
+
return actor.length <= 255 && /^[^\s\p{Cc}]+$/u.test(actor) ? actor : externalSubject({ provider, subject: `sha256:${subjectHash}` });
|
|
387
|
+
}
|