diff --git a/docs/19-roadmap.md b/docs/19-roadmap.md index 0f900cb..26035c1 100644 --- a/docs/19-roadmap.md +++ b/docs/19-roadmap.md @@ -50,7 +50,7 @@ gantt | Deliverable | Detail | | --- | --- | | Repository | `git init`, `.gitignore`, `LICENSE` (MIT), `README.md` (already drafted), `docs/` (this set) | -| Solution | `AegisOps.slnx` (SDK .NET 10 default) with Domain / Application / Infrastructure / Api / Worker + 5 test projects; `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig` | +| Solution | `AegisOps.slnx` (SDK .NET 10 default) with Domain / Application / Infrastructure / Api / Worker + 3 test projects; `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig` | | Architecture tests | Dependency rule tests pass on the empty skeleton | | Frontend | Vite + React + TS + Tailwind scaffold, ESLint/Prettier, AppShell with sidebar, `/login` placeholder | | Compose | `postgres`, `redis`, `api`, `worker`, `web` services build and start; `/health` green | @@ -70,7 +70,7 @@ executor — this is the MVP and the heart of the portfolio story. | Organization | Teams, members, projects (auto Dev/Staging/Prod), environments (`noop` target), repositories; team scoping | | Artifacts | Register artifact (manual via UI and via API key); list/detail; test summary | | Policies | `Policy`/`PolicyRule`/`PolicyEvaluation`; **PolicyEngine** with rules `RequireTestsPassed`, `RequireApprovals`, `AllowedBranches`, `RequirePriorEnvironment`, `RequireImageDigest`, `DeploymentWindow` (`MaxFindings`/`RequireScan` land in Phase 3 but the engine is complete); rule-type catalogue endpoint; simulate endpoint | -| Deployments | Request → in-process job loop (Worker project runs the same `JobDispatcherService`) → evaluate → `Denied` / `AwaitingApproval` / `Approved` → `NoopDeploymentExecutor` → `Succeeded`; approvals with separation of duties; cancel; timeline events | +| Deployments | Request → persist PostgreSQL job → separate Worker `DeploymentJobService`/`DeploymentProcessor` claims and evaluates it → `Denied` / `AwaitingApproval` / `Approved` → baseline `NoopDeploymentExecutor` → `Succeeded`; approvals with separation of duties; cancel; timeline events | | Jobs | PostgreSQL job table, claim with `SKIP LOCKED`, retry/backoff, reaper | | Audit | `IAuditWriter`, audit endpoints, coverage for all Phase-1 actions | | Frontend | Login, dashboard (basic counts), projects list/detail, artifact list/detail with "Deploy", deployments list/detail with **PolicyEvaluationPanel** and **ApprovalPanel**, approvals queue, policies list + **RuleBuilder**, audit log, admin users/teams (polling refresh every 10 s; SignalR comes in Phase 5) | diff --git a/docs/v1-progress-audit.md b/docs/v1-progress-audit.md index 949c852..a0243af 100644 --- a/docs/v1-progress-audit.md +++ b/docs/v1-progress-audit.md @@ -1,6 +1,6 @@ # v1 progress audit -Audit baseline: 2026-11-04, commit `6b2e894` on `dev`. +Audit baseline: 2026-11-04, starting commit `bbf2a54` on `dev`; this refreshed estimate includes the local, uncommitted roadmap and `IAuditWriter` changes. Per `19-roadmap.md`, a check receives credit only when its implementation and the stated passing verification evidence are both established. All Phases 0–8 are enumerated below; unresolved scope mismatches are conservatively counted as zero. @@ -22,7 +22,7 @@ Status meanings: | P0-06 | Infrastructure project is in the solution | Verified | Listed in `AegisOps.slnx`. | | P0-07 | API project is in the solution | Verified | Listed in `AegisOps.slnx`. | | P0-08 | Worker project is in the solution | Verified | Listed in `AegisOps.slnx`. | -| P0-09 | Five test projects are in the solution | Scope mismatch | `AegisOps.slnx` lists three test projects, not five. Reconcile roadmap scope before scoring. | +| P0-09 | Three test projects are in the solution | Verified | `AegisOps.slnx` lists the three agreed test projects; the full solution suite passed in the audit baseline. | | P0-10 | Architecture dependency tests exist and pass | Verified | `tests/AegisOps.Architecture.Tests`; latest full solution test run passed. | | P0-11 | React, Vite, and TypeScript frontend scaffold exists | Verified | Dependencies/configuration and source are tracked. | | P0-12 | Tailwind frontend integration exists | Verified | `@tailwindcss/vite` is configured in the frontend package. | @@ -45,13 +45,13 @@ Status meanings: ### Verification evidence -- `dotnet test AegisOps.slnx --no-restore`: 154 total, 153 passed, 0 failed, 1 skipped (Redis integration test skipped without `AEGISOPS_TEST_REDIS`). +- `AEGISOPS_TEST_REDIS=localhost:6379 dotnet test AegisOps.slnx --no-restore`: 155 passed, 0 failed, 0 skipped. - `npm run build` in `src/AegisOps.Web`: passed. - Docker check earlier in this session: daemon unavailable; PostgreSQL port 5432 not listening. Redis is available locally and the focused Redis limiter integration test passed separately. -### Phase 0 reconciliation needed +### Phase 0 remaining gaps -The roadmap currently promises five test projects, `scripts/dev-up.sh`, `gen-secrets.sh`, Dependabot, ESLint, and Prettier; the tracked repo does not currently satisfy those named items (and only three test projects are listed). Decide whether to add the deliverables or formally rebaseline the roadmap before treating those checks as scoped out. +The roadmap has been rebaselined to the three test projects already in `AegisOps.slnx`. It still calls for `scripts/dev-up.sh`, `gen-secrets.sh`, Dependabot, ESLint, and Prettier, which are not tracked/configured. ## Phase 1 — Core control plane (weight: 3 / 15.5 weeks) @@ -70,22 +70,23 @@ The roadmap currently promises five test projects, `scripts/dev-up.sh`, `gen-sec | P1-11 | Policy entities and the six Phase 1 rule behaviors are implemented and tested | Verified | `PolicyEngineTests` covers tests, approvals, branch, prior environment, digest, and deployment window; full suite passed. | | P1-12 | Rule-type catalogue and policy simulation endpoints work | Unverified | Routes and handlers exist; no endpoint-level verification was found. | | P1-13 | Deployment state transitions, approval quorum, and cancellation are tested | Verified | `DeploymentStateTests` exercises transitions; `DeploymentAuthorizationTests` covers approval/cancellation authorization; full suite passed. | -| P1-14 | Request → evaluate → noop execute → succeeded runs through an in-process job loop | Scope mismatch | Current API does not register a job processor; the Worker registers `DeploymentJobService`. `JobDispatcherService` named in the roadmap is absent. Verify whether this was intentionally replaced by the separate Worker architecture. | +| P1-14 | Request → durable PostgreSQL job → separate Worker `DeploymentJobService`/`DeploymentProcessor` → evaluate → noop execute → succeeded | Unverified | The API enqueues jobs and Worker code claims/processes them; PostgreSQL-backed end-to-end execution was not verified because PostgreSQL/Docker are unavailable. | | P1-15 | Separation of duties is enforced for approvals | Verified | `DeploymentAuthorizationTests` covers requester/non-requester and role restrictions; full suite passed. | | P1-16 | PostgreSQL jobs use `SKIP LOCKED`, retry/backoff, and stale-job recovery | Unverified | `DeploymentProcessor` uses `FOR UPDATE SKIP LOCKED`; `DeploymentLeaseTests` requires `AEGISOPS_TEST_POSTGRES` and returns without running when unset. PostgreSQL is unavailable here. | -| P1-17 | An `IAuditWriter` abstraction is used for state changes | Scope mismatch | No `IAuditWriter` symbol exists; repo uses static `AuditLog` plus direct `AuditEvents.Add` calls. Reconcile implementation-vs-roadmap contract. | +| P1-17 | An `IAuditWriter` abstraction is used for state changes | Verified | API, AI gateway, and Worker audit writes use the Application-layer contract; scoped `EfAuditWriter` adds to the existing context without saving independently. The focused unit-of-work test and full suite passed. | | P1-18 | Audit endpoint exposes records | Verified | `/api/v1/audit` is mapped and permission-protected. | | P1-19 | Audit coverage is tested for all Phase 1 actions | Unverified | Audit writes exist on key paths, but no audit-coverage test was found. | | P1-20 | Phase 1 frontend views are implemented: login/dashboard, project/artifact views, deployments, approvals, policies/rule editing, audit, and admin | Verified | Components are present in `src/AegisOps.Web/src/App.tsx`; `npm run build` passed. End-to-end seeded-data behavior is tracked separately in P1-24. | -| P1-21 | Policy engine matrix and deployment state-machine tests pass | Verified | Included in the latest full solution run: 153 passed, 0 failed, 1 skipped. | +| P1-21 | Policy engine matrix and deployment state-machine tests pass | Verified | Included in the latest full solution run with Redis enabled: 155 passed, 0 failed, 0 skipped. | | P1-22 | Integration flow tests exercise the full control-plane deployment loop | Unverified | Tests cover state and authorization seams; full request-to-noop-success flow was not run. | | P1-23 | ADR statuses are updated where decisions are implemented | Verified | `docs/adr/README.md` lists ADRs 0001–0011 as Accepted. | | P1-24 | Journey A and Journey D complete from the seeded UI, with every step in the audit log | Unverified | Requires a running seeded application and browser verification; Docker/PostgreSQL are unavailable. | ### Phase 1 verification caveats -- The full test suite passed, but `DeploymentLeaseTests` silently returns when `AEGISOPS_TEST_POSTGRES` is absent. Therefore, the pass count does not prove PostgreSQL locking, migration, or lease behavior ran. -- `IAuditWriter` is not merely untested; it is absent. Audit behavior is implemented through other mechanisms, so determine whether the abstraction is still required before scoring or refactoring. +- The full test suite passed with Redis, but `DeploymentLeaseTests` silently returns when `AEGISOPS_TEST_POSTGRES` is absent. Therefore, the pass count does not prove PostgreSQL locking, migration, or lease behavior ran. +- `EfAuditWriterTests` verifies that the scoped writer adds an event to the shared `AegisOpsDbContext` unit of work without saving independently; audit coverage for every Phase-1 action remains unverified (P1-19). +- PostgreSQL-backed job claiming and end-to-end request-to-execution remain unverified in this environment; audit coverage is tracked separately in P1-19. ## Phase 2 — CI integration & target applications (weight: 1.5 / 15.5 weeks) @@ -111,8 +112,6 @@ The roadmap currently promises five test projects, `scripts/dev-up.sh`, `gen-sec The audit inspected local `main` refs, which match their locally cached `origin/main` refs. `gh` is not installed, so remote state may have advanced since the last fetch and hosted Actions/GHCR publication could not be checked. Several sibling service worktrees have user-modified files; they were inspected read-only and not changed. -## Next audit slice - ## Phase 3 — Security scanning (weight: 2 / 15.5 weeks) | ID | Acceptance check | Status | Evidence / gap | @@ -134,9 +133,7 @@ The audit inspected local `main` refs, which match their locally cached `origin/ ### Phase 3 verification evidence - Focused parser and policy tests: 21 passed, 0 failed, 0 skipped. -- The previous complete solution test run passed 153 tests and skipped one Redis test, but did not exercise the PostgreSQL-backed scan upload/async parse path. - -## Next audit slice +- The latest complete solution test run passed 155/155 with Redis enabled, but did not exercise the PostgreSQL-backed scan upload/async parse path. ## Phase 4 — Worker & Docker executor (weight: 2 / 15.5 weeks) @@ -186,7 +183,7 @@ The audit inspected local `main` refs, which match their locally cached `origin/ - `npm run test:e2e`: 3 passed, 0 failed; the SignalR update scenario uses mocked WebSocket frames and REST responses. - `DeploymentAuthorizationTests`/`DockerTargetOwnershipTests`: 28 passed, 0 failed; includes TestServer SignalR client delivery and access checks, but not live Redis pub/sub. -- Full solution run: 153 passed, 0 failed, 1 skipped. The Redis-specific AI rate limiter test was run separately against local Redis and passed. +- Full solution run with local Redis: 155 passed, 0 failed, 0 skipped. PostgreSQL-backed Compose behavior remains unverified. ## Phase 6 — AI gateway (weight: 2 / 15.5 weeks) @@ -260,8 +257,8 @@ All Phase 0–8 checks have now been enumerated. The score counts only `Verified | Phase | Weight (weeks) | Verified / eligible checks | Weighted contribution | | --- | ---: | ---: | ---: | -| 0 | 1 | 18 / 28 | 4.15 percentage points | -| 1 | 3 | 8 / 24 | 6.45 percentage points | +| 0 | 1 | 19 / 28 | 4.38 percentage points | +| 1 | 3 | 9 / 24 | 7.26 percentage points | | 2 | 1.5 | 9 / 15 | 5.81 percentage points | | 3 | 2 | 5 / 13 | 4.96 percentage points | | 4 | 2 | 7 / 14 | 6.45 percentage points | @@ -269,14 +266,14 @@ All Phase 0–8 checks have now been enumerated. The score counts only `Verified | 6 | 2 | 8 / 13 | 7.94 percentage points | | 7 | 1.5 | 0 / 8 | 0.00 percentage points | | 8 | 1.5 | 2 / 16 | 1.21 percentage points | -| **Total** | **15.5** | **62 / 144** | **39.45% complete** | +| **Total** | **15.5** | **64 / 144** | **40.49% complete** | -**Estimated remaining: 60.55%** by the same weighted, evidence-based measure. Status totals are 62 Verified, 43 Unverified, 36 Missing, and 3 Scope mismatch. The mismatches are P0-09 (five test projects vs. three), P1-14 (roadmap in-process job loop vs. separate Worker implementation), and P1-17 (`IAuditWriter` vs. current audit implementation). Resolve these by implementation or a recorded scope rebaseline; this estimate currently gives them no credit. +**Estimated remaining: 59.51%** by the same weighted, evidence-based measure. Status totals are 64 Verified, 44 Unverified, 36 Missing, and 0 Scope mismatch. P0-09 and the deployment architecture criterion are rebaselined to match the agreed solution; `IAuditWriter` is now implemented and verified. The durable Worker flow remains unverified until PostgreSQL-backed execution can run. ### Highest-impact next steps -1. Decide the three scope mismatches, then fix/rebaseline the foundation and core acceptance criteria. -2. Restore runnable Compose/PostgreSQL and Ollama-backed demo paths; verify the real Journeys A–D rather than relying on mocks. -3. Implement the hard Phase 7/8 gaps: observability, readiness checks, CSP/audit coverage, retention/cleanup, and a repeatable demo/release path. +1. Run Compose with PostgreSQL and verify the durable Worker flow plus Journeys A and D end to end. +2. Implement Phase 7 observability and service-specific readiness; it currently has 0/8 verified checks. +3. Close Phase 8 gaps: CSP/audit coverage, retention/cleanup, AI demo assets, and a repeatable release path. -The local audit changes are not committed. Once the roadmap wording and this evidence ledger are reviewed together, they are ready to commit as a documentation-only audit update. +The roadmap/audit rebaseline and IAuditWriter implementation are not committed. Review the documentation and test output together before committing this combined change. diff --git a/src/AegisOps.Api/Deploy/DeploymentEndpoints.cs b/src/AegisOps.Api/Deploy/DeploymentEndpoints.cs index a4f281b..fd9173b 100644 --- a/src/AegisOps.Api/Deploy/DeploymentEndpoints.cs +++ b/src/AegisOps.Api/Deploy/DeploymentEndpoints.cs @@ -1,3 +1,4 @@ +using AegisOps.Application.Audit; using System.Security.Claims; using System.Text.Json; using AegisOps.Api.Idempotency; @@ -25,6 +26,7 @@ public static async Task Request( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, IIdempotencyStore idempotency, HttpContext http @@ -62,6 +64,7 @@ HttpContext http var now = time.GetUtcNow(); var deployment = Enqueue( db, + audit, artifact, environment, now, @@ -81,6 +84,7 @@ public static async Task Rollback( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, IIdempotencyStore idempotency, HttpContext http @@ -128,6 +132,7 @@ HttpContext http var now = time.GetUtcNow(); var deployment = Enqueue( db, + audit, artifact, environment, now, @@ -264,6 +269,7 @@ public static async Task Decide( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -285,12 +291,12 @@ HttpContext http var access = await DeploymentAccess.ResolveAsync(principal, users); if (access.Error is not null) return access.Error; if (!await access.CanSeeAsync(deployment.ProjectId, db, cancellationToken)) { - await AuditDeniedAsync(db, time, actor, deployment, "deployment.decision_denied", cancellationToken); + await AuditDeniedAsync(db, audit, time, actor, deployment, "deployment.decision_denied", cancellationToken); return DeploymentAccess.NotFound(); } if (deployment.RequestedById == actor.UserId || !await access.CanApproveAsync(deployment, db, cancellationToken)) { - await AuditDeniedAsync(db, time, actor, deployment, "deployment.decision_denied", cancellationToken); + await AuditDeniedAsync(db, audit, time, actor, deployment, "deployment.decision_denied", cancellationToken); return DeploymentAccess.Forbidden(); } if (deployment.Status != DeploymentStatus.AwaitingApproval) return Results.Problem(title: "Deployment is not awaiting approval.", statusCode: StatusCodes.Status409Conflict); @@ -308,7 +314,7 @@ HttpContext http db.Jobs.Add(Job.Enqueue(JobType.EvaluateDeployment, JsonSerializer.Serialize(new { deploymentId = deployment.Id }), now, deployment.CorrelationId)); } - db.AuditEvents.Add(AuditEvent.Record(now, "User", actor.UserId, actor.Display, "deployment." + decision.ToString().ToLowerInvariant(), "Deployment", deployment.Id, decision.ToString(), deployment.CorrelationId)); + audit.Write(AuditEvent.Record(now, "User", actor.UserId, actor.Display, "deployment." + decision.ToString().ToLowerInvariant(), "Deployment", deployment.Id, decision.ToString(), deployment.CorrelationId)); await db.SaveChangesAsync(cancellationToken); await DeploymentUpdates.PublishAsync(http.RequestServices.GetService(), deployment.Id); return Results.NoContent(); @@ -319,6 +325,7 @@ public static async Task Cancel( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -335,11 +342,11 @@ HttpContext http var access = await DeploymentAccess.ResolveAsync(principal, users); if (access.Error is not null) return access.Error; if (!await access.CanSeeAsync(deployment.ProjectId, db, http.RequestAborted)) { - await AuditDeniedAsync(db, time, actor, deployment, "deployment.cancel_denied", http.RequestAborted); + await AuditDeniedAsync(db, audit, time, actor, deployment, "deployment.cancel_denied", http.RequestAborted); return DeploymentAccess.NotFound(); } if (!await access.CanCancelAsync(deployment, db, http.RequestAborted)) { - await AuditDeniedAsync(db, time, actor, deployment, "deployment.cancel_denied", http.RequestAborted); + await AuditDeniedAsync(db, audit, time, actor, deployment, "deployment.cancel_denied", http.RequestAborted); return DeploymentAccess.Forbidden(); } if (deployment.Status is not DeploymentStatus.Requested and not DeploymentStatus.AwaitingApproval) return Results.Problem(title: "Deployment cannot be cancelled.", statusCode: StatusCodes.Status409Conflict); @@ -348,7 +355,7 @@ HttpContext http deployment.Cancel(now); var sequence = await db.DeploymentEvents.CountAsync(item => item.DeploymentId == deployment.Id, http.RequestAborted) + 1; db.DeploymentEvents.Add(DeploymentEvent.Create(deployment.Id, sequence, now, DeploymentEventType.Cancelled, "Deployment cancelled.")); - db.AuditEvents.Add(AuditEvent.Record(now, actor.Type, actor.UserId ?? actor.ApiKeyId, actor.Display, "deployment.cancelled", "Deployment", deployment.Id, "Cancelled", deployment.CorrelationId)); + audit.Write(AuditEvent.Record(now, actor.Type, actor.UserId ?? actor.ApiKeyId, actor.Display, "deployment.cancelled", "Deployment", deployment.Id, "Cancelled", deployment.CorrelationId)); await db.SaveChangesAsync(http.RequestAborted); await DeploymentUpdates.PublishAsync(http.RequestServices.GetService(), deployment.Id); return Results.NoContent(); @@ -388,14 +395,15 @@ CancellationToken cancellationToken return null; } - private static async Task AuditDeniedAsync(AegisOpsDbContext db, TimeProvider time, ActorResult actor, Deployment deployment, string action, CancellationToken token) { - db.AuditEvents.Add(AuditEvent.Record(time.GetUtcNow(), actor.Type, actor.UserId ?? actor.ApiKeyId, actor.Display, + private static async Task AuditDeniedAsync(AegisOpsDbContext db, IAuditWriter audit, TimeProvider time, ActorResult actor, Deployment deployment, string action, CancellationToken token) { + audit.Write(AuditEvent.Record(time.GetUtcNow(), actor.Type, actor.UserId ?? actor.ApiKeyId, actor.Display, action, "Deployment", deployment.Id, "Denied", deployment.CorrelationId)); await db.SaveChangesAsync(token); } private static Deployment Enqueue( AegisOpsDbContext db, + IAuditWriter audit, AegisOps.Domain.Security.Artifact artifact, AegisOps.Domain.Organization.Environment environment, DateTimeOffset now, @@ -422,7 +430,7 @@ private static Deployment Enqueue( now, deployment.CorrelationId )); - db.AuditEvents.Add(AuditEvent.Record(now, actor.Type, actor.UserId ?? actor.ApiKeyId, actor.Display, "deployment.requested", "Deployment", deployment.Id, "Accepted", deployment.CorrelationId)); + audit.Write(AuditEvent.Record(now, actor.Type, actor.UserId ?? actor.ApiKeyId, actor.Display, "deployment.requested", "Deployment", deployment.Id, "Accepted", deployment.CorrelationId)); return deployment; } } diff --git a/src/AegisOps.Api/Identity/ChangePassword.cs b/src/AegisOps.Api/Identity/ChangePassword.cs index 24b14cf..6fa3ee5 100644 --- a/src/AegisOps.Api/Identity/ChangePassword.cs +++ b/src/AegisOps.Api/Identity/ChangePassword.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using AegisOps.Domain.Identity; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.JsonWebTokens; @@ -15,6 +15,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time ) { if (string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword)) { @@ -61,7 +62,7 @@ TimeProvider time throw new InvalidOperationException(errors); } - AuditLog.Write(db, time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "auth.password_changed", "User", user.Id); + audit.Write(time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "auth.password_changed", "User", user.Id); await db.SaveChangesAsync(); return Results.NoContent(); } diff --git a/src/AegisOps.Api/Identity/Login.cs b/src/AegisOps.Api/Identity/Login.cs index b883057..f511739 100644 --- a/src/AegisOps.Api/Identity/Login.cs +++ b/src/AegisOps.Api/Identity/Login.cs @@ -1,5 +1,5 @@ using AegisOps.Domain.Identity; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Identity; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; @@ -17,6 +17,7 @@ public static async Task Handle( IOptions jwtOptions, TimeProvider time, AegisOpsDbContext db, + IAuditWriter audit, HttpContext http ) { if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) { @@ -42,7 +43,7 @@ HttpContext http var token = JwtAccessTokenFactory.Create(user, roles, options, now); await RefreshCookie.IssueAsync(http, db, user, options, now, http.RequestAborted); - AuditLog.Write(db, now, user.Id, user.Email ?? user.DisplayName, "auth.login", "User", user.Id); + audit.Write(now, user.Id, user.Email ?? user.DisplayName, "auth.login", "User", user.Id); await db.SaveChangesAsync(http.RequestAborted); return Results.Ok(new LoginResponse(token, now.AddMinutes(options.AccessTokenMinutes))); diff --git a/src/AegisOps.Api/Identity/Logout.cs b/src/AegisOps.Api/Identity/Logout.cs index 101ccdf..51c41be 100644 --- a/src/AegisOps.Api/Identity/Logout.cs +++ b/src/AegisOps.Api/Identity/Logout.cs @@ -1,5 +1,5 @@ using AegisOps.Domain.Identity; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.JsonWebTokens; @@ -12,6 +12,7 @@ public static async Task Handle( HttpContext http, ClaimsPrincipal principal, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time ) { var cancellationToken = http.RequestAborted; @@ -41,7 +42,7 @@ TimeProvider time var now = time.GetUtcNow(); await Refresh.RevokeFamilyAsync(db, current.FamilyId, now, cancellationToken); - AuditLog.Write(db, now, userId, principal.FindFirst(JwtRegisteredClaimNames.Email)?.Value ?? subject, "auth.logout", "User", userId); + audit.Write(now, userId, principal.FindFirst(JwtRegisteredClaimNames.Email)?.Value ?? subject, "auth.logout", "User", userId); await db.SaveChangesAsync(cancellationToken); RefreshCookie.Clear(http); return Results.NoContent(); diff --git a/src/AegisOps.Api/Jobs/AdminJobs.cs b/src/AegisOps.Api/Jobs/AdminJobs.cs index da3df0c..4f52d4f 100644 --- a/src/AegisOps.Api/Jobs/AdminJobs.cs +++ b/src/AegisOps.Api/Jobs/AdminJobs.cs @@ -1,3 +1,4 @@ +using AegisOps.Application.Audit; using AegisOps.Domain.Audit; using AegisOps.Domain.Jobs; using AegisOps.Infrastructure.Persistence; @@ -43,7 +44,7 @@ public static async Task List(AegisOpsDbContext db, HttpContext http, s return Results.Ok(items); } - public static async Task Retry(Guid id, AegisOpsDbContext db, TimeProvider time, HttpContext http) { + public static async Task Retry(Guid id, AegisOpsDbContext db, TimeProvider time, IAuditWriter audit, HttpContext http) { var job = await db.Jobs.SingleOrDefaultAsync(item => item.Id == id, http.RequestAborted); if (job is null) { return Results.Problem(title: "Job was not found.", statusCode: StatusCodes.Status404NotFound); @@ -61,7 +62,7 @@ public static async Task Retry(Guid id, AegisOpsDbContext db, TimeProvi return Results.Problem(title: exception.Message, statusCode: StatusCodes.Status409Conflict); } - db.AuditEvents.Add(AuditEvent.Record( + audit.Write(AuditEvent.Record( time.GetUtcNow(), "User", null, diff --git a/src/AegisOps.Api/Organization/ArchivePolicy.cs b/src/AegisOps.Api/Organization/ArchivePolicy.cs index 7b3bd4e..b24cf03 100644 --- a/src/AegisOps.Api/Organization/ArchivePolicy.cs +++ b/src/AegisOps.Api/Organization/ArchivePolicy.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using AegisOps.Domain.Identity; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -13,6 +13,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -47,7 +48,7 @@ HttpContext http try { policy.Archive(userId, time.GetUtcNow()); - AuditLog.Write(db, time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "policy.archived", "Policy", policy.Id); + audit.Write(time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "policy.archived", "Policy", policy.Id); await db.SaveChangesAsync(cancellationToken); } catch (InvalidOperationException exception) { return Results.Problem( diff --git a/src/AegisOps.Api/Organization/BackfillArtifactDigest.cs b/src/AegisOps.Api/Organization/BackfillArtifactDigest.cs index 748f42e..2b7b022 100644 --- a/src/AegisOps.Api/Organization/BackfillArtifactDigest.cs +++ b/src/AegisOps.Api/Organization/BackfillArtifactDigest.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using AegisOps.Domain.Identity; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -16,6 +16,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, HttpContext http ) { if (string.IsNullOrWhiteSpace(request.ImageDigest)) { @@ -72,7 +73,7 @@ HttpContext http try { artifact.BackfillDigest(request.ImageDigest); - AuditLog.Write(db, TimeProvider.System.GetUtcNow(), actorId, actorDisplay, "artifact.digest_backfilled", "Artifact", artifact.Id); + audit.Write(TimeProvider.System.GetUtcNow(), actorId, actorDisplay, "artifact.digest_backfilled", "Artifact", artifact.Id); await db.SaveChangesAsync(cancellationToken); } catch (ArgumentException exception) { return Results.Problem( diff --git a/src/AegisOps.Api/Organization/CreateApiKey.cs b/src/AegisOps.Api/Organization/CreateApiKey.cs index 6a393e7..97c1038 100644 --- a/src/AegisOps.Api/Organization/CreateApiKey.cs +++ b/src/AegisOps.Api/Organization/CreateApiKey.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -43,6 +43,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -114,7 +115,7 @@ HttpContext http ); db.ApiKeys.Add(apiKey); - AuditLog.Write(db, now, user.Id, user.Email ?? user.DisplayName, "apikey.created", "ApiKey", apiKey.Id); + audit.Write(now, user.Id, user.Email ?? user.DisplayName, "apikey.created", "ApiKey", apiKey.Id); await db.SaveChangesAsync(cancellationToken); return Results.Created( diff --git a/src/AegisOps.Api/Organization/CreateArtifact.cs b/src/AegisOps.Api/Organization/CreateArtifact.cs index 93426b0..1ff388e 100644 --- a/src/AegisOps.Api/Organization/CreateArtifact.cs +++ b/src/AegisOps.Api/Organization/CreateArtifact.cs @@ -3,7 +3,7 @@ using AegisOps.Application.Idempotency; using AegisOps.Domain.Identity; using AegisOps.Domain.Security; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -52,6 +52,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, IIdempotencyStore idempotency, HttpContext http @@ -172,7 +173,7 @@ HttpContext http ); db.Artifacts.Add(artifact); - AuditLog.Write(db, time.GetUtcNow(), createdById ?? createdByApiKeyId, actorDisplay, "artifact.created", "Artifact", artifact.Id); + audit.Write(time.GetUtcNow(), createdById ?? createdByApiKeyId, actorDisplay, "artifact.created", "Artifact", artifact.Id); await db.SaveChangesAsync(cancellationToken); return await IdempotencyRequests.FinishAsync(http, idempotency, StatusCodes.Status201Created, ToResponse(artifact)); diff --git a/src/AegisOps.Api/Organization/CreatePolicy.cs b/src/AegisOps.Api/Organization/CreatePolicy.cs index 6b5d73b..f4ffed9 100644 --- a/src/AegisOps.Api/Organization/CreatePolicy.cs +++ b/src/AegisOps.Api/Organization/CreatePolicy.cs @@ -3,7 +3,7 @@ using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; using AegisOps.Domain.Policy; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -53,6 +53,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -154,7 +155,7 @@ HttpContext http db.Policies.Add(policy); db.PolicyRules.AddRange(rules); - AuditLog.Write(db, now, user.Id, user.Email ?? user.DisplayName, "policy.created", "Policy", policy.Id); + audit.Write(now, user.Id, user.Email ?? user.DisplayName, "policy.created", "Policy", policy.Id); await db.SaveChangesAsync(cancellationToken); return Results.Created($"/api/v1/policies/{policy.Id}", ToResponse(policy, rules)); diff --git a/src/AegisOps.Api/Organization/CreateProject.cs b/src/AegisOps.Api/Organization/CreateProject.cs index 4b3fa2a..812f4a9 100644 --- a/src/AegisOps.Api/Organization/CreateProject.cs +++ b/src/AegisOps.Api/Organization/CreateProject.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -50,6 +50,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -119,7 +120,7 @@ HttpContext http db.Projects.Add(project); db.Environments.AddRange(environments); - AuditLog.Write(db, time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "project.created", "Project", project.Id); + audit.Write(time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "project.created", "Project", project.Id); await db.SaveChangesAsync(cancellationToken); return Results.Created( diff --git a/src/AegisOps.Api/Organization/CreateTeam.cs b/src/AegisOps.Api/Organization/CreateTeam.cs index 19c5926..59e33cc 100644 --- a/src/AegisOps.Api/Organization/CreateTeam.cs +++ b/src/AegisOps.Api/Organization/CreateTeam.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -17,6 +17,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -58,7 +59,7 @@ HttpContext http var team = Team.Create(request.Name, slug, now, request.Description); db.Teams.Add(team); db.TeamMembers.Add(TeamMember.Create(team.Id, userId, TeamRole.Owner, now)); - AuditLog.Write(db, now, user.Id, user.Email ?? user.DisplayName, "team.created", "Team", team.Id); + audit.Write(now, user.Id, user.Email ?? user.DisplayName, "team.created", "Team", team.Id); await db.SaveChangesAsync(cancellationToken); return Results.Created( diff --git a/src/AegisOps.Api/Organization/PolicyPhase1Endpoints.cs b/src/AegisOps.Api/Organization/PolicyPhase1Endpoints.cs index f74a6c3..bfd4dea 100644 --- a/src/AegisOps.Api/Organization/PolicyPhase1Endpoints.cs +++ b/src/AegisOps.Api/Organization/PolicyPhase1Endpoints.cs @@ -1,3 +1,4 @@ +using AegisOps.Application.Audit; using System.Security.Claims; using System.Text.Json; using AegisOps.Application.PolicyEngine; @@ -33,6 +34,7 @@ public static async Task Replace( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -83,7 +85,7 @@ HttpContext http } db.PolicyRules.AddRange(rules); - db.AuditEvents.Add(AuditEvent.Record(now, "User", userId, user.Email ?? user.DisplayName, "policy.updated", "Policy", policy.Id, "Succeeded", null)); + audit.Write(AuditEvent.Record(now, "User", userId, user.Email ?? user.DisplayName, "policy.updated", "Policy", policy.Id, "Succeeded", null)); await db.SaveChangesAsync(cancellationToken); return Results.Ok(CreatePolicy.ToResponse(policy, rules)); } catch (ArgumentException exception) { diff --git a/src/AegisOps.Api/Organization/RemoveTeamMember.cs b/src/AegisOps.Api/Organization/RemoveTeamMember.cs index 06334e7..a1cc50e 100644 --- a/src/AegisOps.Api/Organization/RemoveTeamMember.cs +++ b/src/AegisOps.Api/Organization/RemoveTeamMember.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -16,6 +16,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, HttpContext http ) { var subject = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; @@ -94,7 +95,7 @@ HttpContext http } db.TeamMembers.Remove(membership); - AuditLog.Write(db, TimeProvider.System.GetUtcNow(), actor.Id, actor.Email ?? actor.DisplayName, "team.member_removed", "Team", team.Id); + audit.Write(TimeProvider.System.GetUtcNow(), actor.Id, actor.Email ?? actor.DisplayName, "team.member_removed", "Team", team.Id); await db.SaveChangesAsync(cancellationToken); return Results.NoContent(); } diff --git a/src/AegisOps.Api/Organization/RevokeApiKey.cs b/src/AegisOps.Api/Organization/RevokeApiKey.cs index fd4b509..a2abd5a 100644 --- a/src/AegisOps.Api/Organization/RevokeApiKey.cs +++ b/src/AegisOps.Api/Organization/RevokeApiKey.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -16,6 +16,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -79,7 +80,7 @@ HttpContext http try { apiKey.Revoke(time.GetUtcNow()); - AuditLog.Write(db, time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "apikey.revoked", "ApiKey", apiKey.Id); + audit.Write(time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "apikey.revoked", "ApiKey", apiKey.Id); await db.SaveChangesAsync(cancellationToken); } catch (InvalidOperationException exception) { return Results.Problem( diff --git a/src/AegisOps.Api/Organization/SetPolicyEnabled.cs b/src/AegisOps.Api/Organization/SetPolicyEnabled.cs index c81b7ec..39a7139 100644 --- a/src/AegisOps.Api/Organization/SetPolicyEnabled.cs +++ b/src/AegisOps.Api/Organization/SetPolicyEnabled.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using AegisOps.Domain.Identity; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -16,6 +16,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -55,7 +56,7 @@ HttpContext http policy.Disable(userId, time.GetUtcNow()); } - AuditLog.Write(db, time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, request.IsEnabled ? "policy.enabled" : "policy.disabled", "Policy", policy.Id); + audit.Write(time.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, request.IsEnabled ? "policy.enabled" : "policy.disabled", "Policy", policy.Id); await db.SaveChangesAsync(cancellationToken); } catch (InvalidOperationException exception) { return Results.Problem( diff --git a/src/AegisOps.Api/Organization/TeamWebhookSettingsEndpoints.cs b/src/AegisOps.Api/Organization/TeamWebhookSettingsEndpoints.cs index c323bc5..b2809cf 100644 --- a/src/AegisOps.Api/Organization/TeamWebhookSettingsEndpoints.cs +++ b/src/AegisOps.Api/Organization/TeamWebhookSettingsEndpoints.cs @@ -4,7 +4,7 @@ using AegisOps.Domain.Deploy; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Deploy; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; @@ -85,6 +85,7 @@ public static async Task Set( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TeamWebhookSecretProtector protector, HttpContext http ) { @@ -130,7 +131,7 @@ HttpContext http } else { settings.Update(uri.AbsoluteUri, encryptedSecret, now, eventTypes); } - AuditLog.Write(db, now, access.User!.Id, access.User.Email ?? access.User.DisplayName, + audit.Write(now, access.User!.Id, access.User.Email ?? access.User.DisplayName, "team.webhook_updated", "TeamWebhook", access.Team!.Id); await db.SaveChangesAsync(http.RequestAborted); return Results.Ok(new TeamWebhookSettingsResponse(uri.AbsoluteUri, true, now, ParseEventTypes(eventTypes))); @@ -144,6 +145,7 @@ public static async Task Rewrap( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TeamWebhookSecretProtector protector, HttpContext http ) { @@ -170,7 +172,7 @@ HttpContext http if (item.RewrapSecret(encrypted, now)) changed++; } if (changed > 0) { - AuditLog.Write(db, now, identity.User!.Id, identity.User.Email ?? identity.User.DisplayName, + audit.Write(now, identity.User!.Id, identity.User.Email ?? identity.User.DisplayName, "team.webhook_keys_rewrapped", "TeamWebhook", null); await db.SaveChangesAsync(http.RequestAborted); } diff --git a/src/AegisOps.Api/Organization/UpdateEnvironment.cs b/src/AegisOps.Api/Organization/UpdateEnvironment.cs index 71f7e2d..1640d6e 100644 --- a/src/AegisOps.Api/Organization/UpdateEnvironment.cs +++ b/src/AegisOps.Api/Organization/UpdateEnvironment.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -50,6 +50,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, HttpContext http ) { if (request.Name is null && request.Order is null && request.Target is null) { @@ -168,7 +169,7 @@ HttpContext http } } - AuditLog.Write(db, TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "environment.updated", "Environment", environment.Id); + audit.Write(TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "environment.updated", "Environment", environment.Id); await db.SaveChangesAsync(cancellationToken); } catch (ArgumentException exception) { return Results.Problem( diff --git a/src/AegisOps.Api/Organization/UpdateProject.cs b/src/AegisOps.Api/Organization/UpdateProject.cs index 6fd6895..beb009f 100644 --- a/src/AegisOps.Api/Organization/UpdateProject.cs +++ b/src/AegisOps.Api/Organization/UpdateProject.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -22,6 +22,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, HttpContext http ) { if (request.Name is null && request.Description is null && request.IsArchived is null) { @@ -89,7 +90,7 @@ HttpContext http project.Restore(); } - AuditLog.Write(db, TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "project.updated", "Project", project.Id); + audit.Write(TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "project.updated", "Project", project.Id); await db.SaveChangesAsync(cancellationToken); } catch (ArgumentException exception) { return Results.Problem( diff --git a/src/AegisOps.Api/Organization/UpdateTeam.cs b/src/AegisOps.Api/Organization/UpdateTeam.cs index fe3d325..8f446cb 100644 --- a/src/AegisOps.Api/Organization/UpdateTeam.cs +++ b/src/AegisOps.Api/Organization/UpdateTeam.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -17,6 +17,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, HttpContext http ) { if (request.Name is null && request.Description is null) { @@ -78,7 +79,7 @@ HttpContext http team.Describe(request.Description); } - AuditLog.Write(db, TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "team.updated", "Team", team.Id); + audit.Write(TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "team.updated", "Team", team.Id); await db.SaveChangesAsync(cancellationToken); } catch (ArgumentException exception) { return Results.Problem( diff --git a/src/AegisOps.Api/Organization/UploadScan.cs b/src/AegisOps.Api/Organization/UploadScan.cs index 2ef1dbb..9c56ab0 100644 --- a/src/AegisOps.Api/Organization/UploadScan.cs +++ b/src/AegisOps.Api/Organization/UploadScan.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using System.Text.Json; using AegisOps.Domain.Security; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using AegisOps.Infrastructure.Scanning; using Microsoft.AspNetCore.Identity; @@ -20,6 +20,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, IWebHostEnvironment environment, IConfiguration configuration @@ -86,7 +87,7 @@ IConfiguration configuration time.GetUtcNow(), cancellationToken ); - AuditLog.Write(db, time.GetUtcNow(), actor.UserId ?? actor.ApiKeyId, actor.Display, "scan.uploaded", "SecurityScan", scan.Id); + audit.Write(time.GetUtcNow(), actor.UserId ?? actor.ApiKeyId, actor.Display, "scan.uploaded", "SecurityScan", scan.Id); await db.SaveChangesAsync(cancellationToken); return Results.Json(new { scan.Id, @@ -144,6 +145,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -185,7 +187,7 @@ HttpContext http finding.Suppress(actor.UserId.Value, request.Reason); } - AuditLog.Write(db, now, actor.UserId, actor.Display, "finding.suppressed", "FindingSuppression", suppression.Id); + audit.Write(now, actor.UserId, actor.Display, "finding.suppressed", "FindingSuppression", suppression.Id); await db.SaveChangesAsync(cancellationToken); return Results.Ok(new { suppression.Id, suppression.Fingerprint }); } diff --git a/src/AegisOps.Api/Organization/UpsertRepository.cs b/src/AegisOps.Api/Organization/UpsertRepository.cs index 6523f2d..809fe5b 100644 --- a/src/AegisOps.Api/Organization/UpsertRepository.cs +++ b/src/AegisOps.Api/Organization/UpsertRepository.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -32,6 +32,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, HttpContext http ) { if (!Enum.TryParse(request.Provider, ignoreCase: true, out var provider) || !Enum.IsDefined(provider)) { @@ -108,7 +109,7 @@ HttpContext http ); } - AuditLog.Write(db, TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "repository.upserted", "Repository", existing.Id); + audit.Write(TimeProvider.System.GetUtcNow(), user.Id, user.Email ?? user.DisplayName, "repository.upserted", "Repository", existing.Id); await db.SaveChangesAsync(cancellationToken); return Results.Ok(new RepositoryResponse( diff --git a/src/AegisOps.Api/Organization/UpsertTeamMember.cs b/src/AegisOps.Api/Organization/UpsertTeamMember.cs index 7db3bac..a177f43 100644 --- a/src/AegisOps.Api/Organization/UpsertTeamMember.cs +++ b/src/AegisOps.Api/Organization/UpsertTeamMember.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using AegisOps.Domain.Identity; using AegisOps.Domain.Organization; -using AegisOps.Infrastructure.Audit; +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -20,6 +20,7 @@ public static async Task Handle( ClaimsPrincipal principal, UserManager users, AegisOpsDbContext db, + IAuditWriter audit, TimeProvider time, HttpContext http ) { @@ -102,7 +103,7 @@ HttpContext http } } - AuditLog.Write(db, time.GetUtcNow(), actor.Id, actor.Email ?? actor.DisplayName, "team.member_upserted", "Team", team.Id); + audit.Write(time.GetUtcNow(), actor.Id, actor.Email ?? actor.DisplayName, "team.member_upserted", "Team", team.Id); await db.SaveChangesAsync(cancellationToken); return Results.Ok(new TeamMemberItem( diff --git a/src/AegisOps.Api/Program.cs b/src/AegisOps.Api/Program.cs index 59b51e5..1349fec 100644 --- a/src/AegisOps.Api/Program.cs +++ b/src/AegisOps.Api/Program.cs @@ -1,3 +1,5 @@ +using AegisOps.Application.Audit; +using AegisOps.Infrastructure.Audit; using AegisOps.Api.Identity; using AegisOps.Infrastructure.Identity; using AegisOps.Application.Authorization; @@ -20,6 +22,7 @@ builder.Services.AddHealthChecks(); builder.Services.AddJwtAuthentication(builder.Configuration); builder.Services.AddIdentityStore(builder.Configuration); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(services => new RedisAiRateLimiter( diff --git a/src/AegisOps.Application/Audit/IAuditWriter.cs b/src/AegisOps.Application/Audit/IAuditWriter.cs new file mode 100644 index 0000000..2dc81f4 --- /dev/null +++ b/src/AegisOps.Application/Audit/IAuditWriter.cs @@ -0,0 +1,16 @@ +using AegisOps.Domain.Audit; + +namespace AegisOps.Application.Audit; + +public interface IAuditWriter { + void Write(AuditEvent auditEvent); +} + +public static class AuditWriterExtensions { + public static void Write(this IAuditWriter writer, DateTimeOffset timestamp, Guid? actorId, + string? actorDisplay, string action, string resourceType, Guid? resourceId) { + writer.Write(AuditEvent.Record(timestamp, "User", actorId, + string.IsNullOrWhiteSpace(actorDisplay) ? "unknown" : actorDisplay.Trim(), + action, resourceType, resourceId, "Succeeded", null)); + } +} diff --git a/src/AegisOps.Infrastructure/Ai/AiGateway.cs b/src/AegisOps.Infrastructure/Ai/AiGateway.cs index aa4298a..d25f6c0 100644 --- a/src/AegisOps.Infrastructure/Ai/AiGateway.cs +++ b/src/AegisOps.Infrastructure/Ai/AiGateway.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; +using AegisOps.Application.Audit; using AegisOps.Domain.Ai; using AegisOps.Domain.Audit; using AegisOps.Domain.Policy; @@ -23,7 +24,7 @@ public interface IAiRateLimiter { public sealed record AiGatewayResult(int StatusCode, AiRequest Request, string? Content, RateLimitDecision? RateLimit); public sealed class AiGateway(AegisOpsDbContext db, ILlmClient llm, IAiRateLimiter limiter, - ISensitiveDataDetector detector, TimeProvider time) { + ISensitiveDataDetector detector, TimeProvider time, IAuditWriter audit) { public async Task Chat(Guid userId, Guid? teamId, string? modelName, IReadOnlyList? messages, string correlationId, CancellationToken ct, AiPurpose purpose = AiPurpose.Chat) { var now = time.GetUtcNow(); @@ -95,7 +96,7 @@ async Task Finish(int status, string? reason, LlmReply? reply, reply?.PromptTokens, reply?.CompletionTokens, watch.ElapsedMilliseconds, correlationId, now, purpose, safeReply, storePromptContent, promptChars, promptHash); db.AiRequests.Add(request); - db.AuditEvents.Add(AuditEvent.Record(now, "User", userId, "AI user", "ai.request", "AiRequest", request.Id, + audit.Write(AuditEvent.Record(now, "User", userId, "AI user", "ai.request", "AiRequest", request.Id, status == 200 ? "Succeeded" : "Denied", correlationId[..Math.Min(64, correlationId.Length)])); await db.SaveChangesAsync(ct); return new AiGatewayResult(status, request, reply?.Content, rate); diff --git a/src/AegisOps.Infrastructure/Audit/AuditLog.cs b/src/AegisOps.Infrastructure/Audit/AuditLog.cs deleted file mode 100644 index f90d25d..0000000 --- a/src/AegisOps.Infrastructure/Audit/AuditLog.cs +++ /dev/null @@ -1,28 +0,0 @@ -using AegisOps.Domain.Audit; -using AegisOps.Infrastructure.Persistence; - -namespace AegisOps.Infrastructure.Audit; - -public static class AuditLog { - public static void Write( - AegisOpsDbContext db, - DateTimeOffset timestamp, - Guid? actorId, - string? actorDisplay, - string action, - string resourceType, - Guid? resourceId - ) { - db.AuditEvents.Add(AuditEvent.Record( - timestamp, - "User", - actorId, - string.IsNullOrWhiteSpace(actorDisplay) ? "unknown" : actorDisplay.Trim(), - action, - resourceType, - resourceId, - "Succeeded", - null - )); - } -} diff --git a/src/AegisOps.Infrastructure/Audit/EfAuditWriter.cs b/src/AegisOps.Infrastructure/Audit/EfAuditWriter.cs new file mode 100644 index 0000000..8774520 --- /dev/null +++ b/src/AegisOps.Infrastructure/Audit/EfAuditWriter.cs @@ -0,0 +1,9 @@ +using AegisOps.Application.Audit; +using AegisOps.Domain.Audit; +using AegisOps.Infrastructure.Persistence; + +namespace AegisOps.Infrastructure.Audit; + +public sealed class EfAuditWriter(AegisOpsDbContext db) : IAuditWriter { + public void Write(AuditEvent auditEvent) => db.AuditEvents.Add(auditEvent); +} diff --git a/src/AegisOps.Infrastructure/Deploy/DeploymentJobService.cs b/src/AegisOps.Infrastructure/Deploy/DeploymentJobService.cs index 95cf6a8..8a1eaf7 100644 --- a/src/AegisOps.Infrastructure/Deploy/DeploymentJobService.cs +++ b/src/AegisOps.Infrastructure/Deploy/DeploymentJobService.cs @@ -1,3 +1,4 @@ +using AegisOps.Application.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -21,8 +22,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { await using var scope = _scopes.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); + var audit = scope.ServiceProvider.GetRequiredService(); var worked = await DeploymentProcessor.TryProcessOneAsync( db, + audit, _scopes, _time, Environment.MachineName, diff --git a/src/AegisOps.Infrastructure/Deploy/DeploymentProcessor.cs b/src/AegisOps.Infrastructure/Deploy/DeploymentProcessor.cs index 075da83..6c1150c 100644 --- a/src/AegisOps.Infrastructure/Deploy/DeploymentProcessor.cs +++ b/src/AegisOps.Infrastructure/Deploy/DeploymentProcessor.cs @@ -1,3 +1,4 @@ +using AegisOps.Application.Audit; using System.Text.Json; using AegisOps.Application.PolicyEngine; using AegisOps.Domain.Audit; @@ -17,6 +18,7 @@ namespace AegisOps.Infrastructure.Deploy; public static class DeploymentProcessor { public static async Task TryProcessOneAsync( AegisOpsDbContext db, + IAuditWriter audit, IServiceScopeFactory scopes, TimeProvider time, string workerId, @@ -40,9 +42,9 @@ public static async Task TryProcessOneAsync( } else { var deploymentId = JsonDocument.Parse(job.Payload).RootElement.GetProperty("deploymentId").GetGuid(); if (job.Type == JobType.EvaluateDeployment) { - await EvaluateAsync(db, deploymentId, time, lost.Token); + await EvaluateAsync(db, audit, deploymentId, time, lost.Token); } else if (job.Type == JobType.ExecuteDeployment) { - await ExecuteAsync(db, deploymentId, time, lost.Token, redis); + await ExecuteAsync(db, audit, deploymentId, time, lost.Token, redis); } } @@ -215,6 +217,7 @@ private static async Task FinishAsync(AegisOpsDbContext db, Job job, Guid token, public static async Task EvaluateAsync( AegisOpsDbContext db, + IAuditWriter audit, Guid deploymentId, TimeProvider time, CancellationToken cancellationToken @@ -248,7 +251,7 @@ CancellationToken cancellationToken )); } - db.AuditEvents.Add(AuditEvent.Record( + audit.Write(AuditEvent.Record( time.GetUtcNow(), "System", null, @@ -263,6 +266,7 @@ CancellationToken cancellationToken public static async Task ExecuteAsync( AegisOpsDbContext db, + IAuditWriter audit, Guid deploymentId, TimeProvider time, CancellationToken cancellationToken, @@ -277,7 +281,7 @@ await AddEventAsync(db, deployment.Id, denied ? DeploymentEventType.Denied : DeploymentEventType.ApprovalRequested, outcome.Message ?? (denied ? "Denied before execution." : "Approval required before execution."), time, cancellationToken); - db.AuditEvents.Add(AuditEvent.Record(time.GetUtcNow(), "System", null, "policy-engine", "deployment.evaluated", + audit.Write(AuditEvent.Record(time.GetUtcNow(), "System", null, "policy-engine", "deployment.evaluated", "Deployment", deployment.Id, outcome.Decision.ToString(), deployment.CorrelationId)); return; } @@ -303,7 +307,7 @@ await AddEventAsync(db, deployment.Id, if (!execution.Succeeded) { deployment.Fail(execution.Message, time.GetUtcNow()); await AddEventAsync(db, deployment.Id, DeploymentEventType.Failed, execution.Message, time, cancellationToken); - db.AuditEvents.Add(AuditEvent.Record(time.GetUtcNow(), "System", null, "docker-executor", "deployment.failed", "Deployment", deployment.Id, "Failed", deployment.CorrelationId)); + audit.Write(AuditEvent.Record(time.GetUtcNow(), "System", null, "docker-executor", "deployment.failed", "Deployment", deployment.Id, "Failed", deployment.CorrelationId)); return; } } else { @@ -314,7 +318,7 @@ await AddEventAsync(db, deployment.Id, environment.RecordDeployment(deployment.ArtifactId, deployment.Id); deployment.Succeed(time.GetUtcNow()); await AddEventAsync(db, deployment.Id, DeploymentEventType.Succeeded, docker ? "Container is healthy." : "Simulated deployment succeeded.", time, cancellationToken); - db.AuditEvents.Add(AuditEvent.Record( + audit.Write(AuditEvent.Record( time.GetUtcNow(), "System", null, diff --git a/src/AegisOps.Worker/Program.cs b/src/AegisOps.Worker/Program.cs index bbe1081..331101b 100644 --- a/src/AegisOps.Worker/Program.cs +++ b/src/AegisOps.Worker/Program.cs @@ -1,3 +1,5 @@ +using AegisOps.Application.Audit; +using AegisOps.Infrastructure.Audit; using AegisOps.Infrastructure.Deploy; using AegisOps.Infrastructure.Identity; using StackExchange.Redis; @@ -5,6 +7,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddHealthChecks(); builder.Services.AddIdentityStore(builder.Configuration); +builder.Services.AddScoped(); var redis = builder.Configuration.GetConnectionString("Redis"); if (!string.IsNullOrWhiteSpace(redis)) builder.Services.AddSingleton(_ => { var options = ConfigurationOptions.Parse(redis); diff --git a/tests/AegisOps.Api.Tests/AiGatewayTests.cs b/tests/AegisOps.Api.Tests/AiGatewayTests.cs index 23bf4dd..50805b6 100644 --- a/tests/AegisOps.Api.Tests/AiGatewayTests.cs +++ b/tests/AegisOps.Api.Tests/AiGatewayTests.cs @@ -1,4 +1,5 @@ using AegisOps.Infrastructure.Ai; +using AegisOps.Infrastructure.Audit; using AegisOps.Domain.Ai; using AegisOps.Domain.Policy; using AegisOps.Infrastructure.Persistence; @@ -213,7 +214,7 @@ public async Task Chat_http_endpoint_returns_redacted_provider_response() { await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); const string secret = "api_key=abcdefghijklmnopqrstuv"; var fake = new FakeLlm { Reply = "provider echoed " + secret }; - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddAuthentication("test").AddScheme("test", _ => { }); @@ -258,7 +259,7 @@ public async Task Finding_explanation_uses_governed_gateway_and_records_findings await using var fixture = new GatewayFixture(); var (finding, model, _) = await SeedAiFinding(fixture); var fake = new FakeLlm { Reply = "Upgrade the package to the fixed version." }; - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", fixture.UserId.ToString())], "test")); var http = new DefaultHttpContext(); @@ -280,7 +281,7 @@ public async Task Finding_explanation_does_not_disclose_findings_outside_members await using var fixture = new GatewayFixture(); var (finding, _, _) = await SeedAiFinding(fixture, addMember: false); var fake = new FakeLlm(); - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", fixture.UserId.ToString())], "test")); var result = await AiEndpoints.ExplainFinding(finding.Id, new AiFeatureRequest(null), principal, @@ -296,7 +297,7 @@ public async Task Deployment_summary_uses_governed_gateway_and_records_deploymen await using var fixture = new GatewayFixture(); var deployment = await SeedAiDeployment(fixture); var fake = new FakeLlm { Reply = "Review the high-severity dependency before approving." }; - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", fixture.UserId.ToString())], "test")); var result = await AiEndpoints.SummarizeDeployment(deployment.Id, new AiFeatureRequest(null), principal, @@ -318,7 +319,7 @@ public async Task Deployment_summary_requires_awaiting_approval_before_calling_m await using var fixture = new GatewayFixture(); var deployment = await SeedAiDeployment(fixture, awaitingApproval: false); var fake = new FakeLlm(); - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", fixture.UserId.ToString())], "test")); var result = await AiEndpoints.SummarizeDeployment(deployment.Id, new AiFeatureRequest(null), principal, @@ -459,7 +460,7 @@ public async Task Gateway_enforces_sensitive_policy_and_minimizes_audit(string a fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm(); - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); const string secret = "AKIA1234567890123456"; var result = await gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello " + secret)], "correlation", TestContext.Current.CancellationToken); Assert.Equal(status, result.StatusCode); @@ -481,7 +482,7 @@ public async Task Gateway_logs_rate_limit_and_disallowed_model_without_calling_l policy.Configure([model.Id], 20, 500, 16000, SensitiveDataAction.Redact, [], false, false, true, fixture.UserId, DateTimeOffset.UtcNow); fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm(); var limiter = new FakeLimiter { Allow = false }; - var gateway = new AiGateway(fixture.Db, fake, limiter, new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, limiter, new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); Assert.Equal(429, (await gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hi")], "a", TestContext.Current.CancellationToken)).StatusCode); Assert.Equal(403, (await gateway.Chat(fixture.UserId, null, "other", [new LlmMessage("user", "hi")], "b", TestContext.Current.CancellationToken)).StatusCode); Assert.Equal(0, fake.Calls); @@ -498,7 +499,7 @@ public async Task Gateway_redacts_sensitive_values_from_provider_response_before await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); const string secret = "AKIA1234567890ABCDEF"; var fake = new FakeLlm { Reply = "provider echoed " + secret }; - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var result = await gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello")], "response-redaction", TestContext.Current.CancellationToken); @@ -521,7 +522,7 @@ public async Task Gateway_records_generic_failure_when_provider_throws_without_e fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm { Failure = new HttpRequestException("private endpoint detail") }; - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var result = await gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello")], "provider-failure", TestContext.Current.CancellationToken); @@ -542,7 +543,7 @@ public async Task Gateway_propagates_provider_cancellation_without_fabricating_f fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm { Failure = new OperationCanceledException() }; - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); await Assert.ThrowsAnyAsync(() => gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello")], "provider-cancel", TestContext.Current.CancellationToken)); @@ -559,7 +560,7 @@ public async Task Gateway_rejects_null_message_items_as_prompt_errors() { fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm(); - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var result = await gateway.Chat(fixture.UserId, null, model.Name, [null!], "null-message", TestContext.Current.CancellationToken); @@ -577,7 +578,7 @@ public async Task Gateway_turns_sensitive_inspection_timeout_into_generic_failur fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm(); - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new TimeoutDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new TimeoutDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var result = await gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello")], "inspection-timeout", TestContext.Current.CancellationToken); @@ -599,7 +600,7 @@ public async Task Gateway_does_not_return_success_when_audit_persistence_fails() await fixture.Db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_ai_audit BEFORE INSERT ON ai_requests BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END", TestContext.Current.CancellationToken); var fake = new FakeLlm(); - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new RegexSensitiveDataDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); await Assert.ThrowsAnyAsync(() => gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello")], "audit-store-failure", TestContext.Current.CancellationToken)); @@ -618,7 +619,7 @@ public async Task Gateway_rejects_oversized_prompt_before_sensitive_inspection_a fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var detector = new TrackingDetector(); - var gateway = new AiGateway(fixture.Db, new FakeLlm(), new FakeLimiter(), detector, TimeProvider.System); + var gateway = new AiGateway(fixture.Db, new FakeLlm(), new FakeLimiter(), detector, TimeProvider.System, new EfAuditWriter(fixture.Db)); var oversized = new[] { new string('a', 4095) + "🌐" + new string('a', 45_903), new string('b', 50_000) }; var joinedPrompt = string.Join("\n", oversized); @@ -645,7 +646,7 @@ public async Task Gateway_audits_timeout_while_redacting_forwarded_messages() { await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm(); var detector = new FailOnSecondInspectionDetector(); - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), detector, TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), detector, TimeProvider.System, new EfAuditWriter(fixture.Db)); var result = await gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello")], "message-redaction-timeout", TestContext.Current.CancellationToken); @@ -665,7 +666,7 @@ public async Task Gateway_reports_response_inspection_timeout_without_returning_ fixture.Db.AiModels.Add(model); fixture.Db.AiPolicies.Add(policy); await fixture.Db.SaveChangesAsync(TestContext.Current.CancellationToken); var fake = new FakeLlm { Reply = "potentially sensitive reply" }; - var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new FailOnSecondInspectionDetector(), TimeProvider.System); + var gateway = new AiGateway(fixture.Db, fake, new FakeLimiter(), new FailOnSecondInspectionDetector(), TimeProvider.System, new EfAuditWriter(fixture.Db)); var result = await gateway.Chat(fixture.UserId, null, model.Name, [new LlmMessage("user", "hello")], "response-inspection-timeout", TestContext.Current.CancellationToken); diff --git a/tests/AegisOps.Api.Tests/DeploymentAuthorizationTests.cs b/tests/AegisOps.Api.Tests/DeploymentAuthorizationTests.cs index 3bb2a08..7962467 100644 --- a/tests/AegisOps.Api.Tests/DeploymentAuthorizationTests.cs +++ b/tests/AegisOps.Api.Tests/DeploymentAuthorizationTests.cs @@ -9,6 +9,7 @@ using AegisOps.Domain.Policy; using AegisOps.Domain.Security; using AegisOps.Infrastructure.Persistence; +using AegisOps.Infrastructure.Audit; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Authentication; @@ -735,12 +736,12 @@ private DefaultHttpContext Context() { } public async Task Request(ClaimsPrincipal principal, RequestDeploymentRequest? request = null) { - var result = await DeploymentEndpoints.Request(request ?? new RequestDeploymentRequest(Artifact.Id, Environment.Id, null, null), principal, Users, Db, TimeProvider.System, Replay, Context()); + var result = await DeploymentEndpoints.Request(request ?? new RequestDeploymentRequest(Artifact.Id, Environment.Id, null, null), principal, Users, Db, new EfAuditWriter(Db), TimeProvider.System, Replay, Context()); return ((IStatusCodeHttpResult)result).StatusCode ?? 200; } public async Task Rollback(ClaimsPrincipal principal, Guid? id = null) { - var result = await DeploymentEndpoints.Rollback(id ?? Source.Id, principal, Users, Db, TimeProvider.System, Replay, Context()); + var result = await DeploymentEndpoints.Rollback(id ?? Source.Id, principal, Users, Db, new EfAuditWriter(Db), TimeProvider.System, Replay, Context()); return ((IStatusCodeHttpResult)result).StatusCode ?? 200; } @@ -767,10 +768,10 @@ public async Task Read(string action, ClaimsPrincipal principal, bool evalu } public async Task Decide(ClaimsPrincipal principal, string decision) => - ((IStatusCodeHttpResult)await DeploymentEndpoints.Decide(Source.Id, new DeploymentDecisionRequest(decision, null), principal, Users, Db, TimeProvider.System, Context())).StatusCode ?? 200; + ((IStatusCodeHttpResult)await DeploymentEndpoints.Decide(Source.Id, new DeploymentDecisionRequest(decision, null), principal, Users, Db, new EfAuditWriter(Db), TimeProvider.System, Context())).StatusCode ?? 200; public async Task Cancel(ClaimsPrincipal principal) => - ((IStatusCodeHttpResult)await DeploymentEndpoints.Cancel(Source.Id, principal, Users, Db, TimeProvider.System, Context())).StatusCode ?? 200; + ((IStatusCodeHttpResult)await DeploymentEndpoints.Cancel(Source.Id, principal, Users, Db, new EfAuditWriter(Db), TimeProvider.System, Context())).StatusCode ?? 200; public void Dispose() { services.Dispose(); diff --git a/tests/AegisOps.Api.Tests/EfAuditWriterTests.cs b/tests/AegisOps.Api.Tests/EfAuditWriterTests.cs new file mode 100644 index 0000000..2ce05aa --- /dev/null +++ b/tests/AegisOps.Api.Tests/EfAuditWriterTests.cs @@ -0,0 +1,44 @@ +using AegisOps.Domain.Audit; +using AegisOps.Infrastructure.Audit; +using AegisOps.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Xunit; + +namespace AegisOps.Api.Tests; + +public sealed class EfAuditWriterTests { + [Fact] + public async Task Write_tracks_event_in_same_unit_of_work_without_saving() { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection).UseSnakeCaseNamingConvention() + .ReplaceService().Options; + await using var db = new AegisOpsDbContext(options); + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + var audit = AuditEvent.Record(DateTimeOffset.UtcNow, "System", null, "policy-engine", + "deployment.evaluated", "Deployment", Guid.NewGuid(), "Denied", "correlation-1"); + + new EfAuditWriter(db).Write(audit); + + Assert.Same(audit, Assert.Single(db.ChangeTracker.Entries()).Entity); + Assert.Equal(EntityState.Added, db.Entry(audit).State); + Assert.Empty(await db.AuditEvents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken)); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + var persisted = Assert.Single(await db.AuditEvents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(audit.Id, persisted.Id); + Assert.Equal("System", persisted.ActorType); + Assert.Equal("Denied", persisted.Outcome); + Assert.Equal("correlation-1", persisted.CorrelationId); + } + + private sealed class SqliteCustomizer(ModelCustomizerDependencies dependencies) : ModelCustomizer(dependencies) { + public override void Customize(ModelBuilder modelBuilder, DbContext context) { + base.Customize(modelBuilder, context); + modelBuilder.Entity().Ignore(key => key.Scopes); + modelBuilder.Entity().Ignore(policy => policy.AppliesToTiers); + } + } +} diff --git a/tests/AegisOps.Api.Tests/TeamWebhookManagementTests.cs b/tests/AegisOps.Api.Tests/TeamWebhookManagementTests.cs index 3f00284..e5376e0 100644 --- a/tests/AegisOps.Api.Tests/TeamWebhookManagementTests.cs +++ b/tests/AegisOps.Api.Tests/TeamWebhookManagementTests.cs @@ -7,6 +7,7 @@ using AegisOps.Domain.Policy; using AegisOps.Domain.Security; using AegisOps.Infrastructure.Deploy; +using AegisOps.Infrastructure.Audit; using AegisOps.Infrastructure.Persistence; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; @@ -31,7 +32,7 @@ public async Task Owner_can_update_settings_but_secret_is_never_returned() { var response = await TeamWebhookSettingsEndpoints.Set( fixture.Team.Slug, new SetTeamWebhookRequest("https://example.com/hook", Convert.ToBase64String(secret)), - Principal(fixture.Owner.Id), fixture.Users, fixture.Db, protector, Context(cancellationToken)); + Principal(fixture.Owner.Id), fixture.Users, fixture.Db, new EfAuditWriter(fixture.Db), protector, Context(cancellationToken)); Assert.Equal(StatusCodes.Status200OK, Status(response)); var returned = Assert.IsType(Assert.IsAssignableFrom(response).Value); @@ -54,11 +55,11 @@ public async Task Set_rejects_query_credentials_and_oversized_secret_input() { var queryUrl = await TeamWebhookSettingsEndpoints.Set( fixture.Team.Slug, new SetTeamWebhookRequest("https://example.com/hook?token=do-not-store", Convert.ToBase64String(Key(2))), - Principal(fixture.Owner.Id), fixture.Users, fixture.Db, protector, Context(cancellationToken)); + Principal(fixture.Owner.Id), fixture.Users, fixture.Db, new EfAuditWriter(fixture.Db), protector, Context(cancellationToken)); var oversizedSecret = await TeamWebhookSettingsEndpoints.Set( fixture.Team.Slug, new SetTeamWebhookRequest("https://example.com/hook", new string('A', 1369)), - Principal(fixture.Owner.Id), fixture.Users, fixture.Db, protector, Context(cancellationToken)); + Principal(fixture.Owner.Id), fixture.Users, fixture.Db, new EfAuditWriter(fixture.Db), protector, Context(cancellationToken)); Assert.Equal(StatusCodes.Status400BadRequest, Status(queryUrl)); Assert.Equal(StatusCodes.Status400BadRequest, Status(oversizedSecret)); @@ -73,7 +74,7 @@ public async Task Set_rejects_unknown_or_empty_event_subscriptions() { foreach (var eventTypes in new[] { new[] { "NotADeploymentEvent" }, Array.Empty() }) { var result = await TeamWebhookSettingsEndpoints.Set(fixture.Team.Slug, new SetTeamWebhookRequest("https://example.com/hook", Convert.ToBase64String(Key(8)), eventTypes), - Principal(fixture.Owner.Id), fixture.Users, fixture.Db, protector, Context(cancellationToken)); + Principal(fixture.Owner.Id), fixture.Users, fixture.Db, new EfAuditWriter(fixture.Db), protector, Context(cancellationToken)); Assert.Equal(StatusCodes.Status400BadRequest, Status(result)); } Assert.Empty(await fixture.Db.TeamWebhookSettings.ToListAsync(cancellationToken)); @@ -138,8 +139,8 @@ public async Task Non_owner_cannot_read_or_update_team_webhook_settings() { var set = await TeamWebhookSettingsEndpoints.Set( fixture.Team.Slug, new SetTeamWebhookRequest("https://example.com/hook", Convert.ToBase64String(Key(3))), - Principal(fixture.Member.Id), fixture.Users, fixture.Db, protector, Context(cancellationToken)); - var rewrap = await TeamWebhookSettingsEndpoints.Rewrap(Principal(fixture.Owner.Id), fixture.Users, fixture.Db, protector, Context(cancellationToken)); + Principal(fixture.Member.Id), fixture.Users, fixture.Db, new EfAuditWriter(fixture.Db), protector, Context(cancellationToken)); + var rewrap = await TeamWebhookSettingsEndpoints.Rewrap(Principal(fixture.Owner.Id), fixture.Users, fixture.Db, new EfAuditWriter(fixture.Db), protector, Context(cancellationToken)); Assert.Equal(StatusCodes.Status404NotFound, Status(get)); Assert.Equal(StatusCodes.Status404NotFound, Status(set)); @@ -158,7 +159,7 @@ public async Task Admin_rewraps_all_secrets_to_active_key_without_returning_them await fixture.Db.SaveChangesAsync(cancellationToken); var newConfig = Configuration("new", ("old", Key(4)), ("new", Key(5))); var response = await TeamWebhookSettingsEndpoints.Rewrap(Principal(fixture.Admin.Id), fixture.Users, fixture.Db, - new TeamWebhookSecretProtector(newConfig), Context(cancellationToken)); + new EfAuditWriter(fixture.Db), new TeamWebhookSecretProtector(newConfig), Context(cancellationToken)); Assert.Equal(StatusCodes.Status200OK, Status(response)); var metadata = Assert.IsAssignableFrom(response).Value!; @@ -181,7 +182,7 @@ public async Task Admin_rewrap_fails_safely_without_mutating_rows_when_ciphertex var rotatingProtector = new TeamWebhookSecretProtector(Configuration("new", ("old", Key(4)), ("new", Key(5)))); var response = await TeamWebhookSettingsEndpoints.Rewrap(Principal(fixture.Admin.Id), fixture.Users, fixture.Db, - rotatingProtector, Context(cancellationToken)); + new EfAuditWriter(fixture.Db), rotatingProtector, Context(cancellationToken)); Assert.Equal(StatusCodes.Status409Conflict, Status(response)); Assert.Equal(corrupted, (await fixture.Db.TeamWebhookSettings.SingleAsync(cancellationToken)).EncryptedSecret); @@ -199,7 +200,7 @@ public async Task Set_fails_closed_when_management_is_disabled_even_if_a_key_rem var result = await TeamWebhookSettingsEndpoints.Set( fixture.Team.Slug, new SetTeamWebhookRequest("https://example.com/hook", Convert.ToBase64String(Key(9))), - Principal(fixture.Owner.Id), fixture.Users, fixture.Db, protector, Context(cancellationToken)); + Principal(fixture.Owner.Id), fixture.Users, fixture.Db, new EfAuditWriter(fixture.Db), protector, Context(cancellationToken)); Assert.Equal(StatusCodes.Status503ServiceUnavailable, Status(result)); Assert.Empty(await fixture.Db.TeamWebhookSettings.ToListAsync(cancellationToken));