Commit Graph

936 Commits

Author SHA1 Message Date
Joseph Doherty
5fc6790c36 feat(transport): BundleImporter.LoadAsync with manifest validation 2026-05-24 04:37:02 -04:00
Joseph Doherty
7c70ce0dbf feat(transport): BundleExporter with audit logging 2026-05-24 04:30:18 -04:00
Joseph Doherty
901d9affdf feat(transport): in-memory BundleSessionStore with TTL + lockout 2026-05-24 04:20:55 -04:00
Joseph Doherty
06c2b20178 feat(transport): DependencyResolver with topological closure 2026-05-24 04:19:23 -04:00
Joseph Doherty
550ab0e034 feat(transport): BundleSerializer ZIP packer/reader 2026-05-24 04:11:11 -04:00
Joseph Doherty
ee76b84b0f feat(transport): bundle entity DTOs + secret carving in EntitySerializer 2026-05-24 04:08:43 -04:00
Joseph Doherty
447bf84b13 feat(transport): ManifestBuilder + ManifestValidator with schema-version gating 2026-05-24 04:04:58 -04:00
Joseph Doherty
dc669a119b feat(transport): AES-256-GCM + PBKDF2 BundleSecretEncryptor 2026-05-24 04:03:44 -04:00
Joseph Doherty
c5bd5418ad feat(transport): add TransportOptions 2026-05-24 03:58:07 -04:00
Joseph Doherty
7e51274812 feat(transport): scaffold ScadaLink.Transport project + test projects 2026-05-24 03:57:07 -04:00
Joseph Doherty
f32b59a557 feat(transport): AuditService stamps BundleImportId from correlation context 2026-05-24 03:55:17 -04:00
Joseph Doherty
233e0f996e feat(transport): EF migration AddBundleImportIdToAuditLog 2026-05-24 03:50:30 -04:00
Joseph Doherty
33f7b3979d feat(transport): add IAuditCorrelationContext scoped service 2026-05-24 03:49:26 -04:00
Joseph Doherty
ee10eba04c feat(transport): add BundleImportId column on AuditLogEntry 2026-05-24 03:48:22 -04:00
Joseph Doherty
9442c9a92c feat(transport): add IBundleExporter / IBundleImporter interfaces 2026-05-24 03:47:27 -04:00
Joseph Doherty
7e89f2092f feat(transport): add bundle manifest DTOs in Commons 2026-05-24 03:46:09 -04:00
Joseph Doherty
1bc98e10a1 docs(plans): add Transport (Component #24) implementation plan
30-task plan covering ScadaLink.Transport project, AES-256-GCM bundle
encryption, IAuditCorrelationContext for BundleImportId threading,
TreeView checkbox-selection mode + TemplateFolderTree wrapper, two
Central UI wizard pages, EF migration, integration tests, README +
cross-reference updates. Single shipping slice, no feature flag.
2026-05-24 03:43:18 -04:00
Joseph Doherty
1b02f33829 docs(plans): add Transport (Component #24) brainstorming design
File-based, encrypted bundle export/import via the Central UI for
promoting templates, system artifacts, and central-only configuration
across environments. Site-scoped artifacts excluded. Per-artifact
conflict resolution; config-only import (user redeploys via existing
Deployments page). Per-entity audit rows correlated by BundleImportId.
2026-05-24 03:32:21 -04:00
Joseph Doherty
d630e2646b feat(ui): show SourceNode under SourceSiteId in audit log detail popup
The audit log drilldown drawer (and the execution-tree node-detail modal,
which shares this component) now renders the SourceNode field directly
under SourceSiteId so provenance reads 'site → node → instance → script'
in declared order. Two focused tests pin the field's presence in both
populated and null cases plus the inter-field ordering.
2026-05-23 19:01:48 -04:00
Joseph Doherty
f973f49254 fix(ui): remove LDAP credentials tagline from Login page 2026-05-23 18:56:24 -04:00
Joseph Doherty
e66b01a849 Merge branch 'feature/audit-source-node'
End-to-end SourceNode audit stamping across AuditLog, Notifications, and
SiteCalls — captures the cluster node (node-a/node-b for site rows,
central-a/central-b for central direct-write rows) that produced each
audit row. 23 commits, 2159 tests passing across 9 affected projects,
live-smoke verified against the running cluster.

Per 'docs/plans/2026-05-23-audit-source-node.md'.
2026-05-23 18:50:50 -04:00
Joseph Doherty
c754666a3d fix(ui): carry SourceNode on SiteCallDetail + NotificationDetail records
The Site Calls and Notifications detail modals were reading SourceNode from
the summary record (d.SourceNode) while every other field read from the
detail record (det.X). The pattern works today because the modal always
opens via a row click that pre-loads the summary, but a future drill-in
from a deep link or refresh path could leave the summary stale or null and
the field would render blank or wrong.

Add SourceNode to both detail records, project it through the actor's
ToDetail mapping, and switch the razor markup to read det.SourceNode. Now
the modal binds uniformly to the detail record across all fields.
2026-05-23 18:37:53 -04:00
Joseph Doherty
8bf84fb7f3 chore(docker): set NodeName on all 8 cluster nodes
Adds "NodeName" to the ScadaLink:Node section of each per-node
appsettings:
- central-a, central-b for the two central nodes
- node-a, node-b under each of the three sites (site-a, site-b, site-c)

After this commit + a redeploy, every fresh AuditLog / Notifications /
SiteCalls row gets stamped with the originating node's role name via
INodeIdentityProvider, satisfying the design's SourceNode invariant
end-to-end.
2026-05-23 18:16:42 -04:00
Joseph Doherty
d18a6e6fa0 feat(ui): add Node column + filter to SiteCalls grid 2026-05-23 18:08:25 -04:00
Joseph Doherty
b9c017136d feat(ui): add Node column + filter to NotificationOutbox grid 2026-05-23 18:04:59 -04:00
Joseph Doherty
bb29d65a94 feat(ui): add Node column + filter to AuditLog grid 2026-05-23 18:01:36 -04:00
Joseph Doherty
466e1454fe test(sitecall-audit): symmetric SourceNode coverage on DbOutbound emitter + clarify DI comments
Two follow-ups from the T13/T14 code review:

- M1: Add CachedWrite_StampsSourceNode_OnSubmitTelemetryRow and
  CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull to DatabaseCachedWriteEmissionTests,
  mirroring the existing ApiOutbound SourceNode tests in
  ExternalSystemCachedCallEmissionTests. Site-emitter coverage now symmetric
  across both cached-call channels.
- M2: Clarify the GetService(INodeIdentityProvider) DI comments on the
  CachedCallTelemetryForwarder and CachedCallLifecycleBridge factories:
  it's test composition roots that may not register the provider, not
  central production. Both site and central hosts always register it via
  SiteServiceRegistration.BindSharedOptions.
2026-05-23 17:50:14 -04:00
Joseph Doherty
06ed0acead feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry
Site: site emitters of SiteCallOperational (ExternalSystemClient, the script-API
cached call path in ScriptRuntimeContext, CachedCallLifecycleBridge) inject
INodeIdentityProvider and stamp SourceNode = NodeName at construction.

OperationTrackingStore call site in CachedCallTelemetryForwarder now stamps
SourceNode too.

Central: SiteCallAuditRepository.UpsertAsync INSERT includes SourceNode in the
column list; conditional monotonic UPDATE uses
COALESCE(@SourceNode, SourceNode) so later packets cannot blank a previously-
stamped value. After this commit every SiteCalls row carries node-a/node-b in
SourceNode (subject to monotonic preservation).
2026-05-23 17:41:22 -04:00
Joseph Doherty
d1fcab490c feat(notif-outbox): carry + persist SourceNode end-to-end via NotificationSubmit
Site: inject INodeIdentityProvider where NotificationSubmit is built; stamp
SourceNode = NodeName at construction.

Central: NotificationOutboxActor.HandleSubmit copies submit.SourceNode onto
the Notification row; the repository INSERT persists it (EF tracked-entity
insert flows it through automatically; raw-SQL extension if not).

After this commit, every Notifications row carries the originating site
node-a/node-b in SourceNode. Existing notifications submitted pre-feature
remain NULL.
2026-05-23 17:28:23 -04:00
Joseph Doherty
e6341580b3 test(audit): lock null-provider passthrough on CentralAuditWriter
Two follow-ups flagged by code review on Tasks 11/12:

- Lock the back-compat contract for CentralAuditWriter's optional
  `nodeIdentity = null` ctor parameter with two explicit tests
  (`WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected` and
  `WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected`). The previous
  null-provider path was only exercised incidentally via legacy
  CentralAuditWriterTests setups; the new tests make the contract explicit
  and distinct from the "provider supplied, returns null" path.

- Document why the catch-block log references `evt` rather than the
  post-stamp record: the three logged fields (EventId, Kind, Status) are
  immutable across the filter+stamp chain, so referencing either name is
  equivalent — but the comment warns future maintainers to switch names if
  they ever add a field the chain mutates (e.g. SourceNode).
2026-05-23 17:18:45 -04:00
Joseph Doherty
974a36826a feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository
CentralAuditWriter injects INodeIdentityProvider and stamps the event before
handing to the repository. AuditLogRepository.InsertIfNotExistsAsync now
includes SourceNode in the INSERT column list. Caller-provided value wins
(supports any future direct-write callsite that already has its own node id).
2026-05-23 17:11:23 -04:00
Joseph Doherty
479870e40c feat(audit): stamp SourceNode at site SqliteAuditWriter from INodeIdentityProvider
Caller-provided SourceNode wins (preserves reconciled rows from other nodes);
otherwise the writer fills it from the local INodeIdentityProvider.NodeName.
Reads from the provider on every write — singleton lifetime means zero overhead.
2026-05-23 17:08:21 -04:00
Joseph Doherty
277882d230 feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync 2026-05-23 16:54:48 -04:00
Joseph Doherty
f3cb8c0791 feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade) 2026-05-23 16:50:16 -04:00
Joseph Doherty
8fb9eb0ce7 chore(db): align SourceNode unicode metadata + document partition-aligned index recipe
Tidies flagged by code review on the T6/T7/T8 migration bundle:

- Add `.IsUnicode(false)` to the three SourceNode EF property mappings to
  match every other ASCII varchar column on the same entities. Physical
  column was already `varchar(64)` because `HasColumnType` wins, but the EF
  model metadata flag was inconsistent.
- Add `unicode: false` to the three AddColumn<string> calls in the migrations
  + their Designer snapshots so the historical snapshots match the model.
- Update the model snapshot to carry IsUnicode(false) on each SourceNode entry.
- Document the SELECT-list invariant on SiteCallAuditRepository.QueryAsync:
  EF Core's FromSqlInterpolated requires every entity-tracked column in the
  result set, so future SiteCall columns must extend the list too.
- Amend plan Task 6 Step 2 to document the partition-aligned raw-SQL index
  recipe and the staging-table sync requirement.
2026-05-23 16:45:19 -04:00
Joseph Doherty
1a77bc5f38 feat(db): add SourceNode column to SiteCalls 2026-05-23 16:34:30 -04:00
Joseph Doherty
16b685b96b feat(db): add SourceNode column to Notifications 2026-05-23 16:34:30 -04:00
Joseph Doherty
552d7832a3 feat(db): add SourceNode column + IX_AuditLog_Node_Occurred index to AuditLog 2026-05-23 16:34:28 -04:00
Joseph Doherty
dfaa416ebe feat(comm): add source_node field to AuditEventDto + SiteCallOperationalDto proto
- AuditEventDto field 22, SiteCallOperationalDto field 12. Both follow the
  existing empty-string-means-null convention.
- Mappers carry SourceNode end-to-end; round-trip tests cover both populated
  and null cases.
2026-05-23 16:10:03 -04:00
Joseph Doherty
990eb02fe0 feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity 2026-05-23 15:53:44 -04:00
Joseph Doherty
354f8792bf feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit 2026-05-23 15:46:30 -04:00
Joseph Doherty
ad625eb36d feat(audit): add SourceNode property to AuditEvent record 2026-05-23 15:45:31 -04:00
Joseph Doherty
2e10cbe42d feat(host): add NodeName to NodeOptions + INodeIdentityProvider
- NodeName: semantic role-within-cluster identifier (node-a/node-b on sites,
  central-a/central-b on central). Bound from ScadaLink:Node:NodeName.
- INodeIdentityProvider exposes the trimmed name (null if unconfigured) so
  downstream audit writers can stamp the new SourceNode column.
2026-05-23 15:38:27 -04:00
Joseph Doherty
9e5e32d0f2 docs(audit): add SourceNode column to AuditLog/Notifications/SiteCalls design + plan
- Adds SourceNode varchar(64) NULL to AuditLog, Notifications, and SiteCalls
  tables with role-name semantics: node-a/node-b for site rows (qualified by
  SourceSiteId), central-a/central-b for central direct-write rows.
- New IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc) index.
- Reframes CLAUDE.md from documentation-only to implementation project.
- Adds docs/plans/2026-05-23-audit-source-node.md + tasks.json companion.
2026-05-23 15:34:44 -04:00
Joseph Doherty
e3345a0fc1 Merge branch 'feature/inbound-api-full-response-audit': inbound API full request/response audit capture
Inbound API audit rows (Channel = ApiInbound) now capture request and
response bodies in full up to a configurable 1 MiB per-body ceiling
(AuditLog:InboundMaxBytes), instead of the global 8 KiB / 64 KiB caps
that other audit channels use. Implements the M5-deferred response-body
capture in AuditWriteMiddleware via a write-only Stream wrapper that
forwards every byte to the framework's response sink while bounding the
audit copy at the capture site (ArrayPool-backed for the request side).
Other channels untouched.
2026-05-23 09:49:52 -04:00
Joseph Doherty
e6ccee1a16 refactor(inboundapi): pool the request audit buffer + reset Position in finally 2026-05-23 09:46:53 -04:00
Joseph Doherty
e567eb334c docs(audit): drop stale InboundAuthFailure exclusion from design doc
The design doc claimed (in two places) that InboundAuthFailure rows
were excluded from the inbound full-body carve-out — but the actual
implementation gates the carve-out on Channel == ApiInbound, NOT Kind.
Every audit row the InboundAPI middleware emits (whether
Kind = InboundRequest or Kind = InboundAuthFailure) carries
Channel = ApiInbound, so both Kinds receive the inbound ceiling. That
is the intended behaviour: an auth-failure row's request body is
exactly the body the operator wants to see in full when investigating
a rejected request.

Update both occurrences (Decision block + Not in Scope block) to say
the carve-out applies to all Channel = ApiInbound rows regardless of
Kind. Pure documentation change — no code drift.
2026-05-23 09:25:23 -04:00
Joseph Doherty
7d87994ac0 feat(inboundapi): bound audit capture at InboundMaxBytes (memory safety)
AuditWriteMiddleware previously buffered the FULL request and response
bodies into memory and only let DefaultAuditPayloadFilter trim them
after persistence. A 500 MiB upload allocated 500 MiB of MemoryStream
plus 1 GiB of UTF-16 string transiently before the filter pulled it
back to the 1 MiB inbound ceiling — the cap was real on the persisted
row but not at the capture site.

Inject IOptionsMonitor<AuditLogOptions> and read InboundMaxBytes
per-request (same convention as DefaultAuditPayloadFilter so a live
config change picks up the next request). The request reader now pulls
at most cap + 1 bytes into a UTF-8 byte-safe-truncated string and
rewinds the stream so the endpoint handler still sees the full body.
The response wrap is a new CapturedResponseStream that forwards every
Write / WriteAsync to the real sink (the client still receives all
bytes) while capturing at most cap + 1 bytes for the audit copy. The
middleware now sets PayloadTruncated itself when either body hit the
cap; the filter still OR's its own determination on top.

Adds a project reference from ScadaLink.InboundAPI to
ScadaLink.AuditLog so AuditLogOptions resolves. AuditLog does NOT
reference InboundAPI back, so no cycle is introduced.

Tests:
 - All 21 existing AuditWriteMiddlewareTests still pass (the helper
   gains an optional AuditLogOptions argument; default is the standard
   1 MiB ceiling so existing small-body tests are unaffected).
 - MiddlewareOrderTests' construction site updated for the new ctor
   arg; a StaticAuditLogOptionsMonitor file-local double mirrors the
   InboundChannelCapTests pattern.
 - New RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue
   pins a 4 KiB cap against a 20 KB body: audit copy <= 4 KiB,
   PayloadTruncated = true, downstream handler reads the full 20 KB.
 - New ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue
   pins the same shape on the response side: client sink receives
   20 KB, audit copy <= 4 KiB, PayloadTruncated = true.

InboundAPI test count: 133 -> 135.
2026-05-23 09:25:00 -04:00
Joseph Doherty
651c4b6833 docs(inboundapi): note request/response bodies captured in full to InboundMaxBytes 2026-05-23 06:09:10 -04:00
Joseph Doherty
7efb004a02 docs(audit): schema + Payload Capture Policy note inbound full-body carve-out 2026-05-23 06:07:11 -04:00