feat(foundry): support restricted subject aspects
This commit is contained in:
@@ -348,6 +348,9 @@ function validateMapDataProductBinding(value) {
|
||||
const joinToBindingId = value.joinToBindingId === undefined
|
||||
? null
|
||||
: requireNonEmptyString(value.joinToBindingId, "invalid_map_data_product_join_binding_id", 128);
|
||||
const dataClass = value.dataClass === undefined
|
||||
? "operational"
|
||||
: requireNonEmptyString(value.dataClass, "invalid_map_data_product_data_class", 32);
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(id)) throw applicationError("invalid_map_data_product_binding_id");
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(dataProductId)) throw applicationError("invalid_map_data_product_id");
|
||||
if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot");
|
||||
@@ -367,6 +370,9 @@ function validateMapDataProductBinding(value) {
|
||||
if (joinToBindingId && !/^[A-Za-z0-9._:-]+$/.test(joinToBindingId)) {
|
||||
throw applicationError("invalid_map_data_product_join_binding_id");
|
||||
}
|
||||
if (!new Set(["operational", "restricted"]).has(dataClass)) {
|
||||
throw applicationError("invalid_map_data_product_data_class");
|
||||
}
|
||||
if (Object.keys(value).some((key) => /(provider|tenant|connection|endpoint|url|credential|token|secret|payload)/i.test(key))) {
|
||||
throw applicationError("map_data_product_binding_contains_transport");
|
||||
}
|
||||
@@ -383,6 +389,7 @@ function validateMapDataProductBinding(value) {
|
||||
...(subjectDetailProfileId ? { subjectDetailProfileId } : {}),
|
||||
aspectId,
|
||||
...(joinToBindingId ? { joinToBindingId } : {}),
|
||||
dataClass,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -524,9 +531,27 @@ function validateMapPageLayout(value) {
|
||||
}
|
||||
}
|
||||
for (const primary of dataProductBindings.filter((binding) => !binding.joinToBindingId)) {
|
||||
const aspectIds = [primary, ...dataProductBindings.filter((binding) => binding.joinToBindingId === primary.id)]
|
||||
.map((binding) => binding.aspectId);
|
||||
const composition = [
|
||||
primary,
|
||||
...dataProductBindings.filter((binding) => binding.joinToBindingId === primary.id),
|
||||
];
|
||||
const aspectIds = composition.map((binding) => binding.aspectId);
|
||||
if (new Set(aspectIds).size !== aspectIds.length) throw applicationError("duplicate_map_data_product_aspect_id");
|
||||
const detailProfile = primary.subjectDetailProfileId
|
||||
? subjectDetailProfilesById.get(primary.subjectDetailProfileId)
|
||||
: null;
|
||||
if (!detailProfile) continue;
|
||||
const aspectsById = new Map(composition.map((binding) => [binding.aspectId, binding]));
|
||||
const detailFields = detailProfile.tabs.flatMap(
|
||||
(tab) => tab.sections.flatMap((section) => section.fields),
|
||||
);
|
||||
for (const field of detailFields) {
|
||||
const aspect = aspectsById.get(field.aspectId);
|
||||
if (!aspect) throw applicationError("map_subject_detail_profile_aspect_not_found");
|
||||
if (field.dataClass !== aspect.dataClass) {
|
||||
throw applicationError("map_subject_detail_profile_data_class_mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -80,6 +80,7 @@ function safeTarget(target) {
|
||||
delivery: "snapshot+patch",
|
||||
semanticTypes: [...(binding.semanticTypes || [])],
|
||||
fieldProjection: [...(binding.fieldProjection || [])],
|
||||
dataClass: binding.dataClass === "restricted" ? "restricted" : "operational",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,11 +272,16 @@ function validatePolicy(policy, product) {
|
||||
throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
}
|
||||
const consumerContract = validateConsumerContract(policy.consumerContract);
|
||||
const dataClass = policy.dataClass ?? "operational";
|
||||
if (!["operational", "restricted"].includes(dataClass)) {
|
||||
throw consumerError("data_product_consumer_policy_invalid", 500);
|
||||
}
|
||||
return {
|
||||
id: String(policy.id || ""),
|
||||
version: String(policy.version || ""),
|
||||
dataProductId: product.id,
|
||||
productVersion: product.version,
|
||||
dataClass,
|
||||
freshness,
|
||||
staleAfterMs: policy.staleAfterMs,
|
||||
terminalStatuses: [...terminalStatuses],
|
||||
@@ -450,6 +456,9 @@ export function createFoundryDataProductConsumerManager({
|
||||
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
|
||||
}
|
||||
const policy = validatePolicy(resolvePolicy(product), product);
|
||||
if ((target.binding.dataClass ?? "operational") !== policy.dataClass) {
|
||||
throw consumerError("data_product_consumer_data_class_mismatch", 409);
|
||||
}
|
||||
assertConsumerContract(policy.consumerContract, product, target.binding);
|
||||
if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) {
|
||||
throw consumerError("data_product_consumer_status_field_not_projected", 409);
|
||||
|
||||
@@ -1079,3 +1079,46 @@ test("non-geometric unit profile aspect fails closed on projection drift and inj
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("restricted aspect consumers require the exact declared binding data class", async () => {
|
||||
for (const [bindingDataClass, expected] of [
|
||||
["restricted", "ok"],
|
||||
["operational", "data_product_consumer_data_class_mismatch"],
|
||||
]) {
|
||||
const stateDir = await mkdtemp(join(tmpdir(), `foundry-consumer-data-class-${bindingDataClass}-`));
|
||||
const currentTarget = unitProfileV1Target();
|
||||
currentTarget.binding.dataClass = bindingDataClass;
|
||||
const manager = createFoundryDataProductConsumerManager({
|
||||
stateDir,
|
||||
dataPlaneUrl: "http://edp.test",
|
||||
resolveTarget: async () => structuredClone(currentTarget),
|
||||
readReaderToken: async () => "ndc_edprb_profile-reader-capability",
|
||||
inspectReaderGrant: async () => ({
|
||||
product: unitProfileV1Product,
|
||||
readerGrantAction: "reuse",
|
||||
readerGrantGeneration: 1,
|
||||
}),
|
||||
resolvePolicy: () => ({ ...unitProfileV1Policy, dataClass: "restricted" }),
|
||||
sanitizeSnapshot: (value) => value,
|
||||
sanitizePatch: (value) => value,
|
||||
fetchImpl: async () => Response.json(unitProfileV1Snapshot()),
|
||||
});
|
||||
const input = {
|
||||
applicationId: currentTarget.application.id,
|
||||
pageId: currentTarget.page.id,
|
||||
bindingId: currentTarget.binding.id,
|
||||
};
|
||||
try {
|
||||
if (expected === "ok") {
|
||||
const plan = await manager.plan(input);
|
||||
assert.equal(plan.configuration.policy.dataClass, "restricted");
|
||||
assert.equal(plan.configuration.target.dataClass, "restricted");
|
||||
} else {
|
||||
await assert.rejects(manager.plan(input), new RegExp(expected));
|
||||
}
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -310,6 +310,11 @@ const mapSubjectDetailProfileFieldInputSchema = {
|
||||
properties: {
|
||||
id: { type: "string", description: "Stable field presentation id." },
|
||||
aspectId: { type: "string", description: "Provider-neutral subject aspect id; defaults to primary." },
|
||||
dataClass: {
|
||||
type: "string",
|
||||
enum: ["operational", "restricted"],
|
||||
description: "Explicit field presentation class. Restricted identifiers require a restricted joined aspect.",
|
||||
},
|
||||
source: { type: "string", enum: ["fact", "attribute", "geometry", "context"] },
|
||||
field: { type: "string", description: "Exact registered fact, context, geometry or provider-neutral attribute field." },
|
||||
label: { type: "string", minLength: 1, maxLength: 100 },
|
||||
@@ -715,6 +720,11 @@ const tools = [
|
||||
subjectDetailProfileId: { type: "string", description: "Existing page-owned provider-neutral subject detail profile id." },
|
||||
aspectId: { type: "string", description: "Stable provider-neutral aspect id inside the selected subject composition." },
|
||||
joinToBindingId: { type: "string", description: "Primary Map binding whose stable sourceId is used to join this subject-details aspect." },
|
||||
dataClass: {
|
||||
type: "string",
|
||||
enum: ["operational", "restricted"],
|
||||
description: "Declared data class, verified against the versioned Foundry consumer policy before runtime.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/;
|
||||
const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+$/;
|
||||
const SECRET_LIKE = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
|
||||
const SECRET_MATERIAL = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|decrypt|raw[_-]?params?)/i;
|
||||
const RESTRICTED_IDENTIFIER = /(?:imei|phone|telephone|address)/i;
|
||||
const PROFILE_KEYS = new Set(["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
|
||||
const TAB_KEYS = new Set(["id", "label", "emptyMessage", "sections"]);
|
||||
const SECTION_KEYS = new Set(["id", "label", "fields"]);
|
||||
const FIELD_KEYS = new Set(["id", "aspectId", "source", "field", "label", "format", "unit", "allowedReadingIds"]);
|
||||
const FIELD_KEYS = new Set([
|
||||
"id", "aspectId", "source", "field", "label", "format", "unit", "allowedReadingIds", "dataClass",
|
||||
]);
|
||||
const SOURCES = new Set(["fact", "attribute", "geometry", "context"]);
|
||||
const FORMATS = new Set([
|
||||
"text", "number", "timestamp", "boolean", "coordinate",
|
||||
"signal_state", "movement_state", "telemetry_readings",
|
||||
]);
|
||||
const DATA_CLASSES = new Set(["operational", "restricted"]);
|
||||
const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]);
|
||||
const GEOMETRY_FIELDS = new Set(["latitude", "longitude"]);
|
||||
const CONTEXT_FIELDS = new Set(["dataProductId", "bindingId"]);
|
||||
@@ -75,11 +79,22 @@ function normalizeField(value) {
|
||||
if (!SOURCES.has(source)) fail("invalid_map_subject_detail_profile_field_source");
|
||||
if (!FORMATS.has(format)) fail("invalid_map_subject_detail_profile_field_format");
|
||||
const field = sourceField(source, value.field);
|
||||
if (source === "attribute" && SECRET_LIKE.test(field)) fail("map_subject_detail_profile_restricted_field");
|
||||
const dataClass = value.dataClass === undefined
|
||||
? "operational"
|
||||
: text(value.dataClass, 32, "invalid_map_subject_detail_profile_field_data_class");
|
||||
if (!DATA_CLASSES.has(dataClass)) fail("invalid_map_subject_detail_profile_field_data_class");
|
||||
if (source === "attribute" && SECRET_MATERIAL.test(field)) {
|
||||
fail("map_subject_detail_profile_secret_field");
|
||||
}
|
||||
if (source === "attribute" && RESTRICTED_IDENTIFIER.test(field) && dataClass !== "restricted") {
|
||||
fail("map_subject_detail_profile_restricted_field");
|
||||
}
|
||||
const allowedReadingIds = value.allowedReadingIds === undefined
|
||||
? []
|
||||
: fields(value.allowedReadingIds, 0, 256, "invalid_map_subject_detail_profile_reading_ids");
|
||||
if (allowedReadingIds.some((item) => SECRET_LIKE.test(item))) fail("map_subject_detail_profile_restricted_reading_id");
|
||||
if (allowedReadingIds.some((item) => SECRET_MATERIAL.test(item) || RESTRICTED_IDENTIFIER.test(item))) {
|
||||
fail("map_subject_detail_profile_restricted_reading_id");
|
||||
}
|
||||
if (format === "telemetry_readings" && (source !== "attribute" || field !== "sensor_readings")) {
|
||||
fail("map_subject_detail_profile_readings_source_invalid");
|
||||
}
|
||||
@@ -93,8 +108,9 @@ function normalizeField(value) {
|
||||
aspectId: identifier(value.aspectId ?? "primary", "invalid_map_subject_detail_profile_aspect_id"),
|
||||
source,
|
||||
field,
|
||||
label: safeLabel(value.label, 100, "invalid_map_subject_detail_profile_field_label"),
|
||||
label: fieldLabel(value.label, dataClass),
|
||||
format,
|
||||
dataClass,
|
||||
...(unit ? { unit } : {}),
|
||||
...(format === "telemetry_readings" ? { allowedReadingIds } : {}),
|
||||
};
|
||||
@@ -143,7 +159,15 @@ function fields(value, min, max, code) {
|
||||
|
||||
function safeLabel(value, max, code) {
|
||||
const normalized = text(value, max, code);
|
||||
if (SECRET_LIKE.test(normalized)) fail(code);
|
||||
if (SECRET_MATERIAL.test(normalized)) fail(code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function fieldLabel(value, dataClass) {
|
||||
const normalized = safeLabel(value, 100, "invalid_map_subject_detail_profile_field_label");
|
||||
if (RESTRICTED_IDENTIFIER.test(normalized) && dataClass !== "restricted") {
|
||||
fail("map_subject_detail_profile_restricted_field");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,29 @@ test("dynamic sensor readings are fail-closed until ids are explicitly classifie
|
||||
test("restricted fields and reading ids cannot be introduced through MCP profiles", () => {
|
||||
const restrictedField = structuredClone(registry.profiles[0]);
|
||||
restrictedField.tabs[0].sections[0].fields[0].field = "api_key";
|
||||
assert.throws(() => normalizeMapSubjectDetailProfile(restrictedField), /map_subject_detail_profile_restricted_field/);
|
||||
assert.throws(() => normalizeMapSubjectDetailProfile(restrictedField), /map_subject_detail_profile_secret_field/);
|
||||
|
||||
const restrictedReading = structuredClone(registry.profiles[0]);
|
||||
restrictedReading.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0].allowedReadingIds = ["sensor.imei"];
|
||||
assert.throws(() => normalizeMapSubjectDetailProfile(restrictedReading), /map_subject_detail_profile_restricted_reading_id/);
|
||||
});
|
||||
|
||||
test("restricted identifiers require an explicit restricted presentation class", () => {
|
||||
const profile = structuredClone(registry.profiles[0]);
|
||||
const field = profile.tabs[0].sections[0].fields[0];
|
||||
field.field = "device_phone_primary";
|
||||
field.label = "Телефон 1";
|
||||
assert.throws(
|
||||
() => normalizeMapSubjectDetailProfile(profile),
|
||||
/map_subject_detail_profile_restricted_field/,
|
||||
);
|
||||
field.dataClass = "restricted";
|
||||
assert.equal(
|
||||
normalizeMapSubjectDetailProfile(profile).tabs[0].sections[0].fields[0].dataClass,
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
|
||||
test("profiles reject unknown schema fields and duplicate presentation ids", () => {
|
||||
const unknown = structuredClone(registry.profiles[0]);
|
||||
unknown.provider = "gelios";
|
||||
|
||||
Reference in New Issue
Block a user