diff --git a/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs new file mode 100644 index 00000000..926e9aeb --- /dev/null +++ b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace FactoryConnect.Api; + +/// +/// Imports only MTConnect machine addresses from a trusted local Edge configuration file. +/// Never merges the Edge document's security, database or identity settings into the API. +/// +internal static class EdgeMtConnectConfiguration +{ + internal static void Load(WebApplicationBuilder builder) + { + var path = builder.Configuration["MTConnect:EdgeConfigurationFile"]; + if (string.IsNullOrWhiteSpace(path)) return; + if (!Path.IsPathFullyQualified(path)) + throw new InvalidOperationException("MTConnect:EdgeConfigurationFile must be an absolute path."); + using var file = File.OpenRead(path); + if (file.Length > 2_000_000) + throw new InvalidOperationException("Edge configuration exceeds the 2 MB limit."); + using var document = JsonDocument.Parse(file, new JsonDocumentOptions { MaxDepth = 32 }); + if (!document.RootElement.TryGetProperty("MTConnect", out var mtconnect) || + !mtconnect.TryGetProperty("Machines", out var machines) || + machines.ValueKind != JsonValueKind.Array || machines.GetArrayLength() > 500) + throw new InvalidOperationException("Edge configuration must contain MTConnect.Machines."); + var values = new Dictionary(); + var seen = new HashSet(); + var index = 0; + foreach (var machine in machines.EnumerateArray()) + { + if (machine.ValueKind != JsonValueKind.Object || + !machine.TryGetProperty("MachineId", out var idValue) || + idValue.ValueKind != JsonValueKind.String || + !Guid.TryParse(idValue.GetString(), out var machineId) || + !seen.Add(machineId) || + !machine.TryGetProperty("DeviceKey", out var keyValue) || + keyValue.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(keyValue.GetString()) || + !machine.TryGetProperty("BaseUri", out var uriValue) || + uriValue.ValueKind != JsonValueKind.String || + !Uri.TryCreate(uriValue.GetString(), UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https") || + !string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Query) || + !string.IsNullOrEmpty(uri.Fragment)) + throw new InvalidOperationException("Invalid or duplicate MTConnect machine entry in Edge configuration."); + var prefix = $"MTConnect:Machines:{index++}:"; + values[prefix + "MachineId"] = machineId.ToString(); + values[prefix + "DeviceKey"] = keyValue.GetString(); + values[prefix + "BaseUri"] = uri.ToString(); + } + builder.Configuration.AddInMemoryCollection(values); + } +} diff --git a/backend/FactoryConnect.Api/EvidenceApiHost.cs b/backend/FactoryConnect.Api/EvidenceApiHost.cs index 2cf7a67f..242ee9a2 100644 --- a/backend/FactoryConnect.Api/EvidenceApiHost.cs +++ b/backend/FactoryConnect.Api/EvidenceApiHost.cs @@ -16,6 +16,8 @@ public static WebApplication Build(string[] args, Action? var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ApplicationName = typeof(EvidenceApiHost).Assembly.FullName, WebRootPath = webRoot }); configure?.Invoke(builder); +// Opt-in: import only MTConnect machine entries from the trusted local Edge file. +EdgeMtConnectConfiguration.Load(builder); builder.Services.AddOperatorLogin(builder.Configuration); // Forwarding is opt-in and accepts only explicitly enumerated proxy IP addresses. // An unset configuration keeps direct HTTPS behavior unchanged. @@ -247,6 +249,8 @@ public static WebApplication Build(string[] args, Action? }).RequireRateLimiting("security-boundary"); app.MapActivityHistory(); +app.MapMasterRegistration(); +app.MapMtConnectDiscovery(); app.MapMethods("/api/{**path}", ["GET", "POST"], NotReady); app.MapFallbackToFile("index.html"); diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs new file mode 100644 index 00000000..27418791 --- /dev/null +++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs @@ -0,0 +1,234 @@ +using System.Security.Cryptography; +using FactoryConnect.Sql.Security; + +namespace FactoryConnect.Api; + +public static class MasterRegistrationApi +{ + private sealed record CommissioningRequest(Guid OperationId, Guid MachineId, Guid ProductionLineId, string DeviceId); + private sealed record RegistrationRequest(Guid OperationId, string Kind, Guid? SiteId, string DisplayName, Guid? MachineId = null); + + public static void MapMasterRegistration(this WebApplication app) + { + app.MapGet("/api/admin/v2/sites", async (HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Master-data authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var hash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + var sites = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .ReadAdministrationSitesAsync(hash, ct); + return Results.Ok(new { sites }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-sites", "Site inventory unavailable"); } + finally { CryptographicOperations.ZeroMemory(hash); } + }).RequireRateLimiting("security-boundary"); + + app.MapGet("/api/admin/v2/machines", async (HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Master-data authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var hash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .ReadAdministrationMachinesAsync(hash, ct); + return Results.Ok(new { machines }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-machines", "Machine inventory unavailable"); } + finally { CryptographicOperations.ZeroMemory(hash); } + }).RequireRateLimiting("security-boundary"); + + app.MapGet("/api/admin/v2/commissioning-assignments", async (HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Master-data authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var hash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + var assignments = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .ReadCommissioningAssignmentsAsync(hash, ct); + return Results.Ok(new { assignments }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "commissioning-assignments", "Commissioning assignments unavailable"); } + finally { CryptographicOperations.ZeroMemory(hash); } + }).RequireRateLimiting("security-boundary"); + + app.MapGet("/api/admin/v2/lines", async (HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Master-data authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var hash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + var lines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .ReadAdministrationLinesAsync(hash, ct); + return Results.Ok(new { lines }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-lines", "Production-line inventory unavailable"); } + finally { CryptographicOperations.ZeroMemory(hash); } + }).RequireRateLimiting("security-boundary"); + + app.MapPost("/api/admin/v2/master-registration", async ( + HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) + return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Master-data authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) + return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var sessionHash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + RegistrationRequest? request; + try { request = await http.Request.ReadFromJsonAsync(cancellationToken: ct); } + catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "Invalid registration JSON" }); } + if (request is null || request.OperationId == Guid.Empty || + request.Kind is not ("site" or "line" or "machine") || + (request.Kind == "site" ? request.SiteId is not null : + request.SiteId is null || request.SiteId == Guid.Empty) || + string.IsNullOrWhiteSpace(request.DisplayName) || + request.DisplayName.Trim().Length > 200 || + (request.MachineId.HasValue && (request.Kind != "machine" || request.MachineId == Guid.Empty))) + return Results.BadRequest(new { error = "Invalid registration request" }); + var recordId = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .RegisterMasterRecordAsync(sessionHash, request.OperationId, request.Kind, + request.SiteId, request.DisplayName, ct, request.MachineId); + return Results.Ok(new { recordId, request.OperationId, request.Kind }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (SecurityConflictException) { return Results.Conflict(new { error = "Operation ID conflict" }); } + catch (InvalidOperationException) { return Results.Conflict(new { error = "Registration conflicts with existing master data" }); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "master-registration", "Master-data authority unavailable"); } + finally { CryptographicOperations.ZeroMemory(sessionHash); } + }).RequireRateLimiting("security-boundary"); + app.MapPost("/api/admin/v2/commissioning-assignments", async ( + HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) + return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Master-data authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) + return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var sessionHash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + CommissioningRequest? request; + try { request = await http.Request.ReadFromJsonAsync(cancellationToken: ct); } + catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "Invalid commissioning JSON" }); } + if (request is null || request.OperationId == Guid.Empty || + request.MachineId == Guid.Empty || request.ProductionLineId == Guid.Empty || + string.IsNullOrWhiteSpace(request.DeviceId) || request.DeviceId.Trim().Length > 200) + return Results.BadRequest(new { error = "Invalid commissioning assignment" }); + await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .SaveCommissioningAssignmentAsync(sessionHash, request.OperationId, + request.MachineId, request.ProductionLineId, request.DeviceId, ct); + return Results.Ok(new { saved = true, request.OperationId, acquisitionActivated = false }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (SecurityConflictException) { return Results.Conflict(new { error = "Operation ID conflict" }); } + catch (InvalidOperationException) { return Results.Conflict(new { error = "Machine or production line invalid for commissioning" }); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "commissioning-save", "Commissioning authority unavailable"); } + finally { CryptographicOperations.ZeroMemory(sessionHash); } + }).RequireRateLimiting("security-boundary"); + } +} diff --git a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs new file mode 100644 index 00000000..f7c441f1 --- /dev/null +++ b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs @@ -0,0 +1,153 @@ +using System.Security.Cryptography; +using FactoryConnect.Sql.Security; + +namespace FactoryConnect.Api; + +/// Read-only discovery against explicitly configured Edge MTConnect machines. +public static class MtConnectDiscoveryApi +{ + private sealed record ConfiguredMachine(string MachineId, string DeviceKey, string BaseUri); + + public static void MapMtConnectDiscovery(this WebApplication app) + { + app.MapGet("/api/admin/v2/mtconnect/configured-machines", async ( + HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Administration authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var hash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .ReadAdministrationMachinesAsync(hash, ct); + var registered = machines.Where(machine => !machine.IsRetired) + .Select(machine => machine.MachineId).ToHashSet(); + var configured = config.GetSection("MTConnect:Machines").Get() ?? []; + var duplicateIds = configured.GroupBy(machine => machine.MachineId, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var entries = configured.Select(machine => new + { + machineId = machine.MachineId, + deviceKey = machine.DeviceKey, + registered = Guid.TryParse(machine.MachineId, out var id) && registered.Contains(id), + duplicateConfiguration = duplicateIds.Contains(machine.MachineId) + }).ToArray(); + return Results.Ok(new { configured = entries, configuredCount = entries.Length }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, + "mtconnect-configuration-inventory", "MTConnect configuration inventory unavailable"); } + finally { CryptographicOperations.ZeroMemory(hash); } + }).RequireRateLimiting("security-boundary"); + + app.MapGet("/api/admin/v2/mtconnect/discover/{machineId:guid}", async ( + Guid machineId, HttpContext http, IConfiguration config, CancellationToken ct) => + { + http.Response.Headers.CacheControl = "no-store"; + if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required"); + if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) || + string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence"))) + return Results.Problem(statusCode: 503, title: "Administration authority unavailable"); + if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) || + authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) || + authorization[0]!.Length > 192) return Results.Unauthorized(); + byte[] bearer; + try { bearer = Convert.FromBase64String(authorization[0]![7..]); } + catch (FormatException) { return Results.Unauthorized(); } + if (bearer.Length != 32) + { + CryptographicOperations.ZeroMemory(bearer); + return Results.Unauthorized(); + } + var hash = SHA256.HashData(bearer); + CryptographicOperations.ZeroMemory(bearer); + try + { + // The same administration permission as machine inventory is required. + var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId) + .ReadAdministrationMachinesAsync(hash, ct); + if (!machines.Any(machine => machine.MachineId == machineId && !machine.IsRetired)) + return Results.NotFound(new { error = "Active registered machine not found" }); + + // Read the Edge configuration as supplied to this host; no second Agent URL registry. + var configured = config.GetSection("MTConnect:Machines").Get() ?? []; + var targets = configured.Where(machine => + Guid.TryParse(machine.MachineId, out var id) && id == machineId).ToArray(); + if (targets.Length == 0) return Results.NotFound(new { error = "Machine not configured for MTConnect discovery" }); + if (targets.Length > 1) return Results.Conflict(new { error = "Multiple Edge entries reference this MachineId; resolve the configuration before discovery" }); + var target = targets[0]; + if (!Uri.TryCreate(target.BaseUri, UriKind.Absolute, out var baseUri) || + baseUri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(baseUri.UserInfo) || + !string.IsNullOrEmpty(baseUri.Query) || !string.IsNullOrEmpty(baseUri.Fragment)) + return Results.Problem(statusCode: 503, title: "Configured MTConnect Agent URI invalid"); + // The endpoint is derived exclusively from trusted host configuration, never user input. + var probeUri = new Uri(baseUri.ToString().TrimEnd('/') + "/probe"); + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + UseProxy = false + }; + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(8) }; + using var response = await client.GetAsync(probeUri, HttpCompletionOption.ResponseHeadersRead, ct); + if (!response.IsSuccessStatusCode) + return Results.Problem(statusCode: 502, title: "Configured MTConnect Agent probe failed"); + if (response.Content.Headers.ContentLength is > 4_000_000) + return Results.Problem(statusCode: 502, title: "MTConnect probe response exceeds limit"); + await using var source = await response.Content.ReadAsStreamAsync(ct); + await using var limited = new MemoryStream(); + var chunk = new byte[8192]; + int count; + while ((count = await source.ReadAsync(chunk, ct)) != 0) + { + if (limited.Length + count > 4_000_000) + return Results.Problem(statusCode: 502, title: "MTConnect probe response exceeds limit"); + limited.Write(chunk, 0, count); + } + limited.Position = 0; + var devices = MtConnectProbeDevices.Parse(limited); + var matching = devices.Where(device => + string.Equals(device.Id, target.DeviceKey, StringComparison.Ordinal) || + string.Equals(device.Name, target.DeviceKey, StringComparison.Ordinal) || + (!string.IsNullOrWhiteSpace(device.Uuid) && + string.Equals(device.Uuid, target.DeviceKey, StringComparison.OrdinalIgnoreCase))).ToArray(); + var identityStatus = matching.Length switch + { + 0 => "unmatched", + 1 => "potential-match", + _ => "ambiguous" + }; + return Results.Ok(new { machineId, target.DeviceKey, devices, identityStatus, + matchingDeviceIds = matching.Select(device => device.Id).ToArray(), + acquisitionActivated = false }); + } + catch (SecurityDeniedException) { return Results.Unauthorized(); } + catch (InvalidDataException) { return Results.Problem(statusCode: 502, title: "Invalid MTConnect probe document"); } + catch (System.Xml.XmlException) { return Results.Problem(statusCode: 502, title: "Invalid MTConnect probe XML"); } + catch (HttpRequestException) { return Results.Problem(statusCode: 502, title: "Configured MTConnect Agent unreachable"); } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { return Results.Problem(statusCode: 504, title: "MTConnect Agent probe timed out"); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "mtconnect-discovery", "MTConnect discovery unavailable"); } + finally { CryptographicOperations.ZeroMemory(hash); } + }).RequireRateLimiting("security-boundary"); + } +} diff --git a/backend/FactoryConnect.Api/MtConnectProbeDevices.cs b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs new file mode 100644 index 00000000..15cbb5b3 --- /dev/null +++ b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs @@ -0,0 +1,63 @@ +using System.Xml; +using System.Xml.Linq; + +namespace FactoryConnect.Api; + +/// +/// Reads MTConnect /probe device metadata without making network requests or changing +/// machine configuration. The caller is responsible for using a trusted configured Agent. +/// +internal static class MtConnectProbeDevices +{ + internal sealed record DataItem(string Id, string? Name, string? Type, string? SubType); + internal sealed record Device(string Id, string Name, string? Uuid, IReadOnlyList DataItems); + + internal static IReadOnlyList Parse(Stream xml) + { + ArgumentNullException.ThrowIfNull(xml); + using var reader = XmlReader.Create(xml, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = 4_000_000, + MaxCharactersFromEntities = 0, + IgnoreComments = true + }); + var document = XDocument.Load(reader); + if (document.Root?.Name.LocalName != "MTConnectDevices") + throw new InvalidDataException("Response is not an MTConnect Devices document."); + var devices = document.Descendants().Where(node => node.Name.LocalName == "Device") + .Take(501).ToArray(); + if (devices.Length > 500) throw new InvalidDataException("Too many MTConnect devices."); + var result = new List(devices.Length); + var deviceIds = new HashSet(StringComparer.Ordinal); + var deviceUuids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var device in devices) + { + var id = (string?)device.Attribute("id"); + var name = (string?)device.Attribute("name"); + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) + throw new InvalidDataException("MTConnect device is missing its ID or name."); + if (!deviceIds.Add(id)) + throw new InvalidDataException("Duplicate MTConnect device ID."); + var uuid = ((string?)device.Attribute("uuid"))?.Trim(); + if (!string.IsNullOrEmpty(uuid) && !deviceUuids.Add(uuid)) + throw new InvalidDataException("Duplicate MTConnect device UUID."); + var items = device.Descendants().Where(node => node.Name.LocalName == "DataItem") + .Take(5001).ToArray(); + if (items.Length > 5000) throw new InvalidDataException("Too many data items in MTConnect device."); + var dataItemIds = new HashSet(StringComparer.Ordinal); + foreach (var item in items) + { + var dataItemId = (string?)item.Attribute("id"); + if (string.IsNullOrWhiteSpace(dataItemId) || !dataItemIds.Add(dataItemId)) + throw new InvalidDataException("Missing or duplicate MTConnect data item ID."); + } + result.Add(new Device(id, name, uuid, + items.Select(item => new DataItem((string?)item.Attribute("id") ?? "", + (string?)item.Attribute("name"), (string?)item.Attribute("type"), + (string?)item.Attribute("subType"))).ToArray())); + } + return result; + } +} diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs new file mode 100644 index 00000000..9d718708 --- /dev/null +++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs @@ -0,0 +1,122 @@ +using System.Data; +using Microsoft.Data.SqlClient; + +namespace FactoryConnect.Sql.MasterData; + +/// +/// SQL-backed site, line and machine inventory. Registration must be called only +/// after an audited, transaction-bound SQL authorization command has verified the actor. +/// No runtime configuration, acquisition or activation is performed here. +/// +internal sealed class MachineAdministrationStore(string connectionString) +{ + public sealed record Site(Guid SiteId, string DisplayName, bool IsRetired); + public sealed record ProductionLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired); + public sealed record Machine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired); + + public async Task> ListSitesAsync(CancellationToken ct = default) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var command = new SqlCommand( + "SELECT SiteId, DisplayName, IsRetired FROM master.Site ORDER BY DisplayName, SiteId", connection); + await using var reader = await command.ExecuteReaderAsync(ct); + var rows = new List(); + while (await reader.ReadAsync(ct)) + rows.Add(new Site(reader.GetGuid(0), reader.GetString(1), reader.GetBoolean(2))); + return rows; + } + + public async Task> ListLinesAsync(Guid siteId, CancellationToken ct = default) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var command = new SqlCommand( + "SELECT ProductionLineId, SiteId, DisplayName, IsRetired FROM master.ProductionLine WHERE SiteId=@site ORDER BY DisplayName, ProductionLineId", connection); + command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId; + await using var reader = await command.ExecuteReaderAsync(ct); + var rows = new List(); + while (await reader.ReadAsync(ct)) + rows.Add(new ProductionLine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3))); + return rows; + } + + public async Task> ListMachinesAsync(Guid siteId, CancellationToken ct = default) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + await using var command = new SqlCommand( + "SELECT MachineId, SiteId, DisplayName, IsRetired FROM master.Machine WHERE SiteId=@site ORDER BY DisplayName, MachineId", connection); + command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId; + await using var reader = await command.ExecuteReaderAsync(ct); + var rows = new List(); + while (await reader.ReadAsync(ct)) + rows.Add(new Machine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3))); + return rows; + } + + // The caller must supply the SQL transaction that performed authorization. + // No independent connection or commit is permitted for administrative writes. + internal Task RegisterSiteAsync(SqlConnection connection, SqlTransaction transaction, string name, CancellationToken ct = default) + => RegisterAsync(connection, transaction, "Site", "SiteId", null, name, ct); + + internal Task RegisterLineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, CancellationToken ct = default) + => RegisterAsync(connection, transaction, "ProductionLine", "ProductionLineId", siteId, name, ct); + + internal Task RegisterMachineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, Guid? machineId = null, CancellationToken ct = default) + => RegisterAsync(connection, transaction, "Machine", "MachineId", siteId, name, ct, machineId); + + private static async Task RegisterAsync(SqlConnection connection, SqlTransaction transaction, + string table, string idColumn, Guid? siteId, string name, CancellationToken ct, Guid? requestedId = null) + { + // Identifiers are selected exclusively from the fixed methods above. + if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200) + throw new ArgumentException("Display name must contain 1 to 200 characters.", nameof(name)); + if (siteId == Guid.Empty) throw new ArgumentException("Site ID is required.", nameof(siteId)); + name = name.Trim(); + var id = requestedId ?? Guid.NewGuid(); + if (id == Guid.Empty) throw new ArgumentException("Machine ID cannot be empty."); + // A supplied Edge identity must not silently collide with an existing V2 machine. + // Check it under the same transaction as the registration insert. + if (requestedId.HasValue) + { + await using var identityCheck = new SqlCommand( + "SELECT COUNT_BIG(*) FROM master.Machine WITH (UPDLOCK, HOLDLOCK) WHERE MachineId=@id", + connection, transaction); + identityCheck.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = id; + if ((long)(await identityCheck.ExecuteScalarAsync(ct) ?? 0L) != 0) + throw new InvalidOperationException("This Edge machine identity is already registered in V2."); + } + // The security authority owns transaction isolation and commit. UPDLOCK/HOLDLOCK + // serializes duplicate-name checks until that transaction completes. + var predicate = siteId.HasValue ? "SiteId=@site AND " : ""; + await using (var check = new SqlCommand( + $"SELECT COUNT_BIG(*) FROM master.{table} WITH (UPDLOCK, HOLDLOCK) WHERE {predicate}UPPER(LTRIM(RTRIM(DisplayName)))=UPPER(@name)", + connection, transaction)) + { + check.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name; + if (siteId.HasValue) check.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value; + if ((long)(await check.ExecuteScalarAsync(ct) ?? 0L) != 0) + throw new InvalidOperationException("A record with this display name already exists in the selected scope."); + } + if (siteId.HasValue) + { + await using var parent = new SqlCommand( + "SELECT COUNT_BIG(*) FROM master.Site WITH (UPDLOCK, HOLDLOCK) WHERE SiteId=@site AND IsRetired=0", + connection, transaction); + parent.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value; + if ((long)(await parent.ExecuteScalarAsync(ct) ?? 0L) != 1) + throw new InvalidOperationException("The selected site does not exist or is retired."); + } + await using (var insert = new SqlCommand( + $"INSERT master.{table} ({idColumn}, {(siteId.HasValue ? "SiteId, " : "")}DisplayName) VALUES (@id, {(siteId.HasValue ? "@site, " : "")}@name)", + connection, transaction)) + { + insert.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = id; + insert.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name; + if (siteId.HasValue) insert.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value; + await insert.ExecuteNonQueryAsync(ct); + } + return id; + } +} diff --git a/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql new file mode 100644 index 00000000..bd6b5c2a --- /dev/null +++ b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql @@ -0,0 +1,14 @@ +-- Commissioning assignments are administrative intent only; acquisition remains independently disabled. +CREATE TABLE master.MachineCommissioning ( + MachineId uniqueidentifier NOT NULL PRIMARY KEY, + ProductionLineId uniqueidentifier NOT NULL, + MtConnectDeviceId nvarchar(200) NOT NULL, + LastOperationId uniqueidentifier NOT NULL, + CONSTRAINT FK_MachineCommissioning_Machine FOREIGN KEY (MachineId) + REFERENCES master.Machine(MachineId), + CONSTRAINT FK_MachineCommissioning_Line FOREIGN KEY (ProductionLineId) + REFERENCES master.ProductionLine(ProductionLineId), + CONSTRAINT FK_MachineCommissioning_Operation FOREIGN KEY (LastOperationId) + REFERENCES security.SecurityOperation(OperationId), + CONSTRAINT CK_MachineCommissioning_DeviceId CHECK (LEN(LTRIM(RTRIM(MtConnectDeviceId))) BETWEEN 1 AND 200) +); diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs new file mode 100644 index 00000000..3fb836d2 --- /dev/null +++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs @@ -0,0 +1,213 @@ +using System.Security.Cryptography; +using System.Text.Json; +using FactoryConnect.Sql.MasterData; + +namespace FactoryConnect.Sql.Security; + +public sealed partial class SqlSecurityAuthority +{ + public sealed record AdministrationSite(Guid SiteId, string DisplayName, bool IsRetired); + + public Task> ReadAdministrationSitesAsync( + byte[] sessionHash, CancellationToken ct = default) + { + if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException(); + return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation, + async (connection, transaction, token) => + { + await using var command = new Microsoft.Data.SqlClient.SqlCommand( + "SELECT SiteId, DisplayName, IsRetired FROM master.Site ORDER BY DisplayName, SiteId", + connection, transaction); + await using var reader = await command.ExecuteReaderAsync(token); + var sites = new List(); + while (await reader.ReadAsync(token)) + sites.Add(new AdministrationSite(reader.GetGuid(0), reader.GetString(1), reader.GetBoolean(2))); + return (IReadOnlyList)sites; + }, ct); + } + + + public sealed record AdministrationLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired); + public Task> ReadAdministrationLinesAsync( + byte[] sessionHash, CancellationToken ct = default) + { + if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException(); + return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation, + async (connection, transaction, token) => + { + await using var command = new Microsoft.Data.SqlClient.SqlCommand( + "SELECT ProductionLineId, SiteId, DisplayName, IsRetired FROM master.ProductionLine ORDER BY DisplayName, ProductionLineId", + connection, transaction); + await using var reader = await command.ExecuteReaderAsync(token); + var lines = new List(); + while (await reader.ReadAsync(token)) + lines.Add(new AdministrationLine(reader.GetGuid(0), reader.GetGuid(1), + reader.GetString(2), reader.GetBoolean(3))); + return (IReadOnlyList)lines; + }, ct); + } + + public sealed record AdministrationMachine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired); + public Task> ReadAdministrationMachinesAsync( + byte[] sessionHash, CancellationToken ct = default) + { + if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException(); + return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation, + async (connection, transaction, token) => + { + await using var command = new Microsoft.Data.SqlClient.SqlCommand( + "SELECT MachineId, SiteId, DisplayName, IsRetired FROM master.Machine ORDER BY DisplayName, MachineId", + connection, transaction); + await using var reader = await command.ExecuteReaderAsync(token); + var machines = new List(); + while (await reader.ReadAsync(token)) + machines.Add(new AdministrationMachine(reader.GetGuid(0), reader.GetGuid(1), + reader.GetString(2), reader.GetBoolean(3))); + return (IReadOnlyList)machines; + }, ct); + } + + public sealed record CommissioningAssignment(Guid MachineId, Guid ProductionLineId, + string MtConnectDeviceId, Guid LastOperationId); + + public Task> ReadCommissioningAssignmentsAsync( + byte[] sessionHash, CancellationToken ct = default) + { + if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException(); + return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation, + async (connection, transaction, token) => + { + await using var command = new Microsoft.Data.SqlClient.SqlCommand( + "SELECT MachineId, ProductionLineId, MtConnectDeviceId, LastOperationId FROM master.MachineCommissioning ORDER BY MachineId", + connection, transaction); + await using var reader = await command.ExecuteReaderAsync(token); + var rows = new List(); + while (await reader.ReadAsync(token)) + rows.Add(new CommissioningAssignment(reader.GetGuid(0), reader.GetGuid(1), + reader.GetString(2), reader.GetGuid(3))); + return (IReadOnlyList)rows; + }, ct); + } + + // A commissioning assignment is administrative configuration, never acquisition activation. + public Task SaveCommissioningAssignmentAsync(byte[] sessionHash, Guid operationId, + Guid machineId, Guid productionLineId, string deviceId, CancellationToken ct = default) + { + if (sessionHash is null || sessionHash.Length != 32 || operationId == Guid.Empty) + throw new SecurityDeniedException(); + if (machineId == Guid.Empty || productionLineId == Guid.Empty || + string.IsNullOrWhiteSpace(deviceId) || deviceId.Trim().Length > 200) + throw new ArgumentException("Invalid commissioning assignment."); + var normalizedDeviceId = deviceId.Trim(); + var token = sessionHash.ToArray(); + var request = JsonSerializer.SerializeToUtf8Bytes(new + { + version = 1, action = "commissioning-assign", machineId, productionLineId, + deviceId = normalizedDeviceId + }); + return Transaction(operationId, true, async (connection, transaction) => + { + var state = await State(connection, transaction, ct); + var actor = await Session(connection, transaction, token, state.Now, ct); + await Authorize(connection, transaction, actor, "configuration.master.manage", + AuthorizationScope.Installation, state.Now, ct); + var completed = await FindOperation(connection, transaction, operationId, ct); + if (completed is not null) + { + if (completed.Actor != actor || !completed.Request.AsSpan().SequenceEqual(request)) + throw new SecurityConflictException(); + return 0; + } + + await using (var check = new Microsoft.Data.SqlClient.SqlCommand(""" + SELECT COUNT_BIG(*) + FROM master.Machine AS m WITH (UPDLOCK, HOLDLOCK) + JOIN master.ProductionLine AS l WITH (UPDLOCK, HOLDLOCK) ON l.SiteId=m.SiteId + WHERE m.MachineId=@machine AND l.ProductionLineId=@line + AND m.IsRetired=0 AND l.IsRetired=0 + """, connection, transaction)) + { + check.Parameters.AddWithValue("@machine", machineId); + check.Parameters.AddWithValue("@line", productionLineId); + if ((long)(await check.ExecuteScalarAsync(ct) ?? 0L) != 1) + throw new InvalidOperationException("Machine and production line must be active and in the same site."); + } + await Exec(connection, transaction, """ + INSERT security.SecurityOperation + (OperationId,ActorPrincipalId,RequestBytes,RequestHash,ScopeKind,SiteId,MachineId,ResultBytes) + VALUES (@op,@actor,@bytes,@hash,'I',NULL,NULL,@result); + """, ct, ("@op", operationId), ("@actor", actor), ("@bytes", request), + ("@hash", SHA256.HashData(request)), + ("@result", JsonSerializer.SerializeToUtf8Bytes(new { saved = true }))); + await Exec(connection, transaction, """ + MERGE master.MachineCommissioning WITH (HOLDLOCK) AS target + USING (SELECT @machine AS MachineId) AS source ON target.MachineId=source.MachineId + WHEN MATCHED THEN UPDATE SET ProductionLineId=@line, + MtConnectDeviceId=@device, LastOperationId=@op + WHEN NOT MATCHED THEN INSERT (MachineId,ProductionLineId,MtConnectDeviceId,LastOperationId) + VALUES (@machine,@line,@device,@op); + """, ct, ("@machine", machineId), ("@line", productionLineId), + ("@device", normalizedDeviceId), ("@op", operationId)); + await Exec(connection, transaction, """ + INSERT security.SecurityAudit(AuditId,OperationId,ActorPrincipalId,Action,Reason) + VALUES (@id,@op,@actor,@action,@reason); + """, ct, ("@id", Guid.NewGuid()), ("@op", operationId), ("@actor", actor), + ("@action", "machine-commissioning-assign"), + ("@reason", "Administrator assigned production line and MTConnect device")); + return 0; + }, ct); + } + + // Registration is a master-data change, not an acquisition or machine activation. + // The installation-level grant is deliberately separate from evidence-reading rights. + public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId, + string kind, Guid? siteId, string displayName, CancellationToken ct = default, Guid? machineId = null) + { + if (sessionHash is null || sessionHash.Length != 32 || operationId == Guid.Empty) + throw new SecurityDeniedException(); + if (kind is not ("site" or "line" or "machine") || + (kind == "site" ? siteId is not null : siteId is null || siteId == Guid.Empty) || + string.IsNullOrWhiteSpace(displayName) || displayName.Trim().Length > 200 || + (machineId.HasValue && (kind != "machine" || machineId == Guid.Empty))) + throw new ArgumentException("Invalid master-data registration request."); + var name = displayName.Trim(); + var token = sessionHash.ToArray(); + var request = JsonSerializer.SerializeToUtf8Bytes(new { version = 1, kind, siteId, displayName = name, machineId }); + return Transaction(operationId, true, async (connection, transaction) => + { + var state = await State(connection, transaction, ct); + var actor = await Session(connection, transaction, token, state.Now, ct); + await Authorize(connection, transaction, actor, "configuration.master.manage", + AuthorizationScope.Installation, state.Now, ct); + var completed = await FindOperation(connection, transaction, operationId, ct); + if (completed is not null) + { + if (completed.Actor != actor || !completed.Request.AsSpan().SequenceEqual(request)) + throw new SecurityConflictException(); + using var result = JsonDocument.Parse(completed.Result); + return result.RootElement.GetProperty("recordId").GetGuid(); + } + + var store = new MachineAdministrationStore(connectionString); + var recordId = kind switch + { + "site" => await store.RegisterSiteAsync(connection, transaction, name, ct), + "line" => await store.RegisterLineAsync(connection, transaction, siteId!.Value, name, ct), + _ => await store.RegisterMachineAsync(connection, transaction, siteId!.Value, name, machineId, ct) + }; + var resultBytes = JsonSerializer.SerializeToUtf8Bytes(new { recordId }); + await Exec(connection, transaction, """ + INSERT security.SecurityOperation + (OperationId,ActorPrincipalId,RequestBytes,RequestHash,ScopeKind,SiteId,MachineId,ResultBytes) + VALUES (@op,@actor,@bytes,@hash,'I',NULL,NULL,@result); + """, ct, ("@op", operationId), ("@actor", actor), ("@bytes", request), + ("@hash", SHA256.HashData(request)), ("@result", resultBytes)); + await Exec(connection, transaction, """ + INSERT security.SecurityAudit(AuditId,OperationId,ActorPrincipalId,Action,Reason) + VALUES (@id,@op,@actor,@action,@reason); + """, ct, ("@id", Guid.NewGuid()), ("@op", operationId), ("@actor", actor), + ("@action", "master-register-" + kind), ("@reason", "Administrator registered " + kind)); + return recordId; + }, ct); + } +} diff --git a/docs/commissioning/mtconnect-agent-discovery.md b/docs/commissioning/mtconnect-agent-discovery.md new file mode 100644 index 00000000..65a8eaeb --- /dev/null +++ b/docs/commissioning/mtconnect-agent-discovery.md @@ -0,0 +1,79 @@ +# FC2-COM-002 — MTConnect Agent discovery and machine commissioning + +Status: implementation contract; discovery and activation are **not** implemented by PR #76. + +## Reuse existing acquisition configuration + +FactoryConnect already has Edge-side MTConnect acquisition. Before adding any new persistent Agent configuration, inspect the existing Edge configuration model, its storage, deployment flow and the actual URL consumer. Extend that model and its approved configuration path instead of creating a second independent list of MTConnect URLs. + +An Agent endpoint and a CNC machine are different identities. One Agent can expose many devices. Store or reference the Agent configuration once; associate selected device identities with commissioned machine records. Never infer identity from a display name alone. + +## Required administrator workflow + +1. List existing configured Agents (URL, descriptive label and configuration status) from the authoritative configuration source. Do not expose credentials or secrets. +2. Read the Agent's MTConnect `/probe` document through a server-side, explicitly configured Agent endpoint; do not accept arbitrary URLs from an unauthenticated browser request. +3. Parse `Devices` and `Device` identities, including UUID when supplied, device name, data items and data item IDs. Treat probe data as untrusted external XML; disable DTD/external entities and bound response size and request duration. +4. Show discovered devices separately from registered machines. Indicate matched, available-to-add and ambiguous identities. Require explicit review for ambiguous matches. +5. For selected devices, register or associate the machine with an existing site and production line, then configure signal mappings through the existing configuration approval workflow. +6. Test reachability and usable observations using the existing Edge/MTConnect integration. A successful probe does not prove a live device or usable OEE data. +7. Activate acquisition only through the existing controlled activation path, after an administrator explicitly confirms the target machine and configuration. Do not activate on discovery or registration. + +## Product acceptance + +- One Agent with multiple devices: administrator can select a subset; no duplicate Agent URL per machine. +- Multiple Agents: distinguish devices by stable Agent and device identity; report ambiguous identity conflicts. +- Missing, malformed or unreachable Agent: actionable diagnostics, no machine registration or activation side effect. +- Existing machine: reconcile by durable configured identity; no silent duplicate registration. +- Machine assignment: site and production line are visible and editable through a controlled configuration path. +- Read-only discovery and test: no acquisition activation, no production database change. +- Once activated, Edge observations can be traced through accepted activity/quantity processing to operator screens and management OEE reports. + +## Delivery order + +1. Identify existing Edge Agent URL configuration and source-of-truth (V1 and V2), plus configuration publishing/acknowledgement contract. +2. Add a read-only, authenticated API that enumerates configured Agents and discovers their `/probe` devices using that configuration. +3. Add discovery and selection UI to Machine Administration. +4. Implement machine-to-line assignment and durable device-to-machine mapping using the established master/configuration schema. +5. Implement connection test, signal mapping, controlled activation and end-to-end data acceptance. + +Keep production activation and deployment out of repository-only work. + + +## Local developer-machine live discovery (opt-in) + +The V2 API can now read the existing Edge `MTConnect.Machines` entries from a trusted, absolute local file path. No Agent URL is entered through the browser and no new Agent registry is created. + +In the **API process environment** set `MTConnect__EdgeConfigurationFile` to the absolute path of the existing Edge JSON configuration (for example, a local development copy of `edge.production.json`). Restart the API after changing the path. The API fails startup if an explicitly configured file is absent, malformed, duplicated by MachineId, or contains invalid machine addresses. Only MachineId, DeviceKey and BaseUri are imported; database credentials, security configuration and other Edge settings are not loaded. + +The machine must already be registered in V2 with the **same MachineId** and must not be retired. Open **Administration → Machines**, select the registered machine, then choose **Discover Agent devices**. The API makes a bounded read-only `/probe` request to the configured Agent. It reports the devices and whether the configured DeviceKey has zero, one, or multiple matching devices. A potential match still requires administrator verification. Discovery never enables acquisition or saves a mapping. + +The API host must be able to reach the configured Agent address from its own network context. Edge loopback URLs such as `127.0.0.1` only work when the API and Agent run on the same host; do not assume a copied production Edge configuration is reachable from a developer machine. Never activate production machines just to test discovery. + + +## Local commissioning save/read acceptance (COM-001) + +Run against the developer V2 database only, after applying migration +`022-machine-commissioning.sql`. Use an administrator session with +`configuration.master.manage`. No Edge acquisition or production deployment is required. + +1. Open `/admin/machines` and register a site and production line if none exist. +2. Register an Edge-configured machine using its existing MachineId. Verify that a + second registration using the same MachineId is rejected, not duplicated. +3. Select the registered machine for live Agent discovery. Confirm that the + configured DeviceKey has exactly one match; ambiguous or failed discovery + must not allow the Save commissioning assignment action. +4. Choose a production line belonging to the machine's site and save the + matching device. Confirm the UI reports **saved and verified**, then refresh + the browser and confirm the assignment is still listed. +5. Change the assignment to another valid line in the same site, if available. + Confirm the machine has one current assignment and the audit history records + the change. Reusing an operation ID with different request bytes must fail. +6. Attempt to assign a production line from a different site (using the + authenticated API, not just the UI). Expect HTTP 409 and no assignment change. +7. Confirm acquisition has not been activated: the saved assignment is + administrative intent, not a running connector or proof of production data. + +If any step fails, keep PR #76 in draft and record the request, HTTP status, +database migration version, and relevant application log without exposing +session tokens or Edge credentials. Do not perform these checks against Gajra +production systems. diff --git a/scripts/ci/activity-browser.mjs b/scripts/ci/activity-browser.mjs index 16a5ebf4..57c76eb0 100644 --- a/scripts/ci/activity-browser.mjs +++ b/scripts/ci/activity-browser.mjs @@ -17,7 +17,7 @@ try { const url = process.env.FC2_BROWSER_URL; async function login() { await page.goto(url + '/operator-evidence'); - await page.getByRole('link', { name: 'Sign in to view machine evidence' }).click(); + await page.getByRole('link', { name: 'Sign in to FactoryConnect' }).click(); await page.getByLabel('Password').fill(process.env.FC2_BROWSER_PASSWORD); await page.getByRole('button', { name: 'Provider sign in' }).click(); } diff --git a/scripts/ci/operator-browser.mjs b/scripts/ci/operator-browser.mjs index 3f56082c..a0ff5035 100644 --- a/scripts/ci/operator-browser.mjs +++ b/scripts/ci/operator-browser.mjs @@ -17,7 +17,7 @@ try { const url = process.env.FC2_BROWSER_URL; async function login() { await page.goto(url + '/operator-evidence'); - await page.getByRole('link', { name: 'Sign in to view machine evidence' }).click(); + await page.getByRole('link', { name: 'Sign in to FactoryConnect' }).click(); await page.getByLabel('Password').fill(process.env.FC2_BROWSER_PASSWORD); await page.getByRole('button', { name: 'Provider sign in' }).click(); } diff --git a/src/App.tsx b/src/App.tsx index 34702bbf..c564daf3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense } from "react"; +import { MachineAdministrationPage } from "./presentation/MachineAdministrationPage.tsx"; import { CommissioningPage } from "./presentation/CommissioningPage.tsx"; import { OperatorEvidencePage } from "./presentation/OperatorEvidencePage.tsx"; import { RetainedMachineEvidence } from "./presentation/RetainedMachineEvidence.tsx"; @@ -103,10 +104,11 @@ export function App({ runtime }: AppProps) {
{validEvidenceSession ? - : Sign in to view machine evidence}
+ : Sign in to FactoryConnect}