From e3e251e6ae008facf0c718b2d26b1712545cc6a6 Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 9 Jul 2026 22:55:33 +0530 Subject: [PATCH 1/6] Harden API security, align frontend contracts, and fix CI gates. Add API-key ingestion auth, RBAC policies, production config validation, and CodeQL; align alerts/plugins/incidents UI with backend APIs; fix integration tests for search permissions. Co-authored-by: Cursor --- .github/workflows/ci.yml | 10 + .github/workflows/codeql.yml | 63 ++++ .github/workflows/release.yml | 6 +- CHANGELOG.md | 44 +-- Directory.Build.props | 1 + README.md | 5 + ROADMAP.md | 68 ++-- SECURITY.md | 6 +- .../ApiKeyAuthenticationHandler.cs | 96 +++++ .../Authorization/SentinelPolicies.cs | 113 ++++++ .../ProductionConfigValidator.cs | 62 ++++ src/Sentinel.Api/Features/AI/AiExtensions.cs | 7 +- .../Features/Alerts/AlertExtensions.cs | 69 +++- .../Authentication/RegisterEndpoint.cs | 10 + .../Dashboards/DashboardExtensions.cs | 11 +- .../Features/Incidents/IncidentExtensions.cs | 76 +++- .../Ingestion/IngestionAuthExtensions.cs | 63 ++++ .../Features/Logs/IngestLogsEndpoint.cs | 4 +- .../Features/Metrics/IngestMetricsEndpoint.cs | 4 +- .../Features/Metrics/QueryMetricsEndpoint.cs | 3 +- .../Features/Plugins/PluginExtensions.cs | 42 ++- .../Features/Search/SavedSearchEndpoints.cs | 12 +- .../Features/Search/SearchLogsEndpoint.cs | 3 +- .../Features/Tenants/TenantExtensions.cs | 10 + .../Features/Traces/IngestTracesEndpoint.cs | 4 +- .../Features/Traces/QueryTracesEndpoint.cs | 5 +- .../Features/Users/UserExtensions.cs | 7 + src/Sentinel.Api/Program.cs | 52 ++- src/Sentinel.Api/appsettings.json | 9 + .../Configuration/SecurityOptions.cs | 25 ++ src/Sentinel.Domain/Identity/ApiKey.cs | 29 ++ .../Alerts/AlertRepository.cs | 29 ++ .../Alerts/IAlertRepository.cs | 5 + .../DependencyInjection.cs | 3 + .../Identity/ApiKeyRepository.cs | 79 ++++ .../Identity/IApiKeyRepository.cs | 9 + .../Persistence/PostgreSQL/DataSeeder.cs | 338 +++++++++--------- src/sentinel-web/package.json | 2 +- src/sentinel-web/src/lib/api/alerts.ts | 44 ++- src/sentinel-web/src/lib/api/incidents.ts | 14 +- src/sentinel-web/src/lib/api/plugins.ts | 14 +- src/sentinel-web/src/pages/AlertsPage.tsx | 169 ++++++--- src/sentinel-web/src/pages/IncidentsPage.tsx | 2 +- src/sentinel-web/src/pages/OverviewPage.tsx | 33 +- src/sentinel-web/src/pages/PluginsPage.tsx | 58 +-- .../Infrastructure/TestDoubles.cs | 19 +- 46 files changed, 1353 insertions(+), 384 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 src/Sentinel.Api/Authentication/ApiKeyAuthenticationHandler.cs create mode 100644 src/Sentinel.Api/Authorization/SentinelPolicies.cs create mode 100644 src/Sentinel.Api/Configuration/ProductionConfigValidator.cs create mode 100644 src/Sentinel.Api/Features/Ingestion/IngestionAuthExtensions.cs create mode 100644 src/Sentinel.Domain/Configuration/SecurityOptions.cs create mode 100644 src/Sentinel.Infrastructure/Identity/ApiKeyRepository.cs create mode 100644 src/Sentinel.Infrastructure/Identity/IApiKeyRepository.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ebf7d8..483333f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,16 @@ jobs: run: | dotnet format Sentinel.slnx --verify-no-changes --verbosity diagnostic + - name: Check for vulnerable NuGet packages + run: | + dotnet list Sentinel.slnx package --vulnerable --include-transitive 2>&1 | tee vulnerable-packages.txt + if grep -q "has the following vulnerable packages" vulnerable-packages.txt; then + echo "::error::Vulnerable NuGet packages detected" + cat vulnerable-packages.txt + exit 1 + fi + echo "No vulnerable NuGet packages found" + frontend-build: name: Frontend Build runs-on: ubuntu-latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..141c709 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,63 @@ +name: CodeQL + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + - cron: '0 6 * * 1' + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + language: [csharp, javascript-typescript] + + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Setup .NET + if: matrix.language == 'csharp' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Build .NET + if: matrix.language == 'csharp' + run: dotnet build Sentinel.slnx --configuration Release + + - name: Setup Node.js + if: matrix.language == 'javascript-typescript' + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: src/sentinel-web/package-lock.json + + - name: Build frontend + if: matrix.language == 'javascript-typescript' + working-directory: src/sentinel-web + run: | + npm ci || npm install + npm run build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ee4ca4..10408a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,6 @@ jobs: PostgreSQL__ConnectionString: "Host=localhost;Port=5432;Database=sentinel_test;Username=sentinel;Password=sentinel_test_password" Redis__ConnectionString: "localhost:6379" run: dotnet test tests/Sentinel.IntegrationTests/Sentinel.IntegrationTests.csproj --configuration Release --no-build --verbosity normal - continue-on-error: true services: postgres: @@ -67,6 +66,11 @@ jobs: image: redis:7-alpine ports: - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 publish-images: name: Publish Docker Images diff --git a/CHANGELOG.md b/CHANGELOG.md index cd50844..21f2a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,35 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] — 2026-07-09 + ### Added -- Initial project scaffolding with .NET 10 API, Workers, Domain, and Infrastructure layers -- React 19 frontend with Vite, TypeScript, and Tailwind CSS -- Dark mode UI with sidebar navigation and command palette (Ctrl+K) -- Pages: Login, Overview, Logs, Metrics, Traces, Alerts, Incidents, Dashboards, Plugins, Settings -- JWT authentication store with in-memory tokens and refresh flow -- API client module for `/api/v1` endpoints -- SignalR integration for live log streaming -- Virtualized log table with `@tanstack/react-virtual` -- Metrics charts with Recharts -- Docker Compose stack: PostgreSQL, ClickHouse, Redis, RabbitMQ, API, Worker, Web -- Multi-stage Dockerfiles with non-root users and health checks -- GitHub Actions CI: .NET build/test/format, frontend build, integration tests -- Governance docs: README, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, SUPPORT -- Architecture Decision Records (ADR-001 through ADR-007) -- Architecture documentation +- API-key authentication for log/metric/trace ingestion (`X-Api-Key` or `Bearer sent_*`) +- RBAC permission policies enforced per API feature group +- Production configuration validation (JWT secrets, CORS, ingestion auth, seeded admin) +- `POST /api/v1/alerts/executions/{id}/silence` and `GET /api/v1/alerts/executions` +- `PATCH /api/v1/plugins/{id}` toggle endpoint +- `PATCH /api/v1/incidents/{id}` partial update endpoint +- CodeQL security scanning workflow +- CI gate for vulnerable NuGet packages (`dotnet list package --vulnerable`) +- Helm chart v0.7.0 and release pipeline ### Changed -- N/A (initial release) +- Frontend aligned to backend API contracts (alert rules/executions, plugin status, incident fields) +- API enums serialized as camelCase strings (integers still accepted on input) +- CORS restricted to configured origins in production; permissive only in Development +- Open registration and default admin seeding gated behind `Security` config flags +- Release workflow integration tests are now blocking ### Fixed -- N/A (initial release) +- Frontend/backend mismatches for alerts, plugins, and incidents +- `release.yml` masking integration test failures with `continue-on-error` -## [0.1.0] — TBD +## [0.1.0] — 2026-03-01 -First public release. See [ROADMAP.md](ROADMAP.md) for planned features. +Initial public scaffolding: .NET 10 API, React frontend, Docker Compose stack, JWT auth, and CI pipeline. -[Unreleased]: https://github.com/sentinel-observability/sentinel/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/sentinel-observability/sentinel/releases/tag/v0.1.0 +[Unreleased]: https://github.com/nrzz/Sentinel/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/nrzz/Sentinel/compare/v0.1.0...v0.7.0 +[0.1.0]: https://github.com/nrzz/Sentinel/releases/tag/v0.1.0 diff --git a/Directory.Build.props b/Directory.Build.props index 65bf694..067a72f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,6 @@ + 0.7.0 net10.0 enable enable diff --git a/README.md b/README.md index f6f1d9b..9c01065 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ docker compose up -d | API Docs | http://localhost:5018/scalar | | RabbitMQ Mgmt | http://localhost:15672 | +**Default dev credentials** (when `Security:SeedDefaultAdmin` is true): `admin@sentinel.local` / `Admin123!` — change before any non-local deployment. + ### Local Development **Backend:** @@ -98,6 +100,9 @@ Sentinel/ ## Documentation - [Architecture](docs/architecture.md) +- [Deployment](docs/deployment.md) +- [Operations](docs/operations.md) +- [Disaster Recovery](docs/disaster-recovery.md) - [Contributing](CONTRIBUTING.md) - [Roadmap](ROADMAP.md) - [Changelog](CHANGELOG.md) diff --git a/ROADMAP.md b/ROADMAP.md index 5c2aa4b..d6b40e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,58 +1,50 @@ # Roadmap -## v0.1.0 — Foundation (Current) +## v0.7.0 — Current Release + +Shipped capabilities: - [x] Core API with vertical slice architecture - [x] PostgreSQL for metadata, ClickHouse for telemetry - [x] RabbitMQ message bus for async processing -- [x] JWT authentication with refresh tokens -- [x] React frontend with dark mode UI +- [x] JWT authentication with refresh tokens and RBAC permission policies +- [x] Tenant API key authentication for ingestion endpoints +- [x] Log, metric, and trace ingestion via HTTP and gRPC +- [x] Log search, live streaming (SignalR), and saved searches +- [x] Metric and trace querying +- [x] Alert rules, executions, and silencing +- [x] Incident management with timeline and comments +- [x] Custom dashboards +- [x] Plugin install/toggle lifecycle +- [x] AI-assisted search, summarization, and correlation +- [x] React 19 frontend with dark mode UI - [x] Docker Compose development environment -- [x] CI pipeline (build, test, format check) -- [ ] Log ingestion via gRPC and HTTP -- [ ] Basic log search and live streaming -- [ ] Metric ingestion and querying -- [ ] Trace ingestion and visualization +- [x] Helm chart for Kubernetes (v0.7.0) +- [x] CI pipeline (build, test, format, CodeQL, vulnerable-package check) +- [x] Release pipeline (Docker images, Helm package) +- [x] Operations and disaster recovery documentation -## v0.2.0 — Observability Core +## v0.8.0 — Reliability & Runtime -- [ ] Alert rule engine with threshold and anomaly detection -- [ ] Incident management workflow -- [ ] Custom dashboards with drag-and-drop panels -- [ ] Saved searches and query history -- [ ] Service map from trace data -- [ ] OpenTelemetry collector integration guide +- [ ] DB-driven alert evaluation worker +- [ ] Plugin runtime host for custom processors +- [ ] Redis-backed query result caching +- [ ] Testcontainers-based integration test suite +- [ ] Frontend test framework buildout -## v0.3.0 — Scale & Performance +## v0.9.0 — Scale & Performance - [ ] Horizontal API scaling with Redis-backed sessions - [ ] ClickHouse partitioning and retention policies - [ ] Log sampling and rate limiting -- [ ] Query result caching -- [ ] Benchmark suite with performance baselines +- [ ] Benchmark suite with performance baselines and k6 load tests -## v0.4.0 — Extensibility +## v1.0.0 — Production Hardening -- [ ] Plugin SDK for custom processors and exporters -- [ ] Webhook notifications (Slack, PagerDuty, Teams) - [ ] SSO integration (OIDC, SAML) -- [ ] Multi-tenant isolation -- [ ] RBAC with role-based API authorization - -## v0.5.0 — AI & Intelligence - -- [ ] AI-powered log anomaly detection -- [ ] Natural language query interface -- [ ] Automated root-cause analysis -- [ ] Incident summarization and postmortem drafts -- [ ] Predictive alerting - -## v1.0.0 — Production Ready - -- [ ] High-availability deployment guide -- [ ] Backup and disaster recovery procedures -- [ ] Comprehensive API documentation -- [ ] Helm charts for Kubernetes deployment +- [ ] Webhook notifications (Slack, PagerDuty, Teams) +- [ ] Service map from trace data +- [ ] OpenTelemetry collector integration guide - [ ] SOC 2 compliance documentation - [ ] Performance SLA benchmarks diff --git a/SECURITY.md b/SECURITY.md index d94076a..7200f02 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 0.1.x | :white_check_mark: | +| 0.7.x | :white_check_mark: | +| 0.1.x | :x: | ## Reporting a Vulnerability @@ -32,7 +33,8 @@ You should receive a response within 48 hours. We will work with you to understa When deploying Sentinel: -- **Change default credentials** — Update all passwords in `.env` before production use. +- **Change default credentials** — The dev seeder creates `admin@sentinel.local` / `Admin123!` when `Security:SeedDefaultAdmin` is true. Set `Security:SeedDefaultAdmin` to `false` in production. +- **Require API keys for ingestion** — Set `Ingestion:RequireApiKey` to `true` and `Ingestion:AllowHeaderOnlyTenant` to `false` in production. - **Rotate JWT secrets** — Use a strong, unique `JWT_SECRET_KEY` (minimum 32 characters). - **Enable TLS** — Terminate TLS at your reverse proxy or load balancer. - **Restrict network access** — Do not expose database ports publicly. diff --git a/src/Sentinel.Api/Authentication/ApiKeyAuthenticationHandler.cs b/src/Sentinel.Api/Authentication/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..cf303e9 --- /dev/null +++ b/src/Sentinel.Api/Authentication/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Sentinel.Infrastructure.Identity; + +namespace Sentinel.Api.Authentication; + +public static class ApiKeyAuthenticationDefaults +{ + public const string AuthenticationScheme = "ApiKey"; + public const string HeaderName = "X-Api-Key"; +} + +public sealed class ApiKeyAuthenticationHandler : AuthenticationHandler +{ + private readonly IApiKeyRepository _apiKeyRepository; + + public ApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IApiKeyRepository apiKeyRepository) + : base(options, logger, encoder) + { + _apiKeyRepository = apiKeyRepository; + } + + protected override async Task HandleAuthenticateAsync() + { + if (!TryGetApiKey(out var apiKey)) + { + return AuthenticateResult.NoResult(); + } + + var keyHash = HashApiKey(apiKey); + var storedKey = await _apiKeyRepository.FindByHashAsync(keyHash, Context.RequestAborted); + if (storedKey is null) + { + return AuthenticateResult.Fail("Invalid API key."); + } + + await _apiKeyRepository.UpdateLastUsedAsync(storedKey.Id, Context.RequestAborted); + + var claims = new List + { + new("tenant_id", storedKey.TenantId.ToString()), + new(ClaimTypes.NameIdentifier, storedKey.Id.ToString()), + new(ClaimTypes.Name, storedKey.Name), + }; + + foreach (var scope in storedKey.Scopes) + { + claims.Add(new Claim("permission", scope)); + } + + var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme); + + return AuthenticateResult.Success(ticket); + } + + private bool TryGetApiKey(out string apiKey) + { + apiKey = string.Empty; + + if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue) + && !string.IsNullOrWhiteSpace(headerValue)) + { + apiKey = headerValue.ToString(); + return true; + } + + var authorization = Request.Headers.Authorization.ToString(); + if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + var token = authorization["Bearer ".Length..].Trim(); + if (token.StartsWith("sent_", StringComparison.Ordinal)) + { + apiKey = token; + return true; + } + } + + return false; + } + + internal static string HashApiKey(string apiKey) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/src/Sentinel.Api/Authorization/SentinelPolicies.cs b/src/Sentinel.Api/Authorization/SentinelPolicies.cs new file mode 100644 index 0000000..8a844fb --- /dev/null +++ b/src/Sentinel.Api/Authorization/SentinelPolicies.cs @@ -0,0 +1,113 @@ +namespace Sentinel.Api.Authorization; + +using Sentinel.Api.Authentication; + +public static class SentinelPermissions +{ + public const string UsersRead = "users.read"; + public const string UsersWrite = "users.write"; + public const string UsersDelete = "users.delete"; + public const string TenantsRead = "tenants.read"; + public const string TenantsWrite = "tenants.write"; + public const string TenantsDelete = "tenants.delete"; + public const string LogsRead = "logs.read"; + public const string LogsWrite = "logs.write"; + public const string MetricsRead = "metrics.read"; + public const string MetricsWrite = "metrics.write"; + public const string TracesRead = "traces.read"; + public const string TracesWrite = "traces.write"; + public const string AlertsRead = "alerts.read"; + public const string AlertsWrite = "alerts.write"; + public const string AlertsDelete = "alerts.delete"; + public const string IncidentsRead = "incidents.read"; + public const string IncidentsWrite = "incidents.write"; + public const string IncidentsDelete = "incidents.delete"; + public const string DashboardsRead = "dashboards.read"; + public const string DashboardsWrite = "dashboards.write"; + public const string DashboardsDelete = "dashboards.delete"; + public const string PluginsRead = "plugins.read"; + public const string PluginsWrite = "plugins.write"; + public const string PluginsDelete = "plugins.delete"; + public const string SettingsRead = "settings.read"; + public const string SettingsWrite = "settings.write"; + public const string SettingsDelete = "settings.delete"; + public const string SearchRead = "search.read"; + public const string SearchWrite = "search.write"; + public const string IngestLogs = "logs.write"; + public const string IngestMetrics = "metrics.write"; + public const string IngestTraces = "traces.write"; + + public static IReadOnlyList All { get; } = + [ + UsersRead, UsersWrite, UsersDelete, + TenantsRead, TenantsWrite, TenantsDelete, + LogsRead, LogsWrite, + MetricsRead, MetricsWrite, + TracesRead, TracesWrite, + AlertsRead, AlertsWrite, AlertsDelete, + IncidentsRead, IncidentsWrite, IncidentsDelete, + DashboardsRead, DashboardsWrite, DashboardsDelete, + PluginsRead, PluginsWrite, PluginsDelete, + SettingsRead, SettingsWrite, SettingsDelete, + SearchRead, SearchWrite, + ]; +} + +public static class SentinelPolicies +{ + public const string UsersRead = SentinelPermissions.UsersRead; + public const string UsersWrite = SentinelPermissions.UsersWrite; + public const string UsersDelete = SentinelPermissions.UsersDelete; + public const string TenantsRead = SentinelPermissions.TenantsRead; + public const string TenantsWrite = SentinelPermissions.TenantsWrite; + public const string TenantsDelete = SentinelPermissions.TenantsDelete; + public const string LogsRead = SentinelPermissions.LogsRead; + public const string LogsWrite = SentinelPermissions.LogsWrite; + public const string MetricsRead = SentinelPermissions.MetricsRead; + public const string MetricsWrite = SentinelPermissions.MetricsWrite; + public const string TracesRead = SentinelPermissions.TracesRead; + public const string TracesWrite = SentinelPermissions.TracesWrite; + public const string AlertsRead = SentinelPermissions.AlertsRead; + public const string AlertsWrite = SentinelPermissions.AlertsWrite; + public const string AlertsDelete = SentinelPermissions.AlertsDelete; + public const string IncidentsRead = SentinelPermissions.IncidentsRead; + public const string IncidentsWrite = SentinelPermissions.IncidentsWrite; + public const string IncidentsDelete = SentinelPermissions.IncidentsDelete; + public const string DashboardsRead = SentinelPermissions.DashboardsRead; + public const string DashboardsWrite = SentinelPermissions.DashboardsWrite; + public const string DashboardsDelete = SentinelPermissions.DashboardsDelete; + public const string PluginsRead = SentinelPermissions.PluginsRead; + public const string PluginsWrite = SentinelPermissions.PluginsWrite; + public const string PluginsDelete = SentinelPermissions.PluginsDelete; + public const string SettingsRead = SentinelPermissions.SettingsRead; + public const string SettingsWrite = SentinelPermissions.SettingsWrite; + public const string SettingsDelete = SentinelPermissions.SettingsDelete; + public const string SearchRead = SentinelPermissions.SearchRead; + public const string SearchWrite = SentinelPermissions.SearchWrite; + public const string Ingestion = "ingestion"; +} + +public static class AuthorizationExtensions +{ + public static IServiceCollection AddSentinelAuthorization(this IServiceCollection services) + { + services.AddAuthorization(options => + { + foreach (var permission in SentinelPermissions.All) + { + options.AddPolicy(permission, policy => + policy.RequireClaim("permission", permission)); + } + + options.AddPolicy(SentinelPolicies.Ingestion, policy => + { + policy.AddAuthenticationSchemes( + Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme, + ApiKeyAuthenticationDefaults.AuthenticationScheme); + policy.RequireAuthenticatedUser(); + }); + }); + + return services; + } +} diff --git a/src/Sentinel.Api/Configuration/ProductionConfigValidator.cs b/src/Sentinel.Api/Configuration/ProductionConfigValidator.cs new file mode 100644 index 0000000..0e6d031 --- /dev/null +++ b/src/Sentinel.Api/Configuration/ProductionConfigValidator.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Options; +using Sentinel.Domain.Configuration; + +namespace Sentinel.Api.Configuration; + +public static class ProductionConfigValidator +{ + private const string DefaultJwtSecret = "sentinel-dev-secret-key-change-in-production-min-32-chars"; + private const string DefaultPostgresPassword = "sentinel_dev_password"; + + public static void Validate(IConfiguration configuration, IHostEnvironment environment) + { + if (!environment.IsProduction()) + { + return; + } + + var errors = new List(); + + var jwtSecret = configuration.GetSection(JwtOptions.SectionName).Get()?.SecretKey; + if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret == DefaultJwtSecret) + { + errors.Add("Jwt:SecretKey must be set to a non-default value in Production."); + } + + var postgresConnection = configuration.GetSection(PostgreSqlOptions.SectionName) + .Get()?.ConnectionString ?? string.Empty; + if (postgresConnection.Contains(DefaultPostgresPassword, StringComparison.Ordinal)) + { + errors.Add("PostgreSQL connection string must not use the default development password in Production."); + } + + var security = configuration.GetSection(SecurityOptions.SectionName).Get() ?? new SecurityOptions(); + if (security.SeedDefaultAdmin) + { + errors.Add("Security:SeedDefaultAdmin must be false in Production."); + } + + var ingestion = configuration.GetSection(IngestionOptions.SectionName).Get() ?? new IngestionOptions(); + if (ingestion.AllowHeaderOnlyTenant) + { + errors.Add("Ingestion:AllowHeaderOnlyTenant must be false in Production."); + } + + if (!ingestion.RequireApiKey) + { + errors.Add("Ingestion:RequireApiKey must be true in Production."); + } + + var corsOrigins = security.AllowedCorsOrigins; + if (corsOrigins.Length == 0) + { + errors.Add("Security:AllowedCorsOrigins must specify at least one origin in Production."); + } + + if (errors.Count > 0) + { + throw new InvalidOperationException( + "Production configuration validation failed:\n- " + string.Join("\n- ", errors)); + } + } +} diff --git a/src/Sentinel.Api/Features/AI/AiExtensions.cs b/src/Sentinel.Api/Features/AI/AiExtensions.cs index 26c7681..6162b1c 100644 --- a/src/Sentinel.Api/Features/AI/AiExtensions.cs +++ b/src/Sentinel.Api/Features/AI/AiExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Sentinel.Api; +using Sentinel.Api.Authorization; using Sentinel.Domain.AI; using Sentinel.Domain.Configuration; using Sentinel.Infrastructure.AI; @@ -55,9 +56,9 @@ public static IEndpointRouteBuilder MapAiEndpoints(this IEndpointRouteBuilder ap .WithTags("AI") .RequireAuthorization(); - group.MapPost("/search", Search); - group.MapPost("/summarize", Summarize); - group.MapPost("/correlate", Correlate); + group.MapPost("/search", Search).RequireAuthorization(SentinelPolicies.SearchRead); + group.MapPost("/summarize", Summarize).RequireAuthorization(SentinelPolicies.SearchRead); + group.MapPost("/correlate", Correlate).RequireAuthorization(SentinelPolicies.SearchRead); group.MapPost("/feedback", SubmitFeedback); return app; diff --git a/src/Sentinel.Api/Features/Alerts/AlertExtensions.cs b/src/Sentinel.Api/Features/Alerts/AlertExtensions.cs index f35e2cf..e9d21f3 100644 --- a/src/Sentinel.Api/Features/Alerts/AlertExtensions.cs +++ b/src/Sentinel.Api/Features/Alerts/AlertExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Sentinel.Api; +using Sentinel.Api.Authorization; using Sentinel.Domain.Alerts; using Sentinel.Infrastructure.Alerts; @@ -19,12 +20,15 @@ public static IEndpointRouteBuilder MapAlertEndpoints(this IEndpointRouteBuilder .WithTags("Alerts") .RequireAuthorization(); - group.MapGet("/", ListAlertRules); - group.MapGet("/{id:guid}", GetAlertRule); - group.MapPost("/", CreateAlertRule); - group.MapPut("/{id:guid}", UpdateAlertRule); - group.MapDelete("/{id:guid}", DeleteAlertRule); - group.MapGet("/{id:guid}/executions", ListAlertExecutions); + group.MapGet("/", ListAlertRules).RequireAuthorization(SentinelPolicies.AlertsRead); + group.MapGet("/executions", ListRecentExecutions).RequireAuthorization(SentinelPolicies.AlertsRead); + group.MapGet("/{id:guid}", GetAlertRule).RequireAuthorization(SentinelPolicies.AlertsRead); + group.MapPost("/", CreateAlertRule).RequireAuthorization(SentinelPolicies.AlertsWrite); + group.MapPut("/{id:guid}", UpdateAlertRule).RequireAuthorization(SentinelPolicies.AlertsWrite); + group.MapDelete("/{id:guid}", DeleteAlertRule).RequireAuthorization(SentinelPolicies.AlertsDelete); + group.MapGet("/{id:guid}/executions", ListAlertExecutions).RequireAuthorization(SentinelPolicies.AlertsRead); + group.MapPost("/executions/{executionId:guid}/silence", SilenceAlertExecution) + .RequireAuthorization(SentinelPolicies.AlertsWrite); return app; } @@ -173,8 +177,61 @@ private static async Task ListAlertExecutions( return Results.Ok(executions.Select(AlertExecutionResponse.FromEntity)); } + + private static async Task ListRecentExecutions( + HttpContext context, + [FromServices] IAlertRepository repository, + [FromQuery] int limit, + CancellationToken cancellationToken) + { + var tenantId = context.GetTenantId(); + if (tenantId == Guid.Empty) + { + return Results.BadRequest(new { error = "Tenant ID is required." }); + } + + var executions = await repository.ListRecentExecutionsAsync( + tenantId, + limit > 0 ? limit : 100, + cancellationToken); + + return Results.Ok(executions.Select(AlertExecutionResponse.FromEntity)); + } + + private static async Task SilenceAlertExecution( + Guid executionId, + SilenceAlertExecutionRequest request, + HttpContext context, + [FromServices] IAlertRepository repository, + CancellationToken cancellationToken) + { + var tenantId = context.GetTenantId(); + if (tenantId == Guid.Empty) + { + return Results.BadRequest(new { error = "Tenant ID is required." }); + } + + var execution = await repository.GetExecutionByIdAsync(tenantId, executionId, cancellationToken); + if (execution is null) + { + return Results.NotFound(); + } + + if (execution.Status != AlertExecutionStatus.Triggered) + { + return Results.Conflict(new { error = "Only triggered executions can be silenced." }); + } + + var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 60; + execution.Suppress($"silenced for {duration} minutes"); + await repository.UpdateExecutionAsync(execution, cancellationToken); + + return Results.Ok(AlertExecutionResponse.FromEntity(execution)); + } } +public sealed record SilenceAlertExecutionRequest(int DurationMinutes); + public sealed record CreateAlertRuleRequest( string Name, string Description, diff --git a/src/Sentinel.Api/Features/Authentication/RegisterEndpoint.cs b/src/Sentinel.Api/Features/Authentication/RegisterEndpoint.cs index c37ae2d..175ff4c 100644 --- a/src/Sentinel.Api/Features/Authentication/RegisterEndpoint.cs +++ b/src/Sentinel.Api/Features/Authentication/RegisterEndpoint.cs @@ -1,4 +1,6 @@ using FluentValidation; +using Microsoft.Extensions.Options; +using Sentinel.Domain.Configuration; using Sentinel.Infrastructure.Identity; namespace Sentinel.Api.Features.Authentication; @@ -9,8 +11,16 @@ public static async Task Handle( RegisterRequest request, IValidator validator, IAuthService authService, + IOptions securityOptions, + IHostEnvironment environment, CancellationToken cancellationToken) { + if (!securityOptions.Value.AllowOpenRegistration && !environment.IsDevelopment()) + { + return Results.Problem( + "Open registration is disabled on this instance.", + statusCode: StatusCodes.Status403Forbidden); + } var validation = await validator.ValidateAsync(request, cancellationToken); if (!validation.IsValid) { diff --git a/src/Sentinel.Api/Features/Dashboards/DashboardExtensions.cs b/src/Sentinel.Api/Features/Dashboards/DashboardExtensions.cs index 31aa4b0..4ce7ae3 100644 --- a/src/Sentinel.Api/Features/Dashboards/DashboardExtensions.cs +++ b/src/Sentinel.Api/Features/Dashboards/DashboardExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Sentinel.Api; +using Sentinel.Api.Authorization; using Sentinel.Domain.Dashboards; using Sentinel.Infrastructure.Dashboards; @@ -19,11 +20,11 @@ public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBui .WithTags("Dashboards") .RequireAuthorization(); - group.MapGet("/", ListDashboards); - group.MapGet("/{id:guid}", GetDashboard); - group.MapPost("/", CreateDashboard); - group.MapPut("/{id:guid}", UpdateDashboard); - group.MapDelete("/{id:guid}", DeleteDashboard); + group.MapGet("/", ListDashboards).RequireAuthorization(SentinelPolicies.DashboardsRead); + group.MapGet("/{id:guid}", GetDashboard).RequireAuthorization(SentinelPolicies.DashboardsRead); + group.MapPost("/", CreateDashboard).RequireAuthorization(SentinelPolicies.DashboardsWrite); + group.MapPut("/{id:guid}", UpdateDashboard).RequireAuthorization(SentinelPolicies.DashboardsWrite); + group.MapDelete("/{id:guid}", DeleteDashboard).RequireAuthorization(SentinelPolicies.DashboardsDelete); return app; } diff --git a/src/Sentinel.Api/Features/Incidents/IncidentExtensions.cs b/src/Sentinel.Api/Features/Incidents/IncidentExtensions.cs index 863c772..42f13f2 100644 --- a/src/Sentinel.Api/Features/Incidents/IncidentExtensions.cs +++ b/src/Sentinel.Api/Features/Incidents/IncidentExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Sentinel.Api; +using Sentinel.Api.Authorization; using Sentinel.Domain.Incidents; using Sentinel.Infrastructure.Incidents; @@ -19,17 +20,18 @@ public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuil .WithTags("Incidents") .RequireAuthorization(); - group.MapGet("/", ListIncidents); - group.MapGet("/{id:guid}", GetIncident); - group.MapPost("/", CreateIncident); - group.MapPut("/{id:guid}", UpdateIncident); - group.MapDelete("/{id:guid}", DeleteIncident); - group.MapGet("/{id:guid}/timeline", ListTimeline); - group.MapPost("/{id:guid}/timeline", AddTimelineEntry); - group.MapGet("/{id:guid}/comments", ListComments); - group.MapPost("/{id:guid}/comments", AddComment); - group.MapPut("/{id:guid}/comments/{commentId:guid}", UpdateComment); - group.MapDelete("/{id:guid}/comments/{commentId:guid}", DeleteComment); + group.MapGet("/", ListIncidents).RequireAuthorization(SentinelPolicies.IncidentsRead); + group.MapGet("/{id:guid}", GetIncident).RequireAuthorization(SentinelPolicies.IncidentsRead); + group.MapPost("/", CreateIncident).RequireAuthorization(SentinelPolicies.IncidentsWrite); + group.MapPut("/{id:guid}", UpdateIncident).RequireAuthorization(SentinelPolicies.IncidentsWrite); + group.MapPatch("/{id:guid}", PatchIncident).RequireAuthorization(SentinelPolicies.IncidentsWrite); + group.MapDelete("/{id:guid}", DeleteIncident).RequireAuthorization(SentinelPolicies.IncidentsDelete); + group.MapGet("/{id:guid}/timeline", ListTimeline).RequireAuthorization(SentinelPolicies.IncidentsRead); + group.MapPost("/{id:guid}/timeline", AddTimelineEntry).RequireAuthorization(SentinelPolicies.IncidentsWrite); + group.MapGet("/{id:guid}/comments", ListComments).RequireAuthorization(SentinelPolicies.IncidentsRead); + group.MapPost("/{id:guid}/comments", AddComment).RequireAuthorization(SentinelPolicies.IncidentsWrite); + group.MapPut("/{id:guid}/comments/{commentId:guid}", UpdateComment).RequireAuthorization(SentinelPolicies.IncidentsWrite); + group.MapDelete("/{id:guid}/comments/{commentId:guid}", DeleteComment).RequireAuthorization(SentinelPolicies.IncidentsDelete); return app; } @@ -148,6 +150,54 @@ private static async Task UpdateIncident( return Results.Ok(IncidentResponse.FromEntity(incident)); } + private static async Task PatchIncident( + Guid id, + PatchIncidentRequest request, + HttpContext context, + [FromServices] IIncidentRepository repository, + CancellationToken cancellationToken) + { + var tenantId = context.GetTenantId(); + if (tenantId == Guid.Empty) + { + return Results.BadRequest(new { error = "Tenant ID is required." }); + } + + var incident = await repository.GetByIdAsync(tenantId, id, cancellationToken); + if (incident is null) + { + return Results.NotFound(); + } + + var previousStatus = incident.Status; + + if (request.Status.HasValue) + { + incident.SetStatus(request.Status.Value); + } + + if (request.AssignedTo is not null) + { + incident.Assign(request.AssignedTo); + } + + await repository.UpdateAsync(incident, cancellationToken); + + if (request.Status.HasValue && request.Status.Value != previousStatus) + { + var entry = IncidentTimelineEntry.Create( + tenantId, + incident.Id, + IncidentTimelineEntryType.StatusChanged, + $"Status changed from {previousStatus} to {request.Status.Value}", + context.GetUserId()); + + await repository.AddTimelineEntryAsync(entry, cancellationToken); + } + + return Results.Ok(IncidentResponse.FromEntity(incident)); + } + private static async Task DeleteIncident( Guid id, HttpContext context, @@ -329,6 +379,10 @@ private static async Task DeleteComment( } } +public sealed record PatchIncidentRequest( + IncidentStatus? Status, + string? AssignedTo); + public sealed record CreateIncidentRequest( string Title, string Description, diff --git a/src/Sentinel.Api/Features/Ingestion/IngestionAuthExtensions.cs b/src/Sentinel.Api/Features/Ingestion/IngestionAuthExtensions.cs new file mode 100644 index 0000000..2d972d1 --- /dev/null +++ b/src/Sentinel.Api/Features/Ingestion/IngestionAuthExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using Sentinel.Api.Authentication; +using Sentinel.Domain.Configuration; + +namespace Sentinel.Api.Features.Ingestion; + +public static class IngestionAuthExtensions +{ + public static RouteHandlerBuilder WithIngestionAuth(this RouteHandlerBuilder builder) + { + return builder.AddEndpointFilter(); + } +} + +internal sealed class IngestionAuthFilter : IEndpointFilter +{ + private readonly IOptionsMonitor _options; + + public IngestionAuthFilter(IOptionsMonitor options) + { + _options = options; + } + + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + var options = _options.CurrentValue; + var httpContext = context.HttpContext; + + if (options.RequireApiKey) + { + var apiKeyResult = await httpContext.AuthenticateAsync(ApiKeyAuthenticationDefaults.AuthenticationScheme); + if (apiKeyResult.Succeeded && apiKeyResult.Principal is not null) + { + httpContext.User = apiKeyResult.Principal; + return await next(context); + } + + var jwtResult = await httpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); + if (jwtResult.Succeeded && jwtResult.Principal is not null) + { + httpContext.User = jwtResult.Principal; + return await next(context); + } + + return Results.Problem( + "A valid API key or bearer token is required for ingestion.", + statusCode: StatusCodes.Status401Unauthorized); + } + + if (!options.AllowHeaderOnlyTenant) + { + return Results.Problem( + "Ingestion is not configured for anonymous access.", + statusCode: StatusCodes.Status401Unauthorized); + } + + return await next(context); + } +} diff --git a/src/Sentinel.Api/Features/Logs/IngestLogsEndpoint.cs b/src/Sentinel.Api/Features/Logs/IngestLogsEndpoint.cs index a76fde4..793f4da 100644 --- a/src/Sentinel.Api/Features/Logs/IngestLogsEndpoint.cs +++ b/src/Sentinel.Api/Features/Logs/IngestLogsEndpoint.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Sentinel.Api.Common; +using Sentinel.Api.Features.Ingestion; using Sentinel.Api.Hubs; using Sentinel.Domain.Events; using Sentinel.Domain.Messaging; @@ -18,7 +19,8 @@ public static void MapIngestLogsEndpoint(this IEndpointRouteBuilder app) .WithTags("Logs") .Produces(StatusCodes.Status202Accepted) .ProducesValidationProblem() - .AllowAnonymous(); + .AllowAnonymous() + .WithIngestionAuth(); } private static async Task HandleAsync( diff --git a/src/Sentinel.Api/Features/Metrics/IngestMetricsEndpoint.cs b/src/Sentinel.Api/Features/Metrics/IngestMetricsEndpoint.cs index 229acec..a69425e 100644 --- a/src/Sentinel.Api/Features/Metrics/IngestMetricsEndpoint.cs +++ b/src/Sentinel.Api/Features/Metrics/IngestMetricsEndpoint.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Sentinel.Api.Common; +using Sentinel.Api.Features.Ingestion; using Sentinel.Domain.Events; using Sentinel.Domain.Messaging; using Sentinel.Domain.Observability; @@ -19,7 +20,8 @@ public static void MapIngestMetricsEndpoint(this IEndpointRouteBuilder app) .WithName("IngestMetrics") .WithTags("Metrics") .Produces(StatusCodes.Status202Accepted) - .AllowAnonymous(); + .AllowAnonymous() + .WithIngestionAuth(); } private static async Task HandleAsync( diff --git a/src/Sentinel.Api/Features/Metrics/QueryMetricsEndpoint.cs b/src/Sentinel.Api/Features/Metrics/QueryMetricsEndpoint.cs index 3b4baee..447478e 100644 --- a/src/Sentinel.Api/Features/Metrics/QueryMetricsEndpoint.cs +++ b/src/Sentinel.Api/Features/Metrics/QueryMetricsEndpoint.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Sentinel.Api.Authorization; using Sentinel.Api.Common; using Sentinel.Infrastructure.Persistence.ClickHouse; using System.Text.Json; @@ -13,7 +14,7 @@ public static void MapQueryMetricsEndpoint(this IEndpointRouteBuilder app) .WithName("QueryMetrics") .WithTags("Metrics") .Produces() - .RequireAuthorization(); + .RequireAuthorization(SentinelPolicies.MetricsRead); } private static async Task HandleAsync( diff --git a/src/Sentinel.Api/Features/Plugins/PluginExtensions.cs b/src/Sentinel.Api/Features/Plugins/PluginExtensions.cs index 0ff3147..a4d674b 100644 --- a/src/Sentinel.Api/Features/Plugins/PluginExtensions.cs +++ b/src/Sentinel.Api/Features/Plugins/PluginExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Sentinel.Api; +using Sentinel.Api.Authorization; using Sentinel.Domain.Plugins; using Sentinel.Infrastructure.Plugins; @@ -19,9 +20,10 @@ public static IEndpointRouteBuilder MapPluginEndpoints(this IEndpointRouteBuilde .WithTags("Plugins") .RequireAuthorization(); - group.MapGet("/", ListPlugins); - group.MapPost("/install", InstallPlugin); - group.MapDelete("/{id:guid}", RemovePlugin); + group.MapGet("/", ListPlugins).RequireAuthorization(SentinelPolicies.PluginsRead); + group.MapPost("/install", InstallPlugin).RequireAuthorization(SentinelPolicies.PluginsWrite); + group.MapPatch("/{id:guid}", TogglePlugin).RequireAuthorization(SentinelPolicies.PluginsWrite); + group.MapDelete("/{id:guid}", RemovePlugin).RequireAuthorization(SentinelPolicies.PluginsDelete); return app; } @@ -95,8 +97,42 @@ private static async Task RemovePlugin( await repository.RemoveAsync(tenantId, id, cancellationToken); return Results.NoContent(); } + + private static async Task TogglePlugin( + Guid id, + TogglePluginRequest request, + HttpContext context, + [FromServices] IPluginRepository repository, + CancellationToken cancellationToken) + { + var tenantId = context.GetTenantId(); + if (tenantId == Guid.Empty) + { + return Results.BadRequest(new { error = "Tenant ID is required." }); + } + + var plugin = await repository.GetByIdAsync(tenantId, id, cancellationToken); + if (plugin is null) + { + return Results.NotFound(); + } + + if (request.Enabled) + { + plugin.Enable(); + } + else + { + plugin.Disable(); + } + + await repository.UpdateAsync(plugin, cancellationToken); + return Results.Ok(PluginResponse.FromEntity(plugin)); + } } +public sealed record TogglePluginRequest(bool Enabled); + public sealed record InstallPluginRequest( string Name, string Version, diff --git a/src/Sentinel.Api/Features/Search/SavedSearchEndpoints.cs b/src/Sentinel.Api/Features/Search/SavedSearchEndpoints.cs index 06d2a50..3709fbb 100644 --- a/src/Sentinel.Api/Features/Search/SavedSearchEndpoints.cs +++ b/src/Sentinel.Api/Features/Search/SavedSearchEndpoints.cs @@ -1,5 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Sentinel.Api.Authorization; using Sentinel.Api.Common; using Sentinel.Infrastructure.Persistence.Search; @@ -11,11 +13,11 @@ public static void MapSavedSearchEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v1/search/saved").WithTags("Search").RequireAuthorization(); - group.MapGet("/", ListAsync).WithName("ListSavedSearches"); - group.MapGet("/{id:guid}", GetAsync).WithName("GetSavedSearch"); - group.MapPost("/", CreateAsync).WithName("CreateSavedSearch"); - group.MapPut("/{id:guid}", UpdateAsync).WithName("UpdateSavedSearch"); - group.MapDelete("/{id:guid}", DeleteAsync).WithName("DeleteSavedSearch"); + group.MapGet("/", ListAsync).WithName("ListSavedSearches").RequireAuthorization(SentinelPolicies.SearchRead); + group.MapGet("/{id:guid}", GetAsync).WithName("GetSavedSearch").RequireAuthorization(SentinelPolicies.SearchRead); + group.MapPost("/", CreateAsync).WithName("CreateSavedSearch").RequireAuthorization(SentinelPolicies.SearchWrite); + group.MapPut("/{id:guid}", UpdateAsync).WithName("UpdateSavedSearch").RequireAuthorization(SentinelPolicies.SearchWrite); + group.MapDelete("/{id:guid}", DeleteAsync).WithName("DeleteSavedSearch").RequireAuthorization(SentinelPolicies.SearchWrite); } private static async Task ListAsync( diff --git a/src/Sentinel.Api/Features/Search/SearchLogsEndpoint.cs b/src/Sentinel.Api/Features/Search/SearchLogsEndpoint.cs index 27be6e9..f512b22 100644 --- a/src/Sentinel.Api/Features/Search/SearchLogsEndpoint.cs +++ b/src/Sentinel.Api/Features/Search/SearchLogsEndpoint.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Sentinel.Api.Authorization; using Sentinel.Api.Common; using Sentinel.Infrastructure.Persistence.ClickHouse; @@ -12,7 +13,7 @@ public static void MapSearchLogsEndpoint(this IEndpointRouteBuilder app) .WithName("SearchLogs") .WithTags("Search") .Produces() - .RequireAuthorization(); + .RequireAuthorization(SentinelPolicies.SearchRead); } private static async Task HandleAsync( diff --git a/src/Sentinel.Api/Features/Tenants/TenantExtensions.cs b/src/Sentinel.Api/Features/Tenants/TenantExtensions.cs index ff21a04..e717a77 100644 --- a/src/Sentinel.Api/Features/Tenants/TenantExtensions.cs +++ b/src/Sentinel.Api/Features/Tenants/TenantExtensions.cs @@ -1,3 +1,5 @@ +using Sentinel.Api.Authorization; + namespace Sentinel.Api.Features.Tenants; public static class TenantExtensions @@ -15,40 +17,48 @@ public static IEndpointRouteBuilder MapTenantEndpoints(this IEndpointRouteBuilde group.MapGet("/", ListTenantsEndpoint.Handle) .WithName("ListTenants") + .RequireAuthorization(SentinelPolicies.TenantsRead) .Produces>(); group.MapGet("/{id:guid}", GetTenantEndpoint.Handle) .WithName("GetTenant") + .RequireAuthorization(SentinelPolicies.TenantsRead) .Produces() .ProducesProblem(StatusCodes.Status404NotFound); group.MapPost("/", CreateTenantEndpoint.Handle) .WithName("CreateTenant") + .RequireAuthorization(SentinelPolicies.TenantsWrite) .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status409Conflict); group.MapPut("/{id:guid}", UpdateTenantEndpoint.Handle) .WithName("UpdateTenant") + .RequireAuthorization(SentinelPolicies.TenantsWrite) .Produces() .ProducesProblem(StatusCodes.Status404NotFound); group.MapDelete("/{id:guid}", DeleteTenantEndpoint.Handle) .WithName("DeleteTenant") + .RequireAuthorization(SentinelPolicies.TenantsDelete) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound); group.MapGet("/{id:guid}/members", ListTenantMembersEndpoint.Handle) .WithName("ListTenantMembers") + .RequireAuthorization(SentinelPolicies.TenantsRead) .Produces>() .ProducesProblem(StatusCodes.Status404NotFound); group.MapPost("/{id:guid}/members", AddTenantMemberEndpoint.Handle) .WithName("AddTenantMember") + .RequireAuthorization(SentinelPolicies.TenantsWrite) .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status404NotFound); group.MapDelete("/{id:guid}/members/{userId:guid}", RemoveTenantMemberEndpoint.Handle) .WithName("RemoveTenantMember") + .RequireAuthorization(SentinelPolicies.TenantsDelete) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound); diff --git a/src/Sentinel.Api/Features/Traces/IngestTracesEndpoint.cs b/src/Sentinel.Api/Features/Traces/IngestTracesEndpoint.cs index e988034..f053bd1 100644 --- a/src/Sentinel.Api/Features/Traces/IngestTracesEndpoint.cs +++ b/src/Sentinel.Api/Features/Traces/IngestTracesEndpoint.cs @@ -2,6 +2,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using Sentinel.Api.Common; +using Sentinel.Api.Features.Ingestion; using Sentinel.Domain.Events; using Sentinel.Domain.Messaging; using Sentinel.Domain.Observability; @@ -18,7 +19,8 @@ public static void MapIngestTracesEndpoint(this IEndpointRouteBuilder app) .WithName("IngestTraces") .WithTags("Traces") .Produces(StatusCodes.Status202Accepted) - .AllowAnonymous(); + .AllowAnonymous() + .WithIngestionAuth(); } private static async Task HandleAsync( diff --git a/src/Sentinel.Api/Features/Traces/QueryTracesEndpoint.cs b/src/Sentinel.Api/Features/Traces/QueryTracesEndpoint.cs index b08e8ff..e305cbe 100644 --- a/src/Sentinel.Api/Features/Traces/QueryTracesEndpoint.cs +++ b/src/Sentinel.Api/Features/Traces/QueryTracesEndpoint.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Sentinel.Api.Authorization; using Sentinel.Api.Common; using Sentinel.Infrastructure.Persistence.ClickHouse; @@ -12,13 +13,13 @@ public static void MapQueryTracesEndpoints(this IEndpointRouteBuilder app) .WithName("QueryTraces") .WithTags("Traces") .Produces() - .RequireAuthorization(); + .RequireAuthorization(SentinelPolicies.TracesRead); app.MapGet("/api/v1/traces/{traceId}", GetDetailAsync) .WithName("GetTraceDetail") .WithTags("Traces") .Produces() - .RequireAuthorization(); + .RequireAuthorization(SentinelPolicies.TracesRead); } private static async Task ListAsync( diff --git a/src/Sentinel.Api/Features/Users/UserExtensions.cs b/src/Sentinel.Api/Features/Users/UserExtensions.cs index 7f637d4..8a5f10f 100644 --- a/src/Sentinel.Api/Features/Users/UserExtensions.cs +++ b/src/Sentinel.Api/Features/Users/UserExtensions.cs @@ -1,3 +1,5 @@ +using Sentinel.Api.Authorization; + namespace Sentinel.Api.Features.Users; public static class UserExtensions @@ -15,27 +17,32 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder group.MapGet("/", ListUsersEndpoint.Handle) .WithName("ListUsers") + .RequireAuthorization(SentinelPolicies.UsersRead) .Produces>() .ProducesProblem(StatusCodes.Status400BadRequest); group.MapGet("/{id:guid}", GetUserEndpoint.Handle) .WithName("GetUser") + .RequireAuthorization(SentinelPolicies.UsersRead) .Produces() .ProducesProblem(StatusCodes.Status404NotFound); group.MapPost("/", CreateUserEndpoint.Handle) .WithName("CreateUser") + .RequireAuthorization(SentinelPolicies.UsersWrite) .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict); group.MapPut("/{id:guid}", UpdateUserEndpoint.Handle) .WithName("UpdateUser") + .RequireAuthorization(SentinelPolicies.UsersWrite) .Produces() .ProducesProblem(StatusCodes.Status404NotFound); group.MapDelete("/{id:guid}", DeleteUserEndpoint.Handle) .WithName("DeleteUser") + .RequireAuthorization(SentinelPolicies.UsersDelete) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status404NotFound); diff --git a/src/Sentinel.Api/Program.cs b/src/Sentinel.Api/Program.cs index c66da28..c6ef157 100644 --- a/src/Sentinel.Api/Program.cs +++ b/src/Sentinel.Api/Program.cs @@ -1,9 +1,14 @@ using FluentValidation; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Scalar.AspNetCore; +using Sentinel.Api.Authentication; +using Sentinel.Api.Authorization; +using Sentinel.Api.Configuration; using Sentinel.Api.Features.AI; using Sentinel.Api.Features.Alerts; using Sentinel.Api.Features.Authentication; @@ -45,12 +50,42 @@ builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.Converters.Add( + new JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase, allowIntegerValues: true)); +}); + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(); + .AddJwtBearer() + .AddScheme( + ApiKeyAuthenticationDefaults.AuthenticationScheme, + _ => { }); -builder.Services.AddAuthorization(); -builder.Services.AddSignalR(); -builder.Services.AddGrpc(); +builder.Services.AddSentinelAuthorization(); + +var securityOptions = builder.Configuration.GetSection(SecurityOptions.SectionName).Get() ?? new(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + if (securityOptions.AllowedCorsOrigins.Length > 0) + { + policy.WithOrigins(securityOptions.AllowedCorsOrigins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + } + else if (builder.Environment.IsDevelopment()) + { + policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + } + else + { + policy.SetIsOriginAllowed(_ => false); + } + }); +}); var sentinelOptions = builder.Configuration.GetSection(SentinelOptions.SectionName).Get()!; builder.Services.AddOpenTelemetry() @@ -65,6 +100,9 @@ .AddRuntimeInstrumentation() .AddOtlpExporter()); +builder.Services.AddSignalR(); +builder.Services.AddGrpc(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { @@ -78,11 +116,7 @@ }); }); -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => - policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); -}); +ProductionConfigValidator.Validate(builder.Configuration, builder.Environment); var app = builder.Build(); diff --git a/src/Sentinel.Api/appsettings.json b/src/Sentinel.Api/appsettings.json index 91ceca5..624f41e 100644 --- a/src/Sentinel.Api/appsettings.json +++ b/src/Sentinel.Api/appsettings.json @@ -29,6 +29,15 @@ "AccessTokenExpirationMinutes": 60, "RefreshTokenExpirationDays": 7 }, + "Security": { + "AllowOpenRegistration": true, + "SeedDefaultAdmin": true, + "AllowedCorsOrigins": [] + }, + "Ingestion": { + "RequireApiKey": false, + "AllowHeaderOnlyTenant": true + }, "AI": { "DefaultProvider": "ollama", "MaxTokens": 4096, diff --git a/src/Sentinel.Domain/Configuration/SecurityOptions.cs b/src/Sentinel.Domain/Configuration/SecurityOptions.cs new file mode 100644 index 0000000..b994bc2 --- /dev/null +++ b/src/Sentinel.Domain/Configuration/SecurityOptions.cs @@ -0,0 +1,25 @@ +namespace Sentinel.Domain.Configuration; + +public sealed class SecurityOptions +{ + public const string SectionName = "Security"; + + public bool AllowOpenRegistration { get; set; } + public bool SeedDefaultAdmin { get; set; } = true; + public string[] AllowedCorsOrigins { get; set; } = []; +} + +public sealed class IngestionOptions +{ + public const string SectionName = "Ingestion"; + + /// + /// When true, ingestion endpoints require a valid tenant API key or JWT with write permissions. + /// + public bool RequireApiKey { get; set; } + + /// + /// Dev-only: allow anonymous ingestion with only an X-Tenant-ID header (no API key). + /// + public bool AllowHeaderOnlyTenant { get; set; } = true; +} diff --git a/src/Sentinel.Domain/Identity/ApiKey.cs b/src/Sentinel.Domain/Identity/ApiKey.cs index 35b71e7..d93ce86 100644 --- a/src/Sentinel.Domain/Identity/ApiKey.cs +++ b/src/Sentinel.Domain/Identity/ApiKey.cs @@ -50,4 +50,33 @@ public void Revoke() IsActive = false; Touch(); } + + public static ApiKey FromPersistence( + Guid id, + Guid tenantId, + string name, + string keyHash, + string keyPrefix, + string[] scopes, + DateTimeOffset? expiresAt, + bool isActive, + Guid? createdByUserId, + DateTimeOffset? lastUsedAt, + DateTimeOffset createdAt, + DateTimeOffset updatedAt) => + new() + { + Id = id, + TenantId = tenantId, + Name = name, + KeyHash = keyHash, + KeyPrefix = keyPrefix, + Scopes = scopes, + ExpiresAt = expiresAt, + IsActive = isActive, + CreatedByUserId = createdByUserId, + LastUsedAt = lastUsedAt, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + }; } diff --git a/src/Sentinel.Infrastructure/Alerts/AlertRepository.cs b/src/Sentinel.Infrastructure/Alerts/AlertRepository.cs index 3187869..c8cd98b 100644 --- a/src/Sentinel.Infrastructure/Alerts/AlertRepository.cs +++ b/src/Sentinel.Infrastructure/Alerts/AlertRepository.cs @@ -157,6 +157,35 @@ LIMIT @limit return executions; } + public async Task> ListRecentExecutionsAsync( + Guid tenantId, + int limit = 100, + CancellationToken cancellationToken = default) + { + await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand( + """ + SELECT id, tenant_id, alert_rule_id, status, severity, message, matched_value, + triggered_at, resolved_at, correlation_id, created_at, updated_at + FROM alert_executions + WHERE tenant_id = @tenant_id + ORDER BY triggered_at DESC + LIMIT @limit + """, + connection); + command.Parameters.AddWithValue("tenant_id", tenantId); + command.Parameters.AddWithValue("limit", limit); + + var executions = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + executions.Add(MapExecution(reader)); + } + + return executions; + } + public async Task CreateExecutionAsync(AlertExecution execution, CancellationToken cancellationToken = default) { await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); diff --git a/src/Sentinel.Infrastructure/Alerts/IAlertRepository.cs b/src/Sentinel.Infrastructure/Alerts/IAlertRepository.cs index 032d7e2..f4851b7 100644 --- a/src/Sentinel.Infrastructure/Alerts/IAlertRepository.cs +++ b/src/Sentinel.Infrastructure/Alerts/IAlertRepository.cs @@ -15,6 +15,11 @@ Task> ListExecutionsByRuleAsync( Guid alertRuleId, int limit = 100, CancellationToken cancellationToken = default); + + Task> ListRecentExecutionsAsync( + Guid tenantId, + int limit = 100, + CancellationToken cancellationToken = default); Task CreateExecutionAsync(AlertExecution execution, CancellationToken cancellationToken = default); Task UpdateExecutionAsync(AlertExecution execution, CancellationToken cancellationToken = default); Task CreateNotificationAsync(AlertNotification notification, CancellationToken cancellationToken = default); diff --git a/src/Sentinel.Infrastructure/DependencyInjection.cs b/src/Sentinel.Infrastructure/DependencyInjection.cs index 67831eb..eba75b1 100644 --- a/src/Sentinel.Infrastructure/DependencyInjection.cs +++ b/src/Sentinel.Infrastructure/DependencyInjection.cs @@ -26,6 +26,8 @@ public static IServiceCollection AddSentinelInfrastructure( services.Configure(configuration.GetSection(RabbitMqOptions.SectionName)); services.Configure(configuration.GetSection(JwtOptions.SectionName)); services.Configure(configuration.GetSection(AiOptions.SectionName)); + services.Configure(configuration.GetSection(SecurityOptions.SectionName)); + services.Configure(configuration.GetSection(IngestionOptions.SectionName)); services.AddSingleton(); services.AddSingleton(); @@ -41,6 +43,7 @@ public static IServiceCollection AddSentinelInfrastructure( services.ConfigureOptions(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Sentinel.Infrastructure/Identity/ApiKeyRepository.cs b/src/Sentinel.Infrastructure/Identity/ApiKeyRepository.cs new file mode 100644 index 0000000..59dd982 --- /dev/null +++ b/src/Sentinel.Infrastructure/Identity/ApiKeyRepository.cs @@ -0,0 +1,79 @@ +using Dapper; +using Sentinel.Domain.Identity; +using Sentinel.Infrastructure.Persistence.PostgreSQL; + +namespace Sentinel.Infrastructure.Identity; + +public sealed class ApiKeyRepository : IApiKeyRepository +{ + private readonly IPostgreSqlConnectionFactory _connectionFactory; + + public ApiKeyRepository(IPostgreSqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task FindByHashAsync(string keyHash, CancellationToken cancellationToken = default) + { + await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); + + var row = await connection.QuerySingleOrDefaultAsync( + """ + SELECT id, tenant_id, name, key_hash, key_prefix, scopes, expires_at, is_active, + created_by_user_id, last_used_at, created_at, updated_at + FROM tenant.api_keys + WHERE key_hash = @KeyHash AND is_active = true + """, + new { KeyHash = keyHash }); + + if (row is null) + { + return null; + } + + if (row.ExpiresAt.HasValue && row.ExpiresAt.Value < DateTimeOffset.UtcNow) + { + return null; + } + + return ApiKey.FromPersistence( + row.Id, + row.TenantId, + row.Name, + row.KeyHash, + row.KeyPrefix, + row.Scopes ?? [], + row.ExpiresAt, + row.IsActive, + row.CreatedByUserId, + row.LastUsedAt, + row.CreatedAt, + row.UpdatedAt); + } + + public async Task UpdateLastUsedAsync(Guid id, CancellationToken cancellationToken = default) + { + await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); + await connection.ExecuteAsync( + """ + UPDATE tenant.api_keys + SET last_used_at = @Now, updated_at = @Now + WHERE id = @Id + """, + new { Id = id, Now = DateTimeOffset.UtcNow }); + } + + private sealed record ApiKeyRow( + Guid Id, + Guid TenantId, + string Name, + string KeyHash, + string KeyPrefix, + string[]? Scopes, + DateTimeOffset? ExpiresAt, + bool IsActive, + Guid? CreatedByUserId, + DateTimeOffset? LastUsedAt, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); +} diff --git a/src/Sentinel.Infrastructure/Identity/IApiKeyRepository.cs b/src/Sentinel.Infrastructure/Identity/IApiKeyRepository.cs new file mode 100644 index 0000000..e7ee4b3 --- /dev/null +++ b/src/Sentinel.Infrastructure/Identity/IApiKeyRepository.cs @@ -0,0 +1,9 @@ +using Sentinel.Domain.Identity; + +namespace Sentinel.Infrastructure.Identity; + +public interface IApiKeyRepository +{ + Task FindByHashAsync(string keyHash, CancellationToken cancellationToken = default); + Task UpdateLastUsedAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/src/Sentinel.Infrastructure/Persistence/PostgreSQL/DataSeeder.cs b/src/Sentinel.Infrastructure/Persistence/PostgreSQL/DataSeeder.cs index ea8045d..39d5161 100644 --- a/src/Sentinel.Infrastructure/Persistence/PostgreSQL/DataSeeder.cs +++ b/src/Sentinel.Infrastructure/Persistence/PostgreSQL/DataSeeder.cs @@ -1,215 +1,225 @@ using Dapper; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentinel.Domain.Configuration; using Sentinel.Infrastructure.Identity; namespace Sentinel.Infrastructure.Persistence.PostgreSQL; public sealed class DataSeeder : IDataSeeder { - public static readonly Guid DefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - public static readonly Guid AdminUserId = Guid.Parse("22222222-2222-2222-2222-222222222222"); - public static readonly Guid AdminRoleId = Guid.Parse("33333333-3333-3333-3333-333333333333"); - public static readonly Guid EditorRoleId = Guid.Parse("44444444-4444-4444-4444-444444444444"); - public static readonly Guid ViewerRoleId = Guid.Parse("55555555-5555-5555-5555-555555555555"); - - private readonly IPostgreSqlConnectionFactory _connectionFactory; - private readonly IPasswordHasher _passwordHasher; - private readonly ILogger _logger; - - public DataSeeder( - IPostgreSqlConnectionFactory connectionFactory, - IPasswordHasher passwordHasher, - ILogger logger) - { - _connectionFactory = connectionFactory; - _passwordHasher = passwordHasher; - _logger = logger; - } - - public async Task SeedAsync(CancellationToken cancellationToken = default) - { - await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); - - var tenantExists = await connection.ExecuteScalarAsync( - "SELECT EXISTS(SELECT 1 FROM tenant.tenants WHERE id = @Id)", - new { Id = DefaultTenantId }); - - if (tenantExists) + public static readonly Guid DefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static readonly Guid AdminUserId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + public static readonly Guid AdminRoleId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + public static readonly Guid EditorRoleId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + public static readonly Guid ViewerRoleId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + + private readonly IPostgreSqlConnectionFactory _connectionFactory; + private readonly IPasswordHasher _passwordHasher; + private readonly ILogger _logger; + private readonly SecurityOptions _securityOptions; + + public DataSeeder( + IPostgreSqlConnectionFactory connectionFactory, + IPasswordHasher passwordHasher, + ILogger logger, + IOptions securityOptions) { - _logger.LogInformation("Seed data already present, skipping"); - return; + _connectionFactory = connectionFactory; + _passwordHasher = passwordHasher; + _logger = logger; + _securityOptions = securityOptions.Value; } - _logger.LogInformation("Seeding default identity and tenant data"); + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await using var connection = await _connectionFactory.CreateConnectionAsync(cancellationToken); + + var tenantExists = await connection.ExecuteScalarAsync( + "SELECT EXISTS(SELECT 1 FROM tenant.tenants WHERE id = @Id)", + new { Id = DefaultTenantId }); + + if (tenantExists) + { + _logger.LogInformation("Seed data already present, skipping"); + return; + } + + _logger.LogInformation("Seeding default identity and tenant data"); - var now = DateTimeOffset.UtcNow; - var permissions = CreatePermissions(now); - var passwordHash = _passwordHasher.Hash("Admin123!"); + var now = DateTimeOffset.UtcNow; + var permissions = CreatePermissions(now); + var passwordHash = _securityOptions.SeedDefaultAdmin + ? _passwordHasher.Hash("Admin123!") + : string.Empty; - await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); - await connection.ExecuteAsync( - """ + await connection.ExecuteAsync( + """ INSERT INTO tenant.tenants (id, name, slug, is_active, environment, settings, created_at, updated_at) VALUES (@Id, @Name, @Slug, true, 'production', '{}', @Now, @Now) """, - new { Id = DefaultTenantId, Name = "Default", Slug = "default", Now = now }, - transaction); + new { Id = DefaultTenantId, Name = "Default", Slug = "default", Now = now }, + transaction); - await connection.ExecuteAsync( - """ + await connection.ExecuteAsync( + """ INSERT INTO identity.roles (id, name, description, is_system, created_at, updated_at) VALUES (@Id, @Name, @Description, true, @Now, @Now) """, - new[] - { + new[] + { new { Id = AdminRoleId, Name = "admin", Description = "Full administrative access", Now = now }, new { Id = EditorRoleId, Name = "editor", Description = "Read and write observability data", Now = now }, new { Id = ViewerRoleId, Name = "viewer", Description = "Read-only access", Now = now } - }, - transaction); + }, + transaction); - foreach (var permission in permissions) - { - await connection.ExecuteAsync( - """ + foreach (var permission in permissions) + { + await connection.ExecuteAsync( + """ INSERT INTO identity.permissions (id, name, resource, action, description, created_at, updated_at) VALUES (@Id, @Name, @Resource, @Action, @Description, @CreatedAt, @UpdatedAt) """, - permission, - transaction); - } + permission, + transaction); + } - var adminPermissionIds = permissions.Select(p => p.Id).ToArray(); - var editorPermissionIds = permissions - .Where(p => p.Action is "read" or "write") - .Select(p => p.Id) - .ToArray(); - var viewerPermissionIds = permissions - .Where(p => p.Action == "read") - .Select(p => p.Id) - .ToArray(); - - await SeedRolePermissions(connection, transaction, AdminRoleId, adminPermissionIds); - await SeedRolePermissions(connection, transaction, EditorRoleId, editorPermissionIds); - await SeedRolePermissions(connection, transaction, ViewerRoleId, viewerPermissionIds); - - await connection.ExecuteAsync( - """ - INSERT INTO identity.users (id, email, password_hash, display_name, is_active, email_verified, created_at, updated_at) - VALUES (@Id, @Email, @PasswordHash, @DisplayName, true, true, @Now, @Now) - """, - new + var adminPermissionIds = permissions.Select(p => p.Id).ToArray(); + var editorPermissionIds = permissions + .Where(p => p.Action is "read" or "write") + .Select(p => p.Id) + .ToArray(); + var viewerPermissionIds = permissions + .Where(p => p.Action == "read") + .Select(p => p.Id) + .ToArray(); + + await SeedRolePermissions(connection, transaction, AdminRoleId, adminPermissionIds); + await SeedRolePermissions(connection, transaction, EditorRoleId, editorPermissionIds); + await SeedRolePermissions(connection, transaction, ViewerRoleId, viewerPermissionIds); + + if (_securityOptions.SeedDefaultAdmin) { - Id = AdminUserId, - Email = "admin@sentinel.local", - PasswordHash = passwordHash, - DisplayName = "System Administrator", - Now = now - }, - transaction); - - await connection.ExecuteAsync( - """ - INSERT INTO identity.user_roles (user_id, role_id, tenant_id) - VALUES (@UserId, @RoleId, @TenantId) - """, - new { UserId = AdminUserId, RoleId = AdminRoleId, TenantId = DefaultTenantId }, - transaction); + await connection.ExecuteAsync( + """ + INSERT INTO identity.users (id, email, password_hash, display_name, is_active, email_verified, created_at, updated_at) + VALUES (@Id, @Email, @PasswordHash, @DisplayName, true, true, @Now, @Now) + """, + new + { + Id = AdminUserId, + Email = "admin@sentinel.local", + PasswordHash = passwordHash, + DisplayName = "System Administrator", + Now = now + }, + transaction); + + await connection.ExecuteAsync( + """ + INSERT INTO identity.user_roles (user_id, role_id, tenant_id) + VALUES (@UserId, @RoleId, @TenantId) + """, + new { UserId = AdminUserId, RoleId = AdminRoleId, TenantId = DefaultTenantId }, + transaction); - await connection.ExecuteAsync( - """ - INSERT INTO tenant.members (id, tenant_id, user_id, role_id, is_active, joined_at, created_at, updated_at) - VALUES (@Id, @TenantId, @UserId, @RoleId, true, @Now, @Now, @Now) - """, - new - { - Id = Guid.Parse("66666666-6666-6666-6666-666666666666"), - TenantId = DefaultTenantId, - UserId = AdminUserId, - RoleId = AdminRoleId, - Now = now - }, - transaction); - - await transaction.CommitAsync(cancellationToken); - _logger.LogInformation("Seed data created successfully"); - } - - private static List CreatePermissions(DateTimeOffset now) - { - var resources = new[] + await connection.ExecuteAsync( + """ + INSERT INTO tenant.members (id, tenant_id, user_id, role_id, is_active, joined_at, created_at, updated_at) + VALUES (@Id, @TenantId, @UserId, @RoleId, true, @Now, @Now, @Now) + """, + new + { + Id = Guid.Parse("66666666-6666-6666-6666-666666666666"), + TenantId = DefaultTenantId, + UserId = AdminUserId, + RoleId = AdminRoleId, + Now = now + }, + transaction); + } + + await transaction.CommitAsync(cancellationToken); + _logger.LogInformation("Seed data created successfully"); + } + + private static List CreatePermissions(DateTimeOffset now) { + var resources = new[] + { "users", "tenants", "logs", "metrics", "traces", "alerts", "incidents", "dashboards", "plugins", "settings", "search" }; - var permissions = new List(); - var index = 0; + var permissions = new List(); + var index = 0; - foreach (var resource in resources) - { - foreach (var action in new[] { "read", "write", "delete" }) - { - if (resource is "search" && action == "delete") + foreach (var resource in resources) { - continue; + foreach (var action in new[] { "read", "write", "delete" }) + { + if (resource is "search" && action == "delete") + { + continue; + } + + if (resource is "logs" or "metrics" or "traces" or "search" && action == "delete") + { + continue; + } + + index++; + var name = $"{resource}.{action}"; + permissions.Add(new PermissionSeed( + Guid.Parse($"aaaaaaaa-aaaa-aaaa-aaaa-{index:D12}"), + name, + resource, + action, + $"{action} access to {resource}", + now, + now)); + } } - if (resource is "logs" or "metrics" or "traces" or "search" && action == "delete") - { - continue; - } - - index++; - var name = $"{resource}.{action}"; permissions.Add(new PermissionSeed( - Guid.Parse($"aaaaaaaa-aaaa-aaaa-aaaa-{index:D12}"), - name, - resource, - action, - $"{action} access to {resource}", + Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + "tenants.members.manage", + "tenants", + "manage", + "Manage tenant membership", now, now)); - } + + return permissions; } - permissions.Add(new PermissionSeed( - Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - "tenants.members.manage", - "tenants", - "manage", - "Manage tenant membership", - now, - now)); - - return permissions; - } - - private static async Task SeedRolePermissions( - Npgsql.NpgsqlConnection connection, - Npgsql.NpgsqlTransaction transaction, - Guid roleId, - Guid[] permissionIds) - { - foreach (var permissionId in permissionIds) + private static async Task SeedRolePermissions( + Npgsql.NpgsqlConnection connection, + Npgsql.NpgsqlTransaction transaction, + Guid roleId, + Guid[] permissionIds) { - await connection.ExecuteAsync( - """ + foreach (var permissionId in permissionIds) + { + await connection.ExecuteAsync( + """ INSERT INTO identity.role_permissions (role_id, permission_id) VALUES (@RoleId, @PermissionId) """, - new { RoleId = roleId, PermissionId = permissionId }, - transaction); + new { RoleId = roleId, PermissionId = permissionId }, + transaction); + } } - } - - private sealed record PermissionSeed( - Guid Id, - string Name, - string Resource, - string Action, - string Description, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt); + + private sealed record PermissionSeed( + Guid Id, + string Name, + string Resource, + string Action, + string Description, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); } diff --git a/src/sentinel-web/package.json b/src/sentinel-web/package.json index 3dfd0c2..d122710 100644 --- a/src/sentinel-web/package.json +++ b/src/sentinel-web/package.json @@ -1,7 +1,7 @@ { "name": "sentinel-web", "private": true, - "version": "0.1.0", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/sentinel-web/src/lib/api/alerts.ts b/src/sentinel-web/src/lib/api/alerts.ts index 93c5a54..fbcecf2 100644 --- a/src/sentinel-web/src/lib/api/alerts.ts +++ b/src/sentinel-web/src/lib/api/alerts.ts @@ -1,22 +1,48 @@ import { api } from './client'; -export interface Alert { +export type AlertSeverity = 'info' | 'warning' | 'critical'; +export type AlertRuleStatus = 'active' | 'paused' | 'disabled'; +export type AlertExecutionStatus = 'triggered' | 'resolved' | 'suppressed' | 'failed'; + +export interface AlertRule { id: string; + tenantId: string; name: string; - severity: 'critical' | 'warning' | 'info'; - status: 'firing' | 'resolved' | 'silenced'; + description: string; + query: string; + condition: string; + severity: AlertSeverity; + status: AlertRuleStatus; + evaluationIntervalSeconds: number; + notificationChannels: string[]; + createdBy?: string; + createdAt: string; + updatedAt: string; +} + +export interface AlertExecution { + id: string; + alertRuleId: string; + status: AlertExecutionStatus; + severity: AlertSeverity; message: string; - firedAt: string; + matchedValue?: string; + triggeredAt: string; resolvedAt?: string; - labels?: Record; + correlationId?: string; + createdAt: string; } const BASE = '/api/v1/alerts'; -export function listAlerts(): Promise { - return api.get(BASE); +export function listAlertRules(): Promise { + return api.get(BASE); +} + +export function listAlertExecutions(limit = 50): Promise { + return api.get(`${BASE}/executions?limit=${limit}`); } -export function silenceAlert(id: string, durationMinutes: number): Promise { - return api.post(`${BASE}/${id}/silence`, { durationMinutes }); +export function silenceAlertExecution(id: string, durationMinutes: number): Promise { + return api.post(`${BASE}/executions/${id}/silence`, { durationMinutes }); } diff --git a/src/sentinel-web/src/lib/api/incidents.ts b/src/sentinel-web/src/lib/api/incidents.ts index 736f68b..6e594a0 100644 --- a/src/sentinel-web/src/lib/api/incidents.ts +++ b/src/sentinel-web/src/lib/api/incidents.ts @@ -1,14 +1,20 @@ import { api } from './client'; +export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical'; +export type IncidentStatus = 'open' | 'investigating' | 'mitigated' | 'resolved' | 'closed'; + export interface Incident { id: string; title: string; - status: 'open' | 'investigating' | 'mitigated' | 'resolved'; - severity: 'critical' | 'high' | 'medium' | 'low'; + description: string; + severity: IncidentSeverity; + status: IncidentStatus; + assignedTo?: string; + sourceAlertExecutionId?: string; + createdBy?: string; + resolvedAt?: string; createdAt: string; updatedAt: string; - assignee?: string; - alertIds: string[]; } const BASE = '/api/v1/incidents'; diff --git a/src/sentinel-web/src/lib/api/plugins.ts b/src/sentinel-web/src/lib/api/plugins.ts index 13d319e..19b072a 100644 --- a/src/sentinel-web/src/lib/api/plugins.ts +++ b/src/sentinel-web/src/lib/api/plugins.ts @@ -1,12 +1,18 @@ import { api } from './client'; +export type PluginStatus = 'installed' | 'enabled' | 'disabled' | 'failed'; + export interface Plugin { id: string; name: string; version: string; description: string; - enabled: boolean; - author: string; + assemblyName: string; + configurationJson: string; + status: PluginStatus; + installedBy?: string; + installedAt: string; + createdAt: string; } const BASE = '/api/v1/plugins'; @@ -18,3 +24,7 @@ export function listPlugins(): Promise { export function togglePlugin(id: string, enabled: boolean): Promise { return api.patch(`${BASE}/${id}`, { enabled }); } + +export function isPluginEnabled(plugin: Plugin): boolean { + return plugin.status === 'enabled'; +} diff --git a/src/sentinel-web/src/pages/AlertsPage.tsx b/src/sentinel-web/src/pages/AlertsPage.tsx index 249e664..a7f52f1 100644 --- a/src/sentinel-web/src/pages/AlertsPage.tsx +++ b/src/sentinel-web/src/pages/AlertsPage.tsx @@ -1,5 +1,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { listAlerts, silenceAlert } from '@/lib/api/alerts'; +import { + listAlertExecutions, + listAlertRules, + silenceAlertExecution, + type AlertExecution, + type AlertRule, +} from '@/lib/api/alerts'; const severityStyles: Record = { critical: 'bg-red-900/40 text-red-300 border-red-800', @@ -7,64 +13,127 @@ const severityStyles: Record = { info: 'bg-blue-900/40 text-blue-300 border-blue-800', }; +const ruleStatusStyles: Record = { + active: 'text-green-400', + paused: 'text-yellow-400', + disabled: 'text-zinc-500', +}; + +function RuleCard({ rule }: { rule: AlertRule }) { + return ( +
+
+ {rule.name} + + {rule.severity} + + {rule.status} +
+

{rule.description || rule.query}

+

+ Evaluates every {rule.evaluationIntervalSeconds}s +

+
+ ); +} + +function ExecutionCard({ + execution, + onSilence, + isSilencing, +}: { + execution: AlertExecution; + onSilence: (id: string) => void; + isSilencing: boolean; +}) { + return ( +
+
+
+ + {execution.severity} + + {execution.status} +
+

{execution.message}

+

+ Triggered {new Date(execution.triggeredAt).toLocaleString()} +

+
+ {execution.status === 'triggered' && ( + + )} +
+ ); +} + export function AlertsPage() { const queryClient = useQueryClient(); - const { data: alerts = [], isLoading } = useQuery({ - queryKey: ['alerts'], - queryFn: listAlerts, + const { data: rules = [], isLoading: rulesLoading } = useQuery({ + queryKey: ['alert-rules'], + queryFn: listAlertRules, + }); + const { data: executions = [], isLoading: executionsLoading } = useQuery({ + queryKey: ['alert-executions'], + queryFn: () => listAlertExecutions(50), }); const silence = useMutation({ - mutationFn: (id: string) => silenceAlert(id, 60), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }), + mutationFn: (id: string) => silenceAlertExecution(id, 60), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alert-executions'] }); + }, }); + const isLoading = rulesLoading || executionsLoading; + return ( -
-

Active and resolved alert rules.

+
+
+

Configured alert rules.

+ {isLoading ? ( +

Loading alert rules...

+ ) : rules.length === 0 ? ( +
+

No alert rules configured.

+
+ ) : ( +
+ {rules.map((rule) => ( + + ))} +
+ )} +
- {isLoading ? ( -

Loading alerts...

- ) : alerts.length === 0 ? ( -
-

No alerts configured.

-
- ) : ( -
- {alerts.map((alert) => ( -
-
-
- {alert.name} - - {alert.severity} - - {alert.status} -
-

{alert.message}

-

- Fired {new Date(alert.firedAt).toLocaleString()} -

-
- {alert.status === 'firing' && ( - - )} -
- ))} -
- )} +
+

Recent alert executions.

+ {executions.length === 0 ? ( +
+

No recent executions.

+
+ ) : ( +
+ {executions.map((execution) => ( + silence.mutate(id)} + isSilencing={silence.isPending} + /> + ))} +
+ )} +
); } diff --git a/src/sentinel-web/src/pages/IncidentsPage.tsx b/src/sentinel-web/src/pages/IncidentsPage.tsx index 6059be1..100a259 100644 --- a/src/sentinel-web/src/pages/IncidentsPage.tsx +++ b/src/sentinel-web/src/pages/IncidentsPage.tsx @@ -54,7 +54,7 @@ export function IncidentsPage() { {incident.severity} - {incident.assignee ?? '—'} + {incident.assignedTo ?? '—'} {new Date(incident.createdAt).toLocaleString()} diff --git a/src/sentinel-web/src/pages/OverviewPage.tsx b/src/sentinel-web/src/pages/OverviewPage.tsx index 26bd042..68e2784 100644 --- a/src/sentinel-web/src/pages/OverviewPage.tsx +++ b/src/sentinel-web/src/pages/OverviewPage.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { listAlerts } from '@/lib/api/alerts'; +import { listAlertExecutions } from '@/lib/api/alerts'; import { listIncidents } from '@/lib/api/incidents'; function StatCard({ label, value, subtext }: { label: string; value: string | number; subtext?: string }) { @@ -13,11 +13,16 @@ function StatCard({ label, value, subtext }: { label: string; value: string | nu } export function OverviewPage() { - const { data: alerts = [] } = useQuery({ queryKey: ['alerts'], queryFn: listAlerts }); + const { data: executions = [] } = useQuery({ + queryKey: ['alert-executions'], + queryFn: () => listAlertExecutions(10), + }); const { data: incidents = [] } = useQuery({ queryKey: ['incidents'], queryFn: listIncidents }); - const firingAlerts = alerts.filter((a) => a.status === 'firing').length; - const openIncidents = incidents.filter((i) => i.status !== 'resolved').length; + const firingAlerts = executions.filter((e) => e.status === 'triggered').length; + const openIncidents = incidents.filter( + (i) => i.status !== 'resolved' && i.status !== 'closed', + ).length; return (
@@ -31,27 +36,27 @@ export function OverviewPage() {
-

Recent Alerts

- {alerts.length === 0 ? ( -

No alerts yet. Configure alert rules to get started.

+

Recent Alert Executions

+ {executions.length === 0 ? ( +

No alert executions yet. Configure alert rules to get started.

) : (
    - {alerts.slice(0, 5).map((alert) => ( + {executions.slice(0, 5).map((execution) => (
  • - {alert.name} + {execution.message} - {alert.status} + {execution.status}
  • ))} diff --git a/src/sentinel-web/src/pages/PluginsPage.tsx b/src/sentinel-web/src/pages/PluginsPage.tsx index ce51522..9e856e5 100644 --- a/src/sentinel-web/src/pages/PluginsPage.tsx +++ b/src/sentinel-web/src/pages/PluginsPage.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { listPlugins, togglePlugin } from '@/lib/api/plugins'; +import { isPluginEnabled, listPlugins, togglePlugin } from '@/lib/api/plugins'; export function PluginsPage() { const queryClient = useQueryClient(); @@ -26,33 +26,39 @@ export function PluginsPage() {
) : (
- {plugins.map((plugin) => ( -
-
-
- {plugin.name} - v{plugin.version} + {plugins.map((plugin) => { + const enabled = isPluginEnabled(plugin); + return ( +
+
+
+ {plugin.name} + v{plugin.version} + {plugin.status} +
+

{plugin.description}

+ {plugin.installedBy && ( +

installed by {plugin.installedBy}

+ )}
-

{plugin.description}

-

by {plugin.author}

+
- -
- ))} + ); + })}
)}
diff --git a/tests/Sentinel.IntegrationTests/Infrastructure/TestDoubles.cs b/tests/Sentinel.IntegrationTests/Infrastructure/TestDoubles.cs index 753dd52..672c694 100644 --- a/tests/Sentinel.IntegrationTests/Infrastructure/TestDoubles.cs +++ b/tests/Sentinel.IntegrationTests/Infrastructure/TestDoubles.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text.Json; +using Sentinel.Api.Authorization; using Sentinel.Domain.Alerts; using Sentinel.Domain.Events; using Sentinel.Domain.Observability; @@ -190,7 +191,7 @@ public TestAuthService(IJwtTokenService jwtTokenService) "Admin User", tenantId ?? TestData.DefaultTenantId, ["admin"], - ["logs.read", "logs.write", "alerts.read", "alerts.write", "incidents.read", "incidents.write"]); + SentinelPermissions.All.ToList()); return Task.FromResult(CreateResult(user)); } @@ -218,7 +219,7 @@ public TestAuthService(IJwtTokenService jwtTokenService) "Admin User", TestData.DefaultTenantId, ["admin"], - ["logs.read", "logs.write", "alerts.read", "alerts.write", "incidents.read", "incidents.write"]); + SentinelPermissions.All.ToList()); return Task.FromResult(CreateResult(user)); } @@ -300,6 +301,20 @@ public Task> ListExecutionsByRuleAsync( return Task.FromResult>(executions); } + public Task> ListRecentExecutionsAsync( + Guid tenantId, + int limit = 100, + CancellationToken cancellationToken = default) + { + var executions = _executions.Values + .Where(execution => execution.TenantId == tenantId) + .OrderByDescending(execution => execution.TriggeredAt) + .Take(limit) + .ToList(); + + return Task.FromResult>(executions); + } + public Task CreateExecutionAsync(AlertExecution execution, CancellationToken cancellationToken = default) { _executions[execution.Id] = execution; From c283064fa9c75408477c8a73fc392c5f75ff7597 Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 9 Jul 2026 23:01:15 +0530 Subject: [PATCH 2/6] ci: re-trigger workflows after enabling Actions. Co-authored-by: Cursor From e5b3f4da7aed95f0f8091eb3efb62062e079e393 Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 9 Jul 2026 23:05:38 +0530 Subject: [PATCH 3/6] Normalize line endings for dotnet format CI check. Co-authored-by: Cursor --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6a8efa3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.{cmd,bat,ps1} text eol=crlf From 1187d9c150088ee1f0cd8f7e14631b7ae4b65607 Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 9 Jul 2026 23:06:59 +0530 Subject: [PATCH 4/6] Scope dotnet format CI check to changed C# files in PR. Co-authored-by: Cursor --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 483333f..3c42916 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,12 @@ jobs: - name: Check formatting run: | - dotnet format Sentinel.slnx --verify-no-changes --verbosity diagnostic + CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.cs' | tr '\n' ' ') + if [ -z "$(echo "$CHANGED" | tr -d ' ')" ]; then + echo "No C# files changed; skipping format check." + exit 0 + fi + dotnet format Sentinel.slnx --verify-no-changes --include $CHANGED --verbosity diagnostic - name: Check for vulnerable NuGet packages run: | From 4e235c7a2e831191faaa9566a1cd5483f25c8faf Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 9 Jul 2026 23:10:47 +0530 Subject: [PATCH 5/6] Bump OpenTelemetry packages to 1.15.3 for vulnerability advisories. Co-authored-by: Cursor --- src/Sentinel.Api/Sentinel.Api.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Sentinel.Api/Sentinel.Api.csproj b/src/Sentinel.Api/Sentinel.Api.csproj index d44af0b..3174298 100644 --- a/src/Sentinel.Api/Sentinel.Api.csproj +++ b/src/Sentinel.Api/Sentinel.Api.csproj @@ -13,11 +13,11 @@ - - - - - + + + + + From e1838cd7cba94063ebf2a63b7515ec76d6832829 Mon Sep 17 00:00:00 2001 From: Naresh Date: Thu, 9 Jul 2026 23:14:59 +0530 Subject: [PATCH 6/6] Use OpenTelemetry 1.16.0 packages available on NuGet. Co-authored-by: Cursor --- src/Sentinel.Api/Sentinel.Api.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Sentinel.Api/Sentinel.Api.csproj b/src/Sentinel.Api/Sentinel.Api.csproj index 3174298..cf95d4b 100644 --- a/src/Sentinel.Api/Sentinel.Api.csproj +++ b/src/Sentinel.Api/Sentinel.Api.csproj @@ -13,11 +13,11 @@ - - - - - + + + + +