From d6c62da470acee369c122b8e91a533f8d25b2c77 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 12:03:08 +0300 Subject: [PATCH] feat(device-manager): add safe device enrollment --- .../server/device-core-client.mjs | 146 +++++++++++++++++- .../server/device-core-client.test.mjs | 54 ++++++- .../device-manager/src/DeviceControlViews.tsx | 85 +++++++++- apps/device-manager/src/DeviceManagerApp.tsx | 109 ++++++++++++- apps/device-manager/src/api.ts | 12 ++ apps/device-manager/src/types.ts | 4 +- 6 files changed, 396 insertions(+), 14 deletions(-) diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs index 58ba12d..d9186ba 100644 --- a/apps/device-manager/server/device-core-client.mjs +++ b/apps/device-manager/server/device-core-client.mjs @@ -89,6 +89,7 @@ export function createLocalPreviewDeviceCore() { const modelProfiles = new Map(); const edges = new Map(); const routes = new Map(); + const enrollments = new Map(); const bindings = new Map(); const grants = new Map(); const configurationRevisions = new Map(); @@ -131,7 +132,7 @@ export function createLocalPreviewDeviceCore() { project: projectSummary(project), devices: [], discoveries: [], - enrollments: [], + enrollments: projectValues(enrollments, projectRef), collections: projectValues(collections, projectRef) .map(({ projectRef: _projectRef, ...collection }) => collection), adapterPackages: [...adapterPackages.values()], @@ -268,6 +269,12 @@ export function createLocalPreviewDeviceCore() { ); const adapterPackageRef = existing?.adapterPackageRef || `adapter-package:${randomUUID()}`; + assertPreviewTransition( + existing?.lifecycleState, + input.lifecycleState ?? "active", + previewTransitions.adapterPackage, + "device_adapter_package_transition_invalid", + ); const adapterPackage = { adapterPackageRef, packageKey: input.packageKey, @@ -282,15 +289,25 @@ export function createLocalPreviewDeviceCore() { } if (command === "adapter-versions:register") { requirePlatformOwner(actor); - if (!adapterPackages.has(input.adapterPackageRef)) { + const adapterPackage = adapterPackages.get(input.adapterPackageRef); + if (!adapterPackage) { throw serviceError("device_adapter_package_not_found", 404); } + if (adapterPackage.lifecycleState !== "active") { + throw serviceError("device_adapter_package_inactive", 409); + } const existing = [...adapterVersions.values()].find((entry) => entry.adapterPackageRef === input.adapterPackageRef && entry.version === input.version ); const adapterVersionRef = existing?.adapterVersionRef || `adapter-version:${randomUUID()}`; + assertPreviewTransition( + existing?.lifecycleState, + input.lifecycleState ?? "draft", + previewTransitions.catalogVersion, + "device_adapter_version_transition_invalid", + ); const adapterVersion = { adapterVersionRef, adapterPackageRef: input.adapterPackageRef, @@ -308,10 +325,21 @@ export function createLocalPreviewDeviceCore() { } if (command === "model-profiles:register") { requirePlatformOwner(actor); - if (!adapterVersions.has(input.adapterVersionRef)) { + const adapterVersion = adapterVersions.get(input.adapterVersionRef); + if (!adapterVersion) { throw serviceError("device_adapter_version_not_found", 404); } const existing = modelProfiles.get(input.profileRef); + const lifecycleState = input.lifecycleState ?? "draft"; + if (lifecycleState === "active" && adapterVersion.lifecycleState !== "active") { + throw serviceError("device_model_profile_adapter_not_active", 409); + } + assertPreviewTransition( + existing?.lifecycleState, + lifecycleState, + previewTransitions.catalogVersion, + "device_model_profile_transition_invalid", + ); const modelProfile = { modelProfileRef: input.profileRef, adapterVersionRef: input.adapterVersionRef, @@ -323,7 +351,7 @@ export function createLocalPreviewDeviceCore() { schemaArtifactRef: input.schemaArtifactRef, profileDigest: input.profileDigest, capabilities: input.capabilities ?? [], - lifecycleState: input.lifecycleState ?? "draft", + lifecycleState, createdAt: existing?.createdAt || now(), updatedAt: now(), }; @@ -336,6 +364,12 @@ export function createLocalPreviewDeviceCore() { (entry) => entry.edgeKey === input.edgeKey, ); const edgeRef = existing?.edgeRef || `edge:${randomUUID()}`; + assertPreviewTransition( + existing?.lifecycleState, + input.lifecycleState ?? "provisioning", + previewTransitions.edge, + "device_edge_transition_invalid", + ); const edge = { edgeRef, edgeKey: input.edgeKey, @@ -360,6 +394,19 @@ export function createLocalPreviewDeviceCore() { (entry) => entry.routeKey === input.routeKey, ); const routeRef = existing?.routeRef || `route:${randomUUID()}`; + const lifecycleState = input.lifecycleState ?? "draft"; + if ( + lifecycleState === "active" + && (edge.lifecycleState !== "active" || profile.lifecycleState !== "active") + ) { + throw serviceError("device_route_dependency_not_active", 409); + } + assertPreviewTransition( + existing?.lifecycleState, + lifecycleState, + previewTransitions.route, + "device_route_transition_invalid", + ); const route = { routeRef, projectRef: input.projectRef, @@ -372,7 +419,7 @@ export function createLocalPreviewDeviceCore() { listenerRef: input.listenerRef, protocol: input.protocol, direction: input.direction ?? "telemetry", - lifecycleState: input.lifecycleState ?? "draft", + lifecycleState, sessionCount: 0, activeSessionCount: 0, createdAt: existing?.createdAt || now(), @@ -432,7 +479,62 @@ export function createLocalPreviewDeviceCore() { throw serviceError("device_configuration_revision_not_found", 404); } if (command === "enrollment-intents:ensure") { - throw serviceError("device_enrollment_secure_input_required", 409); + if (!projects.has(input.projectRef)) { + throw serviceError("device_project_not_found", 404); + } + const route = routes.get(input.routeRef); + if (!route || route.projectRef !== input.projectRef) { + throw serviceError("device_route_not_found", 404); + } + if (route.lifecycleState !== "active") { + throw serviceError("device_enrollment_route_inactive", 409); + } + if (route.modelProfileRef !== input.modelProfileRef) { + throw serviceError("device_enrollment_profile_mismatch", 409); + } + if ( + input.identifier?.kind !== "imei" + || typeof input.identifier.value !== "string" + || !/^\d{15}$/.test(input.identifier.value) + ) { + throw serviceError("restricted_identifier_imei_invalid", 400); + } + const existing = projectValues(enrollments, input.projectRef).find( + (entry) => entry.enrollmentKey === input.enrollmentKey, + ); + const enrollmentIntentRef = existing?.enrollmentIntentRef + || `enrollment-intent:${randomUUID()}`; + const enrollment = { + enrollmentIntentRef, + projectRef: input.projectRef, + enrollmentKey: input.enrollmentKey, + displayName: input.displayName, + routeRef: input.routeRef, + modelProfileRef: input.modelProfileRef, + expectedIdentifier: { + kind: "imei", + masked: `***********${input.identifier.value.slice(-4)}`, + }, + lifecycleState: "pending", + observedDiscoveryRef: null, + claimedDeviceRef: null, + expiresAt: input.expiresAt ?? null, + createdAt: existing?.createdAt || now(), + updatedAt: now(), + }; + enrollments.set(enrollmentIntentRef, enrollment); + audit(actor, input.projectRef, createdEvent(existing, "enrollment_intent")); + const { expectedIdentifier, ...safeEnrollment } = enrollment; + return { + replayed: false, + result: { + created: !existing, + enrollmentIntent: { + ...safeEnrollment, + identifier: expectedIdentifier, + }, + }, + }; } if (command === "devices:claim") { throw serviceError("device_discovery_not_found", 404); @@ -449,6 +551,7 @@ export function createLocalPreviewDeviceCore() { modelProfiles, edges, routes, + enrollments, bindings, grants, configurationRevisions, @@ -469,6 +572,37 @@ function requirePlatformOwner(actor) { } } +const previewTransitions = Object.freeze({ + adapterPackage: Object.freeze({ + active: Object.freeze(["active", "retired"]), + retired: Object.freeze(["retired"]), + }), + catalogVersion: Object.freeze({ + draft: Object.freeze(["draft", "active", "retired"]), + active: Object.freeze(["active", "retired"]), + retired: Object.freeze(["retired"]), + }), + edge: Object.freeze({ + provisioning: Object.freeze(["provisioning", "active", "retired"]), + active: Object.freeze(["active", "suspended", "retired"]), + suspended: Object.freeze(["suspended", "active", "retired"]), + retired: Object.freeze(["retired"]), + }), + route: Object.freeze({ + draft: Object.freeze(["draft", "active", "retired"]), + active: Object.freeze(["active", "suspended", "retired"]), + suspended: Object.freeze(["suspended", "active", "retired"]), + retired: Object.freeze(["retired"]), + }), +}); + +function assertPreviewTransition(previous, next, transitions, code) { + if (!previous) return; + if (!transitions[previous]?.includes(next)) { + throw serviceError(code, 409); + } +} + const ownerCapabilities = Object.freeze([ "project.read", "project.manage", diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs index 69d4142..0c1a2e6 100644 --- a/apps/device-manager/server/device-core-client.test.mjs +++ b/apps/device-manager/server/device-core-client.test.mjs @@ -122,7 +122,7 @@ test("local preview is empty and creates resources only through canonical comman deploymentRef: "deployment:preview-edge", lifecycleState: "provisioning", }); - await client.execute("routes:ensure", actor, { + const route = await client.execute("routes:ensure", actor, { projectRef, routeKey: "preview-route", displayName: "Preview route", @@ -133,6 +133,56 @@ test("local preview is empty and creates resources only through canonical comman direction: "telemetry", lifecycleState: "draft", }); + await client.execute("adapter-versions:register", platformActor, { + adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef, + version: "1.0.0", + runtimePackageRef: "artifact:generic-tracker:1.0.0", + contentDigest: `sha256:${"a".repeat(64)}`, + contractVersion: "device-adapter.v1", + capabilities: ["telemetry"], + lifecycleState: "active", + }); + await client.execute("model-profiles:register", platformActor, { + adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef, + profileRef: "generic.tracker.v1", + schemaVersion: "1.0.0", + vendor: "Generic", + model: "Tracker", + deviceType: "tracker", + protocol: "INTERNAL", + schemaArtifactRef: "schema:generic.tracker.v1", + profileDigest: `sha256:${"b".repeat(64)}`, + capabilities: ["telemetry"], + lifecycleState: "active", + }); + await client.execute("edges:ensure", platformActor, { + edgeKey: "preview-edge", + displayName: "Preview Edge", + deploymentRef: "deployment:preview-edge", + lifecycleState: "active", + }); + await client.execute("routes:ensure", actor, { + projectRef, + routeKey: "preview-route", + displayName: "Preview route", + edgeRef: edge.result.edge.edgeRef, + modelProfileRef: modelProfile.result.modelProfile.modelProfileRef, + listenerRef: "listener:preview", + protocol: "INTERNAL", + direction: "telemetry", + lifecycleState: "active", + }); + const enrollment = await client.execute("enrollment-intents:ensure", actor, { + projectRef, + enrollmentKey: "preview-device", + routeRef: route.result.route.routeRef, + modelProfileRef: modelProfile.result.modelProfile.modelProfileRef, + displayName: "Preview device", + identifier: { kind: "imei", value: "123456789012345" }, + expiresAt: null, + }); + assert.equal(enrollment.result.enrollmentIntent.identifier.masked, "***********2345"); + assert.equal(JSON.stringify(enrollment).includes("123456789012345"), false); const collectionRef = (await client.getWorkspace(actor, projectRef)) .collections[0].collectionRef; await client.execute("device-bindings:ensure", actor, { @@ -161,6 +211,8 @@ test("local preview is empty and creates resources only through canonical comman assert.equal(workspace.collections[0].collectionKey, "field-devices"); assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker"); assert.equal(workspace.routes[0].routeKey, "preview-route"); + assert.equal(workspace.routes[0].lifecycleState, "active"); + assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********2345"); assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval"); assert.equal(workspace.grants.length, 2); assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created")); diff --git a/apps/device-manager/src/DeviceControlViews.tsx b/apps/device-manager/src/DeviceControlViews.tsx index 8095aeb..7c4a137 100644 --- a/apps/device-manager/src/DeviceControlViews.tsx +++ b/apps/device-manager/src/DeviceControlViews.tsx @@ -76,6 +76,14 @@ export function DeviceControlView({ close(); await onRefresh(); }; + const mutateAndRefresh = async (mutation: () => Promise) => { + try { + await mutation(); + await onRefresh(); + } catch (reason) { + onError(reason); + } + }; return ( <> @@ -86,6 +94,28 @@ export function DeviceControlView({ onCreatePackage={() => setDialog("adapter-package")} onCreateVersion={() => setDialog("adapter-version")} onCreateProfile={() => setDialog("model-profile")} + onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({ + adapterPackageRef: version.adapterPackageRef, + version: version.version, + runtimePackageRef: version.runtimePackageRef, + contentDigest: version.contentDigest, + contractVersion: version.contractVersion, + capabilities: version.capabilities, + lifecycleState: "active", + }))} + onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({ + adapterVersionRef: profile.adapterVersionRef || "", + profileRef: profile.modelProfileRef, + schemaVersion: profile.schemaVersion, + vendor: profile.vendor, + model: profile.model, + deviceType: profile.deviceType, + protocol: profile.protocol, + schemaArtifactRef: profile.schemaArtifactRef || "", + profileDigest: profile.profileDigest || "", + capabilities: profile.capabilities, + lifecycleState: "active", + }))} /> ) : null} {view === "infrastructure" ? ( @@ -95,6 +125,23 @@ export function DeviceControlView({ canManageRoutes={capabilities.has("route.manage")} onCreateEdge={() => setDialog("edge")} onCreateRoute={() => setDialog("route")} + onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({ + edgeKey: edge.edgeKey, + displayName: edge.displayName, + deploymentRef: edge.deploymentRef, + lifecycleState: "active", + }))} + onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({ + projectRef: workspace.project.projectRef, + routeKey: route.routeKey, + displayName: route.displayName, + edgeRef: route.edgeRef, + modelProfileRef: route.modelProfileRef, + listenerRef: route.listenerRef, + protocol: route.protocol, + direction: route.direction, + lifecycleState: "active", + }))} /> ) : null} {view === "sessions" ? : null} @@ -185,12 +232,14 @@ export function DeviceControlView({ ); } -function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile }: { +function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: { workspace: ProjectWorkspace; canManage: boolean; onCreatePackage: () => void; onCreateVersion: () => void; onCreateProfile: () => void; + onActivateVersion: (version: AdapterVersionView) => void; + onActivateProfile: (profile: ModelProfileView) => void; }) { return ( @@ -212,6 +261,26 @@ function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, o description={`${profile.protocol} · ${profile.modelProfileRef}`} status={profile.lifecycleState} meta={profile.capabilities} + action={canManage && profile.lifecycleState === "draft" && profile.adapterVersionRef && profile.schemaArtifactRef && profile.profileDigest ? ( + + ) : null} + /> + ))} + + + + + {workspace.adapterVersions.map((version) => ( + onActivateVersion(version)}>Активировать + ) : null} /> ))} @@ -236,12 +305,14 @@ function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, o ); } -function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute }: { +function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute, onActivateEdge, onActivateRoute }: { workspace: ProjectWorkspace; canManageCatalog: boolean; canManageRoutes: boolean; onCreateEdge: () => void; onCreateRoute: () => void; + onActivateEdge: (edge: EdgeView) => void; + onActivateRoute: (route: ProjectWorkspace["routes"][number]) => void; }) { return ( @@ -265,6 +336,9 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr route.listenerRef, `${route.activeSessionCount}/${route.sessionCount} активных сессий`, ]} + action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? ( + + ) : null} /> ))} @@ -279,6 +353,9 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr description={edge.edgeKey} status={edge.lifecycleState} meta={edge.deploymentRef ? [edge.deploymentRef] : []} + action={canManageCatalog && ["provisioning", "suspended"].includes(edge.lifecycleState) ? ( + + ) : null} /> ))} @@ -741,8 +818,8 @@ function ResourceGrid({ children, empty }: { children: ReactNode; empty: string return hasChildren ?
{children}
:
{empty}
; } -function ResourceCard({ eyebrow, title, description, status, meta }: { eyebrow: string; title: string; description: string; status: string; meta: string[] }) { - return {status}}> +function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) { + return {status}{action}}> {meta.length ?
{meta.map((item) => {item})}
:

Metadata-only projection

}
; } diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx index e00af8f..5b1e492 100644 --- a/apps/device-manager/src/DeviceManagerApp.tsx +++ b/apps/device-manager/src/DeviceManagerApp.tsx @@ -23,6 +23,7 @@ import { import { claimDevice, ensureCollection, + ensureEnrollmentIntent, ensureOwnerScope, ensureProject, loadProjects, @@ -74,6 +75,7 @@ export function DeviceManagerApp() { const [error, setError] = useState(null); const [projectDialogOpen, setProjectDialogOpen] = useState(false); const [collectionDialogOpen, setCollectionDialogOpen] = useState(false); + const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false); const [claimEnrollment, setClaimEnrollment] = useState(null); const refreshProjects = async () => { @@ -131,6 +133,7 @@ export function DeviceManagerApp() { session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef), ); const canManageCollections = capabilities.has("collection.manage"); + const canEnroll = capabilities.has("device.enroll"); const canClaim = capabilities.has("device.claim"); const visibleNavigationItems = navigationItems.filter( (item) => item.capability === null || capabilities.has(item.capability), @@ -279,11 +282,13 @@ export function DeviceManagerApp() { view={activeView} workspace={workspace} canManageCollections={canManageCollections} + canEnroll={canEnroll} canClaim={canClaim} session={session} onRefresh={refreshWorkspace} onError={(reason) => setError(errorText(reason))} onCreateCollection={() => setCollectionDialogOpen(true)} + onCreateEnrollment={() => setEnrollmentDialogOpen(true)} onClaim={setClaimEnrollment} /> @@ -321,6 +326,16 @@ export function DeviceManagerApp() { }} onError={(reason) => setError(errorText(reason))} /> + setEnrollmentDialogOpen(false)} + onCreated={async () => { + setEnrollmentDialogOpen(false); + await refreshWorkspace(); + }} + onError={(reason) => setError(errorText(reason))} + /> ); } @@ -443,15 +458,17 @@ function Metric({ label, value, detail, tone = "neutral" }: { label: string; val ); } -function ProjectView({ view, workspace, canManageCollections, canClaim, session, onRefresh, onError, onCreateCollection, onClaim }: { +function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onError, onCreateCollection, onCreateEnrollment, onClaim }: { view: ViewId; workspace: ProjectWorkspace | null; canManageCollections: boolean; + canEnroll: boolean; canClaim: boolean; session: DeviceManagerSession; onRefresh: () => Promise; onError: (reason: unknown) => void; onCreateCollection: () => void; + onCreateEnrollment: () => void; onClaim: (enrollment: EnrollmentView) => void; }) { if (!workspace) return
Загружаем проект…
; @@ -478,6 +495,17 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, session, ); if (view === "discovery") return (
+
+

Заранее разрешите конкретный идентификатор на активном маршруте. Core сохранит только HMAC и маску.

+ +
{workspace.enrollments.map((enrollment) => ( ; } +function EnrollmentDialog({ open, workspace, onClose, onCreated, onError }: { + open: boolean; + workspace: ProjectWorkspace | null; + onClose: () => void; + onCreated: () => Promise; + onError: (reason: unknown) => void; +}) { + const activeRoutes = useMemo( + () => workspace?.routes.filter((route) => route.lifecycleState === "active") ?? [], + [workspace], + ); + const [routeRef, setRouteRef] = useState(""); + const [name, setName] = useState(""); + const [key, setKey] = useState(""); + const [imei, setImei] = useState(""); + const [expiresAt, setExpiresAt] = useState(""); + const [pending, setPending] = useState(false); + useEffect(() => { + if (!activeRoutes.some((route) => route.routeRef === routeRef)) { + setRouteRef(activeRoutes[0]?.routeRef ?? ""); + } + }, [activeRoutes, routeRef]); + useEffect(() => { + if (!open) setImei(""); + }, [open]); + const submit = async (event: FormEvent) => { + event.preventDefault(); + const route = activeRoutes.find((item) => item.routeRef === routeRef); + if (!workspace || !route) return; + setPending(true); + try { + await ensureEnrollmentIntent({ + projectRef: workspace.project.projectRef, + enrollmentKey: key, + routeRef: route.routeRef, + modelProfileRef: route.modelProfileRef, + displayName: name, + identifier: { kind: "imei", value: imei }, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + }); + setImei(""); + setName(""); + setKey(""); + setExpiresAt(""); + await onCreated(); + } catch (reason) { + onError(reason); + } finally { + setPending(false); + } + }; + return + + + + }> +
+