feat(data-plane): add signed managed writer bindings
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { validateDataProductPatch, validateDataProductSnapshot } from "@nodedc/external-provider-contract";
|
||||
import { validateDataProductPatch, validateDataProductSnapshot } from "@nodedc/external-provider-contract/data-plane";
|
||||
import {
|
||||
MANAGED_PROVISIONER_HEADERS,
|
||||
managedProvisionerSigningPayload,
|
||||
sha256RawBody,
|
||||
} from "../src/managed-provisioner-auth.mjs";
|
||||
|
||||
const databaseUrl = process.env.EXTERNAL_DATA_PLANE_TEST_DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("EXTERNAL_DATA_PLANE_TEST_DATABASE_URL_required");
|
||||
@@ -13,7 +19,17 @@ const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-api-test-"));
|
||||
const provisionerSecret = "nodedc_edp_integration_provisioner_secret_0123456789abcdef";
|
||||
const secretPath = join(directory, "provisioner-token");
|
||||
const managedProvisionerServiceId = "nodedc-engine";
|
||||
const managedProvisionerKeyId = "engine-edp-managed-provisioner-v1";
|
||||
const managedProvisionerAudience = "nodedc-external-data-plane.managed-provisioning.v1";
|
||||
const managedProvisionerPublicKeyPath = join(directory, "engine-public-key.pem");
|
||||
const { privateKey: managedProvisionerPrivateKey, publicKey: managedProvisionerPublicKey } = generateKeyPairSync("ed25519");
|
||||
await writeFile(secretPath, `${provisionerSecret}\n`, { mode: 0o600 });
|
||||
await writeFile(
|
||||
managedProvisionerPublicKeyPath,
|
||||
managedProvisionerPublicKey.export({ type: "spki", format: "pem" }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["src/server.mjs"], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
@@ -22,7 +38,12 @@ const child = spawn(process.execPath, ["src/server.mjs"], {
|
||||
PORT: String(port),
|
||||
EXTERNAL_DATA_PLANE_DATABASE_URL: databaseUrl,
|
||||
EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED: "true",
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED: "true",
|
||||
EXTERNAL_DATA_PLANE_PROVISIONER_TOKEN_FILE: secretPath,
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_PUBLIC_KEY_FILE: managedProvisionerPublicKeyPath,
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID: managedProvisionerServiceId,
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID: managedProvisionerKeyId,
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE: managedProvisionerAudience,
|
||||
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS: "60000",
|
||||
EXTERNAL_DATA_PLANE_STREAM_POLL_MS: "250",
|
||||
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS: "5000",
|
||||
@@ -52,10 +73,139 @@ try {
|
||||
|
||||
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
const scope = { tenantId: "api-tenant", connectionId: "api-connection", providerId: "api-provider" };
|
||||
const managedToken = "ndc_edpwb_managed_api_test_0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
const managedBody = {
|
||||
source: scope,
|
||||
allowedDataProductIds: [productId],
|
||||
expiresAt: expiry,
|
||||
generation: 1,
|
||||
capabilityDigest: createHash("sha256").update(managedToken, "utf8").digest("hex"),
|
||||
};
|
||||
const managedPath = "/internal/data-plane/v1/writer-bindings/by-key/engine.api-connection.positions";
|
||||
const bearerOnlyManaged = await fetch(`${baseUrl}${managedPath}`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${provisionerSecret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(managedBody),
|
||||
});
|
||||
assert.equal(bearerOnlyManaged.status, 401);
|
||||
assert.equal((await bearerOnlyManaged.json()).error, "managed_provisioner_header_invalid");
|
||||
|
||||
const managedResults = await Promise.all([
|
||||
rawJsonRequest(managedPath, {
|
||||
method: "PUT",
|
||||
managedSignature: true,
|
||||
body: managedBody,
|
||||
}),
|
||||
rawJsonRequest(managedPath, {
|
||||
method: "PUT",
|
||||
managedSignature: true,
|
||||
body: managedBody,
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(managedResults.map(({ value }) => value.idempotent).sort(), [false, true]);
|
||||
assert.equal(managedResults[0].value.writerBinding.id, managedResults[1].value.writerBinding.id);
|
||||
assert.equal("token" in managedResults[0].value, false);
|
||||
assert.equal(JSON.stringify(managedResults[0].value).includes(managedBody.capabilityDigest), false);
|
||||
const managedCatalog = await jsonRequest("/internal/data-plane/v1/writer/data-products", { token: managedToken });
|
||||
assert.deepEqual(managedCatalog.dataProducts.map((value) => value.id), [productId]);
|
||||
const replayBody = JSON.stringify(managedBody);
|
||||
const replayHeaders = signedManagedHeaders(managedPath, "PUT", Buffer.from(replayBody, "utf8"));
|
||||
const firstReplayAttempt = await fetch(`${baseUrl}${managedPath}`, {
|
||||
method: "PUT", headers: { ...replayHeaders, "Content-Type": "application/json" }, body: replayBody,
|
||||
});
|
||||
assert.equal(firstReplayAttempt.status, 200);
|
||||
const rejectedReplay = await fetch(`${baseUrl}${managedPath}`, {
|
||||
method: "PUT", headers: { ...replayHeaders, "Content-Type": "application/json" }, body: replayBody,
|
||||
});
|
||||
assert.equal(rejectedReplay.status, 409);
|
||||
assert.equal((await rejectedReplay.json()).error, "managed_provisioner_request_replayed");
|
||||
|
||||
const tamperHeaders = signedManagedHeaders(managedPath, "PUT", Buffer.from(replayBody, "utf8"));
|
||||
const tamperedBody = await fetch(`${baseUrl}${managedPath}`, {
|
||||
method: "PUT",
|
||||
headers: { ...tamperHeaders, "Content-Type": "application/json" },
|
||||
body: `${replayBody} `,
|
||||
});
|
||||
assert.equal(tamperedBody.status, 401);
|
||||
assert.equal((await tamperedBody.json()).error, "managed_provisioner_body_hash_mismatch");
|
||||
|
||||
const pathHeaders = signedManagedHeaders(managedPath, "PUT", Buffer.from(replayBody, "utf8"));
|
||||
const tamperedPath = await fetch(`${baseUrl}${managedPath}?unexpected=true`, {
|
||||
method: "PUT", headers: { ...pathHeaders, "Content-Type": "application/json" }, body: replayBody,
|
||||
});
|
||||
assert.equal(tamperedPath.status, 401);
|
||||
assert.equal((await tamperedPath.json()).error, "managed_provisioner_signature_invalid");
|
||||
|
||||
const audienceHeaders = signedManagedHeaders(managedPath, "PUT", Buffer.from(replayBody, "utf8"));
|
||||
audienceHeaders[MANAGED_PROVISIONER_HEADERS.audience] = "other-audience";
|
||||
const wrongAudience = await fetch(`${baseUrl}${managedPath}`, {
|
||||
method: "PUT", headers: { ...audienceHeaders, "Content-Type": "application/json" }, body: replayBody,
|
||||
});
|
||||
assert.equal(wrongAudience.status, 401);
|
||||
assert.equal((await wrongAudience.json()).error, "managed_provisioner_identity_mismatch");
|
||||
|
||||
const managedConflict = await signedFetch(managedPath, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
...managedBody,
|
||||
capabilityDigest: createHash("sha256").update(`${managedToken}-different`, "utf8").digest("hex"),
|
||||
},
|
||||
});
|
||||
assert.equal(managedConflict.status, 409);
|
||||
assert.equal((await managedConflict.json()).error, "managed_writer_binding_request_conflict");
|
||||
|
||||
const legacyManagedRevoke = await fetch(
|
||||
`${baseUrl}/internal/data-plane/v1/writer-bindings/${managedResults[0].value.writerBinding.id}/revoke`,
|
||||
{ method: "POST", headers: { Authorization: `Bearer ${provisionerSecret}` } },
|
||||
);
|
||||
assert.equal(legacyManagedRevoke.status, 404);
|
||||
|
||||
const managedTokenGeneration2 = `${managedToken}_generation_2`;
|
||||
const managedGeneration2 = await jsonRequest(
|
||||
"/internal/data-plane/v1/writer-bindings/by-key/engine.api-connection.positions",
|
||||
{
|
||||
method: "PUT",
|
||||
managedSignature: true,
|
||||
body: {
|
||||
...managedBody,
|
||||
generation: 2,
|
||||
capabilityDigest: createHash("sha256").update(managedTokenGeneration2, "utf8").digest("hex"),
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(managedGeneration2.writerBinding.generation, 2);
|
||||
assert.deepEqual(
|
||||
(await jsonRequest("/internal/data-plane/v1/writer/data-products", { token: managedTokenGeneration2 })).dataProducts.map((value) => value.id),
|
||||
[productId],
|
||||
);
|
||||
const managedRevoke = await jsonRequest(
|
||||
"/internal/data-plane/v1/writer-bindings/by-key/engine.api-connection.positions/generations/1/revoke",
|
||||
{ method: "POST", managedSignature: true },
|
||||
);
|
||||
assert.equal(managedRevoke.idempotent, false);
|
||||
assert.equal(managedRevoke.writerBinding.active, false);
|
||||
const managedRevokeRetry = await jsonRequest(
|
||||
"/internal/data-plane/v1/writer-bindings/by-key/engine.api-connection.positions/generations/1/revoke",
|
||||
{ method: "POST", managedSignature: true },
|
||||
);
|
||||
assert.equal(managedRevokeRetry.idempotent, true);
|
||||
const rejectedManagedGeneration1 = await fetch(`${baseUrl}/internal/data-plane/v1/writer/data-products`, {
|
||||
headers: { Authorization: `Bearer ${managedToken}` },
|
||||
});
|
||||
assert.equal(rejectedManagedGeneration1.status, 401);
|
||||
|
||||
const manualWriterBody = { source: scope, allowedDataProductIds: [productId], expiresAt: expiry };
|
||||
const signedOnlyManualProvisioning = await signedFetch("/internal/data-plane/v1/writer-bindings", {
|
||||
method: "POST",
|
||||
body: manualWriterBody,
|
||||
});
|
||||
assert.equal(signedOnlyManualProvisioning.status, 401);
|
||||
assert.equal((await signedOnlyManualProvisioning.json()).error, "provisioner_unauthorized");
|
||||
|
||||
const writerIssuance = await rawJsonRequest("/internal/data-plane/v1/writer-bindings", {
|
||||
method: "POST",
|
||||
token: provisionerSecret,
|
||||
body: { source: scope, allowedDataProductIds: [productId], expiresAt: expiry },
|
||||
body: manualWriterBody,
|
||||
});
|
||||
const readerIssuance = await rawJsonRequest("/internal/data-plane/v1/reader-bindings", {
|
||||
method: "POST",
|
||||
@@ -176,26 +326,66 @@ function publish(runId, observedAt, longitude) {
|
||||
};
|
||||
}
|
||||
|
||||
async function jsonRequest(path, { method = "GET", token, body } = {}) {
|
||||
const { response, value } = await rawJsonRequest(path, { method, token, body });
|
||||
async function jsonRequest(path, { method = "GET", token, managedSignature = false, body } = {}) {
|
||||
const { response, value } = await rawJsonRequest(path, { method, token, managedSignature, body });
|
||||
if (!response.ok) throw new Error(`request_failed:${response.status}:${value.error}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function rawJsonRequest(path, { method = "GET", token, body } = {}) {
|
||||
async function rawJsonRequest(path, { method = "GET", token, managedSignature = false, body } = {}) {
|
||||
const rawBody = body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body), "utf8");
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
...(managedSignature ? signedManagedHeaders(path, method, rawBody) : {}),
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: body === undefined ? undefined : rawBody,
|
||||
});
|
||||
const value = await response.json();
|
||||
if (!response.ok) throw new Error(`request_failed:${response.status}:${value.error}`);
|
||||
return { response, value };
|
||||
}
|
||||
|
||||
async function signedFetch(path, { method, body } = {}) {
|
||||
const rawBody = body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body), "utf8");
|
||||
return fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...signedManagedHeaders(path, method, rawBody),
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : rawBody,
|
||||
});
|
||||
}
|
||||
|
||||
function signedManagedHeaders(path, method, rawBody) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const bodySha256 = sha256RawBody(rawBody);
|
||||
const payload = managedProvisionerSigningPayload({
|
||||
audience: managedProvisionerAudience,
|
||||
serviceId: managedProvisionerServiceId,
|
||||
keyId: managedProvisionerKeyId,
|
||||
method,
|
||||
path,
|
||||
timestamp,
|
||||
nonce,
|
||||
bodySha256,
|
||||
});
|
||||
const signature = sign(null, Buffer.from(payload, "utf8"), managedProvisionerPrivateKey).toString("base64url");
|
||||
return {
|
||||
[MANAGED_PROVISIONER_HEADERS.serviceId]: managedProvisionerServiceId,
|
||||
[MANAGED_PROVISIONER_HEADERS.keyId]: managedProvisionerKeyId,
|
||||
[MANAGED_PROVISIONER_HEADERS.audience]: managedProvisionerAudience,
|
||||
[MANAGED_PROVISIONER_HEADERS.timestamp]: timestamp,
|
||||
[MANAGED_PROVISIONER_HEADERS.nonce]: nonce,
|
||||
[MANAGED_PROVISIONER_HEADERS.bodySha256]: bodySha256,
|
||||
[MANAGED_PROVISIONER_HEADERS.signature]: signature,
|
||||
};
|
||||
}
|
||||
|
||||
function assertOneTimeCapabilityResponse(response) {
|
||||
assert.equal(response.headers.get("cache-control"), "no-store, max-age=0");
|
||||
assert.equal(response.headers.get("pragma"), "no-cache");
|
||||
|
||||
Reference in New Issue
Block a user