diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ee8f24c..18df038 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:api.github.com)", "WebSearch", - "PowerShell(Get-ChildItem *)" + "PowerShell(Get-ChildItem *)", + "Bash(npm run *)" ] } } diff --git a/.gitignore b/.gitignore index deb7e96..67778e5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # Dev TLS certificates — never commit private keys certs/ +# Duende IdentityServer signing keys — generated at runtime, must not be committed +**/IdentityServer/keys/ + # User-specific files *.rsuser *.suo @@ -14,6 +17,10 @@ certs/ *.sln.docstates *.env +# IdentityServer auto-generated signing keys (env-specific, protected by Data Protection) +src/RavenDB.Samples.Verity.IdentityServer/keys/ + + # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/IDENTITY.md b/IDENTITY.md new file mode 100644 index 0000000..b3fc571 --- /dev/null +++ b/IDENTITY.md @@ -0,0 +1,124 @@ +# Identity & Security in Verity + +Verity is a financial audit platform — auditors review SEC filings, generate AI-assisted reports, and sign off on findings. That context shapes every identity decision made here. + +## Why Duende IdentityServer + +Verity has multiple companies, multiple auditors per company, and three distinct roles: Viewer, Analyst, and Admin. A single shared login or ad-hoc JWT generation would not scale here. Duende IdentityServer provides a dedicated OAuth 2.0 / OpenID Connect authority that: + +- Owns all user credentials and claims (name, email, `role`, one `company_id` claim per assigned company) +- Issues short-lived access tokens scoped to the `verity-api` resource +- Manages refresh tokens with one-time-use rotation, so a stolen refresh token is immediately invalidated on use +- Keeps auth logic out of the application — the Azure Functions backend only validates tokens, never issues them + +For a financial platform with auditor accountability, having a central, auditable identity authority is not optional. It is the foundation that makes the audit trail meaningful. + +## Why the BFF Pattern + +The Verity frontend is a SvelteKit SPA. SPAs that store OAuth tokens in `localStorage` or JavaScript memory are vulnerable to XSS — any injected script can exfiltrate tokens silently. + +The Backend-for-Frontend (BFF) pattern solves this by keeping tokens on the server: + +``` +Browser ──── cookie ────► BFF ──── Bearer token ────► Azure Functions + │ + └──── OIDC ────────────────► IdentityServer +``` + +The browser never sees an access token. It authenticates using an `httpOnly` session cookie managed by `Duende.BFF`. Every `/api/*` call is forwarded by the BFF, which injects the current access token transparently. Token refresh happens automatically in the background via `Duende.AccessTokenManagement` — the frontend never has to think about token expiry. + +The BFF also acts as the single external entry point: it proxies both the API and the Vite frontend dev server, so the browser always talks to one origin. + +## Why FAPI 2.0 + +FAPI 2.0 (Financial-grade API Security Profile) is the security baseline required by Open Banking standards worldwide (UK, EU PSD2, AU CDR). For a platform that handles financial filings, applying FAPI 2.0 is the right posture — not because it is required here, but because it demonstrates what a real production deployment would need. + +Two mechanisms are enabled on the `verity-bff` client: + +**Pushed Authorization Requests (PAR)** +The browser never carries authorization parameters in the URL. Instead, the BFF pushes the full authorization request to IdentityServer's PAR endpoint first, receives a `request_uri`, and only that opaque reference appears in the browser redirect. This prevents parameter tampering and leakage via the referrer header or browser history. + +**Demonstrating Proof-of-Possession (DPoP)** +Access tokens are issued as DPoP-bound to the BFF's RSA key pair. This sample demonstrates DPoP at the client/issuer; the Azure Functions API validates JWTs but does not validate per-request DPoP proofs. + +## Identity Events in RavenDB + +Every significant authentication action — login, logout, token issuance, client authentication failure — is written to the `SecurityEvents` collection in RavenDB by a custom `IEventSink` (`RavenEventSink`). + +| Event | What it records | +| ----------------------------- | ----------------------------------------------- | +| `UserLoginSuccess` | who logged in, from which IP, via which client | +| `UserLoginFailure` | attempted username, failure reason | +| `UserLogoutSuccess` | who logged out | +| `TokenIssuedSuccess` | subject, client, grant type | +| `TokenIssuedFailure` | client, error reason | +| `ClientAuthenticationFailure` | client ID, error (potential brute-force signal) | + +Documents expire automatically after 90 days via RavenDB's built-in `@expires` metadata. + +This matters for financial applications because compliance frameworks (SOC 2, ISO 27001, FAPI 2.0 itself) require evidence of _who authenticated and when_, not just _who changed what_. Storing these events in the same database as the audit records — queryable with RQL, visible in RavenDB Studio — creates a unified compliance picture: financial operations and the access history surrounding them, in one place. + +## Architecture Overview + +``` +Browser + │ + ▼ +BFF (Duende.BFF + YARP) + │ httpOnly session cookie + │ DPoP-bound access token forwarded to API + ├──── /bff/login → IdentityServer /authorize (PAR + PKCE + DPoP) + ├──── /bff/user → session claims + ├──── /api/* → Azure Functions (Bearer token injected) + └──── /* → Vite dev server (frontend assets) + +IdentityServer (Duende IdentityServer 7.x) + │ RavenDB user store + │ In-memory clients & scopes + └──── SecurityEvents → RavenDB (RavenEventSink) + +Azure Functions (backend API) + │ JWT Bearer validation (Authority = IdentityServer) + │ [Authorize(Roles = "...")] on all non-public endpoints + │ Analyst queries scoped to their CompanyIds at the DB level + └──── RavenDB (Verity database) +``` + +## Setup + +In addition to the prerequisites listed in the main README, Verity's identity layer requires a **Duende license key**. + +Duende offers a free [Community Edition](https://duendesoftware.com/products/communityedition) for qualifying open-source projects. Once you have a key, store it as a user secret in the AppHost project: + +```bash +cd src/RavenDB.Samples.Verity.AppHost +dotnet user-secrets set "Parameters:duende-license" "" +``` + +The same key is forwarded to both the IdentityServer and BFF projects by Aspire at startup. + +## Roles + +| Role | What they can do | +| --------- | ----------------------------------------------------------------------------------------------------------------- | +| `Viewer` | Browse all companies and reports — read-only, no audit access | +| `Analyst` | Read and write audits, fetch 10-Q filings — scoped to their assigned companies only | +| `Admin` | Full access to all companies, reports, and audits; manages user roles and company assignments via the Admin Panel | + +Roles and company assignments are managed in the Admin Panel (`/admin`), visible in the navbar when logged in as Admin. New accounts created via the Register form always start as `Viewer`. + +When an Analyst holds multiple company assignments, IdentityServer emits one `company_id` claim per company. The backend enforces the scope at query level — an Analyst calling `/api/companies` receives only their assigned companies, not the full list. + +## Demo Credentials + +After running the Setup migrations (`POST /api/migrate`), the following accounts are available: + +| Username | Password | Role | Companies | +| -------- | ----------- | ------- | --------------------------------------- | +| `alice` | `Demo1234!` | Admin | — | +| `bob` | `Demo1234!` | Analyst | Apple (companies[0] alphabetically) | +| `carol` | `Demo1234!` | Analyst | Microsoft (companies[1] alphabetically) | +| `dave` | `Demo1234!` | Analyst | Microsoft (companies[1] alphabetically) | +| `eve` | `Demo1234!` | Viewer | — | + +To create additional accounts, use the **Register** link in the top-right corner of the application. New accounts start as `Viewer` — use the Admin Panel to promote them to Analyst or Admin and assign companies. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7450257..e8d00dc 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -40,4 +40,9 @@ + + + + + \ No newline at end of file diff --git a/src/RavenDB.Samples.Verity.App/Api.cs b/src/RavenDB.Samples.Verity.App/Api.cs index f0b0b58..d30f5d9 100644 --- a/src/RavenDB.Samples.Verity.App/Api.cs +++ b/src/RavenDB.Samples.Verity.App/Api.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -16,7 +17,6 @@ using RavenDB.Samples.Verity.Setup; using RavenDB.Samples.Verity.Model.Tasks; using System.Globalization; -using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -29,6 +29,20 @@ public class Api( SecEdgarApi edgar) { + private static string? GetSubjectFromBearer(HttpRequest req) + { + return req.HttpContext.User.FindFirst("sub")?.Value; + } + private async Task GetCurrentUserAsync(HttpRequest req) + { + var sub = GetSubjectFromBearer(req); + if (sub is null) return null; + return await session.LoadAsync(User.BuildId(sub), req.HttpContext.RequestAborted); + } + private static bool CanAccessCompany(User user, string companyId) => + user.Role == UserRole.Admin || + (user.Role == UserRole.Analyst && user.CompanyIds.Contains(companyId)); + // OPTIONS * — CORS preflight handler [Function(nameof(CorsPreflightHandler))] public IActionResult CorsPreflightHandler( @@ -38,6 +52,7 @@ public IActionResult CorsPreflightHandler( } // GET /api/reports?cik=320193 + [Authorize] [Function(nameof(GetReports))] public async Task GetReports( [HttpTrigger("get", Route = "reports")] HttpRequest req) @@ -54,6 +69,10 @@ public async Task GetReports( if (company is null) return new NotFoundObjectResult($"Company with CIK {cik} not found."); + var currentUser = await GetCurrentUserAsync(req); + if (currentUser?.Role == UserRole.Analyst && !currentUser.CompanyIds.Contains(company.Id)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; + var reports = await session.Query() .Where(r => r.CompanyId == company.Id) .OrderByDescending(r => r.ReportDate) @@ -64,8 +83,27 @@ public async Task GetReports( return new NotFoundObjectResult($"No CIK number provided"); } + [Authorize(Roles = nameof(UserRole.Admin))] + [Function(nameof(GetEvents))] + public async Task GetEvents([HttpTrigger("get", Route = "security/events")] HttpRequest req) + { + var page = int.TryParse(req.Query["page"], out var p) && p > 0 ? p : 1; + var pageSize = int.TryParse(req.Query["pageSize"], out var ps) && ps > 0 ? Math.Min(ps, 50) : 20; + var skip = (page - 1) * pageSize; + + var events = await session.Query() + .Statistics(out var stats) + .OrderByDescending(e => e.At) + .Skip(skip) + .Take(pageSize) + .ToListAsync(); + + var totalPages = (int)Math.Ceiling(stats.TotalResults / (double)pageSize); + return new JsonResult(new PagedResult(events, page, pageSize, totalPages)); + } // GET /api/report?accession=0000320193-24-000123 + [Authorize] [Function(nameof(GetReport))] public async Task GetReport( [HttpTrigger("get", Route = "report")] HttpRequest req) @@ -80,17 +118,30 @@ public async Task GetReport( if (report is null) return new NotFoundObjectResult($"Report with accession number '{accession}' not found."); + var currentUser = await GetCurrentUserAsync(req); + if (currentUser?.Role == UserRole.Analyst && !currentUser.CompanyIds.Contains(report.CompanyId)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; + return new JsonResult(report); } // GET /api/companies?page=1&pageSize=20 + [Authorize] [Function(nameof(GetCompanies))] public async Task GetCompanies( [HttpTrigger("get", Route = "companies")] HttpRequest req) { - var page = int.TryParse(req.Query["page"], out var p) && p > 0 ? p : 1; + var currentUser = await GetCurrentUserAsync(req); + if (currentUser?.Role == UserRole.Analyst) + { + var loaded = await session.LoadAsync(currentUser.CompanyIds, req.HttpContext.RequestAborted); + var list = loaded.Values.OfType().OrderBy(c => c.Name).ToList(); + return new JsonResult(new PagedResult(list, 1, list.Count, 1)); + } + + var page = int.TryParse(req.Query["page"], out var p) && p > 0 ? p : 1; var pageSize = int.TryParse(req.Query["pageSize"], out var ps) && ps > 0 ? Math.Min(ps, 100) : 20; - var skip = (page - 1) * pageSize; + var skip = (page - 1) * pageSize; var companies = await session.Query() .Statistics(out var stats) @@ -100,12 +151,13 @@ public async Task GetCompanies( .Take(pageSize) .ToListAsync(); - var total = stats.TotalResults; + var total = stats.TotalResults; var totalPages = (int)Math.Ceiling(total / (double)pageSize); return new JsonResult(new PagedResult(companies, page, pageSize, totalPages)); } // GET /api/users?companyId=Companies/... + [Authorize(Roles = nameof(UserRole.Admin))] [Function(nameof(GetUsers))] public async Task GetUsers( [HttpTrigger("get", Route = "users")] HttpRequest req) @@ -115,7 +167,7 @@ public async Task GetUsers( return new BadRequestObjectResult("Provide the 'companyId' query parameter."); var users = await session.Query() - .Where(u => u.CompanyId == companyId) + .Where(u => u.CompanyIds.Contains(companyId)) .OrderBy(u => u.Surname) .ThenBy(u => u.Name) .ToListAsync(); @@ -123,7 +175,70 @@ public async Task GetUsers( return new JsonResult(users); } + // GET /api/users/me — returns the authenticated user's profile, creating it on first login. + [Authorize] + [Function(nameof(GetMe))] + public async Task GetMe( + [HttpTrigger("get", Route = "users/me")] HttpRequest req) + { + var sub = GetSubjectFromBearer(req); + if (sub is null) + return new UnauthorizedResult(); + + var id = User.BuildId(sub); + var user = await session.LoadAsync(id, req.HttpContext.RequestAborted); + + if (user is null) + { + var principal = req.HttpContext.User; + var roleString = principal.FindFirst("role")?.Value ?? "Viewer"; + + user = new User + { + Id = id, + SubjectId = sub, + Name = principal.FindFirst("given_name")?.Value ?? "", + Surname = principal.FindFirst("family_name")?.Value ?? "", + Email = principal.FindFirst("email")?.Value ?? "", + Role = Enum.TryParse(roleString, out var r) ? r : UserRole.Viewer, + CompanyIds = [.. principal.FindAll("company_id").Select(c => c.Value)], + }; + + await session.StoreAsync(user, id, req.HttpContext.RequestAborted); + await session.SaveChangesAsync(req.HttpContext.RequestAborted); + } + + return new JsonResult(user); + } + + // PUT /api/users/me — update display name fields. + [Authorize] + [Function(nameof(UpdateMe))] + public async Task UpdateMe( + [HttpTrigger("put", Route = "users/me")] HttpRequest req) + { + var sub = GetSubjectFromBearer(req); + if (sub is null) + return new UnauthorizedResult(); + + var dto = await req.ReadFromJsonAsync(req.HttpContext.RequestAborted); + if (dto is null) + return new BadRequestObjectResult("Invalid request body."); + + var id = User.BuildId(sub); + var user = await session.LoadAsync(id, req.HttpContext.RequestAborted); + if (user is null) + return new NotFoundObjectResult("User profile not found. Call GET /api/users/me first."); + + if (!string.IsNullOrWhiteSpace(dto.Name)) user.Name = dto.Name.Trim(); + if (!string.IsNullOrWhiteSpace(dto.Surname)) user.Surname = dto.Surname.Trim(); + + await session.SaveChangesAsync(req.HttpContext.RequestAborted); + return new JsonResult(user); + } + // GET /api/company?cik=320193 + [Authorize] [Function(nameof(GetCompany))] public async Task GetCompany( [HttpTrigger("get", Route = "company")] HttpRequest req) @@ -138,10 +253,15 @@ public async Task GetCompany( if (company is null) return new NotFoundObjectResult($"Company with CIK {cik} does not exist in the database. Use POST /api/company to fetch it."); + var currentUser = await GetCurrentUserAsync(req); + if (currentUser?.Role == UserRole.Analyst && !currentUser.CompanyIds.Contains(company.Id)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; + return new JsonResult(company); } // POST /api/company?cik=320193 + [Authorize(Roles = nameof(UserRole.Admin))] [Function(nameof(SaveCompany))] public async Task SaveCompany( [HttpTrigger("post", Route = "company")] HttpRequest req) @@ -151,7 +271,7 @@ public async Task SaveCompany( return new BadRequestObjectResult("Provide the 'cik' parameter (e.g., ?cik=320193)."); var paddedCik = SecEdgar.NormalizeCik(cik); - var existing = await session.Query().FirstOrDefaultAsync(c => c.Cik == paddedCik, req.HttpContext.RequestAborted); + var existing = await session.Query().FirstOrDefaultAsync(c => c.Cik == paddedCik, req.HttpContext.RequestAborted); if (existing is not null) return new ConflictObjectResult($"Company with CIK {paddedCik} already exists."); @@ -160,6 +280,7 @@ public async Task SaveCompany( } // POST /api/fetch-10q?cik=320193&max=5 + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(Fetch10Q))] public async Task Fetch10Q( [HttpTrigger("post", Route = "fetch-10q")] HttpRequest req) @@ -172,8 +293,24 @@ public async Task Fetch10Q( max = 5; var paddedCik = SecEdgar.NormalizeCik(cik); - var company = await session.Query().FirstOrDefaultAsync(c => c.Cik == paddedCik) - ?? await edgar.FetchAndSaveCompanyAsync(paddedCik, req.HttpContext.RequestAborted); + + // First try to find an existing company + var company = await session.Query().FirstOrDefaultAsync(c => c.Cik == paddedCik); + + var currentUser = await GetCurrentUserAsync(req); + if (currentUser is null) return new UnauthorizedResult(); + + if (company is null) + { + // Only Admin can add a new company via this endpoint + if (currentUser.Role != UserRole.Admin) + return new ObjectResult("Company not found. Only Admin can add new companies.") { StatusCode = 403 }; + company = await edgar.FetchAndSaveCompanyAsync(paddedCik, req.HttpContext.RequestAborted); + } + else if (!CanAccessCompany(currentUser, company.Id)) + { + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; + } await edgar.FetchAndSaveAllFilingsAsync(company, max, req.HttpContext.RequestAborted); @@ -182,6 +319,7 @@ public async Task Fetch10Q( // POST /api/audit — creates an audit for a given report // Body (JSON): { reportId, auditorName, auditorSurname, auditorEmail, auditString } + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(CreateAudit))] public async Task CreateAudit( [HttpTrigger("post", Route = "audit")] HttpRequest req) @@ -211,7 +349,10 @@ public async Task CreateAudit( var company = await session.LoadAsync(report.CompanyId, req.HttpContext.RequestAborted); if (company is null) return new NotFoundObjectResult($"Company '{report.CompanyId}' not found."); - + var currentUser = await GetCurrentUserAsync(req); + if (currentUser is null) return new UnauthorizedResult(); + if (!CanAccessCompany(currentUser, company.Id)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; var auditId = Audit.BuildId(company, report); // Upsert: update existing audit or create a new one @@ -224,11 +365,12 @@ public async Task CreateAudit( await session.StoreAsync(audit, req.HttpContext.RequestAborted); } - audit!.AuditorName = body.AuditorName ?? string.Empty; - audit.AuditorSurname = body.AuditorSurname ?? string.Empty; - audit.AuditorEmail = body.AuditorEmail ?? string.Empty; - audit.AuditString = body.AuditString ?? string.Empty; - audit.GeneratedByAi = body.GeneratedByAi; + // Auditor identity is taken from the authenticated user, not the posted body. + audit!.AuditorName = currentUser.Name; + audit.AuditorSurname = currentUser.Surname; + audit.AuditorEmail = currentUser.Email; + audit.AuditString = body.AuditString ?? string.Empty; + audit.GeneratedByAi = body.GeneratedByAi; await session.SaveChangesAsync(req.HttpContext.RequestAborted); @@ -237,6 +379,7 @@ public async Task CreateAudit( // POST /api/audit/restore — restores an audit document to a specific revision // Body (JSON): { auditId, changeVector } + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(RestoreAuditRevision))] public async Task RestoreAuditRevision( [HttpTrigger("post", Route = "audit/restore")] HttpRequest req) @@ -256,16 +399,23 @@ public async Task RestoreAuditRevision( if (body is null || string.IsNullOrWhiteSpace(body.AuditId) || string.IsNullOrWhiteSpace(body.ChangeVector)) return new BadRequestObjectResult("Provide 'auditId' and 'changeVector' in the request body."); - + var currentUser = await GetCurrentUserAsync(req); + if (currentUser is null) return new UnauthorizedResult(); + var audit = await session.LoadAsync(body.AuditId, req.HttpContext.RequestAborted); + if (audit is null) return new NotFoundObjectResult($"Audit '{body.AuditId}' not found."); + var report = await session.LoadAsync(audit.ReportId, req.HttpContext.RequestAborted); + if (report is null) return new NotFoundObjectResult($"Report '{audit.ReportId}' not found."); + if (!CanAccessCompany(currentUser, report.CompanyId)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; await store.Operations.SendAsync( new RevertRevisionsByIdOperation(body.AuditId, body.ChangeVector), token: req.HttpContext.RequestAborted); - return new OkResult(); } // GET /api/audit/revisions?reportId=Reports/... // Note: revisions must be enabled for the Audits collection in RavenDB Studio. + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(GetAuditRevisions))] public async Task GetAuditRevisions( [HttpTrigger("get", Route = "audit/revisions")] HttpRequest req) @@ -279,14 +429,19 @@ public async Task GetAuditRevisions( if (audit is null) return new NotFoundObjectResult($"Audit for report '{reportId}' not found."); - + var currentUser = await GetCurrentUserAsync(req); + if (currentUser is null) return new UnauthorizedResult(); + var report = await session.LoadAsync(audit.ReportId, req.HttpContext.RequestAborted); + if (report is null) return new NotFoundObjectResult($"Report '{audit.ReportId}' not found."); + if (!CanAccessCompany(currentUser, report.CompanyId)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; // RavenDB includes the current version as the first revision — no need to fetch it separately var revisions = await session.Advanced.Revisions .GetForAsync(audit.Id, 0, 50, req.HttpContext.RequestAborted); var revisionDtos = revisions.Select(rev => { - var meta = session.Advanced.GetMetadataFor(rev); + var meta = session.Advanced.GetMetadataFor(rev); var lastModified = meta.TryGetValue("@last-modified", out var lm) ? lm?.ToString() ?? "" : ""; var changeVector = meta.TryGetValue("@change-vector", out var cv) ? cv?.ToString() ?? "" : ""; return new AuditRevisionDto(rev, changeVector, lastModified); @@ -296,6 +451,7 @@ public async Task GetAuditRevisions( } // GET /api/audit?reportId=Reports/... + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(GetAudit))] public async Task GetAudit( [HttpTrigger("get", Route = "audit")] HttpRequest req) @@ -309,29 +465,84 @@ public async Task GetAudit( if (audit is null) return new NotFoundObjectResult($"Audit for report '{reportId}' not found."); - + var currentUser = await GetCurrentUserAsync(req); + if (currentUser is null) return new UnauthorizedResult(); + var report = await session.LoadAsync(audit.ReportId, req.HttpContext.RequestAborted); + if (report is null) return new NotFoundObjectResult($"Report '{audit.ReportId}' not found."); + if (!CanAccessCompany(currentUser, report.CompanyId)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; return new JsonResult(audit); } + // GET /api/manage/users + [Authorize(Roles = nameof(UserRole.Admin))] + [Function(nameof(GetAllUsers))] + public async Task GetAllUsers( + [HttpTrigger("get", Route = "manage/users")] HttpRequest req) + { + var users = await session.Query() + .Where(u => u.SubjectId != null) + .OrderBy(u => u.Surname) + .ThenBy(u => u.Name) + .ToListAsync(); + return new JsonResult(users); + } + + // PUT /api/manage/users/{subjectId}/role + [Authorize(Roles = nameof(UserRole.Admin))] + [Function(nameof(SetUserRole))] + public async Task SetUserRole( + [HttpTrigger("put", Route = "manage/users/{subjectId}/role")] HttpRequest req, string subjectId) + { + var body = await req.ReadFromJsonAsync(req.HttpContext.RequestAborted); + if (body is null || !Enum.TryParse(body.Role, out var newRole)) + return new BadRequestObjectResult("Provide 'role': Viewer, Analyst, or Admin."); + + var user = await session.LoadAsync(User.BuildId(subjectId), req.HttpContext.RequestAborted); + if (user is null) return new NotFoundObjectResult($"User '{subjectId}' not found."); + + user.Role = newRole; + await session.SaveChangesAsync(req.HttpContext.RequestAborted); + return new JsonResult(user); + } + + // PUT /api/manage/users/{subjectId}/companies + [Authorize(Roles = nameof(UserRole.Admin))] + [Function(nameof(SetUserCompanies))] + public async Task SetUserCompanies( + [HttpTrigger("put", Route = "manage/users/{subjectId}/companies")] HttpRequest req, string subjectId) + { + var body = await req.ReadFromJsonAsync(req.HttpContext.RequestAborted); + if (body is null) + return new BadRequestObjectResult("Provide 'companyIds' array."); + + var user = await session.LoadAsync(User.BuildId(subjectId), req.HttpContext.RequestAborted); + if (user is null) return new NotFoundObjectResult($"User '{subjectId}' not found."); + + user.CompanyIds = body.CompanyIds ?? []; + await session.SaveChangesAsync(req.HttpContext.RequestAborted); + return new JsonResult(user); + } + // QueueTrigger: "auditRevisions" → save AuditNotification to RavenDB [Function(nameof(OnAuditRevision))] public async Task OnAuditRevision( [QueueTrigger(AuditRevisionQueueEtlTask.QueueName, Connection = Constants.EnvVars.AzureStorageConnectionString)] string messageBody) { - var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var envelope = JsonSerializer.Deserialize>(messageBody, opts); - var msg = envelope?.Data; + var msg = envelope?.Data; if (msg is null) return; using var notifSession = store.OpenAsyncSession(); var notification = new AuditNotification { - AuditId = msg.AuditId, - CompanyName = msg.CompanyName, - ReportYear = msg.ReportYear, + AuditId = msg.AuditId, + CompanyName = msg.CompanyName, + ReportYear = msg.ReportYear, ReportQuarter = msg.ReportQuarter, - At = DateTime.UtcNow + At = DateTime.UtcNow }; await notifSession.StoreAsync(notification); @@ -341,17 +552,18 @@ public async Task OnAuditRevision( } // GET /api/audit/stream — SSE: push new AuditNotifications to client + [Authorize] [Function(nameof(StreamAuditEvents))] public async Task StreamAuditEvents( [HttpTrigger("get", Route = "audit/stream")] HttpRequest req) { var res = req.HttpContext.Response; - res.StatusCode = 200; - res.Headers["Content-Type"] = "text/event-stream"; - res.Headers["Cache-Control"] = "no-cache"; + res.StatusCode = 200; + res.Headers["Content-Type"] = "text/event-stream"; + res.Headers["Cache-Control"] = "no-cache"; res.Headers["X-Accel-Buffering"] = "no"; - var ct = req.HttpContext.RequestAborted; + var ct = req.HttpContext.RequestAborted; var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; using var subscription = store.Changes() @@ -383,6 +595,7 @@ public async Task StreamAuditEvents( } // GET /api/report/stream — SSE: push new AuditNotifications to client + [Authorize] [Function(nameof(StreamReportEvents))] public async Task StreamReportEvents( [HttpTrigger("get", Route = "report/stream")] HttpRequest req) @@ -432,7 +645,7 @@ public record CreateAuditRequest( string? AuditorSurname, string? AuditorEmail, string? AuditString, - bool GeneratedByAi = false); + bool GeneratedByAi = false); public record PagedResult(IList Items, int Page, int PageSize, int TotalPages); @@ -443,7 +656,12 @@ public record RestoreAuditRevisionRequest(string? AuditId, string? ChangeVector) public record AuditRevisionMessage( string AuditId, string CompanyName, - int ReportYear, - int ReportQuarter); + int ReportYear, + int ReportQuarter); + +public record UpdateUserRequest(string? Name, string? Surname); public record CloudEventEnvelope(T? Data); + +public record SetRoleRequest(string? Role); +public record SetCompaniesRequest(List? CompanyIds); diff --git a/src/RavenDB.Samples.Verity.App/Infrastructure/VerityAgentApi.cs b/src/RavenDB.Samples.Verity.App/Infrastructure/VerityAgentApi.cs index 8ba63c5..244b6e3 100644 --- a/src/RavenDB.Samples.Verity.App/Infrastructure/VerityAgentApi.cs +++ b/src/RavenDB.Samples.Verity.App/Infrastructure/VerityAgentApi.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -18,25 +19,32 @@ public class VerityAgentApi( IAsyncDocumentSession session, IHttpClientFactory httpClientFactory) { - // GET /api/agent/audit/context?reportId=Reports/1-A&userId=Users/Acme/John+Smith + // GET /api/agent/audit/context?reportId=Reports/1-A // Returns the structured context the frontend injects as the opening message to the agent. + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(GetAuditAgentContext))] public async Task GetAuditAgentContext( [HttpTrigger("get", Route = "agent/audit/context")] HttpRequest req) { var reportId = req.Query["reportId"].ToString().Trim(); - var userId = req.Query["userId"].ToString().Trim(); - if (string.IsNullOrWhiteSpace(reportId) || string.IsNullOrWhiteSpace(userId)) - return new BadRequestObjectResult("Provide 'reportId' and 'userId' query parameters."); + if (string.IsNullOrWhiteSpace(reportId)) + return new BadRequestObjectResult("Provide 'reportId' query parameter."); + + var sub = req.HttpContext.User.FindFirst("sub")?.Value; + if (string.IsNullOrWhiteSpace(sub)) + return new UnauthorizedResult(); + + var currentUser = await session.LoadAsync(User.BuildId(sub), req.HttpContext.RequestAborted); + if (currentUser is null) + return new UnauthorizedResult(); var report = await session.LoadAsync(reportId, req.HttpContext.RequestAborted); if (report is null) return new NotFoundObjectResult($"Report '{reportId}' not found."); - var user = await session.LoadAsync(userId, req.HttpContext.RequestAborted); - if (user is null) - return new NotFoundObjectResult($"User '{userId}' not found."); + if (!CanAccessCompany(currentUser, report.CompanyId)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; // Attempt to read the report HTML attachment for the agent to analyse var attachmentResult = await session.Advanced.Attachments.GetAsync( @@ -64,18 +72,18 @@ public async Task GetAuditAgentContext( { Auditor = new AuditAgentAuditor { - UserId = user.Id, - Name = user.Name, - Surname = user.Surname, - Email = user.Email + UserId = currentUser.Id, + Name = currentUser.Name, + Surname = currentUser.Surname, + Email = currentUser.Email }, Report = new AuditAgentReport { - ReportId = report.Id, - FormType = report.FormType, - Year = report.Year, - Quarter = report.Quarter, - ReportDate = report.ReportDate, + ReportId = report.Id, + FormType = report.FormType, + Year = report.Year, + Quarter = report.Quarter, + ReportDate = report.ReportDate, AccessionNumber = report.AccessionNumber }, ReportText = reportText @@ -85,12 +93,17 @@ public async Task GetAuditAgentContext( } // POST /api/agent/audit/generate - // One-shot endpoint: given a reportId and userId it builds the context and asks the AI + // One-shot endpoint: given a reportId it builds the context and asks the AI // to produce a draft audit text. Returns { notes: string } — no conversation. + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(GenerateAuditNotes))] public async Task GenerateAuditNotes( [HttpTrigger("post", Route = "agent/audit/generate")] HttpRequest req) { + var sub = req.HttpContext.User.FindFirst("sub")?.Value; + if (string.IsNullOrWhiteSpace(sub)) + return new UnauthorizedResult(); + GenerateAuditRequest? body; try { @@ -104,16 +117,19 @@ public async Task GenerateAuditNotes( return new BadRequestObjectResult("Invalid JSON body."); } - if (body is null || string.IsNullOrWhiteSpace(body.ReportId) || string.IsNullOrWhiteSpace(body.UserId)) - return new BadRequestObjectResult("Provide 'reportId' and 'userId'."); + if (body is null || string.IsNullOrWhiteSpace(body.ReportId)) + return new BadRequestObjectResult("Provide 'reportId'."); var report = await session.LoadAsync(body.ReportId, req.HttpContext.RequestAborted); if (report is null) return new NotFoundObjectResult($"Report '{body.ReportId}' not found."); - var user = await session.LoadAsync(body.UserId, req.HttpContext.RequestAborted); + var user = await session.LoadAsync(User.BuildId(sub), req.HttpContext.RequestAborted); if (user is null) - return new NotFoundObjectResult($"User '{body.UserId}' not found."); + return new UnauthorizedResult(); + + if (!CanAccessCompany(user, report.CompanyId)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; // Load HTML attachment, strip tags, truncate to 40k chars var attachmentResult = await session.Advanced.Attachments.GetAsync( @@ -153,14 +169,14 @@ public async Task GenerateAuditNotes( var requestPayload = new { - model = "gpt-4o-mini", + model = "gpt-4o-mini", messages = new[] { new { role = "system", content = systemPrompt }, new { role = "user", content = userMessage } }, max_completion_tokens = 1500, - temperature = 0.3 + temperature = 0.3 }; var httpClient = httpClientFactory.CreateClient(); @@ -194,10 +210,19 @@ public async Task GenerateAuditNotes( // POST /api/agent/audit/save // Action endpoint invoked by the RavenDB AI agent when it executes the SaveAudit action. + [Authorize(Roles = nameof(UserRole.Admin) + "," + nameof(UserRole.Analyst))] [Function(nameof(AgentSaveAudit))] public async Task AgentSaveAudit( [HttpTrigger("post", Route = "agent/audit/save")] HttpRequest req) { + var sub = req.HttpContext.User.FindFirst("sub")?.Value; + if (string.IsNullOrWhiteSpace(sub)) + return new UnauthorizedResult(); + + var currentUser = await session.LoadAsync(User.BuildId(sub), req.HttpContext.RequestAborted); + if (currentUser is null) + return new UnauthorizedResult(); + VeritySaveAuditArgs? args; try { @@ -222,6 +247,9 @@ public async Task AgentSaveAudit( if (company is null) return new NotFoundObjectResult($"Company '{report.CompanyId}' not found."); + if (!CanAccessCompany(currentUser, company.Id)) + return new ObjectResult("Access to this company is not allowed.") { StatusCode = 403 }; + var auditId = Audit.BuildId(company, report); var audit = await session.LoadAsync(auditId, req.HttpContext.RequestAborted); @@ -233,9 +261,10 @@ public async Task AgentSaveAudit( await session.StoreAsync(audit, req.HttpContext.RequestAborted); } - audit!.AuditorName = args.AuditorName; - audit.AuditorSurname = args.AuditorSurname; - audit.AuditorEmail = args.AuditorEmail; + // Auditor identity is taken from the authenticated user, not the posted body. + audit!.AuditorName = currentUser.Name; + audit.AuditorSurname = currentUser.Surname; + audit.AuditorEmail = currentUser.Email; audit.AuditString = args.AuditString; audit.GeneratedByAi = true; @@ -245,37 +274,40 @@ public async Task AgentSaveAudit( return new JsonResult(audit) { StatusCode = isNew ? StatusCodes.Status201Created : StatusCodes.Status200OK }; } + + private static bool CanAccessCompany(User user, string companyId) => + user.Role == UserRole.Admin || + (user.Role == UserRole.Analyst && user.CompanyIds.Contains(companyId)); } // ── Request DTOs ───────────────────────────────────────────── public record GenerateAuditRequest { public string ReportId { get; init; } = ""; - public string UserId { get; init; } = ""; } // ── Context DTOs ───────────────────────────────────────────── public record AuditAgentAuditor { - public string UserId { get; init; } = ""; - public string Name { get; init; } = ""; + public string UserId { get; init; } = ""; + public string Name { get; init; } = ""; public string Surname { get; init; } = ""; - public string Email { get; init; } = ""; + public string Email { get; init; } = ""; } public record AuditAgentReport { - public string ReportId { get; init; } = ""; - public string FormType { get; init; } = ""; - public int? Year { get; init; } - public int? Quarter { get; init; } - public string ReportDate { get; init; } = ""; - public string AccessionNumber { get; init; } = ""; + public string ReportId { get; init; } = ""; + public string FormType { get; init; } = ""; + public int? Year { get; init; } + public int? Quarter { get; init; } + public string ReportDate { get; init; } = ""; + public string AccessionNumber { get; init; } = ""; } public record AuditAgentContext { - public AuditAgentAuditor Auditor { get; init; } = new(); + public AuditAgentAuditor Auditor { get; init; } = new(); public AuditAgentReport Report { get; init; } = new(); public string ReportText { get; init; } = ""; } \ No newline at end of file diff --git a/src/RavenDB.Samples.Verity.App/Middleware/AuthMiddleware.cs b/src/RavenDB.Samples.Verity.App/Middleware/AuthMiddleware.cs new file mode 100644 index 0000000..b3c74eb --- /dev/null +++ b/src/RavenDB.Samples.Verity.App/Middleware/AuthMiddleware.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Middleware; +using System.Collections.Concurrent; +using System.Reflection; +using System.Security.Claims; + +namespace RavenDB.Samples.Verity.App; + +// IFunctionsWorkerMiddleware runs inside the Azure Functions pipeline (before the function executes). +// It authenticates every HTTP request using the registered JWT Bearer scheme and enforces +// [Authorize] and [Authorize(Roles = "...")] on functions that declare it. +public sealed class AuthMiddleware : IFunctionsWorkerMiddleware +{ + public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) + { + var httpContext = context.GetHttpContext(); + + // Non-HTTP triggers (Queue, Timer, …) skip auth entirely. + if (httpContext is null) + { + await next(context); + return; + } + + // Authenticate: validates the JWT and populates HttpContext.User. + var result = await httpContext.AuthenticateAsync(); + if (result.Succeeded) + httpContext.User = result.Principal; + + var authorize = GetAuthorizeAttribute(context); + + // Enforce [Authorize]: user must be authenticated. + if (authorize is not null && httpContext.User.Identity?.IsAuthenticated != true) + { + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + // Enforce [Authorize(Roles = "...")]: user must have at least one of the required roles. + if (authorize?.Roles is { Length: > 0 } roles) + { + var hasRole = roles.Split(',') + .Select(r => r.Trim()) + .Any(r => httpContext.User.HasClaim( + c => (c.Type == "role" || c.Type == ClaimTypes.Role) && c.Value == r)); + + if (!hasRole) + { + httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + } + + await next(context); + } + + // Cached per entry-point string — reflection runs once per unique function, not per request. + private static readonly ConcurrentDictionary _authorizeCache = new(); + + private static AuthorizeAttribute? GetAuthorizeAttribute(FunctionContext context) + { + var entryPoint = context.FunctionDefinition.EntryPoint; + if (string.IsNullOrWhiteSpace(entryPoint)) + return null; + + return _authorizeCache.GetOrAdd(entryPoint, static ep => + { + var dot = ep.LastIndexOf('.'); + if (dot < 0) return null; + + var typeName = ep[..dot]; + var methodName = ep[(dot + 1)..]; + + var type = AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(a => { try { return a.GetTypes(); } catch { return []; } }) + .FirstOrDefault(t => t.FullName == typeName); + + // Include Static so static entry points are not silently skipped. + var method = type?.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static); + + return method?.GetCustomAttribute() + ?? type?.GetCustomAttribute(); + }); + } +} diff --git a/src/RavenDB.Samples.Verity.App/Program.cs b/src/RavenDB.Samples.Verity.App/Program.cs index 49f968c..1c6fce7 100644 --- a/src/RavenDB.Samples.Verity.App/Program.cs +++ b/src/RavenDB.Samples.Verity.App/Program.cs @@ -1,11 +1,14 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Raven.Client.Http; +using RavenDB.Samples.Verity.App; using RavenDB.Samples.Verity.App.Infrastructure; using RavenDB.Samples.Verity.Setup; using System.Net.Security; +using System.Text.Json.Serialization; // Accept self-signed server certs (chain validation fails because our dev CA is not trusted). // Name and revocation checks still apply — only chain-of-trust errors are forgiven. @@ -34,9 +37,12 @@ }); builder.ConfigureFunctionsWebApplication(); - builder.Services.AddHttpContextAccessor(); +//Serialize enums as strings ("Admin", "Analyst", "Viewer"), not as numbers +builder.Services.Configure(o => + o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); + builder.Services.AddHttpClient(client => { var userAgent = Environment.GetEnvironmentVariable(Constants.EnvVars.SecEdgarUserAgent) @@ -44,8 +50,24 @@ client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent); }); +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(opt => + { + opt.Authority = Environment.GetEnvironmentVariable(Constants.EnvVars.IdentityUrl) + ?? throw new InvalidOperationException($"No environment variable '{Constants.EnvVars.IdentityUrl}' found."); + opt.Audience = "verity-api"; + opt.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + opt.MapInboundClaims = false; + opt.TokenValidationParameters.RoleClaimType = "role"; + opt.TokenValidationParameters.NameClaimType = "name"; + }); +builder.Services.AddAuthorization(); + + builder.Services .AddApplicationInsightsTelemetryWorkerService() .ConfigureFunctionsApplicationInsights(); +builder.UseMiddleware(); + builder.Build().Run(); diff --git a/src/RavenDB.Samples.Verity.App/RavenDB.Samples.Verity.App.csproj b/src/RavenDB.Samples.Verity.App/RavenDB.Samples.Verity.App.csproj index 2371112..0ecca13 100644 --- a/src/RavenDB.Samples.Verity.App/RavenDB.Samples.Verity.App.csproj +++ b/src/RavenDB.Samples.Verity.App/RavenDB.Samples.Verity.App.csproj @@ -20,6 +20,7 @@ + diff --git a/src/RavenDB.Samples.Verity.AppHost/AppHost.cs b/src/RavenDB.Samples.Verity.AppHost/AppHost.cs index 3fb74cb..241256d 100644 --- a/src/RavenDB.Samples.Verity.AppHost/AppHost.cs +++ b/src/RavenDB.Samples.Verity.AppHost/AppHost.cs @@ -122,6 +122,7 @@ .First(e => e.Name == "https") .TargetPort = ravenHostPort; + var db = ravenDbServer.AddDatabase("Verity"); var sink = ravenDbServer.AddDatabase("Verity-sink"); @@ -196,15 +197,49 @@ // activates and the TLS handshake to the secured RavenDB sink is rejected. .WithEnvironment("DOTNET_ENVIRONMENT", "Development"); -// Frontend -builder.AddNpmApp("Frontend", "../RavenDB.Samples.Verity.Frontend", "dev") - .WithReference(functions) +// Frontend (Vite dev server — internal; BFF is the external entry point) +var frontend = builder.AddNpmApp("Frontend", "../RavenDB.Samples.Verity.Frontend", "dev") .WithEnvironment("BROWSER", "none") - .WithEnvironment("APP_HTTP", functions.GetEndpoint("http")) .WithHttpEndpoint(env: "VITE_PORT") + .PublishAsDockerFile(); + +// Duende license key — optional. When set, suppresses the trial-mode warning. +// Wired as a regular Aspire parameter (empty default) so it shows up in the dashboard +// like the other parameters, instead of being read silently from raw configuration. +var duendeLicenseKey = builder + .AddParameter("duende-license", value: "", secret: true) + .WithDescription("Optional Duende IdentityServer license key. Leave empty to run in trial mode."); + +// IdentityServer — local Duende IdentityServer for development +var identity = builder.AddProject("identity") .WithExternalHttpEndpoints() - .PublishAsDockerFile() - .WaitFor(functions); + .WithReference(db) + .WaitFor(db) + .WithEnvironment(envRavenDbClientCertificatePath, serverCertPath) + .WithEnvironment("DOTNET_ENVIRONMENT", "Development"); +functions.WithEnvironment("Identity__Url", identity.GetEndpoint("http")); +// BFF — single entry point for the browser; proxies API (with tokens) + frontend +var bff = builder.AddProject("bff") + .WithReference(functions) + .WaitFor(functions) + .WithReference(frontend) + .WaitFor(frontend) + .WithReference(identity) + .WaitFor(identity) + // Pass actual URLs so OIDC issuer validation works correctly. + .WithEnvironment("Identity__Url", identity.GetEndpoint("http")) + .WithEnvironment("Api__Url", functions.GetEndpoint("http")) + .WithEnvironment("Frontend__Url", frontend.GetEndpoint("http")) + .WithExternalHttpEndpoints(); + +// Empty default keeps it optional — Duende falls back to trial mode when unset. +identity.WithEnvironment("IdentityServer__LicenseKey", duendeLicenseKey); +bff.WithEnvironment("IdentityServer__LicenseKey", duendeLicenseKey); + +// Tell IdentityServer the BFF's base URL for redirect URI registration. +// Must be the external HTTPS endpoint — the browser sends redirect_uri based on +// the public URL it sees, so IdentityServer must register the same HTTPS address. +identity.WithEnvironment("Bff__BaseUrl", bff.GetEndpoint("https")); builder.Build().Run(); diff --git a/src/RavenDB.Samples.Verity.AppHost/RavenDB.Samples.Verity.AppHost.csproj b/src/RavenDB.Samples.Verity.AppHost/RavenDB.Samples.Verity.AppHost.csproj index d9e22d6..2db237b 100644 --- a/src/RavenDB.Samples.Verity.AppHost/RavenDB.Samples.Verity.AppHost.csproj +++ b/src/RavenDB.Samples.Verity.AppHost/RavenDB.Samples.Verity.AppHost.csproj @@ -23,6 +23,8 @@ + + diff --git a/src/RavenDB.Samples.Verity.Bff/Program.cs b/src/RavenDB.Samples.Verity.Bff/Program.cs new file mode 100644 index 0000000..6f3fa66 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Bff/Program.cs @@ -0,0 +1,135 @@ +using Duende.Bff.AccessTokenManagement; +using Duende.Bff; +using Microsoft.IdentityModel.Tokens; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.DataProtection; +using Yarp.ReverseProxy.Forwarder; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Services.AddDataProtection(); + +// ─── URLs injected by AppHost ──────────────────────────────────────────────── +var identityUrl = builder.Configuration["Identity:Url"] + ?? throw new InvalidOperationException("Missing configuration: Identity:Url"); +var apiUrl = builder.Configuration["Api:Url"] + ?? throw new InvalidOperationException("Missing configuration: Api:Url"); +var frontendUrl = builder.Configuration["Frontend:Url"] + ?? throw new InvalidOperationException("Missing configuration: Frontend:Url"); + +// ─── Duende BFF (session + management endpoints) ───────────────────────────── +// Generate RSA key for DPoP +var rsaKey = new RsaSecurityKey(RSA.Create(2048)); +var jwk = JsonWebKeyConverter.ConvertFromSecurityKey(rsaKey); +jwk.Alg = SecurityAlgorithms.RsaSsaPssSha256; + +builder.Services.AddBff(options => +{ + options.DPoPJsonWebKey = DPoPProofKey.Parse(JsonSerializer.Serialize(jwk)); +}); + +// ─── Authentication ─────────────────────────────────────────────────────────── +builder.Services + .AddAuthentication(options => + { + options.DefaultScheme = "cookie"; + options.DefaultChallengeScheme = "oidc"; + options.DefaultSignOutScheme = "oidc"; + }) + .AddCookie("cookie", options => + { + options.Cookie.Name = "verity-bff"; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + }) + .AddOpenIdConnect("oidc", options => + { + options.Authority = identityUrl; + options.ClientId = "verity-bff"; + options.ClientSecret = builder.Configuration["Oidc:ClientSecret"] + ?? (builder.Environment.IsDevelopment() ? "bff-secret" : throw new InvalidOperationException("Missing configuration: Oidc:ClientSecret")); + options.ResponseType = "code"; + options.ResponseMode = "query"; + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + options.GetClaimsFromUserInfoEndpoint = true; + options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require; + options.MapInboundClaims = false; + options.SaveTokens = true; + + options.Scope.Clear(); + foreach (var s in new[] { "openid", "profile", "email", "verity-api", "offline_access" }) + options.Scope.Add(s); + }); + +// ─── Authorization ──────────────────────────────────────────────────────────── +builder.Services.AddAuthorization(); + +// ─── HTTP clients ───────────────────────────────────────────────────────────── +builder.Services.AddHttpClient(); +builder.Services.AddHttpForwarder(); + +// ─── Pipeline ───────────────────────────────────────────────────────────────── +var app = builder.Build(); +app.MapDefaultEndpoints(); + +app.UseRouting(); +app.UseAuthentication(); +app.UseBff(); +app.UseAuthorization(); + +// BFF management: /bff/login /bff/logout /bff/user etc. +app.MapBffManagementEndpoints(); + +// POST /bff/register — proxy JSON registration body to IdentityServer. +app.MapPost("/bff/register", async (HttpRequest req, IHttpClientFactory http) => +{ + using var client = http.CreateClient(); + var content = new StreamContent(req.Body); + content.Headers.ContentType = new("application/json"); + + var response = await client.PostAsync($"{identityUrl}/api/register", content, req.HttpContext.RequestAborted); + var body = await response.Content.ReadAsStringAsync(req.HttpContext.RequestAborted); + return Results.Content(body, "application/json", statusCode: (int)response.StatusCode); +}).AllowAnonymous(); + +// SSE streaming endpoints — EventSource cannot send custom headers, so skip X-CSRF. +// Auth is still enforced: unauthenticated requests get 401 from RequireAuthorization(). +app.MapForwarder("/api/audit/stream", apiUrl, new ForwarderRequestConfig(), new ApiTokenTransformer()) + .RequireAuthorization(); +app.MapForwarder("/api/report/stream", apiUrl, new ForwarderRequestConfig(), new ApiTokenTransformer()) + .RequireAuthorization(); + +// /api/* → Azure Functions. +// ApiTokenTransformer reads the access token from the session cookie and adds +// it as a Bearer header, implementing the BFF token-forwarding pattern without YARP. +app.MapForwarder("/api/{**catch-all}", apiUrl, + new ForwarderRequestConfig(), new ApiTokenTransformer()) + .AsBffApiEndpoint(); // ← forces X-CSRF header + +// /* → Vite dev server (plain proxy, WebSocket/HMR included). +app.MapForwarder("/{**catch-all}", frontendUrl); + +app.Run(); + +// ─── Token transformer ──────────────────────────────────────────────────────── +sealed class ApiTokenTransformer : HttpTransformer +{ + public override async ValueTask TransformRequestAsync( + HttpContext httpContext, + HttpRequestMessage proxyRequest, + string destinationPrefix, + CancellationToken cancellationToken) + { + await base.TransformRequestAsync(httpContext, proxyRequest, destinationPrefix, cancellationToken); + + // Reads the access token stored in the session cookie by SaveTokens = true. + // Token refresh is handled automatically by Duende.AccessTokenManagement + // (included with AddBff()) in the background. + var token = await httpContext.GetTokenAsync("access_token"); + if (token is not null) + proxyRequest.Headers.Authorization = new("Bearer", token); + } +} diff --git a/src/RavenDB.Samples.Verity.Bff/Properties/launchSettings.json b/src/RavenDB.Samples.Verity.Bff/Properties/launchSettings.json new file mode 100644 index 0000000..899353f --- /dev/null +++ b/src/RavenDB.Samples.Verity.Bff/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "RavenDB.Samples.Verity.Bff": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:51967;http://localhost:51969" + } + } +} \ No newline at end of file diff --git a/src/RavenDB.Samples.Verity.Bff/RavenDB.Samples.Verity.Bff.csproj b/src/RavenDB.Samples.Verity.Bff/RavenDB.Samples.Verity.Bff.csproj new file mode 100644 index 0000000..5a47d7f --- /dev/null +++ b/src/RavenDB.Samples.Verity.Bff/RavenDB.Samples.Verity.Bff.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/api.ts b/src/RavenDB.Samples.Verity.Frontend/src/lib/api.ts index 5979d4e..14305d3 100644 --- a/src/RavenDB.Samples.Verity.Frontend/src/lib/api.ts +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/api.ts @@ -3,37 +3,52 @@ */ export interface PagedResult { - items: T[]; - page: number; - pageSize: number; - totalPages: number; + items: T[]; + page: number; + pageSize: number; + totalPages: number; } -export const API_BASE_URL: string = _BASE_API_HTTP_ ?? ''; +// API calls use relative paths — BFF proxies them to Azure Functions. +export const API_BASE_URL: string = ""; export function apiUrl(path: string): string { - const base = API_BASE_URL.endsWith('/') - ? API_BASE_URL.slice(0, -1) - : API_BASE_URL; + const base = API_BASE_URL.endsWith("/") + ? API_BASE_URL.slice(0, -1) + : API_BASE_URL; - const route = path.startsWith('/') ? path : `/${path}`; + const route = path.startsWith("/") ? path : `/${path}`; - return `${base}${route}`; + return `${base}${route}`; } -export async function callApi(path: string, options?: RequestInit): Promise { - const res = await fetch(apiUrl(path), options); - - if (!res.ok) { - const txt = await res.text().catch(() => ''); - throw new Error(`HTTP ${res.status} ${res.statusText} ${txt}`); - } - - const ct = res.headers.get('content-type') || ''; - if (ct.includes('application/json')) { - return (await res.json()) as T; - } - - // fallback to plain text for non-JSON responses - return (await res.text()) as unknown as T; -} \ No newline at end of file +export async function callApi( + path: string, + options?: RequestInit, +): Promise { + // Use Headers() so merging works correctly when options.headers is a Headers instance. + const headers = new Headers(options?.headers); + headers.set("X-CSRF", "1"); + const res = await fetch(apiUrl(path), { ...options, headers }); + + if (res.status === 401) { + if (typeof window === "undefined") { + throw new Error("HTTP 401 Unauthorized"); + } + window.location.href = `/bff/login?returnUrl=${encodeURIComponent(window.location.pathname + window.location.search)}`; + return new Promise(() => {}); // never resolves — navigation takes over + } + + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`HTTP ${res.status} ${res.statusText} ${txt}`); + } + + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + return (await res.json()) as T; + } + + // fallback to plain text for non-JSON responses + return (await res.text()) as unknown as T; +} diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/auth.ts b/src/RavenDB.Samples.Verity.Frontend/src/lib/auth.ts new file mode 100644 index 0000000..b8d83e0 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/auth.ts @@ -0,0 +1,76 @@ +/** + * BFF auth client — wraps /bff/user and provides typed session info. + * The /bff/user endpoint returns an array of claims when authenticated, + * or HTTP 401 when the session cookie is absent/expired. + */ + +export interface UserInfo { + sub: string; + name: string; + givenName: string; + familyName: string; + email: string; + role: string; + companyIds: string[]; + logoutUrl: string; +} + +interface BffClaim { + type: string; + value: string; +} + +export async function getUser(): Promise { + try { + // X-CSRF: 1 is required by Duende BFF on all management endpoints. + const res = await fetch('/bff/user', { headers: { 'X-CSRF': '1' } }); + if (!res.ok) return null; + + const claims: BffClaim[] = await res.json(); + const get = (type: string) => claims.find(c => c.type === type)?.value ?? ''; + const getAll = (type: string) => claims.filter(c => c.type === type).map(c => c.value); + + return { + sub: get('sub'), + name: get('name') || get('preferred_username') || get('email') || get('sub'), + givenName: get('given_name'), + familyName: get('family_name'), + email: get('email'), + role: get('role') || 'Viewer', + companyIds: getAll('company_id'), + logoutUrl: get('bff:logout_url'), + }; + } catch { + return null; + } +} + +export interface RegisterData { + username: string; + password: string; + displayName: string; + email: string; +} + +export async function register(data: RegisterData): Promise<{ success: boolean; error?: string }> { + try { + const res = await fetch('/bff/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.ok) return { success: true }; + const body = await res.json().catch(() => ({})); + return { success: false, error: body.error ?? 'Registration failed.' }; + } catch { + return { success: false, error: 'Network error. Please try again.' }; + } +} + +export function loginUrl(returnUrl = '/') { + return `/bff/login?returnUrl=${encodeURIComponent(returnUrl)}`; +} + +export function registerUrl(returnUrl = '/') { + return `/login?tab=register&returnUrl=${encodeURIComponent(returnUrl)}`; +} diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/components/AuthBar.svelte b/src/RavenDB.Samples.Verity.Frontend/src/lib/components/AuthBar.svelte new file mode 100644 index 0000000..b2ffc93 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/components/AuthBar.svelte @@ -0,0 +1,70 @@ + + +{#if user !== 'loading'} +
+ {#if user} + {#if user.role === 'Admin'} + Security Events + Admin + {/if} + {user.name} + Sign out + {:else} + Register + Sign in + {/if} +
+{/if} + + diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/components/AuthModal.svelte b/src/RavenDB.Samples.Verity.Frontend/src/lib/components/AuthModal.svelte new file mode 100644 index 0000000..fda332d --- /dev/null +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/components/AuthModal.svelte @@ -0,0 +1,385 @@ + + + + +{#if $authModal.open} + +
+ +
+{/if} + + diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/services/audit.ts b/src/RavenDB.Samples.Verity.Frontend/src/lib/services/audit.ts index 0d92144..14cf309 100644 --- a/src/RavenDB.Samples.Verity.Frontend/src/lib/services/audit.ts +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/services/audit.ts @@ -61,11 +61,11 @@ export async function getAuditRevisions(reportId: string): Promise { +export async function generateAuditDraft(reportId: string): Promise { const result = await callApi<{ notes: string }>('api/agent/audit/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reportId, userId }), + body: JSON.stringify({ reportId }), }); return result.notes; } diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/services/security.ts b/src/RavenDB.Samples.Verity.Frontend/src/lib/services/security.ts new file mode 100644 index 0000000..29e8c34 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/services/security.ts @@ -0,0 +1,17 @@ +import { callApi, type PagedResult } from '$lib/api'; + +export interface SecurityEvent { + id: string; + eventType: string; + userId: string | null; + userName: string | null; + clientId: string | null; + ipAddress: string | null; + at: string; + success: boolean; + details: string | null; +} + +export async function getSecurityEvents(page = 1, pageSize = 20): Promise> { + return callApi>(`api/security/events?page=${page}&pageSize=${pageSize}`); +} diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/services/users.ts b/src/RavenDB.Samples.Verity.Frontend/src/lib/services/users.ts index 543ef54..6e00565 100644 --- a/src/RavenDB.Samples.Verity.Frontend/src/lib/services/users.ts +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/services/users.ts @@ -1,13 +1,35 @@ import { callApi } from '$lib/api'; export interface User { - id: string; - companyId: string; - name: string; - surname: string; - email: string; + id: string; + subjectId: string; + companyIds: string[]; + name: string; + surname: string; + email: string; + role: string; } export async function getUsersByCompany(companyId: string): Promise { - return callApi(`api/users?companyId=${encodeURIComponent(companyId)}`); + return callApi(`api/users?companyId=${encodeURIComponent(companyId)}`); +} + +export async function getAllUsers(): Promise { + return callApi('api/manage/users'); +} + +export async function setUserRole(subjectId: string, role: string): Promise { + return callApi(`api/manage/users/${encodeURIComponent(subjectId)}/role`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + }); +} + +export async function setUserCompanies(subjectId: string, companyIds: string[]): Promise { + return callApi(`api/manage/users/${encodeURIComponent(subjectId)}/companies`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ companyIds }), + }); } diff --git a/src/RavenDB.Samples.Verity.Frontend/src/lib/stores/authModal.ts b/src/RavenDB.Samples.Verity.Frontend/src/lib/stores/authModal.ts new file mode 100644 index 0000000..760101b --- /dev/null +++ b/src/RavenDB.Samples.Verity.Frontend/src/lib/stores/authModal.ts @@ -0,0 +1,13 @@ +import { writable } from 'svelte/store'; + +export type AuthModalTab = 'login' | 'register'; + +const _store = writable<{ open: boolean; tab: AuthModalTab; hint: string | null }>({ open: false, tab: 'login', hint: null }); + +export const authModal = { + subscribe: _store.subscribe, + openLogin: (hint?: string) => _store.set({ open: true, tab: 'login', hint: hint ?? null }), + openRegister: (hint?: string) => _store.set({ open: true, tab: 'register', hint: hint ?? null }), + close: () => _store.update(s => ({ ...s, open: false, hint: null })), + switchTab: (tab: AuthModalTab) => _store.update(s => ({ ...s, tab })), +}; diff --git a/src/RavenDB.Samples.Verity.Frontend/src/routes/+layout.svelte b/src/RavenDB.Samples.Verity.Frontend/src/routes/+layout.svelte index addcea0..5e9b8ec 100644 --- a/src/RavenDB.Samples.Verity.Frontend/src/routes/+layout.svelte +++ b/src/RavenDB.Samples.Verity.Frontend/src/routes/+layout.svelte @@ -1,6 +1,7 @@ + + + Verity — Admin Panel + + +
+ + + {#if loading} +

Loading…

+ {:else if pageError} +

{pageError}

+ {:else} +
+ +
+ + + + + + + + + + + {#each users as u (u.id)} + + + + + + + {/each} + +
UserRoleCompanies
+ {u.name} {u.surname} + {u.email} + + + + {#if pendingRole[u.subjectId] === 'Analyst'} +
+ {#each companies as c} + + {/each} +
+ {:else} + + {/if} +
+ +
+
+
+ {/if} +
+ + diff --git a/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/+page.svelte b/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/+page.svelte index 0bbd51d..6d7a6f6 100644 --- a/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/+page.svelte +++ b/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/+page.svelte @@ -4,10 +4,14 @@ import { goto } from '$app/navigation'; import { getReportsByCik, fetch10Q, type Report } from '$lib/services/reports'; import { getCompany, type Company } from '$lib/services/companies'; + import { getUser, type UserInfo } from '$lib/auth'; + import { authModal } from '$lib/stores/authModal'; + import AuthBar from '$lib/components/AuthBar.svelte'; import { lastUpdatedReportId } from '$lib/stores/liveUpdates'; - const cik = decodeURIComponent($page.params.cik); + const cik = decodeURIComponent($page.params.cik ?? ''); + let user = $state(null); let company = $state(null); let reports = $state([]); let status = $state<'loading' | 'ok' | 'error'>('loading'); @@ -17,7 +21,17 @@ let fetchErrorMsg = $state(''); let maxReports = $state(5); - onMount(loadData); + let canFetch = $derived( + company !== null && ( + user?.role === 'Admin' || + (user?.role === 'Analyst' && user.companyIds.includes(company.id)) + ) + ); + + onMount(async () => { + user = await getUser(); + await loadData(); + }); $effect(() => { document.title = company ? `Verity - ${company.name}` : 'Verity'; @@ -61,6 +75,14 @@ } } + function openReport(accessionNumber: string) { + if (!user) { + authModal.openLogin('Sign in to view report details.'); + return; + } + goto(`/companies/${encodeURIComponent(cik)}/reports/${encodeURIComponent(accessionNumber)}`); + } + function formatNumber(n: number | null | undefined): string { if (n == null) return '—'; const abs = Math.abs(n); @@ -185,13 +207,16 @@
+ ← Back {#if company}

Verity: {company.name}

{:else}

Verity: Company

{/if} - ← Back - {reports.length} report{reports.length !== 1 ? 's' : ''} +
+ {reports.length} report{reports.length !== 1 ? 's' : ''} + +
{#if status === 'loading'} @@ -238,31 +263,33 @@ {/if} - -
- - - - 10-Q/K reports -
+ + {#if canFetch} +
+ + + + 10-Q/K reports +
- {#if fetchStatus === 'ok'} - - {:else if fetchStatus === 'error'} - + {#if fetchStatus === 'ok'} + + {:else if fetchStatus === 'error'} + + {/if} {/if} @@ -289,8 +316,8 @@ {#each reports as r} - goto(`/companies/${encodeURIComponent(cik)}/reports/${encodeURIComponent(r.accessionNumber)}`)}> - {r.formType} + openReport(r.accessionNumber)}> + {r.formType} {r.year} - {r.quarter != null ? `Q${r.quarter}` : '—'} {r.reportDate} {formatNumber(r.revenues)} {r.abbreviation} @@ -393,15 +420,24 @@ /* Header */ header { - display: flex; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - gap: 1rem; background: #0b2e5c; color: #fff; padding: 1rem 2rem; box-shadow: 0 2px 8px rgba(0,0,0,.5); } + header h1 { text-align: center; } + + .header-right { + display: flex; + align-items: center; + gap: 0.75rem; + justify-self: end; + } + h1 { margin: 0; font-size: 1.3rem; @@ -418,7 +454,6 @@ .back-btn:hover { opacity: 1; } .badge { - margin-left: auto; font-size: 0.8rem; background: rgba(255,255,255,.12); padding: 0.2rem 0.6rem; diff --git a/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/reports/[accession]/+page.svelte b/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/reports/[accession]/+page.svelte index 2a120c3..1ee728b 100644 --- a/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/reports/[accession]/+page.svelte +++ b/src/RavenDB.Samples.Verity.Frontend/src/routes/companies/[cik]/reports/[accession]/+page.svelte @@ -1,13 +1,15 @@ + + + Verity — Create account + + +
+
+ + +

Create account

+ +
+ {#if error} +
{error}
+ {/if} + + + + + + + + + + + + +

At least 8 characters.

+ + + + + +
+ + +
+
+ + diff --git a/src/RavenDB.Samples.Verity.Frontend/src/routes/register/+page.svelte b/src/RavenDB.Samples.Verity.Frontend/src/routes/register/+page.svelte new file mode 100644 index 0000000..834c659 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Frontend/src/routes/register/+page.svelte @@ -0,0 +1,9 @@ + diff --git a/src/RavenDB.Samples.Verity.Frontend/src/routes/security/+page.svelte b/src/RavenDB.Samples.Verity.Frontend/src/routes/security/+page.svelte new file mode 100644 index 0000000..5d2ed93 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Frontend/src/routes/security/+page.svelte @@ -0,0 +1,253 @@ + + Verity — Security Events + + + + +
+
+ ← Companies +

Security Events

+
+
+ +
+ Authentication events recorded by Duende IdentityServer and stored in RavenDB. + Each row represents a login, logout, token issuance, or auth failure. +
+ + {#if status === 'loading'} +
+
+

Loading events…

+
+ + {:else if status === 'error'} +
+

✗ {errorMsg}

+
+ + {:else if status === 'empty'} +
+

No security events yet. Log in or out to generate the first ones.

+
+ + {:else} +
+ + + + + + + + + + + + {#each events as ev (ev.id)} + + + + + + + + {/each} + +
EventUserIPDetailsTime
+ + {ev.success ? '✓' : '✗'} + + {eventLabel(ev.eventType)} + {ev.userName ?? ev.userId ?? '—'}{ev.ipAddress ?? '—'}{ev.details ?? '—'}{formatDate(ev.at)}
+
+ + {#if totalPages > 1} + + {/if} + {/if} +
+ + diff --git a/src/RavenDB.Samples.Verity.Frontend/static/samples-ui-wrapper.js b/src/RavenDB.Samples.Verity.Frontend/static/samples-ui-wrapper.js index 6b8fa80..bac0cee 100644 --- a/src/RavenDB.Samples.Verity.Frontend/static/samples-ui-wrapper.js +++ b/src/RavenDB.Samples.Verity.Frontend/static/samples-ui-wrapper.js @@ -118,6 +118,9 @@ class SamplesUIWrapper extends HTMLElement { this.setTheme(this.getAttribute("theme")); this.shadowRoot.querySelector(".source-link").href = this.getAttribute("sourceLink"); + if (sessionStorage.getItem("ravendb_welcome_seen")) { + this.shadowRoot.querySelector(".welcome-toast").style.display = "none"; + } } validateAttributes() { @@ -145,6 +148,7 @@ class SamplesUIWrapper extends HTMLElement { } closeWelcomeToast() { + sessionStorage.setItem("ravendb_welcome_seen", "1"); this.shadowRoot.querySelector(".welcome-toast").style.display = "none"; } diff --git a/src/RavenDB.Samples.Verity.Frontend/vite.config.ts b/src/RavenDB.Samples.Verity.Frontend/vite.config.ts index 12722cf..ab13936 100644 --- a/src/RavenDB.Samples.Verity.Frontend/vite.config.ts +++ b/src/RavenDB.Samples.Verity.Frontend/vite.config.ts @@ -11,8 +11,8 @@ export default defineConfig(({ mode }) => { port: parseInt(env.VITE_PORT) }, define: { - // Expose APP_HTTP as BASE_API_HTTP for client-side access - _BASE_API_HTTP_: JSON.stringify(process.env.APP_HTTP ?? '') + // API calls use relative paths through the BFF proxy — no base URL needed. + _BASE_API_HTTP_: JSON.stringify(''), }, test: { expect: { requireAssertions: true }, diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Endpoints/RegisterEndpoints.cs b/src/RavenDB.Samples.Verity.IdentityServer/Endpoints/RegisterEndpoints.cs new file mode 100644 index 0000000..ae74e3e --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Endpoints/RegisterEndpoints.cs @@ -0,0 +1,35 @@ +using RavenDB.Samples.Verity.Model; + +namespace RavenDB.Samples.Verity.IdentityServer.Endpoints; + +public static class RegisterEndpoints +{ + public static IEndpointRouteBuilder MapRegisterEndpoints(this IEndpointRouteBuilder app) + { + // Called by the BFF proxy: POST /bff/register → POST /api/register + app.MapPost("/api/register", async (RegisterApiRequest req, UserStore users) => + { + if (string.IsNullOrWhiteSpace(req.Username) || req.Username.Length < 2) + return Results.BadRequest(new { error = "Username must be at least 2 characters." }); + if (string.IsNullOrWhiteSpace(req.Password) || req.Password.Length < 8) + return Results.BadRequest(new { error = "Password must be at least 8 characters." }); + if (string.IsNullOrWhiteSpace(req.DisplayName) || req.DisplayName.Length < 2) + return Results.BadRequest(new { error = "Full name must be at least 2 characters." }); + if (string.IsNullOrWhiteSpace(req.Email)) + return Results.BadRequest(new { error = "Email is required." }); + + var (success, error) = await users.RegisterAsync( + req.Username, req.Password, req.DisplayName, req.Email); + + return success ? Results.Ok() : Results.BadRequest(new { error }); + }).AllowAnonymous(); + + return app; + } +} + +record RegisterApiRequest( + string Username, + string Password, + string DisplayName, + string Email); diff --git a/src/RavenDB.Samples.Verity.IdentityServer/IdentityConfig.cs b/src/RavenDB.Samples.Verity.IdentityServer/IdentityConfig.cs new file mode 100644 index 0000000..0d3b70b --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/IdentityConfig.cs @@ -0,0 +1,63 @@ +using Duende.IdentityModel; +using Duende.IdentityServer.Models; + +namespace RavenDB.Samples.Verity.IdentityServer; + +public static class IdentityConfig +{ + public static IEnumerable IdentityResources => + [ + new IdentityResources.OpenId(), + new IdentityResources.Profile + { + // role and company_id must be in an identity scope so that /bff/user returns them. + UserClaims = [..new IdentityResources.Profile().UserClaims, JwtClaimTypes.Role, "company_id"], + }, + new IdentityResources.Email(), + ]; + + public static IEnumerable ApiScopes => + [ + new ApiScope("verity-api", "Verity API") + { + // Include role and company_id in every access token issued for this scope. + UserClaims = [JwtClaimTypes.Role, "company_id"], + }, + ]; + public static IEnumerable ApiResources => + [ + new ApiResource("verity-api", "Verity API") + { + Scopes = { "verity-api" }, + }, + ]; + + public static IEnumerable GetClients(string bffBaseUrl) => + [ + new Client + { + ClientId = "verity-bff", + ClientSecrets = { new Secret("bff-secret".Sha256()) }, + + AllowedGrantTypes = GrantTypes.Code, + RequirePkce = true, + RequireConsent = false, + + RequirePushedAuthorization = true, + RefreshTokenUsage = TokenUsage.OneTimeOnly, + + RedirectUris = { $"{bffBaseUrl}/signin-oidc" }, + FrontChannelLogoutUri = $"{bffBaseUrl}/signout-oidc", + PostLogoutRedirectUris = { $"{bffBaseUrl}/signout-callback-oidc" }, + + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "email", "verity-api", "offline_access" }, + + // Put all user claims directly in the ID token so the BFF session + // always has them — avoids relying on the UserInfo roundtrip. + AlwaysIncludeUserClaimsInIdToken = true, + + AccessTokenLifetime = 3600, + }, + ]; +} diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Login/Index.cshtml b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Login/Index.cshtml new file mode 100644 index 0000000..2e78ec9 --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Login/Index.cshtml @@ -0,0 +1,59 @@ +@page +@model RavenDB.Samples.Verity.IdentityServer.Pages.Account.Login.IndexModel +@{ + Layout = null; +} + + + + + + Verity — Sign In + + + +
+

Verity Sign In

+ + @if (Model.Registered) + { +
Account created — you can sign in now.
+ } + +
+ @if (Model.ErrorMessage != null) + { +
@Model.ErrorMessage
+ } + + + + + + + + + +
+ + +
+ + diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Login/Index.cshtml.cs b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Login/Index.cshtml.cs new file mode 100644 index 0000000..9b2352f --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Login/Index.cshtml.cs @@ -0,0 +1,78 @@ +using Duende.IdentityServer; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Configuration; + +namespace RavenDB.Samples.Verity.IdentityServer.Pages.Account.Login; + +public class IndexModel( + UserStore users, + IIdentityServerInteractionService interaction, + IConfiguration configuration) : PageModel +{ + [BindProperty] + public InputModel Input { get; set; } = new(); + + public bool Registered { get; set; } + public string? ErrorMessage { get; set; } + + public void OnGet(string? returnUrl = null, bool registered = false) + { + Input.ReturnUrl = returnUrl ?? string.Empty; + Registered = registered; + } + + public async Task OnPostAsync() + { + try + { + var context = string.IsNullOrEmpty(Input.ReturnUrl) + ? null + : await interaction.GetAuthorizationContextAsync(Input.ReturnUrl); + + if (await users.ValidateCredentialsAsync(Input.Username, Input.Password)) + { + var user = (await users.FindByUsernameAsync(Input.Username))!; + + await HttpContext.SignInAsync( + // Registered users always have a SubjectId set (see UserStore.RegisterAsync). + new IdentityServerUser(user.SubjectId!) + { + AdditionalClaims = users.GetClaims(user).ToList(), + }, + new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.Add(TimeSpan.FromDays(1)), + }); + + if (context != null) + return Redirect(Input.ReturnUrl); + + if (Url.IsLocalUrl(Input.ReturnUrl)) + return LocalRedirect(Input.ReturnUrl); + + var bffBaseUrl = configuration["Bff:BaseUrl"] ?? "/"; + return Redirect(bffBaseUrl); + } + + ErrorMessage = "Invalid username or password."; + } + catch (Exception) + { + ErrorMessage = "Login error. Please try again."; + } + + Input.Password = string.Empty; + return Page(); + } + + public class InputModel + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string ReturnUrl { get; set; } = string.Empty; + } +} diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Logout/Index.cshtml b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Logout/Index.cshtml new file mode 100644 index 0000000..1512466 --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Logout/Index.cshtml @@ -0,0 +1,7 @@ +@page +@model RavenDB.Samples.Verity.IdentityServer.Pages.Account.Logout.IndexModel +@{ Layout = null; } + +Signing out… +

Signing out…

+ diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Logout/Index.cshtml.cs b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Logout/Index.cshtml.cs new file mode 100644 index 0000000..8650dc4 --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Logout/Index.cshtml.cs @@ -0,0 +1,23 @@ +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RavenDB.Samples.Verity.IdentityServer.Pages.Account.Logout; + +public class IndexModel(IIdentityServerInteractionService interaction) : PageModel +{ + public async Task OnGetAsync(string? logoutId = null) + { + var context = await interaction.GetLogoutContextAsync(logoutId); + await HttpContext.SignOutAsync(); + + var redirectUri = context?.PostLogoutRedirectUri; + return redirectUri is not null + ? Redirect(redirectUri) + : Redirect("~/"); + } + + public async Task OnPostAsync(string? logoutId = null) => + await OnGetAsync(logoutId); +} diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Register/Index.cshtml b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Register/Index.cshtml new file mode 100644 index 0000000..6d9ab5e --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Register/Index.cshtml @@ -0,0 +1,171 @@ +@page +@model RavenDB.Samples.Verity.IdentityServer.Pages.Account.Register.IndexModel +@{ + Layout = null; +} + + + + + + + Verity — Create Account + + + + +
+

Create Account

+ +
+
+ + + + + + + + + + + + + +

At least 8 characters.

+ + + + + +
+ + +
+ + + diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Register/Index.cshtml.cs b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Register/Index.cshtml.cs new file mode 100644 index 0000000..f2bd791 --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Pages/Account/Register/Index.cshtml.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using RavenDB.Samples.Verity.Model; +using System.ComponentModel.DataAnnotations; + +namespace RavenDB.Samples.Verity.IdentityServer.Pages.Account.Register; + +public class IndexModel(UserStore users, IConfiguration config) : PageModel +{ + private readonly string _bffBaseUrl = + config["Bff:BaseUrl"] ?? throw new InvalidOperationException("Missing: Bff:BaseUrl"); + + [BindProperty] + public InputModel Input { get; set; } = new(); + + public void OnGet(string? bffReturnUrl = null) + { + Input.BffReturnUrl = bffReturnUrl ?? "/"; + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + if (Input.Password != Input.ConfirmPassword) + { + ModelState.AddModelError(nameof(Input.ConfirmPassword), "Passwords do not match."); + return Page(); + } + + var (success, error) = await users.RegisterAsync( + Input.Username, Input.Password, Input.DisplayName, Input.Email, UserRole.Viewer); + + if (!success) + { + ModelState.AddModelError(nameof(Input.Username), error); + return Page(); + } + + var rawReturn = Input.BffReturnUrl; + if (string.IsNullOrEmpty(rawReturn) || !rawReturn.StartsWith('/') || rawReturn.StartsWith("//")) + rawReturn = "/"; + var returnUrl = Uri.EscapeDataString(rawReturn); + return Redirect($"{_bffBaseUrl}/bff/login?returnUrl={returnUrl}"); + } + + public class InputModel + { + [Required, StringLength(50, MinimumLength = 2)] + public string Username { get; set; } = string.Empty; + + [Required, StringLength(100, MinimumLength = 2)] + public string DisplayName { get; set; } = string.Empty; + + [Required, EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required, StringLength(100, MinimumLength = 8)] + public string Password { get; set; } = string.Empty; + + [Required] + public string ConfirmPassword { get; set; } = string.Empty; + + public string BffReturnUrl { get; set; } = "/"; + } +} diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Pages/_ViewImports.cshtml b/src/RavenDB.Samples.Verity.IdentityServer/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..318d08f --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using RavenDB.Samples.Verity.IdentityServer +@using RavenDB.Samples.Verity.IdentityServer.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Program.cs b/src/RavenDB.Samples.Verity.IdentityServer/Program.cs new file mode 100644 index 0000000..00bb68c --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Program.cs @@ -0,0 +1,115 @@ +using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Services; +using Microsoft.IdentityModel.Tokens; +using RavenDB.Samples.Verity.IdentityServer; +using RavenDB.Samples.Verity.IdentityServer.Endpoints; +using RavenDB.Samples.Verity.Setup; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Services.AddRazorPages(); + +// Allow minimal-API JSON deserialization of enum values sent as strings (e.g. "Viewer", "Analyst") +// instead of numeric values. +builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + +var bffBaseUrl = builder.Configuration["Bff:BaseUrl"] + ?? throw new InvalidOperationException("Missing configuration: Bff:BaseUrl"); + +var dbName = Constants.DatabaseName ?? ""; + +builder.AddRavenDBClient(dbName); + +builder.Services.AddSingleton(); +builder.Services.AddTransient(); + +// IdentityServer sets SameSite=None on its auth cookies, which requires Secure +// (HTTPS). In dev we run on plain HTTP so the browser rejects those cookies and +// the user appears unauthenticated on the very next request. Override every +// outgoing SameSite=None cookie at middleware level before headers are sent. +if (builder.Environment.IsDevelopment()) +{ + builder.Services.Configure(options => + { + options.OnAppendCookie = ctx => + { + if (ctx.CookieOptions.SameSite == SameSiteMode.None) + { + ctx.CookieOptions.SameSite = SameSiteMode.Lax; + ctx.CookieOptions.Secure = false; + } + }; + options.OnDeleteCookie = ctx => + { + if (ctx.CookieOptions.SameSite == SameSiteMode.None) + { + ctx.CookieOptions.SameSite = SameSiteMode.Lax; + ctx.CookieOptions.Secure = false; + } + }; + }); +} + +builder.Services + .AddIdentityServer(opt => + { + opt.Events.RaiseErrorEvents = true; + opt.Events.RaiseFailureEvents = true; + opt.Events.RaiseSuccessEvents = true; + if (builder.Environment.IsProduction()) + { + opt.KeyManagement.KeyPath = "/tmp/keys"; + } + opt.KeyManagement.SigningAlgorithms.Add(new SigningAlgorithmOptions(SecurityAlgorithms.RsaSsaPssSha256)); + + opt.DPoP.SupportedDPoPSigningAlgorithms = [ + SecurityAlgorithms.RsaSsaPssSha256, + SecurityAlgorithms.RsaSsaPssSha384, + SecurityAlgorithms.RsaSsaPssSha512, + + SecurityAlgorithms.EcdsaSha256, + SecurityAlgorithms.EcdsaSha384, + SecurityAlgorithms.EcdsaSha512 + ]; + opt.SupportedClientAssertionSigningAlgorithms = [ + SecurityAlgorithms.RsaSsaPssSha256, + SecurityAlgorithms.RsaSsaPssSha384, + SecurityAlgorithms.RsaSsaPssSha512, + + SecurityAlgorithms.EcdsaSha256, + SecurityAlgorithms.EcdsaSha384, + SecurityAlgorithms.EcdsaSha512 + ]; + opt.SupportedRequestObjectSigningAlgorithms = [ + SecurityAlgorithms.RsaSsaPssSha256, + SecurityAlgorithms.RsaSsaPssSha384, + SecurityAlgorithms.RsaSsaPssSha512, + + SecurityAlgorithms.EcdsaSha256, + SecurityAlgorithms.EcdsaSha384, + SecurityAlgorithms.EcdsaSha512 + ]; + opt.JwtValidationClockSkew = TimeSpan.FromSeconds(10); + + + }) + .AddInMemoryIdentityResources(IdentityConfig.IdentityResources) + .AddInMemoryApiScopes(IdentityConfig.ApiScopes) + .AddInMemoryApiResources(IdentityConfig.ApiResources) + .AddInMemoryClients(IdentityConfig.GetClients(bffBaseUrl)); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); +app.UseStaticFiles(); +app.UseCookiePolicy(); +app.UseRouting(); +app.UseIdentityServer(); +app.UseAuthorization(); +app.MapRazorPages().AllowAnonymous(); +app.MapRegisterEndpoints(); + +app.Run(); diff --git a/src/RavenDB.Samples.Verity.IdentityServer/Properties/launchSettings.json b/src/RavenDB.Samples.Verity.IdentityServer/Properties/launchSettings.json new file mode 100644 index 0000000..fb4bf2b --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "RavenDB.Samples.Verity.IdentityServer": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:51966;http://localhost:51968" + } + } +} \ No newline at end of file diff --git a/src/RavenDB.Samples.Verity.IdentityServer/RavenDB.Samples.Verity.IdentityServer.csproj b/src/RavenDB.Samples.Verity.IdentityServer/RavenDB.Samples.Verity.IdentityServer.csproj new file mode 100644 index 0000000..dfeb3e4 --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/RavenDB.Samples.Verity.IdentityServer.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/src/RavenDB.Samples.Verity.IdentityServer/RavenEventSink.cs b/src/RavenDB.Samples.Verity.IdentityServer/RavenEventSink.cs new file mode 100644 index 0000000..517d143 --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/RavenEventSink.cs @@ -0,0 +1,62 @@ +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Services; +using Raven.Client.Documents; +using RavenDB.Samples.Verity.Model; + +namespace RavenDB.Samples.Verity.IdentityServer; + +// Persists IdentityServer auth events to RavenDB as SecurityEvent documents. +// Provides a queryable compliance audit trail alongside financial data in Verity. +public sealed class RavenEventSink(IDocumentStore store) : IEventSink +{ + public async Task PersistAsync(Event evt) + { + string? subjectId = null, username = null, clientId = null, details = null; + + switch (evt) + { + case UserLoginSuccessEvent e: + subjectId = e.SubjectId; username = e.Username; clientId = e.ClientId; + break; + case UserLoginFailureEvent e: + username = e.Username; clientId = e.ClientId; details = e.Message; + break; + case UserLogoutSuccessEvent e: + subjectId = e.SubjectId; + break; + case TokenIssuedSuccessEvent e: + subjectId = e.SubjectId; clientId = e.ClientId; details = $"grant={e.GrantType}"; + break; + case TokenIssuedFailureEvent e: + clientId = e.ClientId; details = e.Error; + break; + case ClientAuthenticationSuccessEvent e: + clientId = e.ClientId; + break; + case ClientAuthenticationFailureEvent e: + clientId = e.ClientId; details = e.Message; + break; + default: + details = evt.Message; + break; + } + + var securityEvent = new SecurityEvent + { + EventType = evt.Name, + UserId = subjectId is not null ? User.BuildId(subjectId) : null, + UserName = username, + ClientId = clientId, + IpAddress = evt.RemoteIpAddress, + At = evt.TimeStamp, + Success = evt.EventType == EventTypes.Success, + Details = details, + }; + + using var session = store.OpenAsyncSession(); + await session.StoreAsync(securityEvent); + session.Advanced.GetMetadataFor(securityEvent)["@expires"] = + DateTime.UtcNow.AddDays(90); + await session.SaveChangesAsync(); + } +} diff --git a/src/RavenDB.Samples.Verity.IdentityServer/UserStore.cs b/src/RavenDB.Samples.Verity.IdentityServer/UserStore.cs new file mode 100644 index 0000000..9d9b16d --- /dev/null +++ b/src/RavenDB.Samples.Verity.IdentityServer/UserStore.cs @@ -0,0 +1,108 @@ +using Duende.IdentityModel; +using Microsoft.AspNetCore.Identity; +using Raven.Client.Documents; +using Raven.Client.Documents.Linq; +using Raven.Client.Documents.Session; +using Raven.Client.Exceptions; +using RavenDB.Samples.Verity.Model; +using System.Security.Claims; + +namespace RavenDB.Samples.Verity.IdentityServer; + +public sealed class UserStore(IDocumentStore store) +{ + private readonly PasswordHasher _hasher = new(); + + // ── Lookup ─────────────────────────────────────────────────────────────── + + public async Task FindByUsernameAsync(string username) + { + using var session = store.OpenAsyncSession(); + return await session.Query() + .Where(u => u.Username == username.ToLowerInvariant()) + .FirstOrDefaultAsync(); + } + + // ── Credential validation ──────────────────────────────────────────────── + + public async Task ValidateCredentialsAsync(string username, string password) + { + var user = await FindByUsernameAsync(username); + if (user is null) return false; + return _hasher.VerifyHashedPassword(user, user.PasswordHash, password) + != PasswordVerificationResult.Failed; + } + + // ── Registration ───────────────────────────────────────────────────────── + + public async Task<(bool Success, string Error)> RegisterAsync( + string username, string password, string displayName, string email, + UserRole role = UserRole.Viewer, List? companyIds = null) + { + var existing = await FindByUsernameAsync(username); + if (existing is not null) + return (false, "Username already taken."); + + var subjectId = Guid.NewGuid().ToString(); + var normalizedUsername = username.ToLowerInvariant(); + var parts = displayName.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + + var user = new User + { + Id = User.BuildId(subjectId), + SubjectId = subjectId, + Username = normalizedUsername, + Name = parts.ElementAtOrDefault(0) ?? username, + Surname = parts.ElementAtOrDefault(1) ?? string.Empty, + Email = email, + Role = role, + CompanyIds = companyIds ?? [], + }; + + user.PasswordHash = _hasher.HashPassword(user, password); + + using var session = store.OpenAsyncSession(new Raven.Client.Documents.Session.SessionOptions + { + TransactionMode = TransactionMode.ClusterWide, + }); + + // Atomically reserve the username via a compare-exchange key — guards against + // two concurrent registrations with the same username racing past the check above. + session.Advanced.ClusterTransaction.CreateCompareExchangeValue($"usernames/{normalizedUsername}", user.Id); + + await session.StoreAsync(user); + + try + { + await session.SaveChangesAsync(); + } + catch (ClusterTransactionConcurrencyException) + { + return (false, "Username already taken."); + } + + return (true, string.Empty); + } + + // ── Claims ─────────────────────────────────────────────────────────────── + + public IEnumerable GetClaims(User user) + { + var claims = new List + { + new(JwtClaimTypes.Name, user.Username ?? string.Empty), + new(JwtClaimTypes.PreferredUserName, user.Username ?? string.Empty), + new(JwtClaimTypes.Email, user.Email), + new(JwtClaimTypes.GivenName, user.Name), + new(JwtClaimTypes.FamilyName, user.Surname), + new(JwtClaimTypes.Role, user.Role.ToString()), + }; + + foreach (var companyId in user.CompanyIds) + { + claims.Add(new Claim("company_id", companyId)); + } + + return claims; + } +} diff --git a/src/RavenDB.Samples.Verity.Model/Agents/VerityAgentCreator.cs b/src/RavenDB.Samples.Verity.Model/Agents/VerityAgentCreator.cs index d5e0a74..2dc3163 100644 --- a/src/RavenDB.Samples.Verity.Model/Agents/VerityAgentCreator.cs +++ b/src/RavenDB.Samples.Verity.Model/Agents/VerityAgentCreator.cs @@ -70,7 +70,7 @@ where id(r) = $reportId Query = $@" from {User.Collection} as u where id(u) = $userId -select u.Name, u.Surname, u.Email, u.CompanyId", +select u.Name, u.Surname, u.Email, u.CompanyIds", ParametersSampleObject = "{}" }, diff --git a/src/RavenDB.Samples.Verity.Model/SecEdgarCompanyImporter.cs b/src/RavenDB.Samples.Verity.Model/SecEdgarCompanyImporter.cs index 0f064d9..3d8d312 100644 --- a/src/RavenDB.Samples.Verity.Model/SecEdgarCompanyImporter.cs +++ b/src/RavenDB.Samples.Verity.Model/SecEdgarCompanyImporter.cs @@ -9,12 +9,12 @@ namespace RavenDB.Samples.Verity.Model; public static class SecEdgarCompanyImporter { public record CompanyImportData( - string PaddedCik, - string Name, - string? Sic, - string? SicDescription, + string PaddedCik, + string Name, + string? Sic, + string? SicDescription, DateTime FiscalYearStart); - + private static readonly string[] FirstNames = [ "James", "Mary", "Robert", "Patricia", "Michael", @@ -37,10 +37,10 @@ public static async Task FetchCompanyDataAsync( response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(ct); - using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); - var root = doc.RootElement; + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + var root = doc.RootElement; - var name = root.GetProperty("name").GetString() ?? paddedCik; + var name = root.GetProperty("name").GetString() ?? paddedCik; var fiscalYearEnd = root.TryGetProperty("fiscalYearEnd", out var fye) ? fye.GetString() : null; var fiscalYearStart = new DateTime(1, int.Parse(fiscalYearEnd!.Substring(0, 2)), 1, 0, 0, 0, DateTimeKind.Utc) @@ -49,9 +49,9 @@ public static async Task FetchCompanyDataAsync( fiscalYearStart = fiscalYearStart.AddYears(-1); return new CompanyImportData( - PaddedCik: paddedCik, - Name: name, - Sic: root.TryGetProperty("sic", out var sic) ? sic.GetString() : null, + PaddedCik: paddedCik, + Name: name, + Sic: root.TryGetProperty("sic", out var sic) ? sic.GetString() : null, SicDescription: root.TryGetProperty("sicDescription", out var sicDesc) ? sicDesc.GetString() : null, FiscalYearStart: fiscalYearStart); } @@ -66,7 +66,7 @@ public static async Task StoreCompanyAsync( CancellationToken ct = default) { var rng = Random.Shared; - + var companyId = Company.BuildId(data.Name); if (await session.Advanced.ExistsAsync(companyId, ct)) @@ -74,18 +74,18 @@ public static async Task StoreCompanyAsync( var company = new Company { - Id = companyId, - Name = data.Name, - Cik = data.PaddedCik, - Sic = data.Sic, - SicDescription = data.SicDescription, + Id = companyId, + Name = data.Name, + Cik = data.PaddedCik, + Sic = data.Sic, + SicDescription = data.SicDescription, FiscalYearStart = data.FiscalYearStart, }; await session.StoreAsync(company, companyId, ct); var usedPairs = new HashSet(); - var domain = data.Name.Replace(" ", "").Replace(",", "").Replace(".", "").ToLowerInvariant(); + var domain = data.Name.Replace(" ", "").Replace(",", "").Replace(".", "").ToLowerInvariant(); for (var i = 0; i < 2; i++) { @@ -93,16 +93,16 @@ public static async Task StoreCompanyAsync( do { firstName = FirstNames[rng.Next(FirstNames.Length)]; - lastName = LastNames[rng.Next(LastNames.Length)]; + lastName = LastNames[rng.Next(LastNames.Length)]; } while (!usedPairs.Add($"{firstName} {lastName}")); await session.StoreAsync(new User { - Id = User.BuildId(data.Name, firstName, lastName), - CompanyId = companyId, - Name = firstName, - Surname = lastName, - Email = $"{firstName.ToLower()}{lastName.ToLower()}@{domain}.com" + Id = User.BuildId(data.Name, firstName, lastName), + CompanyIds = [companyId], + Name = firstName, + Surname = lastName, + Email = $"{firstName.ToLower()}{lastName.ToLower()}@{domain}.com" }, ct); } diff --git a/src/RavenDB.Samples.Verity.Model/SecurityEvent.cs b/src/RavenDB.Samples.Verity.Model/SecurityEvent.cs new file mode 100644 index 0000000..57d1a6a --- /dev/null +++ b/src/RavenDB.Samples.Verity.Model/SecurityEvent.cs @@ -0,0 +1,16 @@ +namespace RavenDB.Samples.Verity.Model; + +public class SecurityEvent : IDocument +{ + public static string Collection => "SecurityEvents"; + + public string Id { get; set; } = null!; + public string EventType { get; set; } = null!; + public string? UserId { get; set; } + public string? UserName { get; set; } + public string? ClientId { get; set; } + public string? IpAddress { get; set; } + public DateTime At { get; set; } + public bool Success { get; set; } + public string? Details { get; set; } +} \ No newline at end of file diff --git a/src/RavenDB.Samples.Verity.Model/User.cs b/src/RavenDB.Samples.Verity.Model/User.cs index 3a0d2db..91584dd 100644 --- a/src/RavenDB.Samples.Verity.Model/User.cs +++ b/src/RavenDB.Samples.Verity.Model/User.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace RavenDB.Samples.Verity.Model; public class User : IDocument @@ -7,12 +9,20 @@ public class User : IDocument public static string BuildId(string companyName, string firstName, string lastName) => $"{Collection}/{companyName}/{firstName} {lastName}"; + public static string BuildId(string subjectId) + => $"{Collection}/{subjectId}"; + public static string BuildId(Company company, string firstName, string lastName) => BuildId(company.Name, firstName, lastName); - public string Id { get; set; } = null!; - public string CompanyId { get; set; } = null!; - public string Name { get; set; } = null!; - public string Surname { get; set; } = null!; - public string Email { get; set; } = null!; + public string Id { get; set; } = null!; + public List CompanyIds { get; set; } = []; + public string Name { get; set; } = null!; + public string Surname { get; set; } = null!; + public string Email { get; set; } = null!; + public string? SubjectId { get; set; } // IS subject (sub claim); null for auditor placeholder users with no login + public string? Username { get; set; } // login name, lowercase; null for auditor placeholder users with no login + [JsonIgnore] + public string PasswordHash { get; set; } = string.Empty; + public UserRole Role { get; set; } = UserRole.Viewer; } diff --git a/src/RavenDB.Samples.Verity.Model/UserRole.cs b/src/RavenDB.Samples.Verity.Model/UserRole.cs new file mode 100644 index 0000000..cfea192 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Model/UserRole.cs @@ -0,0 +1,8 @@ +namespace RavenDB.Samples.Verity.Model; + +public enum UserRole +{ + Viewer, + Analyst, + Admin +} diff --git a/src/RavenDB.Samples.Verity.ServiceDefaults/RavenDB.Samples.Verity.ServiceDefaults.csproj b/src/RavenDB.Samples.Verity.ServiceDefaults/RavenDB.Samples.Verity.ServiceDefaults.csproj index 41822f0..7d25b50 100644 --- a/src/RavenDB.Samples.Verity.ServiceDefaults/RavenDB.Samples.Verity.ServiceDefaults.csproj +++ b/src/RavenDB.Samples.Verity.ServiceDefaults/RavenDB.Samples.Verity.ServiceDefaults.csproj @@ -21,6 +21,7 @@ + diff --git a/src/RavenDB.Samples.Verity.Setup/Constants.cs b/src/RavenDB.Samples.Verity.Setup/Constants.cs index 2ee135b..3fafae2 100644 --- a/src/RavenDB.Samples.Verity.Setup/Constants.cs +++ b/src/RavenDB.Samples.Verity.Setup/Constants.cs @@ -2,16 +2,16 @@ public static class Constants { - public const string RemoteAttachmentId = "verity-azure-storage"; - public const string DatabaseName = "Verity"; - public const string DatabaseSinkName = "Verity-sink"; + public const string RemoteAttachmentId = "verity-azure-storage"; + public const string DatabaseName = "Verity"; + public const string DatabaseSinkName = "Verity-sink"; public const string AzureStorageContainerName = "verity"; - public const string AiConnectionStringName = "Verity AI Model"; + public const string AiConnectionStringName = "Verity AI Model"; public static class EnvVars { - public const string OpenAiApiKey = "SAMPLES_VERITY_OPENAI_API_KEY"; - public const string SecEdgarUserAgent = "SAMPLES_VERITY_SEC_EDGAR_USER_AGENT"; + public const string OpenAiApiKey = "SAMPLES_VERITY_OPENAI_API_KEY"; + public const string SecEdgarUserAgent = "SAMPLES_VERITY_SEC_EDGAR_USER_AGENT"; public const string AzureStorageConnectionString = "SAMPLES_VERITY_AZURE_STORAGE_CONNECTION_STRING"; public const string SinkServerUrl = "SAMPLES_VERITY_SINK_SERVER_URL"; public const string HubServerInternalUrl = "SAMPLES_VERITY_HUB_SERVER_INTERNAL_URL"; @@ -19,6 +19,7 @@ public static class EnvVars public const string SinkCertPfxBase64 = "SAMPLES_VERITY_SINK_CERT_PFX_BASE64"; public const string ServerCertPath = "SAMPLES_VERITY_SERVER_CERT_PATH"; public const string CommandKey = "CommandKey"; + public const string IdentityUrl = "Identity__Url"; } public static class HttpHeaders diff --git a/src/RavenDB.Samples.Verity.Setup/Migrations/006_SeedDemoUsers.cs b/src/RavenDB.Samples.Verity.Setup/Migrations/006_SeedDemoUsers.cs new file mode 100644 index 0000000..9dcf2c9 --- /dev/null +++ b/src/RavenDB.Samples.Verity.Setup/Migrations/006_SeedDemoUsers.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Identity; +using Raven.Migrations; +using RavenDB.Samples.Verity.Model; +using Raven.Client.Documents.Linq; +using Raven.Client.Documents; +namespace RavenDB.Samples.Verity.Setup.Migrations; + + +[Migration(6)] +public sealed class SeedDemoUsers(MigrationContext context) : Migration +{ + public override void Up() + { + RunAsync().GetAwaiter().GetResult(); + } + private async Task RunAsync() + { + using var session = DocumentStore.OpenAsyncSession(); + + var companies = await session.Query() + .OrderBy(c => c.Name) + .Take(2) + .ToListAsync(); + if (companies.Count < 2) + throw new InvalidOperationException("SeedDemoUsers requires at least 2 companies. Run the company import migration first."); + var hasher = new PasswordHasher(); + static User Make(Action init) + { + var s = Guid.NewGuid().ToString(); + var u = new User { Id = User.BuildId(s), SubjectId = s }; + init(u); + return u; + } + var user = new User[] + { + Make(u=> {u.Username = "alice"; u.Name = "Alice"; u.Surname = "Smith"; u.Email = "alice@verity.demo"; u.Role = UserRole.Admin;}), + Make(u=> {u.Username = "bob"; u.Name = "Bob"; u.Surname = "Johnson"; u.Email = "bob@verity.demo"; u.Role = UserRole.Analyst; u.CompanyIds = [companies[0].Id];}), + Make(u=> {u.Username = "carol"; u.Name = "Carol"; u.Surname = "Williams"; u.Email = "carol@verity.demo"; u.Role = UserRole.Analyst; u.CompanyIds = [companies[1].Id];}), + Make(u=> {u.Username = "dave"; u.Name = "Dave"; u.Surname = "Brown"; u.Email = "dave@verity.demo"; u.Role = UserRole.Analyst; u.CompanyIds = [companies[1].Id];}), + Make(u=> {u.Username = "eve"; u.Name = "Eve"; u.Surname = "Davis"; u.Email = "eve@verity.demo"; u.Role = UserRole.Viewer;}), + }; + foreach (var u in user) + { + u.PasswordHash = hasher.HashPassword(u, "Demo1234!"); + await session.StoreAsync(u); + } + await session.SaveChangesAsync(); + } + + public override void Down() + { + DocumentStore.Operations.Send( + new Raven.Client.Documents.Operations.DeleteByQueryOperation( + new Raven.Client.Documents.Queries.IndexQuery { Query = $"from {User.Collection} where Username in ('alice','bob','carol','dave','eve')" })); + } +} \ No newline at end of file diff --git a/src/RavenDB.Samples.Verity.Setup/RavenDB.Samples.Verity.Setup.csproj b/src/RavenDB.Samples.Verity.Setup/RavenDB.Samples.Verity.Setup.csproj index 2773db6..6239f50 100644 --- a/src/RavenDB.Samples.Verity.Setup/RavenDB.Samples.Verity.Setup.csproj +++ b/src/RavenDB.Samples.Verity.Setup/RavenDB.Samples.Verity.Setup.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/RavenDB.Samples.Verity.slnx b/src/RavenDB.Samples.Verity.slnx index 34dd2a5..908ead0 100644 --- a/src/RavenDB.Samples.Verity.slnx +++ b/src/RavenDB.Samples.Verity.slnx @@ -5,4 +5,6 @@ + +