Commit Graph

988 Commits

Author SHA1 Message Date
Joseph Doherty 5d2386cc9d fix(transport): close bundle security + plaintext-retention gaps (4 findings)
T-003: move the unlock lockout server-side. The 3-strike counter used to
live in the Razor page only — a second tab / CLI caller could re-upload
the same bytes and grind PBKDF2 indefinitely. The counter now lives in
IBundleSessionStore, keyed by ContentHash, so retries against identical
bundle bytes are throttled regardless of client. BundleLockedException
surfaces the new typed error path.

T-005: bind the manifest's non-derivative fields into AES-GCM AAD. A
SHA-256 of the manifest (with ContentHash + Encryption normalised to
sentinels) is now passed to AesGcm.Encrypt / .Decrypt, so a tampered
SourceEnvironment / ExportedBy / CreatedAtUtc on a stolen bundle yields
an authentication-tag mismatch instead of slipping past the Step-4
typo-resistant confirmation gate.

T-006: cap zip entry count, decompressed length, and compression ratio
in LoadAsync's envelope validator BEFORE any payload is decompressed,
using ZipArchiveEntry.Length / .CompressedLength. New TransportOptions
fields default to 4 entries / 200 MB / 50x ratio.

T-007: clear decrypted plaintext on the ApplyAsync failure path and zero
the buffer on success before removing the session, so a 100 MB
DecryptedContent doesn't sit in memory for the 30-min TTL after a failed
apply. A BundleSessionEvictionService BackgroundService now also drives
EvictExpired periodically so abandoned sessions clear without needing a
fresh Get() call to trigger lazy eviction.

Also resolves NO-010 — the misleading "writer never throws" XML doc was
the same code+comment my prior NO-004 await-the-writer fix already
rewrote.
2026-05-28 04:14:07 -04:00
Joseph Doherty 291274ae76 fix(notifications): close OAuth2 SMTP + dispatcher resilience gaps (5 findings)
NS-021/NO-001: thread FromAddress into XOAUTH2 so M365 stops rejecting
sends with 535 5.7.3. Added an additive oauth2UserName parameter on
ISmtpClientWrapper.AuthenticateAsync; both NotificationService and
NotificationOutbox now pass config.FromAddress.

NO-002: clamp non-positive SmtpConfiguration.MaxRetries/RetryDelay to the
1-min / 10-attempt fallback with a Warning so a misconfigured row no
longer parks transient failures on the first attempt or burn-loops.

NO-003: route a lifecycle-scoped CancellationToken from the
NotificationOutboxActor through the dispatch sweep into the adapter so
in-flight SMTP sends abort on PostStop instead of blocking
CoordinatedShutdown for the full SMTP timeout per row.

NO-004: await the central audit writer inside the existing try/catch
instead of fire-and-forget so the audit task can't outlive the per-sweep
DI scope and writer faults reach the operator log instead of being
silently dropped.

Two AuditLog integration tests seeded RetryDelay = TimeSpan.Zero to force
immediate re-claim on the second tick; updated them to 1 ms so they keep
the same intent without tripping the NO-002 clamp.
2026-05-28 03:54:43 -04:00
Joseph Doherty e536178323 fix(security): close auth & site-scoping gaps across 8 findings
Resolves the auth-theme batch from the 2026-05-28 baseline review (8 findings
across Security/CentralUI/ManagementService/CLI). The most consequential gaps:
NotificationReport + SiteCallsReport now route through SiteScopeService so a
site-scoped Deployment user cannot see or act on other sites' rows (CUI-028);
QueryAuditLogCommand is no longer "any authenticated user" — gated Admin-only
to match /api/audit/query's strictness (MS-018); RoleMapper preserves the
broader grant when a user is in both an unscoped and scoped Deployment LDAP
group, instead of silently narrowing to the scoped set (Sec-016); and the
dead SiteScopeRequirement/Handler are deleted so SiteScopeService is
unambiguously the sole site-scoping mechanism (Sec-017). Pending findings:
172 → 164.
2026-05-28 03:35:29 -04:00
Joseph Doherty f93b7b99bb code-review: 2026-05-28 baseline re-review of all 23 modules at 1eb6e97
Re-applies the full 10-category checklist to every src/ project — including
first-time reviews of the four newer components (AuditLog, NotificationOutbox,
SiteCallAudit, Transport) — so the code-reviews/ index reflects today's
codebase rather than the 2026-05-16 baseline. 172 new Open findings (0
Critical, 18 High, 62 Medium, 92 Low); 481 findings total across 23 modules.

regen-readme.py now derives each module's Last reviewed + Commit from its
findings.md header instead of hard-coding 2026-05-16 / 9c60592, so future
single-module re-reviews show their own date in the Module Status table.
2026-05-28 02:55:47 -04:00
Joseph Doherty 1eb6e972b0 docs: add XML doc comments across src + Sister Projects section in CLAUDE.md
Bulk CommentChecker pass: fills in <param>/<inheritdoc> tags on public
APIs across all 23 src/ projects so the doc-coverage gate is green. Also
adds a Sister Projects section to CLAUDE.md pointing at the MxAccess
Gateway and OtOpcUa sibling repos, and gitignores local credential
captures (*login*.txt) and the wonder-app-vd03 deploy/ artifacts.
2026-05-28 01:55:24 -04:00
Joseph Doherty 6731845473 docs(transport): document CLI surface, blocker-scan heuristic, Admin import gating
Reflect this session's implementation work in the Transport (#24)
component spec:

- New 'CLI' section covering bundle export / preview / import
  commands, the base64-over-JSON wire format, the 200 MB request-body
  cap, and the 5-minute per-command timeout. Authorization table +
  Interactions section updated to mention ManagementActor handlers.
- Import wizard nav placement corrected from Design to Admin (already
  the case in code; the spec lagged).
- Blocker-scan heuristic boundaries documented under Import Flow:
  the '.' skip, the DataSourceReference exclusion, and the
  KnownNonReferenceNames denylist. Both DetectBlockersAsync and
  RunSemanticValidationAsync Pass 1 share the filter.
2026-05-24 09:03:58 -04:00
Joseph Doherty f6cd097c62 fix(transport): flush folders before resolving template FolderId FKs
ApplyTemplateFoldersAsync staged new folders via AddFolderAsync but
never called SaveChanges -- so on relational providers (MSSQL) the
new folders sit in the change tracker with Id=0 until the outer
SaveChanges much later in ApplyAsync. The previous attempt to read
the folder name->id map via _templateRepo.GetAllFoldersAsync only
saw pre-existing rows, so newly-bundled folders couldn't satisfy a
template's FolderName reference. Add an intermediate SaveChanges
inside ApplyTemplatesAsync before the folder query.
2026-05-24 08:57:56 -04:00
Joseph Doherty f6f7cb8b36 fix(transport): wire TemplateFolder FK on imported templates
ApplyTemplatesAsync built the Template entity via BuildTemplate() but
never read the DTO's FolderName -- so every imported template landed
at the root regardless of which folder it lived in on the source
cluster. ApplyTemplateFoldersAsync had already flushed the folder
rows by that point; the FK just wasn't being set.

Resolve folder-name -> persisted FolderId from the same name table
(via _templateRepo.GetAllFoldersAsync after the folder pass), honour
TemplateFolder Rename resolutions, and set FolderId on Add /
Overwrite / Rename paths alike. The audit-row 'after' state now
includes FolderId so the action's effect is visible in the
configuration audit log.
2026-05-24 08:54:06 -04:00
Joseph Doherty 1361a39770 fix(cli): dedupe import resolutions before ApplyAsync
PreviewAsync can emit multiple ImportPreviewItem rows for the same
(EntityType, Name) -- one per modified member of a template, for
example. ApplyAsync internally calls .ToDictionary() on the
resolutions list and throws ArgumentException on duplicate keys.

The Central UI's BuildDefaultResolutions already dedupes via a
dictionary assignment (last-write-wins). Mirror that in the CLI
handler so 'bundle import' tolerates the duplicate-rows shape the
preview returns.
2026-05-24 08:20:34 -04:00
Joseph Doherty 438f59e74e fix(transport): add 'Parameters' to blocker-scan denylist
Inbound API scripts access request parameters via the Parameters
runtime API root (Parameters.x or Parameters["x"]). The blocker
heuristic was flagging it as a missing SharedScript or ExternalSystem
on bundles that include ApiMethod scripts. Same surgical fix as the
other entries on KnownNonReferenceNames.
2026-05-24 08:18:49 -04:00
Joseph Doherty 901fd58a32 feat(cli): bundle export / preview / import for Transport (#24)
Three new CLI commands automate the Transport feature end-to-end:

  scadalink bundle export  --output FILE --passphrase X [--all | --templates A,B ...] [--include-dependencies] [--source-environment NAME]
  scadalink bundle preview --input FILE  --passphrase X
  scadalink bundle import  --input FILE  --passphrase X [--on-conflict skip|overwrite|rename]

Wire format: bundle bytes travel as base64 inside the existing
/management JSON envelope -- no new endpoints, no streaming plumbing.
The 100 MB raw cap inflates to ~140 MB base64; per-request body size
on the management endpoint is raised to 200 MB via the
IHttpMaxRequestBodySizeFeature.

Server side: three new command records in
ScadaLink.Commons.Messages.Management (auto-discovered by the
existing ManagementCommandRegistry), ManagementActor dispatch and
role rules (Export=Design, Preview/Import=Admin), and three
handlers that delegate to the existing IBundleExporter /
IBundleImporter services with name-keyed selection resolution.
Per-bundle CLI timeout bumped to 5 min for large exports.

Conflict policy on import is a single global flag for all Modified
rows; Identical rows always Skip, New rows always Add, Blocker rows
abort. Rename mints a per-bundle timestamp suffix.
2026-05-24 08:15:28 -04:00
Joseph Doherty f1c3019eca fix(docker-env2): seed Design + Deployment LDAP mappings post-deploy
SecurityConfiguration.HasData declares 4 LdapGroupMapping seed rows
(Admin / Design / Deployment-All / Deployment-SiteA) but the
InitialSchema migration only INSERTs the Admin row -- the other three
were never captured into a migration. A fresh ScadaLinkConfig2 starts
with multi-role getting Admin only, no Design or Deployment access.
(The same divergence exists on primary's ScadaLinkConfig, but it has
the rows from earlier history.)

Insert the missing three idempotently from seed-sites.sh so env2's
fresh deploys end up role-aligned with the running primary cluster.
The longer-term fix is a new EF migration that captures the HasData
diff -- intentionally not done here to avoid touching the primary
cluster's existing rows.
2026-05-24 08:01:06 -04:00
Joseph Doherty ae4169b4cc fix(transport): symmetric blocker-scan fixes in Apply-time validator
RunSemanticValidationAsync's Pass 1 minimal-name-resolution scan
duplicated DetectBlockersAsync's heuristic but had the same two bugs
fixed in the previous two commits: it was scanning
TemplateAttribute.DataSourceReference (an OPC UA address-space path,
not script source) and it was missing the KnownNonReferenceNames
denylist. As a result, an import that passed the diff-step blocker
check would still fail at Apply with the same 30+ identifiers
reappearing as "Bundle semantic validation failed" errors.

Apply the same two fixes here so the diff preview and the Apply-time
validator agree.
2026-05-24 07:55:29 -04:00
Joseph Doherty bae75be2d2 fix(transport): stop scanning DataSourceReference for blocker references
DetectBlockersAsync was feeding TemplateAttribute.DataSourceReference
into the identifier scanner alongside script bodies, but that field is
an OPC UA node-address path (e.g. "ns=3;s=Tank.Level") owned by the
device, not script source. The dot delimiter inside the path tripped
the heuristic into flagging the address segment ("Tank", "Sensor",
"TestChildObject", "DevAppEngine") as a missing SharedScript or
ExternalSystem reference -- a 100% false-positive class on any
template catalog with OPC-UA-mapped attributes.

Drop the DataSourceReference scan entirely. Attribute.Value is still
scanned because it can carry a design-time default expression that
calls into runtime APIs. Add a regression test pinning the new behavior.
2026-05-24 07:52:31 -04:00
Joseph Doherty 6bdada7549 fix(transport): drop blocker false positives for stdlib + member access
The DetectBlockersAsync heuristic was catching every PascalCase
"Identifier(" or "Identifier." token in script bodies and treating it
as a candidate SharedScript or ExternalSystem reference. On a normal
template catalog this surfaced 30+ blocker rows for .NET stdlib
(DateTimeOffset, Convert, ToString, Dispose, UtcNow...), ScadaLink
runtime API roots (Notify, Database, ExternalSystem, Scripts...), and
SQL keywords inside string literals (COUNT), blocking the import.

Two surgical fixes:

1. Skip identifiers preceded by `.` so `obj.Method()` no longer flags
   `Method` as a top-level reference.
2. Maintain a `KnownNonReferenceNames` denylist for the small set of
   well-known stdlib / runtime / SQL tokens that can never be
   user-defined SharedScripts or ExternalSystems.

The documented use case -- a top-level free-standing call to a missing
SharedScript or ExternalSystem (e.g. `MissingHelper()` at the start of
an expression, or `ErpSystem.Call(...)` where ErpSystem is the
external-system identifier) -- still produces a blocker row, pinned
by the existing test plus a new noise-filter regression test.
2026-05-24 07:46:24 -04:00
Joseph Doherty 6299743a35 fix(centralui): show Next button after encrypted-bundle upload
Step 1's Next button was wrapped in `@if (_session is not null)`, which
hid it for encrypted bundles where the first LoadAsync call legitimately
leaves _session null until the passphrase is supplied at Step 2.
Trigger the Next-button region on `_bundleBytes is not null` instead,
showing a placeholder notice when the manifest isn't decrypted yet so
the user has a visible affordance to advance to the passphrase step.
2026-05-24 07:38:23 -04:00
Joseph Doherty f3a571b664 fix(centralui): swallow ArgumentException in TransportImport upload step
OnFileSelectedAsync called TryLoadAsync with a null passphrase to peek
the manifest, but the outer `catch (Exception)` surfaced the expected
"Passphrase required for encrypted bundle" ArgumentException as a fatal
"Failed to read bundle" error -- blocking the user from ever advancing
to the passphrase step. Catch ArgumentException specifically and let
the wizard advance normally on the next click.
2026-05-24 07:34:33 -04:00
Joseph Doherty 80497d1332 chore(plans): mark env2 plan tasks completed 2026-05-24 07:26:20 -04:00
Joseph Doherty 77eb188a2c feat(docker-env2): add lifecycle scripts (init-db, deploy, teardown, seed-sites) 2026-05-24 07:20:49 -04:00
Joseph Doherty 4951e6f81b docs(plans): add env2 + Transport manual verification checklist 2026-05-24 07:17:44 -04:00
Joseph Doherty f63d379048 docs: cross-reference docker-env2 from root README, CLAUDE.md, and infra README 2026-05-24 07:17:41 -04:00
Joseph Doherty 0ee914e36c docs(docker-env2): add env2 README 2026-05-24 07:17:38 -04:00
Joseph Doherty 9d5b814f9b chore(gitignore): add explicit docker-env2 runtime data patterns 2026-05-24 07:17:36 -04:00
Joseph Doherty 4316aacd44 feat(docker-env2): add docker-compose for env2 cluster 2026-05-24 07:17:34 -04:00
Joseph Doherty c252a80f9d feat(docker-env2): add site-x appsettings 2026-05-24 07:17:32 -04:00
Joseph Doherty 63d1a96557 feat(docker-env2): add central node appsettings 2026-05-24 07:17:29 -04:00
Joseph Doherty 9c6abc6517 feat(docker-env2): add Traefik load-balancer config 2026-05-24 07:17:26 -04:00
Joseph Doherty 4b797c9f69 feat(infra): add env2 database setup script + mount 2026-05-24 07:17:23 -04:00
Joseph Doherty e66fee0d26 docs(plans): add second environment (env2) implementation plan
11-task plan (T0-T10) covering the sibling docker-env2/ directory:
SQL setup script + mount, Traefik config, central/site appsettings,
docker-compose, lifecycle scripts, .gitignore, READMEs and cross-refs,
verification checklist, and a manual smoke test. No application code
changes -- pure deploy tooling. Most tasks (T0-T9) are independent
and parallel-ready; T5 is gated on T0 + T4; T10 gates on all of T0-T9.
2026-05-24 07:08:46 -04:00
Joseph Doherty 2fd3426fc2 docs(plans): add second environment (env2) design
Brainstorming output for a sibling docker-env2/ tree that brings up a
minimal second cluster (2 central + 1 site x 2 nodes + Traefik) on the
same machine alongside the primary docker/ stack. Shares the existing
scadalink-net network and scadalink-mssql container but uses separate
logical databases (ScadaLinkConfig2 / ScadaLinkMachineData2) so the
Transport (#24) feature can be exercised end-to-end with real
cross-environment exports and imports.
2026-05-24 07:03:02 -04:00
Joseph Doherty a7141c704f test(centralui): remove stale LoginPage_RendersLdapCredentialHint test
The asserted 'LDAP credentials' tagline was deliberately removed from
Login.razor in f973f49 but the test was not updated alongside. Drop
the test — it asserts on UI text that no longer exists by design.
2026-05-24 06:51:37 -04:00
Joseph Doherty 624cf255a4 feat(transport): wire full SemanticValidator at bundle import time 2026-05-24 06:32:42 -04:00
Joseph Doherty 8e73e60f4a feat(transport): restore composition + alarm-script edges on bundle import 2026-05-24 06:16:24 -04:00
Joseph Doherty cef77e1378 fix(transport): carry TemplateAlarm.OnTriggerScript by name in bundle DTO 2026-05-24 06:10:59 -04:00
Joseph Doherty 79d74ee59c fix(centralui): hint that notification list export does not include SMTP config
Add an unconditional alert-info banner in the Notification Lists fieldset
(Step 1) explaining that SMTP configurations are not auto-included as
dependencies and must be selected separately.
2026-05-24 06:05:53 -04:00
Joseph Doherty e6706c26e6 fix(transport): preserve MinTimeBetweenRuns + ExternalSystem retry fields in bundle DTOs
Add TimeSpan? MinTimeBetweenRuns to TemplateScriptDto and int MaxRetries /
TimeSpan RetryDelay to ExternalSystemDto; wire both directions in
EntitySerializer. Extends the existing script round-trip assertion and adds
Roundtrip_external_system_preserves_retry_config.
2026-05-24 06:05:26 -04:00
Joseph Doherty a2b8b69281 fix(transport): NavMenu Admin-only visibility + BundleImportUnlockFailed audit + docker appsettings
- NavMenu: move Import Bundle out of the nested RequireDesign/RequireAdmin
  double-gate into the top-level Admin section so an Admin-only user sees it
  without needing the Design role; Export Bundle stays in the Design section.
- TransportImport: inject IAuditService + ScadaLinkDbContext; emit a
  BundleImportUnlockFailed audit row (best-effort, swallowed on failure) on
  every wrong-passphrase attempt in SubmitPassphraseAsync, with attempt
  number and error reason in afterState.
- docker central-node-a/b appsettings: add ScadaLink:Transport section with
  SourceEnvironment = "docker-cluster" so the importer picks up a non-null
  environment name in the audit trail.
- CentralUI.Tests: register IAuditService mock + SQLite in-memory
  ScadaLinkDbContext in TransportImportPageTests to satisfy the two new injects.
2026-05-24 05:59:04 -04:00
Joseph Doherty 9f1bb81993 test(transport): integration conflict resolution + rollback 2026-05-24 05:50:11 -04:00
Joseph Doherty 623aa8d061 test(transport): integration round-trip export → wipe → import 2026-05-24 05:48:24 -04:00
Joseph Doherty ef025a325d feat(centralui): Bundle Import filter on ConfigurationAuditLog page 2026-05-24 05:44:21 -04:00
Joseph Doherty 39f994f9bc feat(centralui): add Export/Import Bundle nav entries 2026-05-24 05:38:48 -04:00
Joseph Doherty acadb83712 feat(centralui): TransportImport wizard under Design nav group 2026-05-24 05:38:09 -04:00
Joseph Doherty 0dbc0c02f9 feat(centralui): TransportExport wizard under Design nav group
Implements Task T21 of the Transport feature. A four-step Blazor wizard
(Select → Review → Encrypt → Download) under /design/transport/export,
gated on AuthorizationPolicies.RequireDesign:

  1. Select  — TemplateFolderTree (checkbox-mode) plus flat checkbox
               lists for shared scripts, external systems, DB connections,
               notification lists, SMTP configs, API keys, API methods.
  2. Review  — runs DependencyResolver, surfaces seed vs auto-included.
               "Include all dependencies" toggle re-resolves on flip.
  3. Encrypt — passphrase + confirm with strength meter, secret-count
               warning over the resolved closure, explicit unencrypted
               opt-out path (calls BundleExporter with passphrase=null
               so the audit row tags UnencryptedBundleExport).
  4. Download— calls IBundleExporter.ExportAsync, streams bytes to the
               browser via JS interop (wwwroot/js/transport.js), displays
               filename + size + SHA-256 + encryption status.

Source environment is sourced from new TransportOptions.SourceEnvironment
(bound from ScadaLink:Transport:SourceEnvironment, defaults "scadalink"),
filename pattern scadabundle-{env}-{yyyy-MM-dd-HHmmss}.scadabundle.

Tests (bUnit + policy): step 1 group rendering, step 2 dependency
expansion (Pump composes Motor), step 4 full walkthrough verifying
ExportAsync receives the selected ids + authenticated identity, and a
RequireDesign policy-deny test for users without the Design role. Also
unit-pins the filename-sanitisation contract.
2026-05-24 05:30:16 -04:00
Joseph Doherty 01f4eeaef5 refactor(centralui): extract TemplateFolderTree as shared component 2026-05-24 05:18:12 -04:00
Joseph Doherty e099ed2038 feat(centralui): TreeView checkbox-selection mode with tri-state 2026-05-24 05:13:04 -04:00
Joseph Doherty 9a3f5231db feat(transport): register AddTransport() on central nodes 2026-05-24 05:09:51 -04:00
Joseph Doherty cda80cf821 fix(transport): robust failure-audit when rollback throws + doc clarifications
Address one Blocker and three Important findings from code review of
2c34f12 (BundleImporter.ApplyAsync):

- BLOCKER: wrap RollbackAsync in nested try/catch so a rollback fault
  does not swallow the BundleImportFailed audit row. Dispose the
  failed transaction before the audit-write so the new SaveChangesAsync
  uses a fresh implicit transaction instead of enlisting in the broken
  one. Surface the rollback exception's message on the failure row
  alongside the original cause, and swallow audit-write faults per the
  design's best-effort-audit invariant. Add regression integration
  test using a SQLite transaction interceptor that throws on rollback.

- Document re-entrancy assumption on IAuditCorrelationContext: scoped
  lifetime, single circuit, concurrent imports within a shared scope
  must serialize externally.

- Document repository audit responsibility on BundleImporter: repos
  are thin EF wrappers; ApplyAsync writes audit rows explicitly. If
  repos ever start emitting audit rows, the explicit calls here must
  be removed to avoid double-logging.

- Document BundleSessionStore thread-safety: ConcurrentDictionary
  primitives are safe under concurrent callers; BundleSession itself
  is not thread-safe.
2026-05-24 05:06:04 -04:00
Joseph Doherty 2c34f12a6f feat(transport): BundleImporter.ApplyAsync transactional with audit correlation 2026-05-24 04:55:43 -04:00
Joseph Doherty 90baa4d6d5 docs(transport): manual cluster verification checklist 2026-05-24 04:52:59 -04:00
Joseph Doherty b1daf9abb8 docs: README + component cross-references for Transport (#24) 2026-05-24 04:52:55 -04:00