fix(edp): resolve managed reader source scope
This commit is contained in:
parent
0611a88971
commit
95446bdc24
|
|
@ -96,10 +96,23 @@ async function assertSourceBoundary() {
|
|||
'managedReaderBindingProvisioning: config.managedProvisionerApiEnabled',
|
||||
'managedReaderBindingLifetime: config.managedProvisionerApiEnabled ? "explicit-revoke" : "disabled"',
|
||||
'managedReaderBindings: "digest+idempotent-generation+explicit-revoke"',
|
||||
'readerSourceScope: "writer-resolved+fail-closed-ambiguity"',
|
||||
'source_connection_id as "sourceConnectionId"',
|
||||
'where id = $1 and binding_key is null and active = true',
|
||||
]) {
|
||||
if (!server.includes(marker)) throw new Error(`managed_reader_boundary_missing:${marker}`);
|
||||
}
|
||||
const readerSourceScope = await readFile(
|
||||
resolve(platformRoot, "services/external-data-plane/src/reader-source-scope.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"managed_reader_source_scope_not_found",
|
||||
"managed_reader_source_scope_ambiguous",
|
||||
"external_data_plane_writer_bindings",
|
||||
]) {
|
||||
if (!readerSourceScope.includes(marker)) throw new Error(`reader_source_scope_boundary_missing:${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
|
|
|
|||
|
|
@ -325,6 +325,19 @@ Managed route включается только через
|
|||
`EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED`; legacy plaintext routes —
|
||||
через отдельный `EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED`.
|
||||
|
||||
Managed reader хранит два разных provider-neutral scope. `connectionId` в
|
||||
reader grant идентифицирует consumer connection, а `sourceConnectionId`
|
||||
фиксирует writer connection, из которого разрешено читать Data Product.
|
||||
`sourceConnectionId` не приходит из L2 graph: при ensure EDP разрешает его по
|
||||
активным writer bindings того же `tenantId`, `providerId` и полного набора
|
||||
`allowedDataProductIds`. Точное совпадение connection выбирается напрямую;
|
||||
иначе допускается только один кандидат. Ноль кандидатов возвращает
|
||||
`managed_reader_source_scope_not_found`, несколько —
|
||||
`managed_reader_source_scope_ambiguous`. Поэтому reader из отдельного L2 target
|
||||
не попадает в пустой consumer scope, а несколько provider accounts никогда не
|
||||
смешиваются эвристически. Legacy reader binding сохраняет прежнюю семантику:
|
||||
его `connectionId` одновременно является source connection.
|
||||
|
||||
Managed auth wire contract —
|
||||
`nodedc.external-data-plane.managed-provisioner-request/v1`. Engine отправляет
|
||||
ровно по одному header:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"check": "node --check src/server.mjs && node --check src/schema.mjs && node --check src/config.mjs && node --check src/intake-policy.mjs && node --check src/writer-binding.mjs && node --check src/reader-binding.mjs && node --check src/data-product-policy.mjs && node --check src/data-product-delivery.mjs && node --check src/definitions.mjs && node --check src/managed-provisioner-auth.mjs",
|
||||
"test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/data-product-policy.test.mjs && node test/definitions.test.mjs",
|
||||
"check": "node --check src/server.mjs && node --check src/schema.mjs && node --check src/config.mjs && node --check src/intake-policy.mjs && node --check src/writer-binding.mjs && node --check src/reader-binding.mjs && node --check src/reader-source-scope.mjs && node --check src/data-product-policy.mjs && node --check src/data-product-delivery.mjs && node --check src/definitions.mjs && node --check src/managed-provisioner-auth.mjs",
|
||||
"test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/reader-source-scope.test.mjs && node test/data-product-policy.test.mjs && node test/definitions.test.mjs",
|
||||
"test:integration": "node test/data-product-delivery.integration.test.mjs && node test/api.integration.test.mjs",
|
||||
"test:all": "npm test && npm run test:integration"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ export async function persistDataProductPublish(pool, batch, definition, {
|
|||
}
|
||||
|
||||
export async function readDataProductSnapshot(pool, binding, definition, { limit = 5000 } = {}) {
|
||||
const sourceConnectionId = readerSourceConnectionId(binding);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin isolation level repeatable read read only");
|
||||
|
|
@ -202,7 +203,7 @@ export async function readDataProductSnapshot(pool, binding, definition, { limit
|
|||
where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4
|
||||
order by source_id asc, semantic_type asc
|
||||
limit $5`,
|
||||
[binding.tenantId, binding.connectionId, binding.providerId, definition.id, limit + 1],
|
||||
[binding.tenantId, sourceConnectionId, binding.providerId, definition.id, limit + 1],
|
||||
);
|
||||
if (rows.rowCount > limit) throw deliveryError("data_product_snapshot_limit_exceeded", 413);
|
||||
await client.query("commit");
|
||||
|
|
@ -266,6 +267,7 @@ export function normalizeHistoryReadOptions(value, definition) {
|
|||
}
|
||||
|
||||
export async function readDataProductHistory(pool, binding, definition, options) {
|
||||
const sourceConnectionId = readerSourceConnectionId(binding);
|
||||
const query = normalizeHistoryReadOptions(options, definition);
|
||||
const cursor = query.cursor || {};
|
||||
const rows = await pool.query(
|
||||
|
|
@ -302,7 +304,7 @@ export async function readDataProductHistory(pool, binding, definition, options)
|
|||
order by query_bucket asc, source_id asc, semantic_type asc
|
||||
limit $13`,
|
||||
[
|
||||
binding.tenantId, binding.connectionId, binding.providerId, definition.id,
|
||||
binding.tenantId, sourceConnectionId, binding.providerId, definition.id,
|
||||
query.from, query.to, query.sourceIds, query.resolutionMs,
|
||||
Boolean(query.cursor), cursor.bucketStart || query.from, cursor.sourceId || "", cursor.semanticType || "",
|
||||
query.limit + 1,
|
||||
|
|
@ -337,6 +339,7 @@ export async function readDataProductHistory(pool, binding, definition, options)
|
|||
}
|
||||
|
||||
export async function readPatchEvents(pool, binding, definition, after, { limit = 100 } = {}) {
|
||||
const sourceConnectionId = readerSourceConnectionId(binding);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin isolation level repeatable read read only");
|
||||
|
|
@ -351,7 +354,7 @@ export async function readPatchEvents(pool, binding, definition, after, { limit
|
|||
where tenant_id = $1 and connection_id = $2 and provider_id = $3
|
||||
and data_product_id = $4 and cursor > $5
|
||||
order by cursor asc limit $6`,
|
||||
[binding.tenantId, binding.connectionId, binding.providerId, definition.id, after.toString(), limit],
|
||||
[binding.tenantId, sourceConnectionId, binding.providerId, definition.id, after.toString(), limit],
|
||||
);
|
||||
await client.query("commit");
|
||||
return rows.rows.map((row) => ({
|
||||
|
|
@ -518,26 +521,40 @@ async function persistPatchChunks(client, batch, definition, batchId, chunks) {
|
|||
|
||||
async function currentCursor(db, scope, explicitProductId) {
|
||||
const dataProductId = explicitProductId || scope.contract.dataProductId;
|
||||
const connectionId = scope.source
|
||||
? scope.source.connectionId
|
||||
: readerSourceConnectionId(scope);
|
||||
const result = await db.query(
|
||||
`select current_cursor from external_data_plane_delivery_state
|
||||
where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4`,
|
||||
[scope.tenantId || scope.source.tenantId, scope.connectionId || scope.source.connectionId,
|
||||
[scope.tenantId || scope.source.tenantId, connectionId,
|
||||
scope.providerId || scope.source.providerId, dataProductId],
|
||||
);
|
||||
return result.rowCount ? BigInt(result.rows[0].current_cursor) : 0n;
|
||||
}
|
||||
|
||||
async function patchFloor(db, binding, dataProductId) {
|
||||
const sourceConnectionId = readerSourceConnectionId(binding);
|
||||
const result = await db.query(
|
||||
`select min(cursor) as floor from external_data_plane_patch_outbox
|
||||
where tenant_id = $1 and connection_id = $2 and provider_id = $3 and data_product_id = $4`,
|
||||
[binding.tenantId, binding.connectionId, binding.providerId, dataProductId],
|
||||
[binding.tenantId, sourceConnectionId, binding.providerId, dataProductId],
|
||||
);
|
||||
if (result.rows[0]?.floor !== null && result.rows[0]?.floor !== undefined) return BigInt(result.rows[0].floor);
|
||||
const current = await currentCursor(db, binding, dataProductId);
|
||||
return current + 1n;
|
||||
}
|
||||
|
||||
function readerSourceConnectionId(binding) {
|
||||
const sourceConnectionId = String(binding?.sourceConnectionId || "").trim();
|
||||
if (sourceConnectionId) return sourceConnectionId;
|
||||
if (!binding?.bindingKey) {
|
||||
const legacyConnectionId = String(binding?.connectionId || "").trim();
|
||||
if (legacyConnectionId) return legacyConnectionId;
|
||||
}
|
||||
throw deliveryError("reader_source_scope_unresolved", 409);
|
||||
}
|
||||
|
||||
function scopeValues(batch, tail) {
|
||||
return [
|
||||
batch.source.tenantId, batch.source.connectionId, batch.source.providerId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
|
||||
export async function resolveManagedReaderSourceConnection(db, policy) {
|
||||
const tenantId = identifier(policy?.tenantId);
|
||||
const consumerConnectionId = identifier(policy?.connectionId);
|
||||
const providerId = identifier(policy?.providerId);
|
||||
const allowedDataProductIds = uniqueIdentifiers(policy?.allowedDataProductIds);
|
||||
if (!tenantId || !consumerConnectionId || !providerId || !allowedDataProductIds.length) {
|
||||
throw sourceScopeError("managed_reader_source_scope_request_invalid", 400);
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`select distinct candidate.connection_id as "connectionId"
|
||||
from external_data_plane_writer_bindings as candidate
|
||||
where candidate.tenant_id = $1
|
||||
and candidate.provider_id = $2
|
||||
and candidate.active = true
|
||||
and (candidate.expires_at is null or candidate.expires_at > now())
|
||||
and not exists (
|
||||
select 1
|
||||
from unnest($3::text[]) as requested(data_product_id)
|
||||
where not exists (
|
||||
select 1
|
||||
from external_data_plane_writer_bindings as coverage
|
||||
where coverage.tenant_id = candidate.tenant_id
|
||||
and coverage.connection_id = candidate.connection_id
|
||||
and coverage.provider_id = candidate.provider_id
|
||||
and coverage.active = true
|
||||
and (coverage.expires_at is null or coverage.expires_at > now())
|
||||
and coverage.allowed_data_product_ids ? requested.data_product_id
|
||||
)
|
||||
)
|
||||
order by candidate.connection_id asc
|
||||
limit 3`,
|
||||
[tenantId, providerId, allowedDataProductIds],
|
||||
);
|
||||
const candidates = uniqueIdentifiers(result.rows.map((row) => row.connectionId));
|
||||
if (candidates.includes(consumerConnectionId)) return consumerConnectionId;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
if (!candidates.length) throw sourceScopeError("managed_reader_source_scope_not_found", 409);
|
||||
throw sourceScopeError("managed_reader_source_scope_ambiguous", 409);
|
||||
}
|
||||
|
||||
export async function backfillManagedReaderSourceConnections(db) {
|
||||
const unresolved = await db.query(
|
||||
`select id, tenant_id as "tenantId", connection_id as "connectionId",
|
||||
provider_id as "providerId", allowed_data_product_ids as "allowedDataProductIds"
|
||||
from external_data_plane_reader_bindings
|
||||
where binding_key is not null and source_connection_id is null
|
||||
order by created_at asc, id asc`,
|
||||
);
|
||||
let resolved = 0;
|
||||
let unresolvedCount = 0;
|
||||
for (const binding of unresolved.rows) {
|
||||
try {
|
||||
const sourceConnectionId = await resolveManagedReaderSourceConnection(db, binding);
|
||||
const result = await db.query(
|
||||
`update external_data_plane_reader_bindings
|
||||
set source_connection_id = $2
|
||||
where id = $1 and binding_key is not null and source_connection_id is null`,
|
||||
[binding.id, sourceConnectionId],
|
||||
);
|
||||
resolved += result.rowCount;
|
||||
} catch (error) {
|
||||
if (!new Set([
|
||||
"managed_reader_source_scope_not_found",
|
||||
"managed_reader_source_scope_ambiguous",
|
||||
]).has(error?.code)) throw error;
|
||||
unresolvedCount += 1;
|
||||
}
|
||||
}
|
||||
return { resolved, unresolved: unresolvedCount };
|
||||
}
|
||||
|
||||
function sourceScopeError(code, status) {
|
||||
return Object.assign(new Error(code), { code, status });
|
||||
}
|
||||
|
||||
function identifier(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(identifier).filter(Boolean))];
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { backfillManagedReaderSourceConnections } from "./reader-source-scope.mjs";
|
||||
|
||||
export async function migrate(pool) {
|
||||
await pool.query("create extension if not exists timescaledb");
|
||||
await pool.query("create extension if not exists postgis");
|
||||
|
|
@ -160,6 +162,7 @@ export async function migrate(pool) {
|
|||
await pool.query("alter table external_data_plane_reader_bindings add column if not exists binding_key text");
|
||||
await pool.query("alter table external_data_plane_reader_bindings add column if not exists request_hash text");
|
||||
await pool.query("alter table external_data_plane_reader_bindings add column if not exists generation integer not null default 1");
|
||||
await pool.query("alter table external_data_plane_reader_bindings add column if not exists source_connection_id text");
|
||||
await pool.query("alter table external_data_plane_reader_bindings alter column expires_at drop not null");
|
||||
await pool.query(`
|
||||
do $$
|
||||
|
|
@ -202,6 +205,13 @@ export async function migrate(pool) {
|
|||
`);
|
||||
await pool.query("create unique index if not exists external_data_plane_reader_bindings_managed_key_idx on external_data_plane_reader_bindings (binding_key, generation) where binding_key is not null");
|
||||
await pool.query("create index if not exists external_data_plane_reader_bindings_active_idx on external_data_plane_reader_bindings (active, expires_at)");
|
||||
await pool.query("create index if not exists external_data_plane_reader_bindings_source_idx on external_data_plane_reader_bindings (tenant_id, source_connection_id, provider_id, active)");
|
||||
await pool.query(`
|
||||
update external_data_plane_reader_bindings
|
||||
set source_connection_id = connection_id
|
||||
where binding_key is null and source_connection_id is null
|
||||
`);
|
||||
await backfillManagedReaderSourceConnections(pool);
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists external_data_plane_facts (
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
readerBindingRequestHash,
|
||||
safeReaderBinding,
|
||||
} from "./reader-binding.mjs";
|
||||
import { resolveManagedReaderSourceConnection } from "./reader-source-scope.mjs";
|
||||
import { migrate } from "./schema.mjs";
|
||||
import {
|
||||
createWriterToken,
|
||||
|
|
@ -97,6 +98,7 @@ app.get("/healthz", asyncRoute(async (_req, res) => {
|
|||
managedWriterBindings: "digest+idempotent-generation+explicit-revoke",
|
||||
readerBindings: "supported",
|
||||
managedReaderBindings: "digest+idempotent-generation+explicit-revoke",
|
||||
readerSourceScope: "writer-resolved+fail-closed-ambiguity",
|
||||
dataProductDelivery: "snapshot+history+durable-patch",
|
||||
legacyIntake: config.legacyIntakeEnabled ? "migration-only" : "disabled",
|
||||
legacyCapabilityProvisioning: config.provisionerApiEnabled ? "enabled" : "disabled",
|
||||
|
|
@ -388,7 +390,8 @@ app.put("/internal/data-plane/v1/reader-bindings/by-key/:bindingKey", requireMan
|
|||
await client.query("select pg_advisory_xact_lock(hashtextextended($1, 0))", [bindingKey]);
|
||||
const existing = await client.query(
|
||||
`select id, binding_key as "bindingKey", request_hash as "requestHash", generation,
|
||||
tenant_id as "tenantId", connection_id as "connectionId", provider_id as "providerId",
|
||||
tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId", provider_id as "providerId",
|
||||
allowed_data_product_ids as "allowedDataProductIds", expires_at as "expiresAt",
|
||||
active, created_at as "createdAt", rotated_at as "rotatedAt", revoked_at as "revokedAt"
|
||||
from external_data_plane_reader_bindings
|
||||
|
|
@ -397,22 +400,40 @@ app.put("/internal/data-plane/v1/reader-bindings/by-key/:bindingKey", requireMan
|
|||
[bindingKey, policy.generation],
|
||||
);
|
||||
if (existing.rowCount) {
|
||||
const binding = existing.rows[0];
|
||||
let binding = existing.rows[0];
|
||||
if (binding.requestHash !== requestHash) {
|
||||
throw httpError(409, "managed_reader_binding_request_conflict");
|
||||
}
|
||||
if (binding.active !== true || binding.expiresAt !== null) {
|
||||
throw httpError(409, "managed_reader_binding_generation_inactive");
|
||||
}
|
||||
if (!binding.sourceConnectionId) {
|
||||
const sourceConnectionId = await resolveManagedReaderSourceConnection(client, policy);
|
||||
const resolved = await client.query(
|
||||
`update external_data_plane_reader_bindings
|
||||
set source_connection_id = $3
|
||||
where binding_key = $1 and generation = $2 and source_connection_id is null
|
||||
returning id, binding_key as "bindingKey", request_hash as "requestHash", generation,
|
||||
tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId", provider_id as "providerId",
|
||||
allowed_data_product_ids as "allowedDataProductIds", expires_at as "expiresAt",
|
||||
active, created_at as "createdAt", rotated_at as "rotatedAt", revoked_at as "revokedAt"`,
|
||||
[bindingKey, policy.generation, sourceConnectionId],
|
||||
);
|
||||
binding = resolved.rows[0] || binding;
|
||||
}
|
||||
response = { status: 200, idempotent: true, binding };
|
||||
} else {
|
||||
const sourceConnectionId = await resolveManagedReaderSourceConnection(client, policy);
|
||||
const inserted = await client.query(
|
||||
`insert into external_data_plane_reader_bindings (
|
||||
id, token_hash, binding_key, request_hash, generation,
|
||||
tenant_id, connection_id, provider_id, allowed_data_product_ids, expires_at
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
|
||||
tenant_id, connection_id, source_connection_id, provider_id,
|
||||
allowed_data_product_ids, expires_at
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
|
||||
returning id, binding_key as "bindingKey", generation,
|
||||
tenant_id as "tenantId", connection_id as "connectionId", provider_id as "providerId",
|
||||
tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId", provider_id as "providerId",
|
||||
allowed_data_product_ids as "allowedDataProductIds", expires_at as "expiresAt",
|
||||
active, created_at as "createdAt", rotated_at as "rotatedAt", revoked_at as "revokedAt"`,
|
||||
[
|
||||
|
|
@ -423,6 +444,7 @@ app.put("/internal/data-plane/v1/reader-bindings/by-key/:bindingKey", requireMan
|
|||
policy.generation,
|
||||
policy.tenantId,
|
||||
policy.connectionId,
|
||||
sourceConnectionId,
|
||||
policy.providerId,
|
||||
JSON.stringify(policy.allowedDataProductIds),
|
||||
policy.expiresAt,
|
||||
|
|
@ -459,7 +481,8 @@ app.post("/internal/data-plane/v1/reader-bindings/by-key/:bindingKey/generations
|
|||
await client.query("select pg_advisory_xact_lock(hashtextextended($1, 0))", [bindingKey]);
|
||||
const existing = await client.query(
|
||||
`select id, binding_key as "bindingKey", generation,
|
||||
tenant_id as "tenantId", connection_id as "connectionId", provider_id as "providerId",
|
||||
tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId", provider_id as "providerId",
|
||||
allowed_data_product_ids as "allowedDataProductIds", expires_at as "expiresAt",
|
||||
active, created_at as "createdAt", rotated_at as "rotatedAt", revoked_at as "revokedAt"
|
||||
from external_data_plane_reader_bindings
|
||||
|
|
@ -474,7 +497,8 @@ app.post("/internal/data-plane/v1/reader-bindings/by-key/:bindingKey/generations
|
|||
set active = false, revoked_at = now()
|
||||
where binding_key = $1 and generation = $2 and active = true
|
||||
returning id, binding_key as "bindingKey", generation,
|
||||
tenant_id as "tenantId", connection_id as "connectionId", provider_id as "providerId",
|
||||
tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId", provider_id as "providerId",
|
||||
allowed_data_product_ids as "allowedDataProductIds", expires_at as "expiresAt",
|
||||
active, created_at as "createdAt", rotated_at as "rotatedAt", revoked_at as "revokedAt"`,
|
||||
[bindingKey, generation],
|
||||
|
|
@ -505,10 +529,11 @@ app.post("/internal/data-plane/v1/reader-bindings", requireProvisionerApi, async
|
|||
const bindingId = randomUUID();
|
||||
const result = await pool.query(
|
||||
`insert into external_data_plane_reader_bindings (
|
||||
id, token_hash, tenant_id, connection_id, provider_id,
|
||||
id, token_hash, tenant_id, connection_id, source_connection_id, provider_id,
|
||||
allowed_data_product_ids, expires_at
|
||||
) values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||
) values ($1, $2, $3, $4, $4, $5, $6::jsonb, $7)
|
||||
returning id, tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId",
|
||||
provider_id as "providerId", allowed_data_product_ids as "allowedDataProductIds",
|
||||
expires_at as "expiresAt", active, created_at as "createdAt",
|
||||
rotated_at as "rotatedAt", revoked_at as "revokedAt"`,
|
||||
|
|
@ -533,6 +558,7 @@ app.post("/internal/data-plane/v1/reader-bindings/:bindingId/rotate", requirePro
|
|||
set token_hash = $2, rotated_at = now()
|
||||
where id = $1 and binding_key is null and active = true and expires_at > now()
|
||||
returning id, tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId",
|
||||
provider_id as "providerId", allowed_data_product_ids as "allowedDataProductIds",
|
||||
expires_at as "expiresAt", active, created_at as "createdAt",
|
||||
rotated_at as "rotatedAt", revoked_at as "revokedAt"`,
|
||||
|
|
@ -549,6 +575,7 @@ app.post("/internal/data-plane/v1/reader-bindings/:bindingId/revoke", requirePro
|
|||
set active = false, revoked_at = now()
|
||||
where id = $1 and binding_key is null and active = true
|
||||
returning id, tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId",
|
||||
provider_id as "providerId", allowed_data_product_ids as "allowedDataProductIds",
|
||||
expires_at as "expiresAt", active, created_at as "createdAt",
|
||||
rotated_at as "rotatedAt", revoked_at as "revokedAt"`,
|
||||
|
|
@ -937,7 +964,9 @@ async function resolveReaderBinding(req) {
|
|||
const token = bearerToken(req);
|
||||
if (!token) throw httpError(401, "reader_binding_unauthorized");
|
||||
const result = await pool.query(
|
||||
`select id, token_hash as "tokenHash", tenant_id as "tenantId", connection_id as "connectionId",
|
||||
`select id, binding_key as "bindingKey", token_hash as "tokenHash",
|
||||
tenant_id as "tenantId", connection_id as "connectionId",
|
||||
source_connection_id as "sourceConnectionId",
|
||||
provider_id as "providerId", allowed_data_product_ids as "allowedDataProductIds",
|
||||
expires_at as "expiresAt", active, created_at as "createdAt",
|
||||
rotated_at as "rotatedAt", revoked_at as "revokedAt"
|
||||
|
|
@ -947,6 +976,9 @@ async function resolveReaderBinding(req) {
|
|||
[hashReaderToken(token)],
|
||||
);
|
||||
if (!result.rowCount) throw httpError(401, "reader_binding_unauthorized");
|
||||
if (result.rows[0].bindingKey && !result.rows[0].sourceConnectionId) {
|
||||
throw httpError(409, "reader_source_scope_unresolved");
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ try {
|
|||
|
||||
const managedReaderToken = "ndc_edprb_managed_api_test_0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
const managedReaderBody = {
|
||||
source: scope,
|
||||
source: { ...scope, connectionId: "api-consumer-connection" },
|
||||
allowedDataProductIds: [productId],
|
||||
expiresAt: null,
|
||||
generation: 1,
|
||||
|
|
@ -209,12 +209,25 @@ try {
|
|||
]);
|
||||
assert.deepEqual(managedReaderResults.map(({ value }) => value.idempotent).sort(), [false, true]);
|
||||
assert.equal(managedReaderResults[0].value.readerBinding.id, managedReaderResults[1].value.readerBinding.id);
|
||||
assert.equal(managedReaderResults[0].value.readerBinding.connectionId, "api-consumer-connection");
|
||||
assert.equal("token" in managedReaderResults[0].value, false);
|
||||
assert.equal(JSON.stringify(managedReaderResults[0].value).includes(managedReaderBody.capabilityDigest), false);
|
||||
assert.deepEqual(
|
||||
(await jsonRequest("/internal/data-plane/v1/reader/data-products", { token: managedReaderToken })).dataProducts.map((value) => value.id),
|
||||
[productId],
|
||||
);
|
||||
const managedSourcePublish = await jsonRequest(`/internal/data-plane/v1/data-products/${productId}/publish`, {
|
||||
method: "POST",
|
||||
token: managedTokenGeneration2,
|
||||
body: publish("api-managed-reader-source", "2026-07-15T11:00:00.000Z", 37.60),
|
||||
});
|
||||
assert.equal(managedSourcePublish.currentUpdatedCount, 1);
|
||||
const managedReaderSnapshot = await jsonRequest(
|
||||
`/internal/data-plane/v1/data-products/${productId}/snapshot`,
|
||||
{ token: managedReaderToken },
|
||||
);
|
||||
assert.equal(validateDataProductSnapshot(managedReaderSnapshot).ok, true);
|
||||
assert.equal(managedReaderSnapshot.facts.length, 1);
|
||||
const legacyManagedReaderRevoke = await fetch(
|
||||
`${baseUrl}/internal/data-plane/v1/reader-bindings/${managedReaderResults[0].value.readerBinding.id}/revoke`,
|
||||
{ method: "POST", headers: { Authorization: `Bearer ${provisionerSecret}` } },
|
||||
|
|
@ -236,6 +249,31 @@ try {
|
|||
});
|
||||
assert.equal(rejectedManagedReader.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",
|
||||
managedSignature: true,
|
||||
body: {
|
||||
...managedBody,
|
||||
source: { ...scope, connectionId: "api-connection-2" },
|
||||
capabilityDigest: createHash("sha256").update(secondWriterToken, "utf8").digest("hex"),
|
||||
},
|
||||
});
|
||||
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",
|
||||
{
|
||||
method: "PUT",
|
||||
body: {
|
||||
...managedReaderBody,
|
||||
source: { ...scope, connectionId: "api-other-consumer" },
|
||||
capabilityDigest: createHash("sha256").update(ambiguousReaderToken, "utf8").digest("hex"),
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(ambiguousReader.status, 409);
|
||||
assert.equal((await ambiguousReader.json()).error, "managed_reader_source_scope_ambiguous");
|
||||
|
||||
const manualWriterBody = { source: scope, allowedDataProductIds: [productId], expiresAt: expiry };
|
||||
const signedOnlyManualProvisioning = await signedFetch("/internal/data-plane/v1/writer-bindings", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -49,8 +49,10 @@ try {
|
|||
const storedDefinition = await loadDataProductDefinition(pool, definition.id);
|
||||
const binding = {
|
||||
id: "reader-binding-test",
|
||||
bindingKey: "managed-reader-binding-test",
|
||||
tenantId: "tenant-test",
|
||||
connectionId: "connection-test",
|
||||
connectionId: "consumer-connection-test",
|
||||
sourceConnectionId: "connection-test",
|
||||
providerId: "provider-test",
|
||||
allowedDataProductIds: [definition.id],
|
||||
active: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { resolveManagedReaderSourceConnection } from "../src/reader-source-scope.mjs";
|
||||
|
||||
const policy = {
|
||||
tenantId: "tenant-01",
|
||||
connectionId: "consumer-connection",
|
||||
providerId: "provider-01",
|
||||
allowedDataProductIds: ["fleet.positions.current.v1"],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await resolveManagedReaderSourceConnection(db(["producer-connection"]), policy),
|
||||
"producer-connection",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveManagedReaderSourceConnection(db(["producer-connection", "consumer-connection"]), policy),
|
||||
"consumer-connection",
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveManagedReaderSourceConnection(db([]), policy),
|
||||
(error) => error?.status === 409 && error?.code === "managed_reader_source_scope_not_found",
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveManagedReaderSourceConnection(db(["producer-a", "producer-b"]), policy),
|
||||
(error) => error?.status === 409 && error?.code === "managed_reader_source_scope_ambiguous",
|
||||
);
|
||||
await assert.rejects(
|
||||
resolveManagedReaderSourceConnection(db(["producer-connection"]), { ...policy, allowedDataProductIds: [] }),
|
||||
(error) => error?.status === 400 && error?.code === "managed_reader_source_scope_request_invalid",
|
||||
);
|
||||
|
||||
function db(connectionIds) {
|
||||
return {
|
||||
async query(sql, values) {
|
||||
assert.match(sql, /external_data_plane_writer_bindings/);
|
||||
assert.deepEqual(values, [policy.tenantId, policy.providerId, policy.allowedDataProductIds]);
|
||||
return { rows: connectionIds.map((connectionId) => ({ connectionId })) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.log("external-data-plane reader source scope: ok");
|
||||
Loading…
Reference in New Issue