feat(foundry): support restricted subject aspects

This commit is contained in:
Codex 2026-07-24 09:31:34 +03:00
parent 03aa9e3e7e
commit 49f0c449c1
13 changed files with 313 additions and 15 deletions

View File

@ -115,6 +115,7 @@ export type MapDataProductBinding = {
subjectDetailProfileId?: string; subjectDetailProfileId?: string;
aspectId?: string; aspectId?: string;
joinToBindingId?: string; joinToBindingId?: string;
dataClass?: "operational" | "restricted";
}; };
export type MapSubjectDetailProfile = { export type MapSubjectDetailProfile = {
@ -532,6 +533,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
fact, fact,
bindingId: binding.id, bindingId: binding.id,
dataProductId: binding.dataProductId, dataProductId: binding.dataProductId,
dataClass: binding.dataClass ?? "operational",
}]]; }]];
})); }));
return buildMapSubjectCardModel(entity.fact, { return buildMapSubjectCardModel(entity.fact, {

View File

@ -1,4 +1,5 @@
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 DIRECT_IDENTIFIER_VALUE = /^(?:\+?\d[\d ()-]{8,18}|\d{14,16})$/; const DIRECT_IDENTIFIER_VALUE = /^(?:\+?\d[\d ()-]{8,18}|\d{14,16})$/;
export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE = Object.freeze({ export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE = Object.freeze({
@ -106,7 +107,12 @@ export const DEFAULT_MAP_SUBJECT_DETAIL_PROFILE = Object.freeze({
export function buildMapSubjectCardModel(fact, context = {}) { export function buildMapSubjectCardModel(fact, context = {}) {
if (!isPlainObject(fact)) return null; if (!isPlainObject(fact)) return null;
const profile = isPlainObject(context.profile) ? context.profile : DEFAULT_MAP_SUBJECT_DETAIL_PROFILE; const profile = isPlainObject(context.profile) ? context.profile : DEFAULT_MAP_SUBJECT_DETAIL_PROFILE;
const primary = { fact, dataProductId: context.dataProductId, bindingId: context.bindingId }; const primary = {
fact,
dataProductId: context.dataProductId,
bindingId: context.bindingId,
dataClass: context.dataClass ?? "operational",
};
const aspects = { primary, ...(isPlainObject(context.aspects) ? context.aspects : {}) }; const aspects = { primary, ...(isPlainObject(context.aspects) ? context.aspects : {}) };
const tabs = Array.isArray(profile.tabs) ? profile.tabs.flatMap((tab) => buildTab(tab, aspects)) : []; const tabs = Array.isArray(profile.tabs) ? profile.tabs.flatMap((tab) => buildTab(tab, aspects)) : [];
if (!tabs.length) return null; if (!tabs.length) return null;
@ -138,15 +144,24 @@ function buildSection(section, aspects) {
const rows = []; const rows = [];
const readings = []; const readings = [];
for (const field of Array.isArray(section.fields) ? section.fields : []) { for (const field of Array.isArray(section.fields) ? section.fields : []) {
if (!isPlainObject(field) || SECRET_LIKE.test(String(field.field || "")) || SECRET_LIKE.test(String(field.label || ""))) continue; if (!isPlainObject(field)
|| SECRET_MATERIAL.test(String(field.field || ""))
|| SECRET_MATERIAL.test(String(field.label || ""))) continue;
const aspect = aspects[stringValue(field.aspectId) || "primary"]; const aspect = aspects[stringValue(field.aspectId) || "primary"];
if (!isPlainObject(aspect) || !isPlainObject(aspect.fact)) continue; if (!isPlainObject(aspect) || !isPlainObject(aspect.fact)) continue;
const restricted = field.dataClass === "restricted" && aspect.dataClass === "restricted";
if (
(RESTRICTED_IDENTIFIER.test(String(field.field || ""))
|| RESTRICTED_IDENTIFIER.test(String(field.label || "")))
&& !restricted
) continue;
const value = resolveFieldValue(aspect, field); const value = resolveFieldValue(aspect, field);
if (field.format === "telemetry_readings") { if (field.format === "telemetry_readings") {
readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds)); readings.push(...normalizeTelemetryReadings(value, field.allowedReadingIds));
continue; continue;
} }
if (value === undefined || value === null || value === "") continue; if (value === undefined || value === null || value === "") continue;
if (typeof value === "string" && DIRECT_IDENTIFIER_VALUE.test(value.trim()) && !restricted) continue;
rows.push({ rows.push({
key: String(field.id || `${field.source}.${field.field}`), key: String(field.id || `${field.source}.${field.field}`),
label: stringValue(field.label), label: stringValue(field.label),
@ -171,13 +186,21 @@ function resolveFieldValue(aspect, field) {
export function normalizeTelemetryReadings(value, allowedReadingIds = []) { export function normalizeTelemetryReadings(value, allowedReadingIds = []) {
if (!Array.isArray(value) || !Array.isArray(allowedReadingIds) || allowedReadingIds.length === 0) return []; if (!Array.isArray(value) || !Array.isArray(allowedReadingIds) || allowedReadingIds.length === 0) return [];
const allowed = new Set(allowedReadingIds.filter((item) => safeIdentifier(item) && !SECRET_LIKE.test(item))); const allowed = new Set(allowedReadingIds.filter(
(item) => safeIdentifier(item) && !SECRET_MATERIAL.test(item) && !RESTRICTED_IDENTIFIER.test(item),
));
const seen = new Set(); const seen = new Set();
return value.slice(0, 128).flatMap((candidate) => { return value.slice(0, 128).flatMap((candidate) => {
if (!isPlainObject(candidate)) return []; if (!isPlainObject(candidate)) return [];
const id = stringValue(candidate.id); const id = stringValue(candidate.id);
const label = stringValue(candidate.label); const label = stringValue(candidate.label);
if (!allowed.has(id) || !label || seen.has(id) || SECRET_LIKE.test(id) || SECRET_LIKE.test(label)) return []; if (!allowed.has(id)
|| !label
|| seen.has(id)
|| SECRET_MATERIAL.test(id)
|| SECRET_MATERIAL.test(label)
|| RESTRICTED_IDENTIFIER.test(id)
|| RESTRICTED_IDENTIFIER.test(label)) return [];
if (!isSafeScalar(candidate.value)) return []; if (!isSafeScalar(candidate.value)) return [];
seen.add(id); seen.add(id);
const unit = stringValue(candidate.unit).slice(0, 32); const unit = stringValue(candidate.unit).slice(0, 32);
@ -217,7 +240,10 @@ function isSafeScalar(value) {
if (typeof value === "number") return Number.isFinite(value) && (!Number.isInteger(value) || Math.abs(value) < 1_000_000_000_000); if (typeof value === "number") return Number.isFinite(value) && (!Number.isInteger(value) || Math.abs(value) < 1_000_000_000_000);
if (typeof value !== "string") return false; if (typeof value !== "string") return false;
const normalized = value.trim(); const normalized = value.trim();
return normalized.length <= 512 && !SECRET_LIKE.test(normalized) && !DIRECT_IDENTIFIER_VALUE.test(normalized); return normalized.length <= 512
&& !SECRET_MATERIAL.test(normalized)
&& !RESTRICTED_IDENTIFIER.test(normalized)
&& !DIRECT_IDENTIFIER_VALUE.test(normalized);
} }
function safeIdentifier(value) { function safeIdentifier(value) {

View File

@ -305,6 +305,7 @@ export function useMapDataProductRuntime({
semanticTypes: binding.semanticTypes, semanticTypes: binding.semanticTypes,
fieldProjection: binding.fieldProjection, fieldProjection: binding.fieldProjection,
presentationProfileId: binding.presentationProfileId, presentationProfileId: binding.presentationProfileId,
dataClass: binding.dataClass ?? "operational",
}))), }))),
[bindings], [bindings],
); );

View File

@ -149,6 +149,42 @@
}, },
"removeMode": "canonical-tombstone-or-snapshot-rebase" "removeMode": "canonical-tombstone-or-snapshot-rebase"
}, },
{
"id": "map-moving-object-unit-contacts-current-v1",
"version": "1.0.0",
"dataProductId": "fleet.units.contacts.current.v1",
"productVersion": "1.0.0",
"dataClass": "restricted",
"freshness": "none",
"staleAfterMs": null,
"terminalStatuses": [],
"consumerContract": {
"ontologyRevision": "ontology.map.moving_object.v3",
"deliveryMode": "snapshot+patch",
"semanticTypes": [
"map.moving_object"
],
"fieldProjection": [
"device_imei",
"device_phone_primary",
"device_phone_secondary",
"display_name",
"provider_creator_login"
],
"fieldTypes": {
"device_imei": "string",
"device_phone_primary": "string",
"device_phone_secondary": "string",
"display_name": "string",
"provider_creator_login": "string"
},
"geometryTypes": [],
"subjectIdentity": "semantic-type+source-id",
"snapshotMode": "atomic-replace",
"patchMode": "atomic"
},
"removeMode": "canonical-tombstone-or-snapshot-rebase"
},
{ {
"id": "map-zone-current-v1", "id": "map-zone-current-v1",
"version": "1.0.0", "version": "1.0.0",

View File

@ -83,6 +83,7 @@
"order": { "type": "integer", "minimum": 0, "maximum": 10000 }, "order": { "type": "integer", "minimum": 0, "maximum": 10000 },
"aspectId": { "type": "string", "minLength": 1, "maxLength": 160 }, "aspectId": { "type": "string", "minLength": 1, "maxLength": 160 },
"joinToBindingId": { "type": "string", "minLength": 1, "maxLength": 128 }, "joinToBindingId": { "type": "string", "minLength": 1, "maxLength": 128 },
"dataClass": { "enum": ["operational", "restricted"] },
"subjectDetailProfileId": { "type": "string", "minLength": 1, "maxLength": 160 } "subjectDetailProfileId": { "type": "string", "minLength": 1, "maxLength": 160 }
} }
} }

View File

@ -132,6 +132,73 @@ test("subject details compose declared aspect bindings by stable source id", ()
assert.equal(card.tabs[0].sections[0].rows[0].value, "cargo_trike"); assert.equal(card.tabs[0].sections[0].rows[0].value, "cargo_trike");
}); });
test("restricted joined aspects render declared identifiers and remain fail-closed otherwise", () => {
const profile = {
id: "map.subject-detail.restricted",
version: "1.0.0",
title: "Restricted",
semanticTypes: ["map.moving_object"],
defaultTabId: "contacts",
tabs: [{
id: "contacts",
label: "Контакты",
emptyMessage: "Нет данных",
sections: [{
id: "device",
label: "Контакты устройства",
fields: [
{
id: "contacts.phone",
aspectId: "unit_contacts",
source: "attribute",
field: "device_phone_primary",
label: "Телефон 1",
format: "text",
dataClass: "restricted",
},
{
id: "contacts.imei",
aspectId: "unit_contacts",
source: "attribute",
field: "device_imei",
label: "IMEI",
format: "text",
dataClass: "restricted",
},
],
}],
}],
};
const contactAspect = {
bindingId: "trike-unit-contacts",
dataProductId: "fleet.units.contacts.current.v1",
dataClass: "restricted",
fact: {
sourceId: fact.sourceId,
semanticType: "map.moving_object",
attributes: {
device_phone_primary: "+79991234567",
device_imei: "867236078012345",
},
},
};
const card = buildMapSubjectCardModel(fact, {
profile,
aspects: { unit_contacts: contactAspect },
});
assert.deepEqual(
card.tabs[0].sections[0].rows.map((row) => row.value),
["+79991234567", "867236078012345"],
);
const rejected = buildMapSubjectCardModel(fact, {
profile,
aspects: {
unit_contacts: { ...contactAspect, dataClass: "operational" },
},
});
assert.equal(rejected.tabs[0].empty, true);
});
test("Cesium pin and label share one entity selection that opens the subject card", async () => { test("Cesium pin and label share one entity selection that opens the subject card", async () => {
const [renderer, preview] = await Promise.all([ const [renderer, preview] = await Promise.all([
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),

View File

@ -80,6 +80,7 @@ for (const policy of consumerPolicies) {
"terminalStatuses", "terminalStatuses",
"statusContract", "statusContract",
"consumerContract", "consumerContract",
"dataClass",
"removeMode", "removeMode",
]); ]);
requireValue(Object.keys(policy).every((key) => allowedPolicyKeys.has(key)), `unsupported data product consumer policy key: ${policy.id ?? "unknown"}`); requireValue(Object.keys(policy).every((key) => allowedPolicyKeys.has(key)), `unsupported data product consumer policy key: ${policy.id ?? "unknown"}`);
@ -89,6 +90,7 @@ for (const policy of consumerPolicies) {
requireValue(typeof policy.productVersion === "string" && /^\d+\.\d+\.\d+$/.test(policy.productVersion), `invalid data product consumer product version: ${policy.id ?? "unknown"}`); requireValue(typeof policy.productVersion === "string" && /^\d+\.\d+\.\d+$/.test(policy.productVersion), `invalid data product consumer product version: ${policy.id ?? "unknown"}`);
requireValue(Array.isArray(policy.terminalStatuses) && policy.terminalStatuses.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid terminal statuses: ${policy.id ?? "unknown"}`); requireValue(Array.isArray(policy.terminalStatuses) && policy.terminalStatuses.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid terminal statuses: ${policy.id ?? "unknown"}`);
requireValue(policy.removeMode === "canonical-tombstone-or-snapshot-rebase", `invalid remove mode: ${policy.id ?? "unknown"}`); requireValue(policy.removeMode === "canonical-tombstone-or-snapshot-rebase", `invalid remove mode: ${policy.id ?? "unknown"}`);
requireValue(["operational", "restricted"].includes(policy.dataClass ?? "operational"), `invalid data class: ${policy.id ?? "unknown"}`);
requireValue(["none", "observed-at"].includes(freshness), `invalid freshness mode: ${policy.id ?? "unknown"}`); requireValue(["none", "observed-at"].includes(freshness), `invalid freshness mode: ${policy.id ?? "unknown"}`);
if (policy.consumerContract !== undefined) { if (policy.consumerContract !== undefined) {
const contract = policy.consumerContract; const contract = policy.consumerContract;
@ -205,6 +207,42 @@ requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.fieldProjecti
requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.geometryTypes) === JSON.stringify([]), "fleet.units.profile.current.v1 must remain a non-geometric aspect"); requireValue(JSON.stringify(unitProfileV1Policy?.consumerContract?.geometryTypes) === JSON.stringify([]), "fleet.units.profile.current.v1 must remain a non-geometric aspect");
requireValue(!/(provider|tenant|endpoint|credential|token|secret|authorization)/i.test(JSON.stringify(unitProfileV1Policy)), "fleet.units.profile.current.v1 consumer policy crosses the transport/secret boundary"); requireValue(!/(provider|tenant|endpoint|credential|token|secret|authorization)/i.test(JSON.stringify(unitProfileV1Policy)), "fleet.units.profile.current.v1 consumer policy crosses the transport/secret boundary");
const unitContactsV1Policy = consumerPolicies.find(
(policy) => policy.dataProductId === "fleet.units.contacts.current.v1"
&& policy.productVersion === "1.0.0",
);
const unitContactsV1Projection = [
"device_imei",
"device_phone_primary",
"device_phone_secondary",
"display_name",
"provider_creator_login",
];
requireValue(
unitContactsV1Policy?.id === "map-moving-object-unit-contacts-current-v1"
&& unitContactsV1Policy?.version === "1.0.0",
"fleet.units.contacts.current.v1 consumer policy is missing",
);
requireValue(
unitContactsV1Policy?.dataClass === "restricted",
"fleet.units.contacts.current.v1 must be explicitly restricted",
);
requireValue(
JSON.stringify(unitContactsV1Policy?.consumerContract?.fieldProjection)
=== JSON.stringify(unitContactsV1Projection),
"fleet.units.contacts.current.v1 field projection must be exact",
);
requireValue(
JSON.stringify(unitContactsV1Policy?.consumerContract?.geometryTypes) === JSON.stringify([]),
"fleet.units.contacts.current.v1 must remain a non-geometric aspect",
);
requireValue(
!/(tenant|endpoint|credential|token|secret|authorization)/i.test(
JSON.stringify(unitContactsV1Policy),
),
"fleet.units.contacts.current.v1 consumer policy crosses the transport/secret boundary",
);
const ids = components.components.map((component) => component.id); const ids = components.components.map((component) => component.id);
requireValue(new Set(ids).size === ids.length, "component ids must be unique"); requireValue(new Set(ids).size === ids.length, "component ids must be unique");
const candidateIds = candidates.candidates.map((component) => component.id); const candidateIds = candidates.candidates.map((component) => component.id);

View File

@ -348,6 +348,9 @@ function validateMapDataProductBinding(value) {
const joinToBindingId = value.joinToBindingId === undefined const joinToBindingId = value.joinToBindingId === undefined
? null ? null
: requireNonEmptyString(value.joinToBindingId, "invalid_map_data_product_join_binding_id", 128); : 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(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(dataProductId)) throw applicationError("invalid_map_data_product_id");
if (!/^[A-Za-z0-9-]+$/.test(slotId)) throw applicationError("invalid_map_data_product_slot"); 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)) { if (joinToBindingId && !/^[A-Za-z0-9._:-]+$/.test(joinToBindingId)) {
throw applicationError("invalid_map_data_product_join_binding_id"); 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))) { 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"); throw applicationError("map_data_product_binding_contains_transport");
} }
@ -383,6 +389,7 @@ function validateMapDataProductBinding(value) {
...(subjectDetailProfileId ? { subjectDetailProfileId } : {}), ...(subjectDetailProfileId ? { subjectDetailProfileId } : {}),
aspectId, aspectId,
...(joinToBindingId ? { joinToBindingId } : {}), ...(joinToBindingId ? { joinToBindingId } : {}),
dataClass,
}; };
} }
@ -524,9 +531,27 @@ function validateMapPageLayout(value) {
} }
} }
for (const primary of dataProductBindings.filter((binding) => !binding.joinToBindingId)) { for (const primary of dataProductBindings.filter((binding) => !binding.joinToBindingId)) {
const aspectIds = [primary, ...dataProductBindings.filter((binding) => binding.joinToBindingId === primary.id)] const composition = [
.map((binding) => binding.aspectId); 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"); 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 { return {
schemaVersion: 1, schemaVersion: 1,

View File

@ -80,6 +80,7 @@ function safeTarget(target) {
delivery: "snapshot+patch", delivery: "snapshot+patch",
semanticTypes: [...(binding.semanticTypes || [])], semanticTypes: [...(binding.semanticTypes || [])],
fieldProjection: [...(binding.fieldProjection || [])], 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); throw consumerError("data_product_consumer_policy_invalid", 500);
} }
const consumerContract = validateConsumerContract(policy.consumerContract); const consumerContract = validateConsumerContract(policy.consumerContract);
const dataClass = policy.dataClass ?? "operational";
if (!["operational", "restricted"].includes(dataClass)) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
return { return {
id: String(policy.id || ""), id: String(policy.id || ""),
version: String(policy.version || ""), version: String(policy.version || ""),
dataProductId: product.id, dataProductId: product.id,
productVersion: product.version, productVersion: product.version,
dataClass,
freshness, freshness,
staleAfterMs: policy.staleAfterMs, staleAfterMs: policy.staleAfterMs,
terminalStatuses: [...terminalStatuses], terminalStatuses: [...terminalStatuses],
@ -450,6 +456,9 @@ export function createFoundryDataProductConsumerManager({
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409); throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
} }
const policy = validatePolicy(resolvePolicy(product), product); 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); assertConsumerContract(policy.consumerContract, product, target.binding);
if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) { if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) {
throw consumerError("data_product_consumer_status_field_not_projected", 409); throw consumerError("data_product_consumer_status_field_not_projected", 409);

View File

@ -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 });
}
}
});

View File

@ -310,6 +310,11 @@ const mapSubjectDetailProfileFieldInputSchema = {
properties: { properties: {
id: { type: "string", description: "Stable field presentation id." }, id: { type: "string", description: "Stable field presentation id." },
aspectId: { type: "string", description: "Provider-neutral subject aspect id; defaults to primary." }, 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"] }, source: { type: "string", enum: ["fact", "attribute", "geometry", "context"] },
field: { type: "string", description: "Exact registered fact, context, geometry or provider-neutral attribute field." }, field: { type: "string", description: "Exact registered fact, context, geometry or provider-neutral attribute field." },
label: { type: "string", minLength: 1, maxLength: 100 }, 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." }, 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." }, 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." }, 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.",
},
}, },
}, },
}, },

View File

@ -1,16 +1,20 @@
const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/;
const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/; const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/;
const SEMVER = /^\d+\.\d+\.\d+$/; 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 PROFILE_KEYS = new Set(["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
const TAB_KEYS = new Set(["id", "label", "emptyMessage", "sections"]); const TAB_KEYS = new Set(["id", "label", "emptyMessage", "sections"]);
const SECTION_KEYS = new Set(["id", "label", "fields"]); 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 SOURCES = new Set(["fact", "attribute", "geometry", "context"]);
const FORMATS = new Set([ const FORMATS = new Set([
"text", "number", "timestamp", "boolean", "coordinate", "text", "number", "timestamp", "boolean", "coordinate",
"signal_state", "movement_state", "telemetry_readings", "signal_state", "movement_state", "telemetry_readings",
]); ]);
const DATA_CLASSES = new Set(["operational", "restricted"]);
const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]); const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]);
const GEOMETRY_FIELDS = new Set(["latitude", "longitude"]); const GEOMETRY_FIELDS = new Set(["latitude", "longitude"]);
const CONTEXT_FIELDS = new Set(["dataProductId", "bindingId"]); 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 (!SOURCES.has(source)) fail("invalid_map_subject_detail_profile_field_source");
if (!FORMATS.has(format)) fail("invalid_map_subject_detail_profile_field_format"); if (!FORMATS.has(format)) fail("invalid_map_subject_detail_profile_field_format");
const field = sourceField(source, value.field); 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 const allowedReadingIds = value.allowedReadingIds === undefined
? [] ? []
: fields(value.allowedReadingIds, 0, 256, "invalid_map_subject_detail_profile_reading_ids"); : 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")) { if (format === "telemetry_readings" && (source !== "attribute" || field !== "sensor_readings")) {
fail("map_subject_detail_profile_readings_source_invalid"); 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"), aspectId: identifier(value.aspectId ?? "primary", "invalid_map_subject_detail_profile_aspect_id"),
source, source,
field, field,
label: safeLabel(value.label, 100, "invalid_map_subject_detail_profile_field_label"), label: fieldLabel(value.label, dataClass),
format, format,
dataClass,
...(unit ? { unit } : {}), ...(unit ? { unit } : {}),
...(format === "telemetry_readings" ? { allowedReadingIds } : {}), ...(format === "telemetry_readings" ? { allowedReadingIds } : {}),
}; };
@ -143,7 +159,15 @@ function fields(value, min, max, code) {
function safeLabel(value, max, code) { function safeLabel(value, max, code) {
const normalized = text(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; return normalized;
} }

View File

@ -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", () => { test("restricted fields and reading ids cannot be introduced through MCP profiles", () => {
const restrictedField = structuredClone(registry.profiles[0]); const restrictedField = structuredClone(registry.profiles[0]);
restrictedField.tabs[0].sections[0].fields[0].field = "api_key"; 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]); const restrictedReading = structuredClone(registry.profiles[0]);
restrictedReading.tabs.find((tab) => tab.id === "telemetry").sections[0].fields[0].allowedReadingIds = ["sensor.imei"]; 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/); 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", () => { test("profiles reject unknown schema fields and duplicate presentation ids", () => {
const unknown = structuredClone(registry.profiles[0]); const unknown = structuredClone(registry.profiles[0]);
unknown.provider = "gelios"; unknown.provider = "gelios";