@norskvideo/ctl-sdk 0.1.37 → 0.1.39
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/index.d.ts +2 -0
- package/index.js +2 -0
- package/ingress-identity.d.ts +44 -0
- package/ingress-identity.js +47 -0
- package/manifest-fetch.d.ts +1 -1
- package/manifest-fetch.js +2 -2
- package/manifest-schema.d.ts +1 -0
- package/manifest-schema.js +3 -0
- package/package.json +1 -1
- package/product-pep.d.ts +75 -0
- package/product-pep.js +115 -0
- package/product-service.d.ts +51 -0
- package/product-service.js +56 -8
- package/proxy-middleware.d.ts +24 -1
- package/proxy-middleware.js +9 -2
package/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./dev-url.js";
|
|
|
5
5
|
export * from "./docker-runner.js";
|
|
6
6
|
export * from "./docs-router.js";
|
|
7
7
|
export * from "./http-proxy.js";
|
|
8
|
+
export * from "./ingress-identity.js";
|
|
8
9
|
export * from "./license-registration.js";
|
|
9
10
|
export * from "./license-staged.js";
|
|
10
11
|
export * from "./license-stager.js";
|
|
@@ -15,6 +16,7 @@ export * from "./migration-client.js";
|
|
|
15
16
|
export * from "./openapi-router.js";
|
|
16
17
|
export * from "./product-health-monitor.js";
|
|
17
18
|
export * from "./product-hub.js";
|
|
19
|
+
export * from "./product-pep.js";
|
|
18
20
|
export * from "./product-reach.js";
|
|
19
21
|
export * from "./product-service.js";
|
|
20
22
|
export * from "./product-template-materials.js";
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./dev-url.js";
|
|
|
7
7
|
export * from "./docker-runner.js";
|
|
8
8
|
export * from "./docs-router.js";
|
|
9
9
|
export * from "./http-proxy.js";
|
|
10
|
+
export * from "./ingress-identity.js";
|
|
10
11
|
export * from "./license-registration.js";
|
|
11
12
|
export * from "./license-staged.js";
|
|
12
13
|
export * from "./license-stager.js";
|
|
@@ -17,6 +18,7 @@ export * from "./migration-client.js";
|
|
|
17
18
|
export * from "./openapi-router.js";
|
|
18
19
|
export * from "./product-health-monitor.js";
|
|
19
20
|
export * from "./product-hub.js";
|
|
21
|
+
export * from "./product-pep.js";
|
|
20
22
|
export * from "./product-reach.js";
|
|
21
23
|
export * from "./product-service.js";
|
|
22
24
|
export * from "./product-template-materials.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a product control plane needs to believe ctl's ingress (RFC 0003 D2b).
|
|
3
|
+
*
|
|
4
|
+
* A **library, not a policy decision point**. It answers "did the ingress
|
|
5
|
+
* really say this, and who did it name?"; what a role may then do is the
|
|
6
|
+
* product's own `authorize()` (D4a), and Studio's gating lives in the
|
|
7
|
+
* norsk-studio repo.
|
|
8
|
+
*
|
|
9
|
+
* The format itself lives in `@norskvideo/ctl-foundation` so the ingress
|
|
10
|
+
* renderer and the products that read it cannot drift apart; this module is
|
|
11
|
+
* the published door onto it, plus the one thing only the launched side knows
|
|
12
|
+
* — where ctl puts the audience's credentials.
|
|
13
|
+
*/
|
|
14
|
+
export { type AssertionRefusal, AUDIENCE_HEADER, AUDIENCE_SECRET_HEADER, type AudienceAssertion, type AudienceHeaders, CTL_AUDIENCE, CTL_READINESS_PROBE_SUB, type IngressAssertion, ingressAssertionHeaders, instanceAudience, PRINCIPAL_HEADER, productAudience, ROLES_HEADER, verifyIngressAssertion, } from "@norskvideo/ctl-foundation";
|
|
15
|
+
/** The audience this container is, e.g. `urn:norsk:instance:p-1`. */
|
|
16
|
+
export declare const AUDIENCE_ENV = "NORSK_INGRESS_AUDIENCE";
|
|
17
|
+
/** The secret the ingress stamps on this audience's locations, and on no
|
|
18
|
+
* other's. */
|
|
19
|
+
export declare const AUDIENCE_SECRET_ENV = "NORSK_INGRESS_AUDIENCE_SECRET";
|
|
20
|
+
/**
|
|
21
|
+
* The mode ctl promises this control plane, and the reason it may enforce at
|
|
22
|
+
* all (RFC 0001's 2026-09-21 amendment, condition 3).
|
|
23
|
+
*
|
|
24
|
+
* Declared beside the credentials because it travels with them and means
|
|
25
|
+
* nothing apart from them: a secret without this is a gate ctl has not asked
|
|
26
|
+
* to be live, and this without a secret is a promise ctl cannot keep. Spelled
|
|
27
|
+
* once, here, because ctl writes it and the product reads it -- two spellings
|
|
28
|
+
* is how one side starts sending a word the other stopped honouring.
|
|
29
|
+
*/
|
|
30
|
+
export declare const PRODUCT_ACCESS_ENV = "NORSK_PRODUCT_ACCESS";
|
|
31
|
+
/**
|
|
32
|
+
* The audience credentials ctl injected at launch, or `undefined` when it
|
|
33
|
+
* injected none.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` is a state products must handle rather than a fault: RFC 0001
|
|
36
|
+
* keeps old ctl launching new product releases, so a product built against
|
|
37
|
+
* this will run on a runner that has never heard of it. Returning an empty
|
|
38
|
+
* secret instead would hand that product a verifier that accepts everything,
|
|
39
|
+
* which is the opposite of what the absence means.
|
|
40
|
+
*/
|
|
41
|
+
export declare function audienceFromEnvironment(env?: Record<string, string | undefined>): {
|
|
42
|
+
audience: string;
|
|
43
|
+
secret: string;
|
|
44
|
+
} | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a product control plane needs to believe ctl's ingress (RFC 0003 D2b).
|
|
3
|
+
*
|
|
4
|
+
* A **library, not a policy decision point**. It answers "did the ingress
|
|
5
|
+
* really say this, and who did it name?"; what a role may then do is the
|
|
6
|
+
* product's own `authorize()` (D4a), and Studio's gating lives in the
|
|
7
|
+
* norsk-studio repo.
|
|
8
|
+
*
|
|
9
|
+
* The format itself lives in `@norskvideo/ctl-foundation` so the ingress
|
|
10
|
+
* renderer and the products that read it cannot drift apart; this module is
|
|
11
|
+
* the published door onto it, plus the one thing only the launched side knows
|
|
12
|
+
* — where ctl puts the audience's credentials.
|
|
13
|
+
*/
|
|
14
|
+
export { AUDIENCE_HEADER, AUDIENCE_SECRET_HEADER, CTL_AUDIENCE, CTL_READINESS_PROBE_SUB, ingressAssertionHeaders, instanceAudience, PRINCIPAL_HEADER, productAudience, ROLES_HEADER, verifyIngressAssertion, } from "@norskvideo/ctl-foundation";
|
|
15
|
+
/** The audience this container is, e.g. `urn:norsk:instance:p-1`. */
|
|
16
|
+
export const AUDIENCE_ENV = "NORSK_INGRESS_AUDIENCE";
|
|
17
|
+
/** The secret the ingress stamps on this audience's locations, and on no
|
|
18
|
+
* other's. */
|
|
19
|
+
export const AUDIENCE_SECRET_ENV = "NORSK_INGRESS_AUDIENCE_SECRET";
|
|
20
|
+
/**
|
|
21
|
+
* The mode ctl promises this control plane, and the reason it may enforce at
|
|
22
|
+
* all (RFC 0001's 2026-09-21 amendment, condition 3).
|
|
23
|
+
*
|
|
24
|
+
* Declared beside the credentials because it travels with them and means
|
|
25
|
+
* nothing apart from them: a secret without this is a gate ctl has not asked
|
|
26
|
+
* to be live, and this without a secret is a promise ctl cannot keep. Spelled
|
|
27
|
+
* once, here, because ctl writes it and the product reads it -- two spellings
|
|
28
|
+
* is how one side starts sending a word the other stopped honouring.
|
|
29
|
+
*/
|
|
30
|
+
export const PRODUCT_ACCESS_ENV = "NORSK_PRODUCT_ACCESS";
|
|
31
|
+
/**
|
|
32
|
+
* The audience credentials ctl injected at launch, or `undefined` when it
|
|
33
|
+
* injected none.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` is a state products must handle rather than a fault: RFC 0001
|
|
36
|
+
* keeps old ctl launching new product releases, so a product built against
|
|
37
|
+
* this will run on a runner that has never heard of it. Returning an empty
|
|
38
|
+
* secret instead would hand that product a verifier that accepts everything,
|
|
39
|
+
* which is the opposite of what the absence means.
|
|
40
|
+
*/
|
|
41
|
+
export function audienceFromEnvironment(env = process.env) {
|
|
42
|
+
const audience = env[AUDIENCE_ENV];
|
|
43
|
+
const secret = env[AUDIENCE_SECRET_ENV];
|
|
44
|
+
if (!audience || !secret)
|
|
45
|
+
return undefined;
|
|
46
|
+
return { audience, secret };
|
|
47
|
+
}
|
package/manifest-fetch.d.ts
CHANGED
|
@@ -44,4 +44,4 @@ export declare function waitForReady(baseUrl: string, opts?: {
|
|
|
44
44
|
* Soft warnings (future) would return via the unused return-value slot
|
|
45
45
|
* here. For now both detections are hard fails.
|
|
46
46
|
*/
|
|
47
|
-
export declare function probeConfigScreen(baseUrl: string, configScreenUrl: string): Promise<void>;
|
|
47
|
+
export declare function probeConfigScreen(baseUrl: string, configScreenUrl: string, headers?: Record<string, string>): Promise<void>;
|
package/manifest-fetch.js
CHANGED
|
@@ -96,12 +96,12 @@ export async function waitForReady(baseUrl, opts = {}) {
|
|
|
96
96
|
* Soft warnings (future) would return via the unused return-value slot
|
|
97
97
|
* here. For now both detections are hard fails.
|
|
98
98
|
*/
|
|
99
|
-
export async function probeConfigScreen(baseUrl, configScreenUrl) {
|
|
99
|
+
export async function probeConfigScreen(baseUrl, configScreenUrl, headers = {}) {
|
|
100
100
|
const path = configScreenUrl.startsWith("/") ? configScreenUrl : `/${configScreenUrl}`;
|
|
101
101
|
const url = `${baseUrl}${path}`;
|
|
102
102
|
let response;
|
|
103
103
|
try {
|
|
104
|
-
response = await fetch(url, { redirect: "follow" });
|
|
104
|
+
response = await fetch(url, { redirect: "follow", headers });
|
|
105
105
|
}
|
|
106
106
|
catch (e) {
|
|
107
107
|
throw new ProductError("CONFIG_SCREEN_UNREACHABLE", `configScreenUrl '${path}' at ${baseUrl} unreachable: ${String(e)}`);
|
package/manifest-schema.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
15
15
|
version: z.ZodString;
|
|
16
16
|
minRunnerVersion: z.ZodOptional<z.ZodString>;
|
|
17
17
|
requires: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
|
+
accessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
19
|
api: z.ZodObject<{
|
|
19
20
|
basePath: z.ZodString;
|
|
20
21
|
proxyPaths: z.ZodArray<z.ZodString>;
|
package/manifest-schema.js
CHANGED
|
@@ -99,6 +99,9 @@ export const ManifestSchema = z.object({
|
|
|
99
99
|
requires: z.array(z.string().min(1)).optional().meta({
|
|
100
100
|
description: "Named runner capabilities this product version cannot launch without (RFC 0001 §3.6). A runner that lacks any of them refuses the product as too old rather than mis-launching it. Usually absent — evolve the contract additively first; never a runner version number.",
|
|
101
101
|
}),
|
|
102
|
+
accessModes: z.array(z.string().min(1)).optional().meta({
|
|
103
|
+
description: "The NORSK_PRODUCT_ACCESS values this product's control plane understands. The runner sets that variable only when the mode it is in appears here; absent, it sets nothing and the control plane runs with its gate dormant. This is the POSITIVE signal RFC 0001's 2026-09-21 amendment requires before a product may decline to serve: a product must never infer that it was promised enforcement from a missing variable, because a developer running it directly and an older runner launching it are indistinguishable from the inside. Declared on the product manifest rather than a product template because the control plane is started by `docker run` at `product add`, before any template for it exists -- templates are fetched FROM it -- and because this manifest is served by the very image that enforces, so the two cannot drift. A list rather than a boolean: the runner must never send a word this image predates.",
|
|
104
|
+
}),
|
|
102
105
|
api: z.object({
|
|
103
106
|
basePath: z.string(),
|
|
104
107
|
proxyPaths: z.array(z.string()),
|
package/package.json
CHANGED
package/product-pep.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy enforcement point a product control plane mounts — RFC 0003 D2b
|
|
3
|
+
* and D4a, the product half of what ctl asserts at `/products/<name>`.
|
|
4
|
+
*
|
|
5
|
+
* ctl's own gate on that route is coarse by construction: the route is not a
|
|
6
|
+
* contract operation, so ctl can only read the METHOD, and a product serving a
|
|
7
|
+
* state change behind a GET is not protected by it. This is where a product
|
|
8
|
+
* says what its own routes are worth — D5 keeps that judgement with the product,
|
|
9
|
+
* because only the product knows that `/configure` is builder's work and
|
|
10
|
+
* `/docs` is anyone's.
|
|
11
|
+
*
|
|
12
|
+
* **Two decisions, kept apart.** What rung a person must hold, and whether a
|
|
13
|
+
* machine may call at all, are different questions. Collapsing them means either
|
|
14
|
+
* minting a machine credential strong enough to pass a human's gate — a
|
|
15
|
+
* builder-equivalent nobody reviews — or exempting a path, which stops being
|
|
16
|
+
* true the moment the path moves. `allowWorkloads` is therefore its own opt-in,
|
|
17
|
+
* defaulting to false so a product that has not thought about machines refuses
|
|
18
|
+
* them.
|
|
19
|
+
*
|
|
20
|
+
* **Why enforcement waits for a positive signal.** RFC 0001's 2026-09-21
|
|
21
|
+
* amendment, condition 3: a product may only decline to serve when it KNOWS it
|
|
22
|
+
* was promised enforcement, from a signal the launcher sends — never from the
|
|
23
|
+
* absence of one. From inside the container, a developer running the backend
|
|
24
|
+
* directly and an older ctl launching it are indistinguishable if you reason
|
|
25
|
+
* from a missing variable, and reasoning from absence is the defect RFC 0003
|
|
26
|
+
* has shipped most often. So the gate is dormant until ctl says otherwise.
|
|
27
|
+
*/
|
|
28
|
+
import { type AudienceAssertion, type PlatformRole } from "@norskvideo/ctl-foundation";
|
|
29
|
+
import type { Request, RequestHandler } from "express";
|
|
30
|
+
/** What this control plane is doing about access, right now. `dormant` is the
|
|
31
|
+
* shape of every product before ctl promises it anything. */
|
|
32
|
+
export type ProductPepMode = "dormant" | "enforced" | "permissive";
|
|
33
|
+
export type PepDenyReason =
|
|
34
|
+
/** Told to enforce, given nothing to verify with. Never served. */
|
|
35
|
+
"ingress-identity-missing"
|
|
36
|
+
/** Nothing proved ctl sent this, so the identity headers are just headers. */
|
|
37
|
+
| "no-assertion"
|
|
38
|
+
/** Authenticated, but does not hold the rung this route asks for. */
|
|
39
|
+
| "insufficient-role"
|
|
40
|
+
/** Authenticated as a machine on a route that admits only people. */
|
|
41
|
+
| "workload-not-admitted";
|
|
42
|
+
export interface PepDecision {
|
|
43
|
+
allow: boolean;
|
|
44
|
+
reason?: PepDenyReason;
|
|
45
|
+
sub?: string;
|
|
46
|
+
roles?: readonly string[];
|
|
47
|
+
/** Whether this was served despite a refusal, because the mode is permissive. */
|
|
48
|
+
unenforced?: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface ProductPepOptions {
|
|
51
|
+
env?: Record<string, string | undefined>;
|
|
52
|
+
/** Every decision, allow and refuse alike. A record that only explains its
|
|
53
|
+
* refusals leaves the allows to be inferred — and under `permissive` the
|
|
54
|
+
* record is the entire point of the exercise. */
|
|
55
|
+
onDecision?: (decision: PepDecision) => void;
|
|
56
|
+
}
|
|
57
|
+
export interface GateOptions {
|
|
58
|
+
/** The weakest rung a PERSON must hold. Omit to require only that ctl
|
|
59
|
+
* asserted somebody. */
|
|
60
|
+
role?: PlatformRole;
|
|
61
|
+
/** Whether a machine principal may call this route at all — ctl's own
|
|
62
|
+
* config-screen probe and template fetches are the ones that matter.
|
|
63
|
+
* Defaults to false: a route that has not said yes refuses them. */
|
|
64
|
+
allowWorkloads?: boolean;
|
|
65
|
+
}
|
|
66
|
+
export interface ProductPep {
|
|
67
|
+
readonly mode: ProductPepMode;
|
|
68
|
+
/** One line an operator can read without having watched the boot — the
|
|
69
|
+
* amendment's "never silent". */
|
|
70
|
+
describe(): string;
|
|
71
|
+
gate(options?: GateOptions): RequestHandler;
|
|
72
|
+
/** What ctl asserted about this request, once a gate has admitted it. */
|
|
73
|
+
identityOf(req: Request): AudienceAssertion | undefined;
|
|
74
|
+
}
|
|
75
|
+
export declare function createProductPep(options?: ProductPepOptions): ProductPep;
|
package/product-pep.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy enforcement point a product control plane mounts — RFC 0003 D2b
|
|
3
|
+
* and D4a, the product half of what ctl asserts at `/products/<name>`.
|
|
4
|
+
*
|
|
5
|
+
* ctl's own gate on that route is coarse by construction: the route is not a
|
|
6
|
+
* contract operation, so ctl can only read the METHOD, and a product serving a
|
|
7
|
+
* state change behind a GET is not protected by it. This is where a product
|
|
8
|
+
* says what its own routes are worth — D5 keeps that judgement with the product,
|
|
9
|
+
* because only the product knows that `/configure` is builder's work and
|
|
10
|
+
* `/docs` is anyone's.
|
|
11
|
+
*
|
|
12
|
+
* **Two decisions, kept apart.** What rung a person must hold, and whether a
|
|
13
|
+
* machine may call at all, are different questions. Collapsing them means either
|
|
14
|
+
* minting a machine credential strong enough to pass a human's gate — a
|
|
15
|
+
* builder-equivalent nobody reviews — or exempting a path, which stops being
|
|
16
|
+
* true the moment the path moves. `allowWorkloads` is therefore its own opt-in,
|
|
17
|
+
* defaulting to false so a product that has not thought about machines refuses
|
|
18
|
+
* them.
|
|
19
|
+
*
|
|
20
|
+
* **Why enforcement waits for a positive signal.** RFC 0001's 2026-09-21
|
|
21
|
+
* amendment, condition 3: a product may only decline to serve when it KNOWS it
|
|
22
|
+
* was promised enforcement, from a signal the launcher sends — never from the
|
|
23
|
+
* absence of one. From inside the container, a developer running the backend
|
|
24
|
+
* directly and an older ctl launching it are indistinguishable if you reason
|
|
25
|
+
* from a missing variable, and reasoning from absence is the defect RFC 0003
|
|
26
|
+
* has shipped most often. So the gate is dormant until ctl says otherwise.
|
|
27
|
+
*/
|
|
28
|
+
import { isWorkloadPrincipal, roleSatisfies, verifyIngressAssertion, } from "@norskvideo/ctl-foundation";
|
|
29
|
+
import { AUDIENCE_ENV, AUDIENCE_SECRET_ENV, PRODUCT_ACCESS_ENV } from "./ingress-identity.js";
|
|
30
|
+
const IDENTITY = Symbol("norsk.ingress.identity");
|
|
31
|
+
function refuse(res, status, reason, message) {
|
|
32
|
+
// The SDK's route error shape: a stable label plus what happened.
|
|
33
|
+
res.status(status).json({ error: reason, message });
|
|
34
|
+
}
|
|
35
|
+
export function createProductPep(options = {}) {
|
|
36
|
+
const env = options.env ?? process.env;
|
|
37
|
+
const declared = env[PRODUCT_ACCESS_ENV];
|
|
38
|
+
const audience = env[AUDIENCE_ENV];
|
|
39
|
+
const secret = env[AUDIENCE_SECRET_ENV];
|
|
40
|
+
// An unrecognised word is treated as `enforced`, never as dormant: ctl only
|
|
41
|
+
// ever sends a mode this product named (membership, not copy), so a value we
|
|
42
|
+
// do not know means something is wrong, and the safe reading of "something is
|
|
43
|
+
// wrong" about a security control is not "carry on unguarded".
|
|
44
|
+
const mode = declared === undefined ? "dormant" : declared === "permissive" ? "permissive" : "enforced";
|
|
45
|
+
const record = (decision) => {
|
|
46
|
+
options.onDecision?.(decision);
|
|
47
|
+
return decision;
|
|
48
|
+
};
|
|
49
|
+
function describe() {
|
|
50
|
+
if (mode === "dormant") {
|
|
51
|
+
return `access control is DORMANT: ${PRODUCT_ACCESS_ENV} is not set, so this control plane was promised no enforcement and serves every caller. This is the shape of a product run directly, or launched by a runner that does not gate it.`;
|
|
52
|
+
}
|
|
53
|
+
if (audience === undefined || secret === undefined) {
|
|
54
|
+
return `access control is ${mode.toUpperCase()} but ${AUDIENCE_ENV}/${AUDIENCE_SECRET_ENV} are not both set, so nothing can be verified and every gated route refuses.`;
|
|
55
|
+
}
|
|
56
|
+
return `access control is ${mode.toUpperCase()} for ${audience}.`;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
mode,
|
|
60
|
+
describe,
|
|
61
|
+
identityOf: (req) => req[IDENTITY],
|
|
62
|
+
gate(gateOptions = {}) {
|
|
63
|
+
return (req, res, next) => {
|
|
64
|
+
if (mode === "dormant") {
|
|
65
|
+
record({ allow: true });
|
|
66
|
+
next();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Promised enforcement, handed nothing to enforce with. Refused in both
|
|
70
|
+
// modes -- `permissive` relaxes a verdict the policy reached, and this
|
|
71
|
+
// is the absence of any means to reach one.
|
|
72
|
+
if (audience === undefined || secret === undefined) {
|
|
73
|
+
record({ allow: false, reason: "ingress-identity-missing" });
|
|
74
|
+
refuse(res, 503, "ingress-identity-missing", describe());
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const served = (decision, status, message) => {
|
|
78
|
+
if (mode === "permissive") {
|
|
79
|
+
record({ ...decision, unenforced: true });
|
|
80
|
+
next();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
record(decision);
|
|
84
|
+
refuse(res, status, decision.reason, message);
|
|
85
|
+
};
|
|
86
|
+
const verified = verifyIngressAssertion(req.headers, { audience, secret });
|
|
87
|
+
if (verified.status !== "ok") {
|
|
88
|
+
served({ allow: false, reason: "no-assertion" }, 401, `this request carries no identity this control plane can trust: ${verified.error.message}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const assertion = verified.value;
|
|
92
|
+
req[IDENTITY] = assertion;
|
|
93
|
+
if (isWorkloadPrincipal(assertion.sub)) {
|
|
94
|
+
// A machine holds no human rung, so the rung is not the question and
|
|
95
|
+
// must not be consulted -- comparing would refuse every machine on a
|
|
96
|
+
// route that deliberately admits them.
|
|
97
|
+
if (gateOptions.allowWorkloads !== true) {
|
|
98
|
+
served({ allow: false, reason: "workload-not-admitted", sub: assertion.sub }, 403, `${assertion.sub} is a machine, and this route admits only people`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
record({ allow: true, sub: assertion.sub, roles: assertion.roles });
|
|
102
|
+
next();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const required = gateOptions.role;
|
|
106
|
+
if (required !== undefined && !assertion.roles.some((held) => roleSatisfies(held, required))) {
|
|
107
|
+
served({ allow: false, reason: "insufficient-role", sub: assertion.sub, roles: assertion.roles }, 403, `this route needs ${required}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
record({ allow: true, sub: assertion.sub, roles: assertion.roles });
|
|
111
|
+
next();
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
package/product-service.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ProductPlacement } from "./docker-runner.js";
|
|
2
2
|
import { type LicenseStager } from "./license-registration.js";
|
|
3
|
+
import type { Manifest } from "./manifest-schema.js";
|
|
3
4
|
import { type ProductReach } from "./product-reach.js";
|
|
4
5
|
import type { ProductTemplateSource } from "./product-template-record.js";
|
|
5
6
|
import type { ProductLicense, ProductRegistration, ProductSpec } from "./product-types.js";
|
|
@@ -125,6 +126,47 @@ export interface ProductServiceOptions {
|
|
|
125
126
|
* whose manifest `requires` any other is refused at add and reload.
|
|
126
127
|
* Default: none. */
|
|
127
128
|
knownCapabilities?: readonly string[];
|
|
129
|
+
/**
|
|
130
|
+
* The ingress identity to run this product's control plane with (RFC 0003
|
|
131
|
+
* D2b): the audience it is, and the secret the daemon stamps on requests to
|
|
132
|
+
* it and to no other audience.
|
|
133
|
+
*
|
|
134
|
+
* Deliberately NOT part of `ProductSpec`. The spec is persisted on the
|
|
135
|
+
* registration and serialised by `GET /api/v1/products`, so a secret living
|
|
136
|
+
* there would leak; this is re-derived from the host's own custody on every
|
|
137
|
+
* path that runs a container. Hosts that mint nothing return undefined and
|
|
138
|
+
* the product is launched exactly as before — the absence is a state
|
|
139
|
+
* products must handle (RFC 0001), not a fault.
|
|
140
|
+
*
|
|
141
|
+
* The manifest comes too, because the host also decides here whether to tell
|
|
142
|
+
* this control plane to ENFORCE, and that has to be read off what the product
|
|
143
|
+
* declared (`accessModes`). On `add` it is the manifest just fetched from the
|
|
144
|
+
* container; on the relaunch paths it is the stored one, so a product that
|
|
145
|
+
* starts declaring a mode is taken at its word from the reload that stores it
|
|
146
|
+
* rather than the run that precedes it. `undefined` where no manifest is known
|
|
147
|
+
* yet.
|
|
148
|
+
*/
|
|
149
|
+
identityEnvFor?: (productName: string, manifest: Manifest | undefined) => Record<string, string> | undefined;
|
|
150
|
+
/** Drop whatever `identityEnvFor` minted, once the registration holding it
|
|
151
|
+
* has gone. */
|
|
152
|
+
forgetIdentity?: (productName: string) => void;
|
|
153
|
+
/**
|
|
154
|
+
* What the daemon asserts when it dials a REGISTERED product's own routes —
|
|
155
|
+
* the config-screen probe and the product-template fetches (RFC 0003 D7's
|
|
156
|
+
* in-stack service→service row).
|
|
157
|
+
*
|
|
158
|
+
* These calls carried nothing, which is fine while no product gates anything
|
|
159
|
+
* and fatal the moment one does: `probeConfigScreen` treats a non-2xx as a
|
|
160
|
+
* hard rejection, so a product gating its own config screen would fail
|
|
161
|
+
* `product add` outright.
|
|
162
|
+
*
|
|
163
|
+
* Deliberately NOT the operator who triggered the add — this is ctl calling
|
|
164
|
+
* in its own name, so the `sub` is a workload's and it carries no human rung.
|
|
165
|
+
* `/manifest.json` is not covered and cannot be: readiness and the manifest
|
|
166
|
+
* fetch happen during the anonymous first run, before the name is known, so
|
|
167
|
+
* there is no secret to assert with.
|
|
168
|
+
*/
|
|
169
|
+
probeHeadersFor?: (productName: string) => Record<string, string> | undefined;
|
|
128
170
|
}
|
|
129
171
|
export declare class ProductService {
|
|
130
172
|
/**
|
|
@@ -150,7 +192,16 @@ export declare class ProductService {
|
|
|
150
192
|
private readonly containerOps;
|
|
151
193
|
private readonly reach;
|
|
152
194
|
private readonly knownCapabilities;
|
|
195
|
+
private readonly identityEnvFor?;
|
|
196
|
+
private readonly forgetIdentity?;
|
|
197
|
+
private readonly probeHeadersFor?;
|
|
153
198
|
constructor(opts: ProductServiceOptions);
|
|
199
|
+
/** The environment a (re)launch of `productName`'s control plane runs with:
|
|
200
|
+
* the spec's deploy-time settings, with the freshly minted ingress identity
|
|
201
|
+
* last. The ordering is load-bearing — an operator who got a reserved key
|
|
202
|
+
* past the API boundary would otherwise hand themselves a forged identity,
|
|
203
|
+
* and the identity is the daemon's decision, never theirs. */
|
|
204
|
+
private runEnvFor;
|
|
154
205
|
/** The address to record for a freshly started container: its own, when
|
|
155
206
|
* this daemon cannot dial the host loopback; otherwise nothing. */
|
|
156
207
|
private reachHostFor;
|
package/product-service.js
CHANGED
|
@@ -12,11 +12,11 @@ import { checkRequiredCapabilities } from "./runner-capabilities.js";
|
|
|
12
12
|
* declared; we treat it as a path on the product's HTTP surface and
|
|
13
13
|
* resolve against the product's base URL. Returns the raw tar bytes,
|
|
14
14
|
* or throws ProductError on any non-2xx / network failure. */
|
|
15
|
-
async function fetchProductTemplateBytes(baseUrl, url) {
|
|
15
|
+
async function fetchProductTemplateBytes(baseUrl, url, headers = {}) {
|
|
16
16
|
const full = `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
|
|
17
17
|
let response;
|
|
18
18
|
try {
|
|
19
|
-
response = await fetch(full);
|
|
19
|
+
response = await fetch(full, { headers });
|
|
20
20
|
}
|
|
21
21
|
catch (e) {
|
|
22
22
|
throw new ProductError("PRODUCT_TEMPLATE_FETCH_FAILED", `fetch ${full} threw: ${String(e)}`);
|
|
@@ -60,6 +60,9 @@ export class ProductService {
|
|
|
60
60
|
containerOps;
|
|
61
61
|
reach;
|
|
62
62
|
knownCapabilities;
|
|
63
|
+
identityEnvFor;
|
|
64
|
+
forgetIdentity;
|
|
65
|
+
probeHeadersFor;
|
|
63
66
|
constructor(opts) {
|
|
64
67
|
this.store = opts.store;
|
|
65
68
|
this.importProductTemplateBytes = opts.importProductTemplateBytes;
|
|
@@ -69,6 +72,20 @@ export class ProductService {
|
|
|
69
72
|
this.containerOps = opts.containerOps ?? defaultProductContainerOps;
|
|
70
73
|
this.reach = opts.reach ?? detectProductReach({});
|
|
71
74
|
this.knownCapabilities = opts.knownCapabilities ?? [];
|
|
75
|
+
this.identityEnvFor = opts.identityEnvFor;
|
|
76
|
+
this.forgetIdentity = opts.forgetIdentity;
|
|
77
|
+
this.probeHeadersFor = opts.probeHeadersFor;
|
|
78
|
+
}
|
|
79
|
+
/** The environment a (re)launch of `productName`'s control plane runs with:
|
|
80
|
+
* the spec's deploy-time settings, with the freshly minted ingress identity
|
|
81
|
+
* last. The ordering is load-bearing — an operator who got a reserved key
|
|
82
|
+
* past the API boundary would otherwise hand themselves a forged identity,
|
|
83
|
+
* and the identity is the daemon's decision, never theirs. */
|
|
84
|
+
runEnvFor(productName, specEnv, manifest) {
|
|
85
|
+
const identity = this.identityEnvFor?.(productName, manifest);
|
|
86
|
+
if (identity === undefined)
|
|
87
|
+
return specEnv;
|
|
88
|
+
return { ...specEnv, ...identity };
|
|
72
89
|
}
|
|
73
90
|
/** The address to record for a freshly started container: its own, when
|
|
74
91
|
* this daemon cannot dial the host loopback; otherwise nothing. */
|
|
@@ -213,6 +230,32 @@ export class ProductService {
|
|
|
213
230
|
warnings.push(...check.warnings);
|
|
214
231
|
}
|
|
215
232
|
}
|
|
233
|
+
// RFC 0003 D2b. A control plane can only be handed its own ingress
|
|
234
|
+
// identity once we know WHICH product it is, and the name comes from the
|
|
235
|
+
// manifest the container itself serves — so the first run is necessarily
|
|
236
|
+
// anonymous and the container is recreated with the identity here, after
|
|
237
|
+
// every refusal above and before anything is registered. An add that is
|
|
238
|
+
// going to be rejected therefore never mints, and never starts a second
|
|
239
|
+
// container. Dev products run no container of ours, so there is nowhere to
|
|
240
|
+
// inject the pair; they assert nothing.
|
|
241
|
+
if (containerId !== undefined && spec.kind === "container") {
|
|
242
|
+
const identity = this.identityEnvFor?.(manifest.name, manifest);
|
|
243
|
+
if (identity !== undefined) {
|
|
244
|
+
await this.containerOps.remove(containerId);
|
|
245
|
+
const placement = await this.containerOps.run(spec.image, { ...spec.env, ...identity });
|
|
246
|
+
containerId = placement.containerId;
|
|
247
|
+
port = placement.hostPort;
|
|
248
|
+
reachHost = await this.reachHostFor(containerId);
|
|
249
|
+
baseUrl = specBaseUrl(spec, port, reachHost);
|
|
250
|
+
try {
|
|
251
|
+
await this.containerOps.waitForReady(baseUrl);
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
await this.containerOps.remove(containerId);
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
216
259
|
// Now that the manifest name is known and unique, give the container a
|
|
217
260
|
// stable, human-readable name (replacing docker's random alias). Tracked
|
|
218
261
|
// by id, so a rename failure is harmless — hence best-effort.
|
|
@@ -224,7 +267,7 @@ export class ProductService {
|
|
|
224
267
|
// because iframed product UIs need the built bundle to function.
|
|
225
268
|
if (manifest.ui?.configScreenUrl) {
|
|
226
269
|
try {
|
|
227
|
-
await probeConfigScreen(baseUrl, manifest.ui.configScreenUrl);
|
|
270
|
+
await probeConfigScreen(baseUrl, manifest.ui.configScreenUrl, this.probeHeadersFor?.(manifest.name) ?? {});
|
|
228
271
|
}
|
|
229
272
|
catch (e) {
|
|
230
273
|
if (containerId)
|
|
@@ -260,7 +303,7 @@ export class ProductService {
|
|
|
260
303
|
if (manifest.defaultProductTemplates.length > 0 && this.importProductTemplateBytes) {
|
|
261
304
|
for (const entry of manifest.defaultProductTemplates) {
|
|
262
305
|
try {
|
|
263
|
-
const bytes = await fetchProductTemplateBytes(baseUrl, entry.url);
|
|
306
|
+
const bytes = await fetchProductTemplateBytes(baseUrl, entry.url, this.probeHeadersFor?.(manifest.name) ?? {});
|
|
264
307
|
await this.importProductTemplateBytes({
|
|
265
308
|
name: entry.name,
|
|
266
309
|
bytes,
|
|
@@ -291,6 +334,11 @@ export class ProductService {
|
|
|
291
334
|
if (target.containerId)
|
|
292
335
|
await this.containerOps.remove(target.containerId);
|
|
293
336
|
await this.store.update((products) => products.filter((p) => p.name !== name));
|
|
337
|
+
// RFC 0003 D2b. The container that held this product's ingress secret has
|
|
338
|
+
// gone; the store's copy would otherwise outlive it as a live credential
|
|
339
|
+
// for a product nothing routes to. Safe because every launch mints afresh,
|
|
340
|
+
// so a re-added product of the same name is stamped anew.
|
|
341
|
+
this.forgetIdentity?.(name);
|
|
294
342
|
logger.info(`Product '${name}' removed`);
|
|
295
343
|
}
|
|
296
344
|
/** Bring a registration up to date with what its image now serves. For a
|
|
@@ -322,7 +370,7 @@ export class ProductService {
|
|
|
322
370
|
// Already gone (crashed / --rm reaped) — nothing to stop.
|
|
323
371
|
}
|
|
324
372
|
}
|
|
325
|
-
const { containerId, hostPort } = await this.containerOps.run(target.spec.image, target.spec.env);
|
|
373
|
+
const { containerId, hostPort } = await this.containerOps.run(target.spec.image, this.runEnvFor(name, target.spec.env, target.manifest));
|
|
326
374
|
const reachHost = await this.reachHostFor(containerId);
|
|
327
375
|
await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
|
|
328
376
|
await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
|
|
@@ -349,7 +397,7 @@ export class ProductService {
|
|
|
349
397
|
if (this.refreshProductTemplateBytes) {
|
|
350
398
|
for (const entry of manifest.defaultProductTemplates) {
|
|
351
399
|
try {
|
|
352
|
-
const bytes = await fetchProductTemplateBytes(baseUrl, entry.url);
|
|
400
|
+
const bytes = await fetchProductTemplateBytes(baseUrl, entry.url, this.probeHeadersFor?.(manifest.name) ?? {});
|
|
353
401
|
await this.refreshProductTemplateBytes({
|
|
354
402
|
name: entry.name,
|
|
355
403
|
bytes,
|
|
@@ -416,7 +464,7 @@ export class ProductService {
|
|
|
416
464
|
return;
|
|
417
465
|
}
|
|
418
466
|
try {
|
|
419
|
-
const { containerId, hostPort } = await this.containerOps.run(reg.spec.image, reg.spec.env);
|
|
467
|
+
const { containerId, hostPort } = await this.containerOps.run(reg.spec.image, this.runEnvFor(reg.name, reg.spec.env, reg.manifest));
|
|
420
468
|
const reachHost = await this.reachHostFor(containerId);
|
|
421
469
|
await this.containerOps.waitForReady(specBaseUrl(reg.spec, hostPort, reachHost));
|
|
422
470
|
await this.containerOps.rename(containerId, productContainerName(reg.name));
|
|
@@ -465,7 +513,7 @@ export class ProductService {
|
|
|
465
513
|
// Already gone (crashed / --rm reaped) — nothing to stop.
|
|
466
514
|
}
|
|
467
515
|
}
|
|
468
|
-
const { containerId, hostPort } = await this.containerOps.run(target.spec.image, target.spec.env);
|
|
516
|
+
const { containerId, hostPort } = await this.containerOps.run(target.spec.image, this.runEnvFor(name, target.spec.env, target.manifest));
|
|
469
517
|
const reachHost = await this.reachHostFor(containerId);
|
|
470
518
|
await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
|
|
471
519
|
await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
|
package/proxy-middleware.d.ts
CHANGED
|
@@ -4,5 +4,28 @@ interface ProductRegistry {
|
|
|
4
4
|
list(): ProductRegistration[];
|
|
5
5
|
}
|
|
6
6
|
export declare function isOauth2SessionCookieName(name: string): boolean;
|
|
7
|
-
|
|
7
|
+
/** What the DAEMON asserts to a product control plane on this request (RFC
|
|
8
|
+
* 0003 D2b), or `undefined` when it has nothing to assert. */
|
|
9
|
+
export interface ProductAssertion {
|
|
10
|
+
audience: string;
|
|
11
|
+
secret: string;
|
|
12
|
+
sub: string;
|
|
13
|
+
roles: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
export interface ProductProxyOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Mint this request's identity for the product it is addressed to.
|
|
18
|
+
*
|
|
19
|
+
* **Why the daemon and not the ingress.** `/products/<name>` is proxied by
|
|
20
|
+
* the daemon, so the ingress has no location on a product's control plane to
|
|
21
|
+
* stamp — asserting there would mean routing products through nginx
|
|
22
|
+
* directly, which is a different topology. Phase 0 item 4 (E6) made this
|
|
23
|
+
* proxy strip the client's `x-norsk-*`, `Cookie` and `Authorization`, and
|
|
24
|
+
* that strip stays and runs first: what crosses here is not forwarded, it is
|
|
25
|
+
* minted from the decision the daemon has already made, and the secret is the
|
|
26
|
+
* product's own rather than `x-norsk-proxy` (root-equivalent at ctl, E2).
|
|
27
|
+
*/
|
|
28
|
+
assertIdentity?: (req: Request, productName: string) => Promise<ProductAssertion | undefined>;
|
|
29
|
+
}
|
|
30
|
+
export declare function createProductProxyMiddleware(svc: ProductRegistry, opts?: ProductProxyOptions): (req: Request, res: ExpressResponse, next: NextFunction) => Promise<void>;
|
|
8
31
|
export {};
|
package/proxy-middleware.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { logger } from "@norskvideo/ctl-foundation";
|
|
1
|
+
import { ingressAssertionHeaders, logger } from "@norskvideo/ctl-foundation";
|
|
2
2
|
import { buildForwardHeaders, forwardUpstreamResponse, readRequestBody } from "./http-proxy.js";
|
|
3
3
|
import { productBaseUrl } from "./manifest-fetch.js";
|
|
4
4
|
function targetBaseUrl(reg) {
|
|
@@ -42,7 +42,7 @@ function buildProductForwardHeaders(req) {
|
|
|
42
42
|
}
|
|
43
43
|
return headers;
|
|
44
44
|
}
|
|
45
|
-
export function createProductProxyMiddleware(svc) {
|
|
45
|
+
export function createProductProxyMiddleware(svc, opts = {}) {
|
|
46
46
|
return async (req, res, next) => {
|
|
47
47
|
const name = req.params.name;
|
|
48
48
|
if (typeof name !== "string" || !name)
|
|
@@ -59,7 +59,14 @@ export function createProductProxyMiddleware(svc) {
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
const targetUrl = `${base}${req.url}`;
|
|
62
|
+
// Strip first, always. The assertion below then SETS its own values, so a
|
|
63
|
+
// client's copies are gone whether or not there is anything to assert.
|
|
62
64
|
const headers = buildProductForwardHeaders(req);
|
|
65
|
+
const asserted = await opts.assertIdentity?.(req, name);
|
|
66
|
+
if (asserted !== undefined) {
|
|
67
|
+
for (const [name, value] of Object.entries(ingressAssertionHeaders(asserted)))
|
|
68
|
+
headers.set(name, value);
|
|
69
|
+
}
|
|
63
70
|
const body = await readRequestBody(req);
|
|
64
71
|
let upstream;
|
|
65
72
|
try {
|