NODEDC_PLATFORM/services/external-data-plane/src/writer-binding.mjs

270 lines
11 KiB
JavaScript

import { createHash, randomBytes } from "node:crypto";
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const TOKEN_PREFIX = "ndc_edpwb_";
const SECRET_LIKE_KEY = /(token|secret|password|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
const BINDING_REQUEST_KEYS = new Set(["source", "allowedDataProductIds", "expiresAt"]);
const MANAGED_BINDING_REQUEST_KEYS = new Set([
"source",
"allowedDataProductIds",
"expiresAt",
"generation",
"capabilityDigest",
]);
const BINDING_SOURCE_KEYS = new Set(["tenantId", "connectionId", "providerId"]);
const SHA256_DIGEST = /^[a-f0-9]{64}$/;
/**
* Produces an opaque, high-entropy capability. The plaintext is returned only
* to the trusted provisioning caller; persistence uses its SHA-256 digest.
*/
export function createWriterToken() {
return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
}
export function hashWriterToken(token) {
return createHash("sha256").update(String(token), "utf8").digest("hex");
}
export function normalizeWriterBindingRequest(value, { now = new Date(), maxTtlDays = 90 } = {}) {
if (!isPlainObject(value) || !isPlainObject(value.source)) {
throw writerBindingError("writer_binding_request_invalid");
}
if (containsSecretLikeKey(value)) {
throw writerBindingError("writer_binding_request_secret_material_forbidden");
}
if (value.endpoint !== undefined || value.url !== undefined || value.host !== undefined) {
throw writerBindingError("writer_binding_request_transport_forbidden");
}
if (!hasOnlyKeys(value, BINDING_REQUEST_KEYS) || !hasOnlyKeys(value.source, BINDING_SOURCE_KEYS)) {
throw writerBindingError("writer_binding_request_fields_invalid");
}
const tenantId = normalizeIdentifier(value.source.tenantId);
const connectionId = normalizeIdentifier(value.source.connectionId);
const providerId = normalizeIdentifier(value.source.providerId);
if (!tenantId || !connectionId || !providerId) {
throw writerBindingError("writer_binding_scope_invalid");
}
const allowedDataProductIds = uniqueIdentifiers(value.allowedDataProductIds);
if (!allowedDataProductIds.length) {
throw writerBindingError("writer_binding_data_products_invalid");
}
const expiresAt = new Date(String(value.expiresAt || ""));
const maxExpiresAt = new Date(now.getTime() + maxTtlDays * 24 * 60 * 60 * 1000);
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= now || expiresAt > maxExpiresAt) {
throw writerBindingError("writer_binding_expiry_invalid");
}
return Object.freeze({
tenantId,
connectionId,
providerId,
allowedDataProductIds,
expiresAt: expiresAt.toISOString(),
});
}
/**
* Validates the zero-touch control-plane form. The native credential secret is
* generated and stored inside Engine; EDP receives only its SHA-256 digest.
* `generation` makes a retry address the same immutable binding generation.
*/
export function normalizeManagedWriterBindingRequest(value) {
if (!isPlainObject(value) || !isPlainObject(value.source)) {
throw writerBindingError("managed_writer_binding_request_invalid");
}
if (containsSecretLikeKey(value)) {
throw writerBindingError("managed_writer_binding_secret_material_forbidden");
}
if (!hasOnlyKeys(value, MANAGED_BINDING_REQUEST_KEYS) || !hasOnlyKeys(value.source, BINDING_SOURCE_KEYS)) {
throw writerBindingError("managed_writer_binding_request_fields_invalid");
}
const tenantId = normalizeIdentifier(value.source.tenantId);
const connectionId = normalizeIdentifier(value.source.connectionId);
const providerId = normalizeIdentifier(value.source.providerId);
const allowedDataProductIds = uniqueIdentifiers(value.allowedDataProductIds);
if (!tenantId || !connectionId || !providerId || !allowedDataProductIds.length) {
throw writerBindingError("managed_writer_binding_scope_invalid");
}
if (value.expiresAt !== null) {
throw writerBindingError("managed_writer_binding_must_be_durable");
}
const generation = Number(value.generation);
if (!Number.isSafeInteger(generation) || generation < 1 || generation > 2_147_483_647) {
throw writerBindingError("managed_writer_binding_generation_invalid");
}
const capabilityDigest = String(value.capabilityDigest || "").toLowerCase();
if (!SHA256_DIGEST.test(capabilityDigest)) {
throw writerBindingError("managed_writer_binding_capability_digest_invalid");
}
return Object.freeze({
tenantId,
connectionId,
providerId,
allowedDataProductIds: Object.freeze([...allowedDataProductIds].sort()),
expiresAt: null,
generation,
capabilityDigest,
});
}
export function writerBindingRequestHash(policy) {
const canonical = JSON.stringify({
tenantId: policy.tenantId,
connectionId: policy.connectionId,
providerId: policy.providerId,
allowedDataProductIds: [...policy.allowedDataProductIds].sort(),
expiresAt: null,
generation: policy.generation,
capabilityDigest: policy.capabilityDigest,
});
return createHash("sha256").update(canonical, "utf8").digest("hex");
}
export function canMigrateManagedWriterBindingToDurable(binding, policy, now = new Date()) {
if (!isPlainObject(binding) || !isPlainObject(policy) || binding.active !== true) return false;
const expiresAt = new Date(binding.expiresAt);
if (binding.expiresAt === null || Number.isNaN(expiresAt.getTime()) || expiresAt <= now) return false;
return binding.tenantId === policy.tenantId
&& binding.connectionId === policy.connectionId
&& binding.providerId === policy.providerId
&& binding.capabilityDigest === policy.capabilityDigest
&& JSON.stringify(uniqueIdentifiers(binding.allowedDataProductIds).sort())
=== JSON.stringify(uniqueIdentifiers(policy.allowedDataProductIds).sort())
&& policy.expiresAt === null;
}
/**
* Converts a caller-provided, deliberately unscoped intake envelope into the
* canonical scoped form. Caller scope is rejected, never trusted or merged.
*/
export function materializeWriterBoundBatch(value, binding, { hasScopeHeaders = false, now = new Date() } = {}) {
if (!isPlainObject(value) || !isPlainObject(value.source) || !isPlainObject(binding)) {
throw writerBindingError("writer_bound_intake_invalid");
}
if (hasScopeHeaders || value.source.tenantId !== undefined || value.source.connectionId !== undefined) {
throw writerBindingError("writer_bound_scope_forbidden");
}
if (binding.active !== true || !bindingIsCurrent(binding, now)) {
throw writerBindingError("writer_binding_inactive");
}
const providerId = normalizeIdentifier(value.source.providerId);
const tenantId = normalizeIdentifier(binding.tenantId);
const connectionId = normalizeIdentifier(binding.connectionId);
if (!providerId || !tenantId || !connectionId || providerId !== binding.providerId) {
throw writerBindingError("writer_binding_provider_forbidden");
}
const dataProductId = normalizeIdentifier(value.contract?.dataProductId);
const allowedDataProductIds = uniqueIdentifiers(binding.allowedDataProductIds);
if (!dataProductId || !allowedDataProductIds.includes(dataProductId)) {
throw writerBindingError("writer_binding_data_product_forbidden");
}
return {
...value,
source: { providerId, tenantId, connectionId },
};
}
/**
* Materializes the provider-neutral Data Product publish wire form. Unlike the
* legacy writer-bound intake, the caller cannot send provider identity,
* contract metadata, receivedAt or any scope at all.
*/
export function materializeDataProductPublish(value, binding, definition, dataProductId, { now = new Date() } = {}) {
if (!isPlainObject(value) || !isPlainObject(binding) || !isPlainObject(definition)) {
throw writerBindingError("data_product_publish_invalid");
}
if (binding.active !== true || !bindingIsCurrent(binding, now)) {
throw writerBindingError("writer_binding_inactive");
}
const normalizedProductId = normalizeIdentifier(dataProductId);
const allowedDataProductIds = uniqueIdentifiers(binding.allowedDataProductIds);
if (!normalizedProductId || normalizedProductId !== definition.id || !allowedDataProductIds.includes(normalizedProductId)) {
throw writerBindingError("writer_binding_data_product_forbidden");
}
const tenantId = normalizeIdentifier(binding.tenantId);
const connectionId = normalizeIdentifier(binding.connectionId);
const providerId = normalizeIdentifier(binding.providerId);
if (!tenantId || !connectionId || !providerId) throw writerBindingError("writer_binding_scope_invalid");
return {
schemaVersion: "nodedc.external-provider-contract/v1",
source: { tenantId, connectionId, providerId },
contract: {
dataProductId: normalizedProductId,
ontologyRevision: definition.ontologyRevision,
version: definition.version,
},
batch: {
runId: value.batch?.runId,
sequence: value.batch?.sequence,
idempotencyKey: value.batch?.idempotencyKey,
receivedAt: now.toISOString(),
...(value.batch?.mode === "replace" ? {
mode: "replace",
generationAt: value.batch?.generationAt,
} : {}),
},
facts: value.facts,
};
}
export function safeWriterBinding(binding) {
return {
id: binding.id,
...(binding.bindingKey ? { bindingKey: binding.bindingKey } : {}),
...(Number.isInteger(Number(binding.generation)) ? { generation: Number(binding.generation) } : {}),
tenantId: binding.tenantId,
connectionId: binding.connectionId,
providerId: binding.providerId,
allowedDataProductIds: uniqueIdentifiers(binding.allowedDataProductIds),
active: binding.active === true,
expiresAt: binding.expiresAt === null ? null : new Date(binding.expiresAt).toISOString(),
createdAt: binding.createdAt ? new Date(binding.createdAt).toISOString() : undefined,
rotatedAt: binding.rotatedAt ? new Date(binding.rotatedAt).toISOString() : undefined,
revokedAt: binding.revokedAt ? new Date(binding.revokedAt).toISOString() : undefined,
};
}
export function writerBindingError(code) {
return Object.assign(new Error(code), { status: 400, code });
}
function bindingIsCurrent(binding, now) {
if (binding.expiresAt === null) return true;
const expiresAt = new Date(binding.expiresAt);
return !Number.isNaN(expiresAt.getTime()) && expiresAt > now;
}
function normalizeIdentifier(value) {
const normalized = typeof value === "string" ? value.trim() : "";
return IDENTIFIER.test(normalized) ? normalized : "";
}
function uniqueIdentifiers(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map(normalizeIdentifier).filter(Boolean))];
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function containsSecretLikeKey(value) {
if (Array.isArray(value)) return value.some(containsSecretLikeKey);
if (!isPlainObject(value)) return false;
return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretLikeKey(child));
}
function hasOnlyKeys(value, allowedKeys) {
return Object.keys(value).every((key) => allowedKeys.has(key));
}