This commit is contained in:
@@ -372,7 +372,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
(case-insensitive; another-instance match beats an own-name match) is refused at save (`DriverConfigSaveGate.ValidateAsync`),
|
||||
deploy (`CredentialReferenceOtherInstance`), Test Connect (`TestDriverConnect.DriverInstanceId`) and browse
|
||||
(`OpenAsync(…, driverInstanceId, …)`; no ConfigDb ⇒ fail closed). One rule: `DriverCredentialPolicy.DescribeOtherInstanceProblems`
|
||||
+ `DriverInstanceIdentities`. The runtime adds no check (drivers don't know the fleet); `env:` is not instance-scoped.
|
||||
+ `DriverInstanceIdentities`. The runtime adds no check (drivers don't know the fleet). Since #849 `env:<PREFIX><INSTANCE>_…`
|
||||
is instance-scoped the same way (INSTANCE = id/name upper-cased, other chars `_`; `_`-bounded prefix match); an id-shaped
|
||||
part (`DRV_<12 HEX>`, `secret:…/DRV-<12 hex>/`) matches by id only, and id-shaped names (`drv`, `DRV-<12 hex>…`) are
|
||||
refused. #848: create/rename refuse a name another driver's stored reference uses, and a rename away from a name the
|
||||
driver's own references use (`DescribeNameChangeProblemsAsync`).
|
||||
⚠️ Test Connect/browse configs can carry UNSAVED edits: the claimed owner counts only when every endpoint value
|
||||
(`DriverCredentialRules.EndpointFields`) is one that driver stores (`EffectiveOwnerAsync`, server-side) — a new
|
||||
credential-sending driver must declare its endpoint keys there. Credential-carrying Test Connect/browse is audited
|
||||
|
||||
+63
-8
@@ -1415,7 +1415,7 @@ driver, device, field, allowed form, never the value. The same list, plus runnin
|
||||
warn-only resolution (**Running**, from driver-health diagnostics, fleet-wide), is shown as a banner on `/raw`,
|
||||
with a Re-check button.
|
||||
|
||||
#### Per-instance credential references (#835)
|
||||
#### Per-instance credential references (#835, #848, #849)
|
||||
|
||||
The namespace confines a reference to values provisioned for that driver *type*, but not to one *instance*: before
|
||||
#835 a Designer could create a Galaxy driver pointing at their own endpoint with
|
||||
@@ -1430,7 +1430,31 @@ that instance's **id or name**, case-insensitively.
|
||||
| `secret:galaxy/DRV-a/apikey`, `secret:galaxy/PlantA/apikey` | allowed — its own |
|
||||
| `secret:galaxy/gw/apikey`, `secret:galaxy/apikey` (no instance is called `gw` / `apikey`) | allowed — a shared key |
|
||||
| `secret:galaxy/DRV-b/apikey`, `secret:galaxy/PlantB/apikey`, `secret:galaxy/plantb` | **refused** — another instance's |
|
||||
| `env:OTOPCUA_GALAXY_…` | not checked — environment variables carry no instance convention (see residuals) |
|
||||
| `env:OTOPCUA_GALAXY_DRV_A_KEY`, `env:OTOPCUA_GALAXY_PLANTA_KEY` | allowed — its own (#849, see below) |
|
||||
| `env:GALAXY_MXGW_API_KEY`, `env:OTOPCUA_GALAXY_API_KEY` (no instance is called `API`) | allowed — a shared variable |
|
||||
| `env:OTOPCUA_GALAXY_DRV_B_KEY`, `env:OTOPCUA_GALAXY_PLANTB_KEY`, `env:GALAXY_MXGW_PLANTB` | **refused** — another instance's (#849) |
|
||||
|
||||
**`env:` references have the same rule (#849).** The convention is `env:<PREFIX><INSTANCE>_…` for every prefix of the
|
||||
driver's scope (`OTOPCUA_GALAXY_`, `GALAXY_MXGW_`, `OTOPCUA_OPCUACLIENT_`, `OTOPCUA_HEIDENHAIN_`), where `INSTANCE` is
|
||||
a driver id or name in environment form — ASCII letters upper-cased, digits kept, every other character `_`
|
||||
(`DRV-1a2b` ⇒ `DRV_1A2B`, `Line 1` ⇒ `LINE_1`). The part after the prefix **names** an instance when it equals that
|
||||
form or starts with it followed by `_` (so `PLANTB` names `env:OTOPCUA_GALAXY_PLANTB_KEY` but not
|
||||
`env:OTOPCUA_GALAXY_PLANTBX_KEY`). It is applied at every seam below with the same code
|
||||
(`CredentialReferenceOtherInstance`, message tagged `lmxopcua#849`). Because `_` both separates words and stands in for
|
||||
`-`/space, a variable can name more than one instance (`OTOPCUA_GALAXY_LINE_1_KEY` names both `Line` and `Line 1`); as
|
||||
for secrets, any match against another instance wins — use the id form (`env:OTOPCUA_GALAXY_DRV_1A2B_KEY`) for
|
||||
per-instance variables. Shared variables that start with no instance's id or name (`GALAXY_MXGW_API_KEY`) keep working.
|
||||
|
||||
- **Id-form references are matched by id only.** A reference whose instance part has the shape of a system-generated
|
||||
id — `secret:…/DRV-<12 hex>/…` or `env:<PREFIX>DRV_<12 HEX>` alone or followed by `_` — is never matched against
|
||||
driver **names**, so a driver named `drv` (env form `DRV`), or one named like another's id, cannot make every
|
||||
sibling's id-form variable "another instance's", whether that name was created before or after this rule. New names
|
||||
of that form are refused outright at create / rename / modal update (`DriverCredentialPolicy.IsReservedInstanceName`:
|
||||
env form `DRV` or starting with `DRV_<12 HEX>`).
|
||||
- **Name-form variables are only reliable for ASCII letter/digit names.** Normalisation turns every other character —
|
||||
including non-ASCII letters — into `_`, so `Linie Süd` and `Linie S_d` share `LINIE_S_D`, and a name with no ASCII
|
||||
letter or digit at all normalises to empty and gets **no** name protection (only its id does). Use the id form for
|
||||
per-instance variables.
|
||||
|
||||
Rules worth knowing:
|
||||
|
||||
@@ -1439,11 +1463,24 @@ Rules worth knowing:
|
||||
`secret:galaxy/<VictimName>/…`. Consequence: two same-type drivers that share a *name* cannot use name-scoped
|
||||
secrets — scope them by **driver id** (`secret:galaxy/<DriverInstanceId>/…`, ids are unique) instead.
|
||||
- A driver **being created** owns nothing yet, so any reference naming an existing instance is refused.
|
||||
- Creating or renaming a driver whose name equals a segment other drivers already use as a *shared* name makes those
|
||||
drivers' next deploy fail (`CredentialReferenceOtherInstance`) — rename the shared secret or the driver.
|
||||
- **A driver's name is checked against the references other drivers store (#848).** Creating a driver — or renaming
|
||||
one, through the `/raw` rename or the driver config modal — whose new name is the instance part of a reference
|
||||
**stored on another same-type driver** (its DriverConfig or any DeviceConfig; `secret:` segment or `env:` part) is
|
||||
refused at save (`DriverConfigSaveGate.ValidateNameAsync` → `DriverInstanceIdentities.DescribeNameChangeProblemsAsync`):
|
||||
otherwise naming a driver `gw` (or `API`, for `env:GALAXY_MXGW_API_KEY`) would turn every other driver's shared
|
||||
reference into "another instance's" and fail their next deploy. The message names the other driver, its id, the device
|
||||
and the field path — never the reference or a value.
|
||||
- **A driver cannot be renamed away from a name its own references are named after (#848).** If the driver's stored
|
||||
references (or the config being saved with the rename) use its *current name* — `secret:galaxy/PlantA/…`,
|
||||
`env:OTOPCUA_GALAXY_PLANTA_…` — and do not also name its id or the new name, the rename is refused: it would free
|
||||
the old name, and that reference would then look like an unclaimed shared name to every other driver's save, Test
|
||||
Connect and browse (the issue's "renaming the victim frees its name until the deploy gate runs" window). Move the
|
||||
reference to the id form first (the config modal can do both in one save), then rename. A case-only rename changes
|
||||
nothing the rule matches and is allowed. Id-scoped references are immune — ids never change.
|
||||
|
||||
One rule — `DriverCredentialPolicy.DescribeOtherInstanceProblems` — at the same seams as #815, reading the instance list
|
||||
through `DriverInstanceIdentities` (only when the config carries an in-scope `secret:` reference at all):
|
||||
through `DriverInstanceIdentities` (only when the config carries an in-scope `secret:` or `env:` reference at all — so
|
||||
since #849 a browse of an `env:`-only config also needs the ConfigDb and fails closed without one):
|
||||
|
||||
1. **AdminUI save** — `DriverConfigSaveGate.ValidateAsync` (every `RawTreeService` driver/device create + update; the
|
||||
owner is the driver itself, or the device's driver).
|
||||
@@ -1477,8 +1514,24 @@ config at its next deploy, and the upgrade preflight / `/raw` banner lists it (D
|
||||
the driver's own value under `galaxy/<its DriverInstanceId>/…` (or a shared name no instance uses) and point the field
|
||||
at it.
|
||||
|
||||
Residuals (accepted): `env:` references are not instance-scoped (a variable is node-wide, and there is no naming
|
||||
convention to match — a deployment that needs per-instance isolation must use the secret store); a reference to a
|
||||
Upgrade note (#848/#849): the `env:` rule is new, so a stored config whose variable starts with **another same-type
|
||||
driver's id or name** (e.g. a Galaxy driver named `API` beside `env:GALAXY_MXGW_API_KEY` users) is refused at its next
|
||||
deploy — the upgrade preflight / `/raw` banner lists it. Rename that driver (the #848 rename check allows it, since the
|
||||
references naming it belong to other drivers — unless its own references use the name too, in which case move those to
|
||||
its id form first) or move the other drivers' variables to their id form. Existing driver names are not
|
||||
re-checked against stored references except when they change.
|
||||
|
||||
Residuals (accepted): a **deleted** driver's id and name stop protecting its secrets and variables — after the delete
|
||||
any driver may reference `secret:galaxy/<deleted-id>/…` — so remove (or rotate) a deleted driver's secrets with it. The
|
||||
#848 rename pin does not stop a Designer who may edit the victim driver: a **single** config-modal save can move (or
|
||||
drop) its name-scoped reference and rename it in the same commit, after which the old name is free for any driver. That
|
||||
is the same "may author the victim's config ⇒ may redirect it" property as a saved endpoint change below (the save is a
|
||||
persisted, visible draft change, and the later credential use is audited) — use id-scoped references where this
|
||||
matters. For the same reason a low-severity race is accepted: a rename and a **concurrent** device save that adds a
|
||||
reference to the old name are not tied together (the driver's `RowVersion` does not cover its device rows), so both can
|
||||
commit and leave a device reference named after a name the driver no longer has — again needs a Designer who may author that driver's
|
||||
config, and audited at use. A variable is node-wide, so the `env:` rule
|
||||
constrains which driver may *name* it, not who can read the process environment. A reference to a
|
||||
**shared** name (one no instance is called) is not tied to any owner, so an unsaved endpoint can still be Test-Connected
|
||||
with it (audited); and a Designer who **saves** a new endpoint on an existing driver can then Test Connect / browse /
|
||||
deploy it with that driver's own reference — the general "anyone who may author config may redirect it" property
|
||||
@@ -1503,7 +1556,9 @@ service account can read (another product's client certificate, a host certifica
|
||||
(string or enum ordinal) — the driver never loads the file then, and the AdminUI form no longer persists the hidden
|
||||
field. An object without `AuthType` stays checked, and the deploy gate also checks the driver's config **as the
|
||||
runtime merges it with its devices**, so splitting `AuthType: Certificate` (device) from the path (driver) cannot slip
|
||||
a path past the gate.
|
||||
a path past the gate. The upgrade preflight and the `/raw` banner run the same merged check
|
||||
(`CredentialReferenceDeployChecks.MergedOnlyProblems`, shared with the gate) and list such a split as a Draft finding
|
||||
"(merged with its devices)", so it shows before a deploy does (#850).
|
||||
- **Refused at the same five seams as #815** (save, deploy gate `CredentialReferenceOutOfScope`, Test Connect, browse —
|
||||
messages never carry the path) and **at load time**: `OpcUaUserIdentityFactory.ResolveSecretRefsAsync` (called by the
|
||||
running driver and the AdminUI browse before the identity is built) joins it to the directory, canonicalises it, and
|
||||
|
||||
+30
-21
@@ -74,35 +74,44 @@ public static class CredentialReferenceDeployChecks
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
|
||||
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId));
|
||||
|
||||
var reported = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
|
||||
reported.Add(problem);
|
||||
foreach (var device in devices)
|
||||
{
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
|
||||
{
|
||||
reported.Add(problem);
|
||||
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
// #836 review L-1: a path field is exempt only in an object that explicitly switches it off (OpcUaClient
|
||||
// AuthType ≠ Certificate), and the switch and the path may sit in different configs (driver vs device). Check
|
||||
// the config exactly as the runtime merges it, so a split can never deploy a path the driver will load.
|
||||
if (rules.PathFieldList.Count > 0)
|
||||
{
|
||||
var merged = DriverDeviceConfigMerger.Merge(
|
||||
driver.DriverConfig,
|
||||
devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(),
|
||||
[]);
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, merged))
|
||||
{
|
||||
if (reported.Add(problem))
|
||||
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}' (merged with its devices): {problem}{MigrationHint}", driver.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
foreach (var problem in MergedOnlyProblems(
|
||||
driver.DriverType, driver.DriverConfig, devices.Select(d => (d.Name, (string?)d.DeviceConfig)).ToList()))
|
||||
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}' (merged with its devices): {problem}{MigrationHint}", driver.DriverInstanceId));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #836 review L-1 / lmxopcua#850: a path field is exempt only in an object that explicitly switches it off
|
||||
/// (OpcUaClient <c>AuthType</c> ≠ Certificate), and the switch and the path may sit in different configs (driver vs
|
||||
/// device). This checks the config exactly as the runtime merges it (<see cref="DriverDeviceConfigMerger"/>) and
|
||||
/// returns the problems the merge reveals that the driver and device configs, checked one by one, do not — so a
|
||||
/// split can never hide a path the driver will load. The ONE implementation the deploy gate and the upgrade
|
||||
/// preflight / <c>/raw</c> banner share. Empty for a type without path fields.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <param name="driverConfig">The stored DriverConfig.</param>
|
||||
/// <param name="devices">Its devices (name, DeviceConfig).</param>
|
||||
/// <returns>The merged-only problems.</returns>
|
||||
public static IReadOnlyList<string> MergedOnlyProblems(
|
||||
string? driverType, string? driverConfig, IReadOnlyList<(string Name, string? DeviceConfig)> devices)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(devices);
|
||||
if (!DriverCredentialPolicy.TryGet(driverType, out var rules) || rules.PathFieldList.Count == 0) return [];
|
||||
var reported = new HashSet<string>(DriverCredentialPolicy.DescribeProblems(driverType, driverConfig), StringComparer.Ordinal);
|
||||
foreach (var (_, deviceConfig) in devices)
|
||||
reported.UnionWith(DriverCredentialPolicy.DescribeProblems(driverType, deviceConfig));
|
||||
var merged = DriverDeviceConfigMerger.Merge(
|
||||
driverConfig ?? "{}",
|
||||
devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig ?? "{}")).ToList(),
|
||||
[]);
|
||||
return DriverCredentialPolicy.DescribeProblems(driverType, merged).Where(reported.Add).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,118 @@ public static class DriverInstanceIdentities
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#848: the problems of giving a driver the name <paramref name="newName"/> (create, or a rename via the
|
||||
/// /raw rename or the driver config modal). Two rules, both about a name being the instance part of a stored
|
||||
/// credential reference (<see cref="DriverCredentialPolicy.ReferenceNamesName"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>Claim</b> — the new name may not be named by a reference STORED on another same-type driver (its
|
||||
/// DriverConfig or any DeviceConfig). Otherwise that reference would start naming "another instance" and the
|
||||
/// other driver's next deploy fails (naming a driver <c>gw</c> when others share <c>secret:galaxy/gw/…</c>).
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Pin</b> — a driver may not be renamed AWAY from a name its own references are named after (unless they
|
||||
/// also name its immutable id or the new name): renaming would free the old name, and a reference to it would
|
||||
/// then look like an unclaimed shared name to every other driver's Test Connect, browse and save.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// A case-only rename changes nothing the rule matches, so it is never refused. Messages name drivers, devices and
|
||||
/// field paths — never a credential value.
|
||||
/// </summary>
|
||||
/// <param name="db">The config DB.</param>
|
||||
/// <param name="driverType">The driver's type.</param>
|
||||
/// <param name="ownDriverInstanceId">The driver being renamed; null when it is being created.</param>
|
||||
/// <param name="currentName">Its stored name; null when it is being created.</param>
|
||||
/// <param name="newName">The name about to be stored.</param>
|
||||
/// <param name="ownDriverConfig">
|
||||
/// The DriverConfig being saved with the rename (the config modal saves both at once); null ⇒ the stored one.
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The problems; empty when the name may be stored.</returns>
|
||||
public static async Task<IReadOnlyList<string>> DescribeNameChangeProblemsAsync(
|
||||
OtOpcUaConfigDbContext db, string? driverType, string? ownDriverInstanceId, string? currentName, string newName,
|
||||
string? ownDriverConfig, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
if (!DriverCredentialPolicy.TryGet(driverType, out var rules)) return [];
|
||||
if (currentName is not null && string.Equals(currentName, newName, StringComparison.OrdinalIgnoreCase)) return [];
|
||||
|
||||
// #849 review: a name in the system-generated id form (env form 'DRV' or 'DRV_<12 HEX>…') would shadow the id
|
||||
// convention every driver's own references are recommended to use. The matcher already ignores such a name for
|
||||
// id-shaped references (so a pre-existing one breaks nothing); this keeps new ones from being created at all.
|
||||
if (DriverCredentialPolicy.IsReservedInstanceName(newName))
|
||||
{
|
||||
return
|
||||
[
|
||||
$"The name '{newName}' has the form of a system-generated driver id ('{DriverCredentialPolicy.DriverIdPrefix}' or "
|
||||
+ $"'{DriverCredentialPolicy.DriverIdPrefix}-<12 hex>…'), which {rules.Scope.Label} credential references use to name a "
|
||||
+ "driver by its id — choose another name (lmxopcua#848/#849).",
|
||||
];
|
||||
}
|
||||
|
||||
// Same shape as LoadAsync: the type filter stays client-side (case-insensitive), the device read is a plain IN.
|
||||
var drivers = (await db.DriverInstances.AsNoTracking()
|
||||
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType, d.DriverConfig })
|
||||
.ToListAsync(ct).ConfigureAwait(false))
|
||||
.Where(d => string.Equals(d.DriverType, rules.DriverType, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var ids = drivers.Select(d => d.DriverInstanceId).ToList();
|
||||
var devices = ids.Count == 0
|
||||
? []
|
||||
: await db.Devices.AsNoTracking()
|
||||
.Where(d => ids.Contains(d.DriverInstanceId))
|
||||
.Select(d => new { d.DriverInstanceId, d.Name, d.DeviceConfig })
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var problems = new List<string>();
|
||||
foreach (var driver in drivers)
|
||||
{
|
||||
var own = string.Equals(driver.DriverInstanceId, ownDriverInstanceId, StringComparison.Ordinal);
|
||||
var configs = new List<(string? Device, string? Json)>
|
||||
{
|
||||
(null, own && ownDriverConfig is not null ? ownDriverConfig : driver.DriverConfig),
|
||||
};
|
||||
configs.AddRange(devices
|
||||
.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))
|
||||
.Select(d => ((string?)d.Name, (string?)d.DeviceConfig)));
|
||||
|
||||
foreach (var (device, json) in configs)
|
||||
{
|
||||
var where = device is null ? "" : $" device '{device}'";
|
||||
foreach (var reference in DriverCredentialPolicy.CollectInstanceReferences(driverType, json))
|
||||
{
|
||||
if (!own && DriverCredentialPolicy.ReferenceNamesName(reference, newName))
|
||||
{
|
||||
problems.Add(
|
||||
$"The name '{newName}' is the instance part of the credential reference {reference.FieldPath} on "
|
||||
+ $"{rules.Scope.Label} driver '{driver.Name}' ({driver.DriverInstanceId}){where} — naming this driver "
|
||||
+ "so would make that reference name another driver instance and fail that driver's next deploy. "
|
||||
+ "Choose another name, or move that reference first (lmxopcua#848).");
|
||||
}
|
||||
else if (own && currentName is not null
|
||||
&& DriverCredentialPolicy.ReferenceNamesName(reference, currentName)
|
||||
&& !DriverCredentialPolicy.ReferenceNamesId(reference, ownDriverInstanceId)
|
||||
&& !DriverCredentialPolicy.ReferenceNamesName(reference, newName))
|
||||
{
|
||||
problems.Add(
|
||||
$"This driver's credential reference {reference.FieldPath}{where} is named after its current name "
|
||||
+ $"'{currentName}' — renaming would free that name, and any other {rules.Scope.Label} driver could then "
|
||||
+ "reference the same credential. Move the reference to this driver's id first "
|
||||
+ $"(secret:{rules.Scope.SecretNamespaces[0]}{ownDriverInstanceId}/… or "
|
||||
+ $"env:{rules.Scope.EnvVariablePrefixes[0]}{DriverCredentialPolicy.NormalizeEnvironmentIdentifier(ownDriverInstanceId)}_…), "
|
||||
+ "then rename (lmxopcua#848).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cross-instance problems of <paramref name="configJson"/> — reading the ConfigDb only when the config carries
|
||||
/// an in-scope <c>secret:</c> reference at all.
|
||||
/// an in-scope <c>secret:</c> or <c>env:</c> reference at all.
|
||||
/// </summary>
|
||||
/// <param name="db">The config DB.</param>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
|
||||
@@ -211,8 +211,8 @@ public static class DriverCredentialPolicy
|
||||
/// <summary>
|
||||
/// The instance-identifying segment of an in-scope <c>secret:</c> reference: the first path segment after the
|
||||
/// matched namespace (<c>secret:galaxy/<X>/apikey</c> ⇒ <c>X</c>; <c>secret:otopcua/galaxy/<X></c> ⇒
|
||||
/// <c>X</c>). Null for anything else — <c>env:</c> references carry no instance convention, and literals and
|
||||
/// out-of-scope references are the #815 rule's business.
|
||||
/// <c>X</c>). Null for anything else — <c>env:</c> references use <see cref="EnvironmentInstancePart"/>, and literals
|
||||
/// and out-of-scope references are the #815 rule's business.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored field value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
@@ -232,24 +232,147 @@ public static class DriverCredentialPolicy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the config carries at least one in-scope <c>secret:</c> reference — i.e. the cross-instance rule
|
||||
/// (<see cref="DescribeOtherInstanceProblems"/>) has something to check, so a seam must load the instance list.
|
||||
/// Lets seams skip the ConfigDb read otherwise.
|
||||
/// lmxopcua#849: the part of an in-scope <c>env:</c> reference after the matched (longest) prefix —
|
||||
/// <c>env:OTOPCUA_GALAXY_<REST></c> ⇒ <c>REST</c>. The per-instance convention is
|
||||
/// <c>env:<PREFIX><INSTANCE>_…</c>, INSTANCE being a driver id or name in
|
||||
/// <see cref="NormalizeEnvironmentIdentifier"/> form; the part NAMES an instance when it equals that form or starts
|
||||
/// with it followed by <c>_</c> (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>). Null for anything but an in-scope <c>env:</c>.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored field value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
/// <returns>The remainder, or null.</returns>
|
||||
public static string? EnvironmentInstancePart(string? value, CredentialScope scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
if (CredentialReferences.Parse(value, scope, out var name) != CredentialReferenceKind.Environment) return null;
|
||||
var prefix = scope.EnvVariablePrefixes
|
||||
.Where(p => name.StartsWith(p, StringComparison.Ordinal))
|
||||
.OrderByDescending(p => p.Length)
|
||||
.FirstOrDefault();
|
||||
return prefix is null ? null : name[prefix.Length..];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#849: a driver id or name in environment-variable form — ASCII letters upper-cased, digits kept, every
|
||||
/// other character <c>_</c> (<c>DRV-1a2b</c> ⇒ <c>DRV_1A2B</c>, <c>Line 1</c> ⇒ <c>LINE_1</c>). Empty when the
|
||||
/// identifier has no letter or digit (such an identifier names no variable).
|
||||
/// </summary>
|
||||
/// <param name="identifier">The id or name.</param>
|
||||
/// <returns>The normalised form.</returns>
|
||||
public static string NormalizeEnvironmentIdentifier(string? identifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identifier) || !identifier.Any(char.IsAsciiLetterOrDigit)) return "";
|
||||
return string.Create(identifier.Length, identifier, static (span, id) =>
|
||||
{
|
||||
for (var i = 0; i < id.Length; i++)
|
||||
span[i] = char.IsAsciiLetterOrDigit(id[i]) ? char.ToUpperInvariant(id[i]) : '_';
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The prefix of every system-generated driver instance id (<c>DRV-</c> + 12 hex, <c>RawTreeService.NewId</c>).</summary>
|
||||
public const string DriverIdPrefix = "DRV";
|
||||
|
||||
private const int DriverIdHexLength = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an instance reference (<see cref="CollectInstanceReferences"/>) names the driver id
|
||||
/// <paramref name="driverInstanceId"/>: a <c>secret:</c> segment equal to it (case-insensitive), or an <c>env:</c>
|
||||
/// part equal to its <see cref="NormalizeEnvironmentIdentifier"/> form or starting with that form plus <c>_</c>.
|
||||
/// </summary>
|
||||
/// <param name="reference">The reference.</param>
|
||||
/// <param name="driverInstanceId">A driver instance id.</param>
|
||||
/// <returns>Whether it names it.</returns>
|
||||
public static bool ReferenceNamesId(InstanceReference reference, string? driverInstanceId) =>
|
||||
Matches(reference, driverInstanceId);
|
||||
|
||||
/// <summary>
|
||||
/// Whether an instance reference names the driver NAME <paramref name="name"/> — as <see cref="ReferenceNamesId"/>,
|
||||
/// except that a reference whose instance part has the shape of a system-generated id
|
||||
/// (<see cref="IsIdShaped"/>: <c>secret:…/DRV-<12 hex>/…</c>, <c>env:<PREFIX>DRV_<12 HEX>[_…]</c>) is
|
||||
/// matched by id only. Otherwise a driver NAMED <c>drv</c> (env form <c>DRV</c>) would "own" the id-form variable of
|
||||
/// every other driver of its type, and the recommended id convention would break fleet-wide — including for a
|
||||
/// name that pre-dates the rule (lmxopcua#849 review).
|
||||
/// </summary>
|
||||
/// <param name="reference">The reference.</param>
|
||||
/// <param name="name">A driver name.</param>
|
||||
/// <returns>Whether it names it.</returns>
|
||||
public static bool ReferenceNamesName(InstanceReference reference, string? name) =>
|
||||
!IsIdShaped(reference) && Matches(reference, name);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the reference's instance part has the shape of a system-generated driver id: a <c>secret:</c> segment
|
||||
/// <c>DRV-<12 hex></c> (case-insensitive), or an <c>env:</c> part <c>DRV_<12 HEX></c> alone or followed by <c>_</c>.
|
||||
/// </summary>
|
||||
/// <param name="reference">The reference.</param>
|
||||
/// <returns>Whether it is id-shaped.</returns>
|
||||
public static bool IsIdShaped(InstanceReference reference) =>
|
||||
reference.Kind == CredentialReferenceKind.Secret
|
||||
? reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength && StartsWithIdForm(reference.Part, '-', ignoreCase: true)
|
||||
: StartsWithIdForm(reference.Part, '_', ignoreCase: false)
|
||||
&& (reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength
|
||||
|| reference.Part[DriverIdPrefix.Length + 1 + DriverIdHexLength] == '_');
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#849 review: a driver NAME that may not be given to a credential-bearing driver — one whose environment
|
||||
/// form (<see cref="NormalizeEnvironmentIdentifier"/>) is <c>DRV</c> or starts with an id form <c>DRV_<12 HEX></c>.
|
||||
/// Such a name would collide with the system-generated id convention every driver's own references are
|
||||
/// recommended to use.
|
||||
/// </summary>
|
||||
/// <param name="name">The proposed name.</param>
|
||||
/// <returns>Whether the name is reserved.</returns>
|
||||
public static bool IsReservedInstanceName(string? name)
|
||||
{
|
||||
var env = NormalizeEnvironmentIdentifier(name);
|
||||
return env == DriverIdPrefix
|
||||
|| IsIdShaped(new InstanceReference("", CredentialReferenceKind.Environment, env));
|
||||
}
|
||||
|
||||
private static bool StartsWithIdForm(string part, char separator, bool ignoreCase)
|
||||
{
|
||||
var length = DriverIdPrefix.Length + 1 + DriverIdHexLength;
|
||||
if (part.Length < length
|
||||
|| !part.StartsWith(DriverIdPrefix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)
|
||||
|| part[DriverIdPrefix.Length] != separator)
|
||||
return false;
|
||||
for (var i = DriverIdPrefix.Length + 1; i < length; i++)
|
||||
{
|
||||
var c = part[i];
|
||||
if (!(char.IsAsciiDigit(c) || c is >= 'A' and <= 'F' || (ignoreCase && c is >= 'a' and <= 'f'))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Matches(InstanceReference reference, string? identifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identifier)) return false;
|
||||
if (reference.Kind == CredentialReferenceKind.Secret)
|
||||
return string.Equals(reference.Part, identifier, StringComparison.OrdinalIgnoreCase);
|
||||
var normalized = NormalizeEnvironmentIdentifier(identifier);
|
||||
return normalized.Length > 0
|
||||
&& (string.Equals(reference.Part, normalized, StringComparison.Ordinal)
|
||||
|| reference.Part.StartsWith(normalized + "_", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the config carries at least one in-scope <c>secret:</c> or <c>env:</c> reference — i.e. the
|
||||
/// cross-instance rule (<see cref="DescribeOtherInstanceProblems"/>) has something to check, so a seam must load
|
||||
/// the instance list. Lets seams skip the ConfigDb read otherwise.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>Whether any instance segment is present.</returns>
|
||||
/// <returns>Whether any instance reference is present.</returns>
|
||||
public static bool HasInstanceSegments(string? driverType, string? json) =>
|
||||
CollectSegments(driverType, json).Count > 0;
|
||||
CollectInstanceReferences(driverType, json).Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#835: inside one namespace, a reference whose instance-identifying segment (see
|
||||
/// <see cref="InstanceSegment"/>) names a DIFFERENT existing instance of the same driver type — by its
|
||||
/// <see cref="DriverInstanceIdentity.DriverInstanceId"/> or its <see cref="DriverInstanceIdentity.Name"/>,
|
||||
/// case-insensitively — is refused: otherwise a Designer could create (or Test Connect / browse) a driver that
|
||||
/// points at their own endpoint with <c>secret:galaxy/<other-instance>/apikey</c> and capture that
|
||||
/// instance's key. Shared names that are no other instance's id or name (<c>secret:galaxy/gw/apikey</c>,
|
||||
/// <c>secret:galaxy/apikey</c>) stay valid, as does a reference naming the instance itself.
|
||||
/// lmxopcua#835/#849: inside one namespace, a reference that names (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>) a DIFFERENT
|
||||
/// existing instance of the same driver type — by its <see cref="DriverInstanceIdentity.DriverInstanceId"/> or its
|
||||
/// <see cref="DriverInstanceIdentity.Name"/> — is refused: otherwise a Designer could create (or Test Connect /
|
||||
/// browse) a driver that points at their own endpoint with <c>secret:galaxy/<other-instance>/apikey</c> (or
|
||||
/// <c>env:OTOPCUA_GALAXY_<OTHER_INSTANCE>_KEY</c>) and capture that instance's key. Shared names that name no
|
||||
/// other instance (<c>secret:galaxy/gw/apikey</c>, <c>env:GALAXY_MXGW_API_KEY</c>) stay valid, as does a reference
|
||||
/// naming the instance itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A match against ANOTHER instance wins over a match against the owner — names are not unique, and renaming
|
||||
@@ -267,24 +390,26 @@ public static class DriverCredentialPolicy
|
||||
ArgumentNullException.ThrowIfNull(instances);
|
||||
var problems = new List<string>();
|
||||
if (!TryGet(driverType, out var rules)) return problems;
|
||||
var segments = CollectSegments(driverType, json);
|
||||
if (segments.Count == 0) return problems;
|
||||
var references = CollectInstanceReferences(driverType, json);
|
||||
if (references.Count == 0) return problems;
|
||||
|
||||
var others = instances
|
||||
.Where(i => string.Equals(i.DriverType, rules.DriverType, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(i.DriverInstanceId, ownDriverInstanceId, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
foreach (var (fieldPath, segment) in segments)
|
||||
foreach (var reference in references)
|
||||
{
|
||||
var other = others.FirstOrDefault(o =>
|
||||
string.Equals(o.DriverInstanceId, segment, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(o.Name, segment, StringComparison.OrdinalIgnoreCase));
|
||||
var other = others.FirstOrDefault(o => ReferenceNamesId(reference, o.DriverInstanceId) || ReferenceNamesName(reference, o.Name));
|
||||
if (other is null) continue;
|
||||
problems.Add(
|
||||
$"{fieldPath} names a secret under '{segment}/', which is another {rules.Scope.Label} driver instance "
|
||||
+ $"('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own secrets "
|
||||
+ $"(secret:{rules.Scope.SecretNamespaces[0]}<this-driver-id>/…) or a shared name that is no other "
|
||||
+ "instance's id or name (lmxopcua#835).");
|
||||
problems.Add(reference.Kind == CredentialReferenceKind.Secret
|
||||
? $"{reference.FieldPath} names a secret under '{reference.Part}/', which is another {rules.Scope.Label} driver "
|
||||
+ $"instance ('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own secrets "
|
||||
+ $"(secret:{rules.Scope.SecretNamespaces[0]}<this-driver-id>/…) or a shared name that is no other "
|
||||
+ "instance's id or name (lmxopcua#835)."
|
||||
: $"{reference.FieldPath} names an environment variable of another {rules.Scope.Label} driver instance "
|
||||
+ $"('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own variables "
|
||||
+ $"(env:{rules.Scope.EnvVariablePrefixes[0]}<THIS_DRIVER_ID>_…, the id upper-cased with every other "
|
||||
+ "character as '_') or a shared name that starts with no other instance's id or name (lmxopcua#849).");
|
||||
}
|
||||
|
||||
return problems;
|
||||
@@ -397,29 +522,36 @@ public static class DriverCredentialPolicy
|
||||
using (doc) use(doc.RootElement);
|
||||
}
|
||||
|
||||
private static List<(string FieldPath, string Segment)> CollectSegments(string? driverType, string? json)
|
||||
/// <summary>
|
||||
/// Every in-scope instance reference of a config (lmxopcua#835 <c>secret:</c> segments + #849 <c>env:</c> parts), at
|
||||
/// any depth. Empty for an unreadable config or a type without rules.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>The references, in document order.</returns>
|
||||
public static IReadOnlyList<InstanceReference> CollectInstanceReferences(string? driverType, string? json)
|
||||
{
|
||||
var found = new List<(string, string)>();
|
||||
var found = new List<InstanceReference>();
|
||||
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return found;
|
||||
JsonDocument doc;
|
||||
try
|
||||
// Unreadable JSON is reported by DescribeProblems.
|
||||
WithDocument(json, root => Visit(root, "", rules, 0, (fieldPath, value, field, _) =>
|
||||
{
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return found; // DescribeProblems reports unreadable JSON.
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
Visit(doc.RootElement, "", rules, 0, (fieldPath, value, field, _) =>
|
||||
{
|
||||
if (field is null || value.ValueKind != JsonValueKind.String) return;
|
||||
if (InstanceSegment(value.GetString(), rules.Scope) is { Length: > 0 } segment) found.Add((fieldPath, segment));
|
||||
});
|
||||
}
|
||||
|
||||
if (field is null || value.ValueKind != JsonValueKind.String) return;
|
||||
var text = value.GetString();
|
||||
if (InstanceSegment(text, rules.Scope) is { Length: > 0 } segment)
|
||||
found.Add(new(fieldPath, CredentialReferenceKind.Secret, segment));
|
||||
else if (EnvironmentInstancePart(text, rules.Scope) is { Length: > 0 } part)
|
||||
found.Add(new(fieldPath, CredentialReferenceKind.Environment, part));
|
||||
}));
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The instance-identifying part of one in-scope credential reference (lmxopcua#835 / #849).</summary>
|
||||
/// <param name="FieldPath">Where it sits in the config (never the value).</param>
|
||||
/// <param name="Kind"><see cref="CredentialReferenceKind.Secret"/> or <see cref="CredentialReferenceKind.Environment"/>.</param>
|
||||
/// <param name="Part">
|
||||
/// The <c>secret:</c> instance segment (<see cref="DriverCredentialPolicy.InstanceSegment"/>) or the <c>env:</c> remainder
|
||||
/// after the prefix (<see cref="DriverCredentialPolicy.EnvironmentInstancePart"/>).
|
||||
/// </param>
|
||||
public readonly record struct InstanceReference(string FieldPath, CredentialReferenceKind Kind, string Part);
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
/// <param name="authenticationStateProvider">Supplies the calling circuit's user.</param>
|
||||
/// <param name="dbFactory">
|
||||
/// The config DB, for the cross-instance credential rule (lmxopcua#835). Null (test graphs) ⇒ a config carrying an
|
||||
/// in-scope <c>secret:</c> reference cannot be checked and is refused (fail-closed).
|
||||
/// in-scope <c>secret:</c>/<c>env:</c> reference cannot be checked and is refused (fail-closed).
|
||||
/// </param>
|
||||
/// <param name="audit">The credential-use audit sink (#835 review I-1); null ⇒ the structured log line only.</param>
|
||||
public sealed class BrowserSessionService(
|
||||
@@ -73,7 +73,7 @@ public sealed class BrowserSessionService(
|
||||
if (DriverCredentialPolicy.HasInstanceSegments(driverType, configJson))
|
||||
{
|
||||
if (dbFactory is null)
|
||||
return new(false, "Browse refused: the driver-instance list needed to check this config's secret: "
|
||||
return new(false, "Browse refused: the driver-instance list needed to check this config's secret:/env: "
|
||||
+ "references is unavailable (lmxopcua#835, fail-closed).", Guid.Empty);
|
||||
try
|
||||
{
|
||||
@@ -88,7 +88,7 @@ public sealed class BrowserSessionService(
|
||||
// Review L-2: a ConfigDb fault must not become a circuit error — refuse (fail-closed) with a message.
|
||||
logger.LogWarning(ex, "Browse credential check failed for driverType={DriverType}", driverType);
|
||||
return new(false, "Browse refused: the driver-instance list could not be read to check this config's "
|
||||
+ "secret: references (lmxopcua#835, fail-closed). Try again.", Guid.Empty);
|
||||
+ "secret:/env: references (lmxopcua#835/#849, fail-closed). Try again.", Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
@@ -85,13 +86,28 @@ public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfig
|
||||
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
|
||||
}
|
||||
|
||||
foreach (var dev in devices)
|
||||
var deviceList = devices.ToList();
|
||||
foreach (var dev in deviceList)
|
||||
{
|
||||
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig, owner.DriverInstanceId, draftInstances))
|
||||
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
|
||||
}
|
||||
|
||||
// lmxopcua#850: what the deploy gate checks on the runtime-merged driver+device config (an AuthType switch in one
|
||||
// config and a UserCertificatePath in the other) — the same helper, so the banner shows it before a deploy does.
|
||||
// Device order follows DraftSnapshotFactory (the deploy gate's input): by DeviceId.
|
||||
foreach (var d in driverList)
|
||||
{
|
||||
var own = deviceList
|
||||
.Where(dev => string.Equals(dev.DriverInstanceId, d.DriverInstanceId, StringComparison.Ordinal))
|
||||
.OrderBy(dev => dev.DeviceId, StringComparer.Ordinal)
|
||||
.Select(dev => (dev.Name, (string?)dev.DeviceConfig))
|
||||
.ToList();
|
||||
foreach (var p in CredentialReferenceDeployChecks.MergedOnlyProblems(d.DriverType, d.DriverConfig, own))
|
||||
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, $"(merged with its devices) {p}"));
|
||||
}
|
||||
|
||||
var deployedList = deployed.ToList();
|
||||
var deployedInstances = deployedList.Select(s => new DriverInstanceIdentity(s.DriverInstanceId, s.Name, s.DriverType)).ToList();
|
||||
foreach (var s in deployedList)
|
||||
|
||||
@@ -34,7 +34,7 @@ public static class DriverConfigSaveGate
|
||||
/// plus the cross-instance rule (lmxopcua#835 — a <c>secret:galaxy/<X>/…</c> reference may not name ANOTHER
|
||||
/// existing instance of the same driver type), which needs the stored instance list.
|
||||
/// </summary>
|
||||
/// <param name="db">The config DB (read only when the config carries an in-scope <c>secret:</c> reference).</param>
|
||||
/// <param name="db">The config DB (read only when the config carries an in-scope <c>secret:</c>/<c>env:</c> reference).</param>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="configJson">The DriverConfig or DeviceConfig JSON about to be stored.</param>
|
||||
/// <param name="ownDriverInstanceId">The owning driver; null when it is being created.</param>
|
||||
@@ -48,4 +48,28 @@ public static class DriverConfigSaveGate
|
||||
.DescribeOtherInstanceProblemsAsync(db, driverType, configJson, ownDriverInstanceId, ct).ConfigureAwait(false);
|
||||
return problems.Count == 0 ? null : string.Join(" ", problems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authority check <see cref="RawTreeService"/> runs on every driver create and rename (lmxopcua#848,
|
||||
/// <see cref="DriverInstanceIdentities.DescribeNameChangeProblemsAsync"/>): the new name may not be the instance part
|
||||
/// of another same-type driver's stored credential reference, and a rename may not free a name the driver's own
|
||||
/// references are named after.
|
||||
/// </summary>
|
||||
/// <param name="db">The config DB.</param>
|
||||
/// <param name="driverType">The driver's type.</param>
|
||||
/// <param name="ownDriverInstanceId">The driver being renamed; null on create.</param>
|
||||
/// <param name="currentName">Its stored name; null on create.</param>
|
||||
/// <param name="newName">The name about to be stored.</param>
|
||||
/// <param name="ownDriverConfig">The DriverConfig saved with it; null ⇒ the stored one.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The refusal (never containing a credential value), or null.</returns>
|
||||
public static async Task<string?> ValidateNameAsync(
|
||||
OtOpcUaConfigDbContext db, string? driverType, string? ownDriverInstanceId, string? currentName, string newName,
|
||||
string? ownDriverConfig, CancellationToken ct)
|
||||
{
|
||||
var problems = await DriverInstanceIdentities
|
||||
.DescribeNameChangeProblemsAsync(db, driverType, ownDriverInstanceId, currentName, newName, ownDriverConfig, ct)
|
||||
.ConfigureAwait(false);
|
||||
return problems.Count == 0 ? null : string.Join(" ", problems);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +492,10 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (await DriverConfigSaveGate.ValidateAsync(db, driverType, driverConfigJson, null, ct) is { } instanceRefusal)
|
||||
return new UnsMutationResult(false, instanceRefusal);
|
||||
|
||||
// lmxopcua#848: nor may its NAME be the instance part of another driver's stored reference.
|
||||
if (await DriverConfigSaveGate.ValidateNameAsync(db, driverType, null, null, name, driverConfigJson, ct) is { } nameRefusal)
|
||||
return new UnsMutationResult(false, nameRefusal);
|
||||
|
||||
if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct))
|
||||
return new UnsMutationResult(false, $"Cluster '{clusterId}' not found.");
|
||||
|
||||
@@ -549,6 +553,12 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
&& d.Name == newName && d.DriverInstanceId != driverInstanceId, ct))
|
||||
return RawRenameResult.Failure($"A driver named '{newName}' already exists here.");
|
||||
|
||||
// lmxopcua#848: the new name may not be another driver's credential segment, nor may this rename free a name
|
||||
// the driver's own credential references are named after.
|
||||
if (await DriverConfigSaveGate.ValidateNameAsync(
|
||||
db, entity.DriverType, entity.DriverInstanceId, entity.Name, newName, null, ct) is { } nameRefusal)
|
||||
return RawRenameResult.Failure(nameRefusal);
|
||||
|
||||
var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDriverAsync(db, driverInstanceId, ct), ct);
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
@@ -1129,6 +1139,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
&& d.Name == name && d.DriverInstanceId != driverInstanceId, ct))
|
||||
return new UnsMutationResult(false, $"A driver named '{name}' already exists here.");
|
||||
|
||||
// lmxopcua#848 — as RenameDriverAsync, judged against the config being saved with the rename.
|
||||
if (await DriverConfigSaveGate.ValidateNameAsync(
|
||||
db, entity.DriverType, entity.DriverInstanceId, entity.Name, name, driverConfigJson, ct) is { } nameRefusal)
|
||||
return new UnsMutationResult(false, nameRefusal);
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
entity.Name = name;
|
||||
entity.DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson;
|
||||
|
||||
@@ -611,9 +611,9 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
// lmxopcua#835: a reference naming ANOTHER driver instance's secret (secret:galaxy/<other>/…) is refused
|
||||
// lmxopcua#835/#849: a reference naming ANOTHER driver instance's secret (secret:galaxy/<other>/…, env:OTOPCUA_GALAXY_<OTHER>_…) is refused
|
||||
// before the probe resolves it — off the mailbox, since it reads the ConfigDb (only when the config
|
||||
// carries an in-scope secret: reference at all).
|
||||
// carries an in-scope secret:/env: reference at all).
|
||||
if (DriverCredentialPolicy.HasInstanceSegments(msg.DriverType, msg.ConfigJson))
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cts.Token);
|
||||
|
||||
+29
@@ -50,6 +50,35 @@ public sealed class DraftValidatorCredentialInstanceAndPathTests
|
||||
[new Device { DeviceId = "dev-b", DriverInstanceId = "DRV-b", Name = "d", DeviceConfig = """{"Password":"secret:opcuaclient/victim/pw"}""" }])
|
||||
.ShouldHaveSingleItem().Context.ShouldBe("dev-b");
|
||||
|
||||
/// <summary>lmxopcua#849: the env: form carries the same per-instance rule, under the same code.</summary>
|
||||
[Fact]
|
||||
public void A_galaxy_env_reference_naming_another_instance_is_refused()
|
||||
{
|
||||
var e = Validate(
|
||||
Drv("DRV-victim", "Victim", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_VICTIM_KEY"}}"""),
|
||||
Drv("DRV-evil", "Evil", "GalaxyMxGateway", """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:GALAXY_MXGW_DRV_VICTIM_KEY"}}"""))
|
||||
.ShouldHaveSingleItem();
|
||||
e.Code.ShouldBe(CredentialReferenceDeployChecks.OtherInstanceCode);
|
||||
e.Context.ShouldBe("DRV-evil");
|
||||
e.Message.ShouldContain("lmxopcua#849");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_and_own_env_variables_pass() =>
|
||||
Validate(
|
||||
Drv("DRV-a", "A", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_A_KEY"}}"""),
|
||||
Drv("DRV-b", "B", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}"""),
|
||||
Drv("DRV-c", "C", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}"""))
|
||||
.ShouldBeEmpty();
|
||||
|
||||
/// <summary>#849 review: a pre-existing driver named 'drv' must not fail the id-form variables of its siblings.</summary>
|
||||
[Fact]
|
||||
public void A_driver_named_drv_does_not_break_id_form_env_variables() =>
|
||||
Validate(
|
||||
Drv("DRV-0123456789ab", "A", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY"}}"""),
|
||||
Drv("DRV-fedcba987654", "drv", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_FEDCBA987654_KEY"}}"""))
|
||||
.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Shared_and_own_keys_pass() =>
|
||||
Validate(
|
||||
|
||||
+133
-2
@@ -44,7 +44,10 @@ public sealed class CredentialInstanceScopeTests
|
||||
[InlineData("secret:galaxy/gw/apikey")] // a shared name no instance uses
|
||||
[InlineData("secret:galaxy/apikey")]
|
||||
[InlineData("secret:galaxy/UpstreamC/apikey")] // another TYPE's instance — not this namespace's owner
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTB_KEY")] // env carries no instance convention
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTA_KEY")] // its own name in env form (lmxopcua#849)
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_AAA_KEY")] // its own id in env form
|
||||
[InlineData("env:GALAXY_MXGW_API_KEY")] // a shared variable that names no instance
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTBX_KEY")] // 'PLANTB' only at a '_' boundary
|
||||
[InlineData("dev:k")]
|
||||
[InlineData("")]
|
||||
public void Own_shared_and_non_secret_references_pass(string reference) =>
|
||||
@@ -98,7 +101,7 @@ public sealed class CredentialInstanceScopeTests
|
||||
public void HasInstanceSegments_gates_the_ConfigDb_read()
|
||||
{
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/x/apikey")).ShouldBeTrue();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_X")).ShouldBeFalse();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_X")).ShouldBeTrue(); // #849
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:ConnectionStrings__ConfigDb")).ShouldBeFalse();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Modbus, """{"Password":"secret:galaxy/x"}""").ShouldBeFalse();
|
||||
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, "not json").ShouldBeFalse();
|
||||
@@ -113,4 +116,132 @@ public sealed class CredentialInstanceScopeTests
|
||||
problems.ShouldContain(p => p.Contains("lmxopcua#815"));
|
||||
problems.ShouldContain(p => p.Contains("lmxopcua#835"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// lmxopcua#849 — env: references follow the same per-instance rule (env:<PREFIX><INSTANCE>_…)
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTB_KEY")] // another instance's name, upper-cased
|
||||
[InlineData("env:OTOPCUA_GALAXY_PLANTB")] // the part alone
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_BBB_KEY")] // another instance's id, '-' as '_'
|
||||
[InlineData("env:GALAXY_MXGW_PLANTB_API_KEY")] // the legacy prefix too
|
||||
public void An_env_reference_naming_another_instance_is_refused(string reference)
|
||||
{
|
||||
var problem = DriverCredentialPolicy
|
||||
.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), "DRV-aaa", Instances)
|
||||
.ShouldHaveSingleItem();
|
||||
problem.ShouldContain("lmxopcua#849");
|
||||
problem.ShouldContain("DRV-bbb");
|
||||
problem.ShouldContain("Gateway.ApiKeySecretRef");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_env_match_against_another_instance_wins_over_the_owners()
|
||||
{
|
||||
// Owner "Line 1" (LINE_1) and another instance "Line" (LINE): OTOPCUA_GALAXY_LINE_1_KEY names both.
|
||||
IEnumerable<DriverInstanceIdentity> lines =
|
||||
[new("DRV-1", "Line 1", DriverTypeNames.Galaxy), new("DRV-2", "Line", DriverTypeNames.Galaxy)];
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_LINE_1_KEY"), "DRV-1", lines)
|
||||
.ShouldHaveSingleItem();
|
||||
// …while the owner's id-scoped variable names only the owner.
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_1_KEY"), "DRV-1", lines)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaClient_and_Heidenhain_env_forms_have_the_rule()
|
||||
{
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.OpcUaClient, """{"Password":"env:OTOPCUA_OPCUACLIENT_UPSTREAMD_PW"}""", "DRV-ccc", Instances)
|
||||
.ShouldHaveSingleItem().ShouldContain("lmxopcua#849");
|
||||
IEnumerable<DriverInstanceIdentity> hh = [new("H1", "line1", DriverTypeNames.Heidenhain), new("H2", "line2", DriverTypeNames.Heidenhain)];
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.Heidenhain, """{"Access":{"DncPassword":"env:OTOPCUA_HEIDENHAIN_LINE2_DNC"}}""", "H1", hh)
|
||||
.ShouldHaveSingleItem();
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.Heidenhain, """{"Access":{"DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC_PASSWORD"}}""", "H1", hh)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DRV-1a2b", "DRV_1A2B")]
|
||||
[InlineData("Line 1", "LINE_1")]
|
||||
[InlineData("plant.b", "PLANT_B")]
|
||||
[InlineData("---", "")]
|
||||
[InlineData("", "")]
|
||||
public void Identifiers_normalise_to_environment_form(string identifier, string expected) =>
|
||||
DriverCredentialPolicy.NormalizeEnvironmentIdentifier(identifier).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Instance_references_are_collected_for_both_kinds()
|
||||
{
|
||||
var refs = DriverCredentialPolicy.CollectInstanceReferences(DriverTypeNames.Galaxy,
|
||||
"""{"Gateway":{"ApiKeySecretRef":"secret:galaxy/gw/k"},"AlarmAck":{"FallbackPasswordSecretRef":"env:OTOPCUA_GALAXY_ACK_PW"}}""");
|
||||
refs.Count.ShouldBe(2);
|
||||
refs[0].ShouldBe(new InstanceReference("Gateway.ApiKeySecretRef", CredentialReferenceKind.Secret, "gw"));
|
||||
refs[1].ShouldBe(new InstanceReference("AlarmAck.FallbackPasswordSecretRef", CredentialReferenceKind.Environment, "ACK_PW"));
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ack").ShouldBeTrue();
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ac").ShouldBeFalse();
|
||||
DriverCredentialPolicy.ReferenceNamesName(refs[0], "GW").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// #849 review — a driver NAMED 'drv' (or id-shaped) must not shadow the id convention
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
private const string RealId = "DRV-0123456789ab"; // the RawTreeService.NewId shape
|
||||
|
||||
private static readonly DriverInstanceIdentity[] WithDrvNamed =
|
||||
[
|
||||
new(RealId, "PlantA", DriverTypeNames.Galaxy),
|
||||
new("DRV-fedcba987654", "drv", DriverTypeNames.Galaxy), // a pre-existing driver named 'drv'
|
||||
new("DRV-111111111111", "DRV-0123456789AB", DriverTypeNames.Galaxy), // …or named like another's id
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY")]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB")]
|
||||
[InlineData("env:GALAXY_MXGW_DRV_0123456789AB_API_KEY")]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab/apikey")]
|
||||
public void An_id_form_reference_is_matched_by_id_only_so_a_pre_existing_drv_name_does_not_break_it(string reference) =>
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), RealId, WithDrvNamed)
|
||||
.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void An_id_form_reference_still_names_the_other_instance_by_its_id() =>
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY"), "DRV-fedcba987654", WithDrvNamed)
|
||||
.ShouldHaveSingleItem().ShouldContain(RealId);
|
||||
|
||||
[Fact]
|
||||
public void A_drv_name_still_matches_a_reference_that_is_not_id_shaped() =>
|
||||
// 'DRV_FOO' is not an id form, so the name 'drv' does claim it (the create/rename check refuses that name anyway).
|
||||
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_FOO_KEY"), RealId, WithDrvNamed)
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY", true)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB", true)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789ABC_KEY", false)] // 13 hex: not an id form
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AG_KEY", false)] // not hex
|
||||
[InlineData("env:OTOPCUA_GALAXY_DRV_KEY", false)]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab/k", true)]
|
||||
[InlineData("secret:galaxy/drv-0123456789AB/k", true)]
|
||||
[InlineData("secret:galaxy/DRV-0123456789ab-x/k", false)]
|
||||
public void Id_shape_detection(string reference, bool expected) =>
|
||||
DriverCredentialPolicy.IsIdShaped(DriverCredentialPolicy.CollectInstanceReferences(DriverTypeNames.Galaxy, Galaxy(reference)).Single())
|
||||
.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("drv", true)]
|
||||
[InlineData("DRV", true)]
|
||||
[InlineData("drv-", false)] // env form 'DRV_' — neither 'DRV' nor an id form
|
||||
[InlineData("DRV-0123456789ab", true)]
|
||||
[InlineData("drv 0123456789AB extra", true)]
|
||||
[InlineData("DRV-0123456789", false)]
|
||||
[InlineData("Driver", false)]
|
||||
[InlineData("PlantA", false)]
|
||||
public void Reserved_instance_names(string name, bool expected) =>
|
||||
DriverCredentialPolicy.IsReservedInstanceName(name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
+35
@@ -72,6 +72,41 @@ public sealed class BrowserSessionCredentialScopeTests
|
||||
universal.Opens.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#849: an env: reference named after ANOTHER instance never opens a browser either.</summary>
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_VICTIM_KEY")]
|
||||
[InlineData("env:GALAXY_MXGW_DRV_VICTIM_KEY")]
|
||||
public async Task An_env_reference_naming_another_instance_never_opens_a_browser(string reference)
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
|
||||
SeededFactory());
|
||||
|
||||
var result = await svc.OpenAsync(DriverTypeNames.Galaxy,
|
||||
"""{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference), "DRV-evil",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#849");
|
||||
bespoke.Opens.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a shared env variable that names no instance reaches the browser.</summary>
|
||||
[Fact]
|
||||
public async Task A_shared_env_variable_reaches_the_browser()
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
|
||||
SeededFactory());
|
||||
|
||||
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
bespoke.Opens.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the owning driver's own id-scoped secret reaches the browser.</summary>
|
||||
[Fact]
|
||||
public async Task The_owners_own_secret_reaches_the_browser()
|
||||
|
||||
+42
@@ -52,4 +52,46 @@ public sealed class CredentialReferencePreflightInstanceAndPathTests
|
||||
f.Problem.ShouldContain("lmxopcua#836");
|
||||
f.Problem.ShouldNotContain("plant.pfx");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#850: the AuthType switch in one config and the path in the other — each config passes alone, the
|
||||
/// runtime-merged config does not. The deploy gate catches it; the banner must show it before a deploy does.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_path_split_from_its_certificate_switch_is_listed_from_the_merged_config()
|
||||
{
|
||||
var findings = CredentialReferencePreflight.Scan(
|
||||
[Drv("o", "Upstream", DriverTypeNames.OpcUaClient, """{"AuthType":"Anonymous","UserCertificatePath":"C:\\stolen\\host.pfx"}""")],
|
||||
[new Device { DeviceId = "dev-o", DriverInstanceId = "o", Name = "d", DeviceConfig = """{"AuthType":"Certificate"}""" }],
|
||||
[]);
|
||||
|
||||
var f = findings.ShouldHaveSingleItem();
|
||||
f.Source.ShouldBe(CredentialFindingSource.Draft);
|
||||
f.DriverInstanceId.ShouldBe("o");
|
||||
f.DeviceName.ShouldBeNull();
|
||||
f.Problem.ShouldContain("merged with its devices");
|
||||
f.Problem.ShouldContain("lmxopcua#836");
|
||||
f.Problem.ShouldNotContain("host.pfx");
|
||||
}
|
||||
|
||||
/// <summary>Control: a problem already reported on one config is not listed a second time from the merge.</summary>
|
||||
[Fact]
|
||||
public void A_path_already_reported_on_its_own_config_is_not_duplicated_by_the_merge() =>
|
||||
CredentialReferencePreflight.Scan(
|
||||
[Drv("o", "Upstream", DriverTypeNames.OpcUaClient, """{"AuthType":"Certificate","UserCertificatePath":"C:\\certs\\plant.pfx"}""")],
|
||||
[new Device { DeviceId = "dev-o", DriverInstanceId = "o", Name = "d", DeviceConfig = "{}" }],
|
||||
[])
|
||||
.ShouldHaveSingleItem().Problem.ShouldNotContain("merged");
|
||||
|
||||
/// <summary>lmxopcua#849: an env: reference naming another instance is listed like a secret: one.</summary>
|
||||
[Fact]
|
||||
public void A_cross_instance_env_reference_is_listed() =>
|
||||
CredentialReferencePreflight.Scan(
|
||||
[
|
||||
Drv("DRV-a", "A", DriverTypeNames.Galaxy, "{}"),
|
||||
Drv("DRV-b", "B", DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:OTOPCUA_GALAXY_DRV_A_KEY"}}"""),
|
||||
],
|
||||
[],
|
||||
[])
|
||||
.ShouldHaveSingleItem().Problem.ShouldContain("lmxopcua#849");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#848: a driver's NAME is checked against the credential references stored on other drivers. Naming a driver
|
||||
/// after another same-type driver's (shared) secret segment or env part would make that reference name "another
|
||||
/// instance" and fail its next deploy; renaming a driver away from a name its own references are named after would
|
||||
/// free that name for every other driver's Test Connect / browse / save. Both are refused at create, at the /raw
|
||||
/// rename and at the config modal's update.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialNameClaimSavePathTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static string Galaxy(string reference) =>
|
||||
"""{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference);
|
||||
|
||||
private static async Task<(RawTreeService Svc, string Db, string SharerId)> WithSharerAsync(string reference)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var sharer = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Sharer", DriverTypeNames.Galaxy, Galaxy(reference), Ct);
|
||||
sharer.Ok.ShouldBeTrue(sharer.Error);
|
||||
return (svc, name, sharer.CreatedId!);
|
||||
}
|
||||
|
||||
private static byte[] RowVersion(string db, string driverId)
|
||||
{
|
||||
using var ctx = RawTreeTestDb.CreateNamed(db);
|
||||
return ctx.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret:galaxy/gw/very-private-name", "gw")]
|
||||
[InlineData("secret:galaxy/gw/very-private-name", "GW")] // case-insensitive, as the rule matches
|
||||
[InlineData("env:GALAXY_MXGW_API_KEY_VERY_PRIVATE", "api")] // #849 env form: API is a '_'-bounded prefix
|
||||
[InlineData("env:OTOPCUA_GALAXY_LINE_1_VERY_PRIVATE", "line-1")] // normalised LINE_1
|
||||
public async Task Creating_a_driver_named_after_another_drivers_reference_is_refused(string reference, string newName)
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync(reference);
|
||||
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Galaxy, "{}", Ct);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("lmxopcua#848");
|
||||
result.Error!.ShouldContain("Sharer");
|
||||
result.Error!.ShouldContain(sharerId);
|
||||
result.Error!.ShouldContain("Gateway.ApiKeySecretRef");
|
||||
result.Error!.ShouldNotContain("PRIVATE", Case.Insensitive); // never the rest of the reference
|
||||
using var check = RawTreeTestDb.CreateNamed(db);
|
||||
check.DriverInstances.ShouldNotContain(d => d.Name == newName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_device_level_reference_claims_the_name_too()
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync("secret:galaxy/sharer-own/k");
|
||||
using (var ctx = RawTreeTestDb.CreateNamed(db))
|
||||
{
|
||||
var device = ctx.Devices.Single(d => d.DriverInstanceId == sharerId);
|
||||
device.DeviceConfig = Galaxy("secret:galaxy/plant7/k");
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Plant7", DriverTypeNames.Galaxy, "{}", Ct);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("device 'Device1'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_driver_type_and_unrelated_names_are_not_claimed()
|
||||
{
|
||||
var (svc, _, _) = await WithSharerAsync("secret:galaxy/gw/k");
|
||||
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gw", DriverTypeNames.OpcUaClient, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gw2", DriverTypeNames.Galaxy, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Renaming_onto_a_claimed_name_is_refused_on_both_rename_paths()
|
||||
{
|
||||
var (svc, db, _) = await WithSharerAsync("secret:galaxy/gw/k");
|
||||
var other = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Other", DriverTypeNames.Galaxy, "{}", Ct);
|
||||
other.Ok.ShouldBeTrue(other.Error);
|
||||
|
||||
var renamed = await svc.RenameDriverAsync(other.CreatedId!, "gw", RowVersion(db, other.CreatedId!), Ct);
|
||||
renamed.Ok.ShouldBeFalse();
|
||||
renamed.Error!.ShouldContain("lmxopcua#848");
|
||||
|
||||
var updated = await svc.UpdateDriverAsync(other.CreatedId!, "gw", "{}", null, RowVersion(db, other.CreatedId!), Ct);
|
||||
updated.Ok.ShouldBeFalse();
|
||||
updated.Error!.ShouldContain("lmxopcua#848");
|
||||
|
||||
using var check = RawTreeTestDb.CreateNamed(db);
|
||||
check.DriverInstances.Single(d => d.DriverInstanceId == other.CreatedId).Name.ShouldBe("Other");
|
||||
}
|
||||
|
||||
/// <summary>Issue (b): renaming the victim away from the name its own secret lives under would free that name.</summary>
|
||||
[Theory]
|
||||
[InlineData("secret:galaxy/Sharer/apikey")]
|
||||
[InlineData("env:OTOPCUA_GALAXY_SHARER_KEY")]
|
||||
public async Task A_driver_whose_references_are_named_after_it_cannot_be_renamed_away(string reference)
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync(reference);
|
||||
|
||||
var renamed = await svc.RenameDriverAsync(sharerId, "Sharer-old", RowVersion(db, sharerId), Ct);
|
||||
renamed.Ok.ShouldBeFalse();
|
||||
renamed.Error!.ShouldContain("lmxopcua#848");
|
||||
renamed.Error!.ShouldContain(sharerId); // points at the id-scoped form
|
||||
|
||||
(await svc.UpdateDriverAsync(sharerId, "Sharer-old", Galaxy(reference), null, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_pointing_to_the_id_in_the_same_save_allows_the_rename()
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync("secret:galaxy/Sharer/apikey");
|
||||
|
||||
var updated = await svc.UpdateDriverAsync(
|
||||
sharerId, "Sharer-new", Galaxy($"secret:galaxy/{sharerId}/apikey"), null, RowVersion(db, sharerId), Ct);
|
||||
|
||||
updated.Ok.ShouldBeTrue(updated.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sharer")] // case-only: the rule matches case-insensitively, so nothing is freed or claimed
|
||||
[InlineData("Renamed")]
|
||||
public async Task Renames_that_free_and_claim_nothing_pass(string newName)
|
||||
{
|
||||
var reference = newName == "sharer" ? "secret:galaxy/Sharer/apikey" : "secret:galaxy/shared-gw/apikey";
|
||||
var (svc, db, sharerId) = await WithSharerAsync(reference);
|
||||
|
||||
var renamed = await svc.RenameDriverAsync(sharerId, newName, RowVersion(db, sharerId), Ct);
|
||||
|
||||
renamed.Ok.ShouldBeTrue(renamed.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #849 review: a name in the system-generated id form ('drv', 'DRV-<12 hex>…') would shadow every driver's
|
||||
/// id-form references — refused at create, the /raw rename and the config modal, even with no reference stored.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("drv")]
|
||||
[InlineData("DRV")]
|
||||
[InlineData("DRV-0123456789ab")]
|
||||
[InlineData("drv-0123456789AB-line")]
|
||||
public async Task An_id_shaped_name_is_refused_on_every_name_path(string newName)
|
||||
{
|
||||
var (svc, db, sharerId) = await WithSharerAsync("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY");
|
||||
|
||||
var create = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Galaxy, "{}", Ct);
|
||||
create.Ok.ShouldBeFalse();
|
||||
create.Error!.ShouldContain("system-generated driver id");
|
||||
|
||||
(await svc.RenameDriverAsync(sharerId, newName, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.UpdateDriverAsync(sharerId, newName, "{}", null, RowVersion(db, sharerId), Ct)).Ok.ShouldBeFalse();
|
||||
|
||||
// A type without credential rules is not affected.
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, newName, DriverTypeNames.Modbus, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// The SQL-Server-backed half of <see cref="CredentialNameClaimSavePathTests"/> (lmxopcua#848): the driver + device
|
||||
/// reads of <see cref="DriverInstanceIdentities.DescribeNameChangeProblemsAsync"/> must translate on the real provider,
|
||||
/// not only run as LINQ-to-Objects under EF InMemory (the #815 live gate's crash-looping boot came from exactly that gap).
|
||||
/// </summary>
|
||||
[Collection(nameof(AdminUiSqlServerCollection))]
|
||||
public sealed class CredentialNameClaimSqlServerTests(AdminUiSqlServerFixture sql)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[SqlServerFact]
|
||||
public async Task Create_and_rename_are_checked_against_stored_driver_and_device_references()
|
||||
{
|
||||
// 8 hex chars: unique per run in the shared database, short enough for the id columns.
|
||||
var token = Guid.NewGuid().ToString("N")[..8];
|
||||
var clusterId = $"C-{token}";
|
||||
var sharerId = $"DRV-s{token}";
|
||||
await using (var db = await sql.Factory.CreateDbContextAsync(Ct))
|
||||
{
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = clusterId, Name = $"Cluster {token}", Enterprise = "zb", Site = "warsaw-west",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = sharerId, ClusterId = clusterId, Name = $"Sharer{token}", DriverType = DriverTypeNames.Galaxy,
|
||||
DriverConfig = "{\"Gateway\":{\"ApiKeySecretRef\":\"secret:galaxy/gw" + token + "/k\"}}",
|
||||
});
|
||||
db.Devices.Add(new Device
|
||||
{
|
||||
DeviceId = $"DEV-s{token}", DriverInstanceId = sharerId, Name = "Device1",
|
||||
DeviceConfig = "{\"AlarmAck\":{\"FallbackPasswordSecretRef\":\"env:OTOPCUA_GALAXY_SHARER" + token.ToUpperInvariant() + "_ACK\"}}",
|
||||
});
|
||||
await db.SaveChangesAsync(Ct);
|
||||
}
|
||||
|
||||
var svc = new RawTreeService(sql.Factory);
|
||||
|
||||
// Claim (driver-level secret: reference).
|
||||
var create = await svc.CreateDriverAsync(clusterId, null, $"gw{token}", DriverTypeNames.Galaxy, "{}", Ct);
|
||||
create.Ok.ShouldBeFalse();
|
||||
create.Error!.ShouldContain("lmxopcua#848");
|
||||
|
||||
// Pin (device-level env: reference named after the sharer's own name).
|
||||
byte[] rv;
|
||||
await using (var db = await sql.Factory.CreateDbContextAsync(Ct))
|
||||
rv = await db.DriverInstances.AsNoTracking().Where(d => d.DriverInstanceId == sharerId).Select(d => d.RowVersion).SingleAsync(Ct);
|
||||
var rename = await svc.RenameDriverAsync(sharerId, $"Moved{token}", rv, Ct);
|
||||
rename.Ok.ShouldBeFalse();
|
||||
rename.Error!.ShouldContain("device 'Device1'");
|
||||
|
||||
// Positive control: an unclaimed name creates.
|
||||
(await svc.CreateDriverAsync(clusterId, null, $"free{token}", DriverTypeNames.Galaxy, "{}", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+38
@@ -76,6 +76,44 @@ public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActor
|
||||
probe.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#849: an env: reference named after ANOTHER instance never reaches the probe either.</summary>
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_VICTIM_API_KEY")]
|
||||
[InlineData("env:GALAXY_MXGW_DRV_VICTIM_KEY")]
|
||||
public void An_env_reference_naming_another_instance_never_reaches_the_probe(string reference)
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
SeedGalaxyDrivers(factory);
|
||||
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(
|
||||
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference),
|
||||
5, Guid.NewGuid(), "DRV-evil"));
|
||||
|
||||
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull().ShouldContain("lmxopcua#849");
|
||||
probe.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a shared env variable naming no instance reaches the probe.</summary>
|
||||
[Fact]
|
||||
public void A_shared_env_variable_reaches_the_probe()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
SeedGalaxyDrivers(factory);
|
||||
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(
|
||||
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""",
|
||||
5, Guid.NewGuid()));
|
||||
|
||||
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
|
||||
probe.Calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the owner's own secret (and a shared one) does reach the probe.</summary>
|
||||
[Theory]
|
||||
[InlineData("secret:galaxy/DRV-victim/apikey")]
|
||||
|
||||
Reference in New Issue
Block a user