feat(edp): provision Foundry reader grants
This commit is contained in:
@@ -24,12 +24,22 @@ 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");
|
||||
const foundryProvisionerServiceId = "nodedc-module-foundry";
|
||||
const foundryProvisionerKeyId = "foundry-edp-managed-provisioner-v1";
|
||||
const foundryProvisionerAudience = "nodedc-external-data-plane.managed-provisioning.v1";
|
||||
const foundryProvisionerPublicKeyPath = join(directory, "foundry-public-key.pem");
|
||||
const { privateKey: foundryProvisionerPrivateKey, publicKey: foundryProvisionerPublicKey } = generateKeyPairSync("ed25519");
|
||||
await writeFile(secretPath, `${provisionerSecret}\n`, { mode: 0o600 });
|
||||
await writeFile(
|
||||
managedProvisionerPublicKeyPath,
|
||||
managedProvisionerPublicKey.export({ type: "spki", format: "pem" }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await writeFile(
|
||||
foundryProvisionerPublicKeyPath,
|
||||
foundryProvisionerPublicKey.export({ type: "spki", format: "pem" }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["src/server.mjs"], {
|
||||
cwd: new URL("..", import.meta.url),
|
||||
@@ -44,6 +54,11 @@ const child = spawn(process.execPath, ["src/server.mjs"], {
|
||||
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_FOUNDRY_PROVISIONING_ENABLED: "true",
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PUBLIC_KEY_FILE: foundryProvisionerPublicKeyPath,
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID: foundryProvisionerServiceId,
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID: foundryProvisionerKeyId,
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE: foundryProvisionerAudience,
|
||||
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS: "60000",
|
||||
EXTERNAL_DATA_PLANE_STREAM_POLL_MS: "250",
|
||||
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS: "5000",
|
||||
@@ -249,6 +264,62 @@ try {
|
||||
});
|
||||
assert.equal(rejectedManagedReader.status, 401);
|
||||
|
||||
const foundryPlanPath = "/internal/data-plane/v1/consumer-reader-bindings/plan";
|
||||
const foundryPlanBody = { allowedDataProductIds: [productId] };
|
||||
const engineCannotProvisionFoundryGrant = await signedFetch(foundryPlanPath, {
|
||||
method: "POST",
|
||||
body: foundryPlanBody,
|
||||
});
|
||||
assert.equal(engineCannotProvisionFoundryGrant.status, 401);
|
||||
assert.equal((await engineCannotProvisionFoundryGrant.json()).error, "managed_provisioner_identity_mismatch");
|
||||
const foundryPlan = await jsonRequest(foundryPlanPath, {
|
||||
method: "POST",
|
||||
foundrySignature: true,
|
||||
body: foundryPlanBody,
|
||||
});
|
||||
assert.equal(foundryPlan.sourceScope, "resolved-server-side");
|
||||
assert.deepEqual(foundryPlan.dataProducts.map((value) => value.id), [productId]);
|
||||
assert.equal(/tenant|provider|connection/i.test(JSON.stringify(foundryPlan)), false);
|
||||
|
||||
const foundryReaderToken = "ndc_edprb_foundry_api_test_0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
const foundryReaderBody = {
|
||||
allowedDataProductIds: [productId],
|
||||
expiresAt: null,
|
||||
generation: 1,
|
||||
capabilityDigest: createHash("sha256").update(foundryReaderToken, "utf8").digest("hex"),
|
||||
};
|
||||
const foundryReaderPath = "/internal/data-plane/v1/consumer-reader-bindings/by-key/fndrc-api-test-positions";
|
||||
const foundryReaderResults = await Promise.all([
|
||||
rawJsonRequest(foundryReaderPath, { method: "PUT", foundrySignature: true, body: foundryReaderBody }),
|
||||
rawJsonRequest(foundryReaderPath, { method: "PUT", foundrySignature: true, body: foundryReaderBody }),
|
||||
]);
|
||||
assert.deepEqual(foundryReaderResults.map(({ value }) => value.idempotent).sort(), [false, true]);
|
||||
assert.equal(foundryReaderResults[0].value.readerBinding.id, foundryReaderResults[1].value.readerBinding.id);
|
||||
assert.equal(JSON.stringify(foundryReaderResults[0].value).includes(foundryReaderBody.capabilityDigest), false);
|
||||
assert.equal(/tenant|provider|connection/i.test(JSON.stringify(foundryReaderResults[0].value)), false);
|
||||
assert.equal("token" in foundryReaderResults[0].value, false);
|
||||
const foundrySnapshot = await jsonRequest(
|
||||
`/internal/data-plane/v1/data-products/${productId}/snapshot`,
|
||||
{ token: foundryReaderToken },
|
||||
);
|
||||
assert.equal(validateDataProductSnapshot(foundrySnapshot).ok, true);
|
||||
assert.equal(foundrySnapshot.facts.length, 1);
|
||||
const foundryRevoke = await jsonRequest(`${foundryReaderPath}/generations/1/revoke`, {
|
||||
method: "POST",
|
||||
foundrySignature: true,
|
||||
});
|
||||
assert.equal(foundryRevoke.idempotent, false);
|
||||
assert.equal(foundryRevoke.readerBinding.active, false);
|
||||
const foundryRevokeRetry = await jsonRequest(`${foundryReaderPath}/generations/1/revoke`, {
|
||||
method: "POST",
|
||||
foundrySignature: true,
|
||||
});
|
||||
assert.equal(foundryRevokeRetry.idempotent, true);
|
||||
const rejectedFoundryReader = await fetch(`${baseUrl}/internal/data-plane/v1/reader/data-products`, {
|
||||
headers: { Authorization: `Bearer ${foundryReaderToken}` },
|
||||
});
|
||||
assert.equal(rejectedFoundryReader.status, 401);
|
||||
|
||||
const secondWriterToken = "ndc_edpwb_second_source_api_test_0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
await jsonRequest("/internal/data-plane/v1/writer-bindings/by-key/engine.api-connection-2.positions", {
|
||||
method: "PUT",
|
||||
@@ -259,6 +330,12 @@ try {
|
||||
capabilityDigest: createHash("sha256").update(secondWriterToken, "utf8").digest("hex"),
|
||||
},
|
||||
});
|
||||
const ambiguousFoundryPlan = await foundrySignedFetch(foundryPlanPath, {
|
||||
method: "POST",
|
||||
body: foundryPlanBody,
|
||||
});
|
||||
assert.equal(ambiguousFoundryPlan.status, 409);
|
||||
assert.equal((await ambiguousFoundryPlan.json()).error, "managed_consumer_reader_source_scope_ambiguous");
|
||||
const ambiguousReaderToken = "ndc_edprb_ambiguous_api_test_0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
const ambiguousReader = await signedFetch(
|
||||
"/internal/data-plane/v1/reader-bindings/by-key/engine.api-ambiguous.positions-reader",
|
||||
@@ -413,19 +490,20 @@ function publish(runId, observedAt, longitude) {
|
||||
};
|
||||
}
|
||||
|
||||
async function jsonRequest(path, { method = "GET", token, managedSignature = false, body } = {}) {
|
||||
const { response, value } = await rawJsonRequest(path, { method, token, managedSignature, body });
|
||||
async function jsonRequest(path, { method = "GET", token, managedSignature = false, foundrySignature = false, body } = {}) {
|
||||
const { response, value } = await rawJsonRequest(path, { method, token, managedSignature, foundrySignature, body });
|
||||
if (!response.ok) throw new Error(`request_failed:${response.status}:${value.error}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function rawJsonRequest(path, { method = "GET", token, managedSignature = false, body } = {}) {
|
||||
async function rawJsonRequest(path, { method = "GET", token, managedSignature = false, foundrySignature = 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}` } : {}),
|
||||
...(managedSignature ? signedManagedHeaders(path, method, rawBody) : {}),
|
||||
...(foundrySignature ? signedFoundryHeaders(path, method, rawBody) : {}),
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : rawBody,
|
||||
@@ -447,6 +525,18 @@ async function signedFetch(path, { method, body } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function foundrySignedFetch(path, { method, body } = {}) {
|
||||
const rawBody = body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body), "utf8");
|
||||
return fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...signedFoundryHeaders(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");
|
||||
@@ -473,6 +563,44 @@ function signedManagedHeaders(path, method, rawBody) {
|
||||
};
|
||||
}
|
||||
|
||||
function signedFoundryHeaders(path, method, rawBody) {
|
||||
return signedHeaders({
|
||||
path,
|
||||
method,
|
||||
rawBody,
|
||||
audience: foundryProvisionerAudience,
|
||||
serviceId: foundryProvisionerServiceId,
|
||||
keyId: foundryProvisionerKeyId,
|
||||
privateKey: foundryProvisionerPrivateKey,
|
||||
});
|
||||
}
|
||||
|
||||
function signedHeaders({ path, method, rawBody, audience, serviceId, keyId, privateKey }) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const bodySha256 = sha256RawBody(rawBody);
|
||||
const payload = managedProvisionerSigningPayload({
|
||||
audience,
|
||||
serviceId,
|
||||
keyId,
|
||||
method,
|
||||
path,
|
||||
timestamp,
|
||||
nonce,
|
||||
bodySha256,
|
||||
});
|
||||
const signature = sign(null, Buffer.from(payload, "utf8"), privateKey).toString("base64url");
|
||||
return {
|
||||
[MANAGED_PROVISIONER_HEADERS.serviceId]: serviceId,
|
||||
[MANAGED_PROVISIONER_HEADERS.keyId]: keyId,
|
||||
[MANAGED_PROVISIONER_HEADERS.audience]: audience,
|
||||
[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");
|
||||
|
||||
@@ -66,6 +66,22 @@ try {
|
||||
assert.equal(managedConfig.managedProvisionerAudience, "nodedc-external-data-plane.managed-provisioning.v1");
|
||||
assert.equal(managedConfig.managedProvisionerMaxSkewMs, 45_000);
|
||||
assert.equal(managedConfig.managedProvisionerReplayCacheMaxEntries, 500);
|
||||
const foundryConfig = readConfig({
|
||||
...base,
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONING_ENABLED: "true",
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PUBLIC_KEY_FILE: publicKeyPath,
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID: "nodedc-module-foundry",
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID: "foundry-edp-managed-provisioner-v1",
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE: "nodedc-external-data-plane.managed-provisioning.v1",
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_MAX_SKEW_SECONDS: "30",
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES: "600",
|
||||
});
|
||||
assert.equal(foundryConfig.foundryProvisionerApiEnabled, true);
|
||||
assert.equal(foundryConfig.foundryProvisionerPublicKey.asymmetricKeyType, "ed25519");
|
||||
assert.equal(foundryConfig.foundryProvisionerServiceId, "nodedc-module-foundry");
|
||||
assert.equal(foundryConfig.foundryProvisionerKeyId, "foundry-edp-managed-provisioner-v1");
|
||||
assert.equal(foundryConfig.foundryProvisionerMaxSkewMs, 30_000);
|
||||
assert.equal(foundryConfig.foundryProvisionerReplayCacheMaxEntries, 600);
|
||||
assert.throws(() => readConfig({
|
||||
...base,
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED: "true",
|
||||
@@ -88,6 +104,7 @@ try {
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED: "false",
|
||||
EXTERNAL_DATA_PLANE_PROVISIONER_TOKEN_FILE: join(directory, "missing"),
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_PUBLIC_KEY_FILE: join(directory, "missing"),
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PUBLIC_KEY_FILE: join(directory, "missing"),
|
||||
}).provisionerAccessToken, "");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
assertReaderProduct,
|
||||
consumerReaderBindingRequestHash,
|
||||
createReaderToken,
|
||||
hashReaderToken,
|
||||
normalizeManagedConsumerReaderBindingRequest,
|
||||
normalizeManagedConsumerReaderPlanRequest,
|
||||
normalizeManagedReaderBindingRequest,
|
||||
normalizeReaderBindingRequest,
|
||||
readerBindingRequestHash,
|
||||
safeManagedConsumerReaderBinding,
|
||||
safeReaderBinding,
|
||||
} from "../src/reader-binding.mjs";
|
||||
|
||||
@@ -64,4 +68,31 @@ assert.throws(() => normalizeManagedReaderBindingRequest({
|
||||
capabilityDigest: managed.capabilityDigest,
|
||||
}), /managed_reader_binding_must_be_durable/);
|
||||
|
||||
const consumerPlan = normalizeManagedConsumerReaderPlanRequest({
|
||||
allowedDataProductIds: ["fleet.positions.current.v1", "fleet.positions.current.v1"],
|
||||
});
|
||||
assert.deepEqual(consumerPlan.allowedDataProductIds, ["fleet.positions.current.v1"]);
|
||||
const consumerManaged = normalizeManagedConsumerReaderBindingRequest({
|
||||
allowedDataProductIds: consumerPlan.allowedDataProductIds,
|
||||
expiresAt: null,
|
||||
generation: 1,
|
||||
capabilityDigest: hashReaderToken(token),
|
||||
});
|
||||
assert.match(consumerReaderBindingRequestHash(consumerManaged), /^[a-f0-9]{64}$/);
|
||||
const safeConsumer = safeManagedConsumerReaderBinding({
|
||||
id: "reader-foundry-01",
|
||||
bindingKey: "fndrc-reader-managed",
|
||||
...consumerManaged,
|
||||
active: true,
|
||||
});
|
||||
assert.equal(safeConsumer.sourceScope, "resolved-server-side");
|
||||
assert.equal(JSON.stringify(safeConsumer).includes(consumerManaged.capabilityDigest), false);
|
||||
assert.equal("tenantId" in safeConsumer, false);
|
||||
assert.equal("connectionId" in safeConsumer, false);
|
||||
assert.equal("providerId" in safeConsumer, false);
|
||||
assert.throws(() => normalizeManagedConsumerReaderBindingRequest({
|
||||
...consumerManaged,
|
||||
capability: "forbidden",
|
||||
}), /managed_consumer_reader_binding_request_invalid/);
|
||||
|
||||
console.log("external-data-plane reader bindings: ok");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveManagedReaderSourceConnection } from "../src/reader-source-scope.mjs";
|
||||
import { resolveManagedReaderScope, resolveManagedReaderSourceConnection } from "../src/reader-source-scope.mjs";
|
||||
|
||||
const policy = {
|
||||
tenantId: "tenant-01",
|
||||
@@ -20,6 +20,24 @@ await assert.rejects(
|
||||
resolveManagedReaderSourceConnection(db([]), policy),
|
||||
(error) => error?.status === 409 && error?.code === "managed_reader_source_scope_not_found",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await resolveManagedReaderScope(scopeDb([
|
||||
{ tenantId: "tenant-01", connectionId: "producer-connection", providerId: "provider-01" },
|
||||
]), policy.allowedDataProductIds),
|
||||
{ tenantId: "tenant-01", connectionId: "producer-connection", providerId: "provider-01" },
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveManagedReaderScope(scopeDb([]), policy.allowedDataProductIds),
|
||||
(error) => error?.status === 409 && error?.code === "managed_consumer_reader_source_scope_not_found",
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveManagedReaderScope(scopeDb([
|
||||
{ tenantId: "tenant-01", connectionId: "producer-a", providerId: "provider-01" },
|
||||
{ tenantId: "tenant-01", connectionId: "producer-b", providerId: "provider-01" },
|
||||
]), policy.allowedDataProductIds),
|
||||
(error) => error?.status === 409 && error?.code === "managed_consumer_reader_source_scope_ambiguous",
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveManagedReaderSourceConnection(db(["producer-a", "producer-b"]), policy),
|
||||
(error) => error?.status === 409 && error?.code === "managed_reader_source_scope_ambiguous",
|
||||
@@ -39,4 +57,14 @@ function db(connectionIds) {
|
||||
};
|
||||
}
|
||||
|
||||
function scopeDb(rows) {
|
||||
return {
|
||||
async query(sql, values) {
|
||||
assert.match(sql, /external_data_plane_writer_bindings/);
|
||||
assert.deepEqual(values, [policy.allowedDataProductIds]);
|
||||
return { rows };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.log("external-data-plane reader source scope: ok");
|
||||
|
||||
Reference in New Issue
Block a user