feat(data-plane): add signed managed writer bindings
This commit is contained in:
@@ -4,7 +4,15 @@ 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
|
||||
@@ -59,6 +67,57 @@ export function normalizeWriterBindingRequest(value, { now = new Date(), maxTtlD
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, { now = new Date(), maxTtlDays = 90 } = {}) {
|
||||
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 scope = normalizeWriterBindingRequest({
|
||||
source: value.source,
|
||||
allowedDataProductIds: value.allowedDataProductIds,
|
||||
expiresAt: value.expiresAt,
|
||||
}, { now, maxTtlDays });
|
||||
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({
|
||||
...scope,
|
||||
allowedDataProductIds: Object.freeze([...scope.allowedDataProductIds].sort()),
|
||||
generation,
|
||||
capabilityDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function writerBindingRequestHash(policy) {
|
||||
const canonical = JSON.stringify({
|
||||
tenantId: policy.tenantId,
|
||||
connectionId: policy.connectionId,
|
||||
providerId: policy.providerId,
|
||||
allowedDataProductIds: [...policy.allowedDataProductIds].sort(),
|
||||
expiresAt: new Date(policy.expiresAt).toISOString(),
|
||||
generation: policy.generation,
|
||||
capabilityDigest: policy.capabilityDigest,
|
||||
});
|
||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a caller-provided, deliberately unscoped intake envelope into the
|
||||
* canonical scoped form. Caller scope is rejected, never trusted or merged.
|
||||
@@ -136,6 +195,8 @@ export function materializeDataProductPublish(value, binding, definition, dataPr
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user