From fae222658110c970c08baf5ca2a71d59c6cd188c Mon Sep 17 00:00:00 2001 From: Grae Jones Date: Sun, 22 Mar 2026 09:37:28 -0700 Subject: [PATCH] Revised Registration --- Registration/Auth/ApiKeyAuthFilter.cs | 55 ++++++ .../Functions/RegistrationFunctions.cs | 178 ++++++++++-------- Registration/Program.cs | 106 ++++++----- Registration/README.md | 165 +++++++++------- Registration/Registration.csproj | 44 +++-- Registration/appsettings.json | 56 ++++++ 6 files changed, 389 insertions(+), 215 deletions(-) create mode 100644 Registration/Auth/ApiKeyAuthFilter.cs create mode 100644 Registration/appsettings.json diff --git a/Registration/Auth/ApiKeyAuthFilter.cs b/Registration/Auth/ApiKeyAuthFilter.cs new file mode 100644 index 0000000..7ed056e --- /dev/null +++ b/Registration/Auth/ApiKeyAuthFilter.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Registration.Auth; + +/// +/// Validates the x-functions-key header on admin endpoints. +/// +/// In Azure Functions mode, admin endpoints used AuthorizationLevel.Function — +/// the Functions host validated the key automatically. In ASP.NET Core mode +/// that host doesn't exist, so this filter replicates the same behaviour. +/// +/// The same header name (x-functions-key) and the same env var +/// (Registration__FunctionKey) are used in both modes, so the Management API +/// and Gateway require zero changes when the host is swapped. +/// +/// Key configuration (docker-compose .env already has this): +/// Registration__FunctionKey=mra0B2boC5m36E7CUn-Urhwp7k3t3QvPZKjJvtNVEdVgAzFuuaAyRA== +/// +/// ── SWAP note ───────────────────────────────────────────────────────────── +/// This file is only compiled and used in ASP.NET Core mode. +/// When restoring Azure Functions mode, the [ApiKeyAuth] attributes on admin +/// endpoints in RegistrationFunctions.cs are replaced by AuthorizationLevel.Function +/// in the [HttpTrigger] attributes, and this filter becomes unused (but harmless). +/// ───────────────────────────────────────────────────────────────────────── +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class ApiKeyAuthAttribute : Attribute, IResourceFilter +{ + private const string HeaderName = "x-functions-key"; + + public void OnResourceExecuting(ResourceExecutingContext context) + { + var config = context.HttpContext.RequestServices.GetRequiredService(); + var expected = config["Registration:FunctionKey"]; + + // If no key is configured, block all admin traffic — fail secure. + if (string.IsNullOrWhiteSpace(expected)) + { + context.Result = new ObjectResult(new { ok = false, error = "Admin API key not configured on server" }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var provided) + || !string.Equals(expected, provided, StringComparison.Ordinal)) + { + context.Result = new UnauthorizedObjectResult(new { ok = false, error = "Invalid or missing API key" }); + } + } + + public void OnResourceExecuted(ResourceExecutedContext context) { } +} diff --git a/Registration/Functions/RegistrationFunctions.cs b/Registration/Functions/RegistrationFunctions.cs index 03da4f7..891539f 100644 --- a/Registration/Functions/RegistrationFunctions.cs +++ b/Registration/Functions/RegistrationFunctions.cs @@ -1,12 +1,21 @@ +// ── SWAP note ───────────────────────────────────────────────────────────── +// Three files change when switching host modes. See Registration.csproj for +// the full checklist. This file: swap the class declaration and each method +// signature (marked below). Program.cs and Registration.csproj also change. +// ───────────────────────────────────────────────────────────────────────── + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.Functions.Worker; // ← SWAP: remove for AspNetCore using Microsoft.Extensions.Logging; +using Registration.Auth; using Registration.Data; using System.Security.Claims; using System.Text.Json; +// ── SWAP: Azure Functions — uncomment this using when restoring Functions mode +// using Microsoft.Azure.Functions.Worker; + namespace Registration.Functions; /// @@ -17,10 +26,10 @@ namespace Registration.Functions; /// entraSubjectId is extracted from the validated /// token — the client never supplies it. /// -/// GET /api/registration/pending — Admin; requires internal API key. -/// GET /api/registration/item/{id} — Admin; requires internal API key. -/// POST /api/registration/action/{id}/reject — Admin; requires internal API key. -/// POST /api/registration/action/{id}/complete — Admin; requires internal API key. +/// GET /api/registration/pending — Admin; x-functions-key header required. +/// GET /api/registration/item/{id} — Admin; x-functions-key header required. +/// POST /api/registration/action/{id}/reject — Admin; x-functions-key header required. +/// POST /api/registration/action/{id}/complete — Admin; x-functions-key header required. /// GET /api/health — Anonymous. /// /// ═══════════════════════════════════════════════════════ @@ -29,20 +38,23 @@ namespace Registration.Functions; /// 2. Program.cs /// 3. Registration.csproj /// -/// To switch to ASP.NET Core: -/// a) In this file: swap class declaration and method signatures (marked below) -/// b) In Program.cs: comment Azure Functions block, uncomment AspNetCore block -/// c) In Registration.csproj: swap ItemGroup (marked in that file) -/// d) In authConfig.js (client): update API_BASE_URL, remove API_FUNCTION_KEY +/// To switch back to Azure Functions: +/// a) Swap class declaration (marked below) +/// b) Swap each method signature (marked below) +/// c) Uncomment [Function(...)] and [HttpTrigger(...)] attributes +/// d) Re-add req parameter and remove [ApiKeyAuth] on admin endpoints +/// (Functions mode uses AuthorizationLevel.Function instead) +/// e) In authConfig.js: update API_BASE_URL to Function App URL, +/// set API_FUNCTION_KEY from Azure Portal → App Keys → default /// ═══════════════════════════════════════════════════════ /// -// ── SWAP: Azure Functions class declaration ─────────────────────────────── -public class RegistrationFunctions -// ── SWAP: AspNetCore class declaration ──────────────────────────────────── -// [ApiController] -// [Route("api")] -// public class RegistrationFunctions : ControllerBase +// ── SWAP: ASP.NET Core class declaration ◄ ACTIVE ─────────────────────── +[ApiController] +[Route("api")] +public class RegistrationFunctions : ControllerBase +// ── SWAP: Azure Functions class declaration ◄ INACTIVE — uncomment to restore +// public class RegistrationFunctions // ───────────────────────────────────────────────────────────────────────── { private readonly IRegistrationDataService _data; @@ -58,7 +70,7 @@ public class RegistrationFunctions public RegistrationFunctions(IRegistrationDataService data, ILogger log) { _data = data; - _log = log; + _log = log; } // ── Helpers ────────────────────────────────────────────────────────── @@ -66,6 +78,7 @@ public class RegistrationFunctions /// /// Extract the Entra Object ID from a validated CIAM JWT. /// The OID claim is stable across sessions and providers (Google, Apple, Microsoft). + /// CIAM tenant: PositiveSpendClients.ciamlogin.com / cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b /// private static string? GetEntraSubjectId(ClaimsPrincipal user) => user.FindFirst("oid")?.Value @@ -74,20 +87,21 @@ public class RegistrationFunctions // ── Public: Register ───────────────────────────────────────────────── - // ── SWAP: Azure Functions ───────────────────────────────────────────── - [Function("Register")] + // ── SWAP: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── + [HttpPost("registration/register")] [Authorize] - public async Task Register( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req, - CancellationToken ct) - // ── SWAP: AspNetCore ────────────────────────────────────────────────── - // [HttpPost("registration/register")] + public async Task Register(CancellationToken ct) + // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore + // [Function("Register")] // [Authorize] - // public async Task Register(CancellationToken ct) + // public async Task Register( + // [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req, + // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { - // AspNetCore only — uncomment when in AspNetCore mode: - // var req = HttpContext.Request; + // ── SWAP: ASP.NET Core uses HttpContext.Request ─────────────────── + var req = HttpContext.Request; + // ── SWAP: Azure Functions — req is passed as parameter, remove above line var entraSubjectId = GetEntraSubjectId(req.HttpContext.User); @@ -110,6 +124,7 @@ public class RegistrationFunctions if (request == null || string.IsNullOrWhiteSpace(request.BusinessName)) return new BadRequestObjectResult(new { ok = false, error = "businessName is required" }); + // Stamp from the validated token — never trust the request body for this. request.EntraSubjectId = entraSubjectId; _log.LogInformation("[Registration] POST register: {Name} by entra={EntraId}", @@ -125,14 +140,15 @@ public class RegistrationFunctions // ── Admin: List pending ─────────────────────────────────────────────── - // ── SWAP: Azure Functions ───────────────────────────────────────────── - [Function("GetPending")] - public async Task GetPending( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/pending")] HttpRequest req, - CancellationToken ct) - // ── SWAP: AspNetCore ────────────────────────────────────────────────── - // [HttpGet("registration/pending")] - // public async Task GetPending(CancellationToken ct) + // ── SWAP: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── + [HttpGet("registration/pending")] + [ApiKeyAuth] + public async Task GetPending(CancellationToken ct) + // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore + // [Function("GetPending")] + // public async Task GetPending( + // [HttpTrigger(AuthorizationLevel.Function, "get", Route = "registration/pending")] HttpRequest req, + // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { _log.LogInformation("[Registration] GET pending"); @@ -142,15 +158,16 @@ public class RegistrationFunctions // ── Admin: Get by ID ───────────────────────────────────────────────── - // ── SWAP: Azure Functions ───────────────────────────────────────────── - [Function("GetById")] - public async Task GetById( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/item/{registrationId}")] HttpRequest req, - string registrationId, - CancellationToken ct) - // ── SWAP: AspNetCore ────────────────────────────────────────────────── - // [HttpGet("registration/item/{registrationId}")] - // public async Task GetById(string registrationId, CancellationToken ct) + // ── SWAP: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── + [HttpGet("registration/item/{registrationId}")] + [ApiKeyAuth] + public async Task GetById(string registrationId, CancellationToken ct) + // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore + // [Function("GetById")] + // public async Task GetById( + // [HttpTrigger(AuthorizationLevel.Function, "get", Route = "registration/item/{registrationId}")] HttpRequest req, + // string registrationId, + // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { _log.LogInformation("[Registration] GET {Id}", registrationId); @@ -164,19 +181,21 @@ public class RegistrationFunctions // ── Admin: Reject ──────────────────────────────────────────────────── - // ── SWAP: Azure Functions ───────────────────────────────────────────── - [Function("Reject")] - public async Task Reject( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req, - string registrationId, - CancellationToken ct) - // ── SWAP: AspNetCore ────────────────────────────────────────────────── - // [HttpPost("registration/action/{registrationId}/reject")] - // public async Task Reject(string registrationId, CancellationToken ct) + // ── SWAP: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── + [HttpPost("registration/action/{registrationId}/reject")] + [ApiKeyAuth] + public async Task Reject(string registrationId, CancellationToken ct) + // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore + // [Function("Reject")] + // public async Task Reject( + // [HttpTrigger(AuthorizationLevel.Function, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req, + // string registrationId, + // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { - // AspNetCore only — uncomment when in AspNetCore mode: - // var req = HttpContext.Request; + // ── SWAP: ASP.NET Core ──────────────────────────────────────────── + var req = HttpContext.Request; + // ── SWAP: Azure Functions — req is passed as parameter, remove above line RejectBody? body = null; try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } @@ -195,19 +214,21 @@ public class RegistrationFunctions // ── Admin: Complete ─────────────────────────────────────────────────── - // ── SWAP: Azure Functions ───────────────────────────────────────────── - [Function("Complete")] - public async Task Complete( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req, - string registrationId, - CancellationToken ct) - // ── SWAP: AspNetCore ────────────────────────────────────────────────── - // [HttpPost("registration/action/{registrationId}/complete")] - // public async Task Complete(string registrationId, CancellationToken ct) + // ── SWAP: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── + [HttpPost("registration/action/{registrationId}/complete")] + [ApiKeyAuth] + public async Task Complete(string registrationId, CancellationToken ct) + // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore + // [Function("Complete")] + // public async Task Complete( + // [HttpTrigger(AuthorizationLevel.Function, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req, + // string registrationId, + // CancellationToken ct) // ───────────────────────────────────────────────────────────────────── { - // AspNetCore only — uncomment when in AspNetCore mode: - // var req = HttpContext.Request; + // ── SWAP: ASP.NET Core ──────────────────────────────────────────── + var req = HttpContext.Request; + // ── SWAP: Azure Functions — req is passed as parameter, remove above line CompleteBody? body = null; try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } @@ -226,20 +247,21 @@ public class RegistrationFunctions // ── Health ─────────────────────────────────────────────────────────── - // ── SWAP: Azure Functions ───────────────────────────────────────────── - [Function("Health")] - public IActionResult Health( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req) - // ── SWAP: AspNetCore ────────────────────────────────────────────────── - // [HttpGet("health")] - // public IActionResult Health() + // ── SWAP: ASP.NET Core ◄ ACTIVE ───────────────────────────────────── + [HttpGet("health")] + [AllowAnonymous] + public IActionResult Health() + // ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore + // [Function("Health")] + // public IActionResult Health( + // [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req) // ───────────────────────────────────────────────────────────────────── { return new OkObjectResult(new { - ok = true, - service = "registration", - mode = _data is Registration.Mock.MockDataService ? "mock" : "database", + ok = true, + service = "registration", + mode = _data is Registration.Mock.MockDataService ? "mock" : "database", timestamp = DateTime.UtcNow }); } @@ -249,11 +271,11 @@ public class RegistrationFunctions internal sealed class RejectBody { - public string? Reason { get; set; } + public string? Reason { get; set; } public string? RejectedBy { get; set; } } internal sealed class CompleteBody { public string? PlatformClientId { get; set; } -} \ No newline at end of file +} diff --git a/Registration/Program.cs b/Registration/Program.cs index 93dc26b..4acd695 100644 --- a/Registration/Program.cs +++ b/Registration/Program.cs @@ -1,29 +1,25 @@ -using Microsoft.Azure.Functions.Worker; // ← SWAP: remove for AspNetCore -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Identity.Web; -using Registration.Data; -using Registration.Mock; - // ───────────────────────────────────────────────────────────────────────────── // Shared service registrations — identical in both hosting modes. // Never changes regardless of which block is active below. // ───────────────────────────────────────────────────────────────────────────── +using Microsoft.Identity.Web; +using Registration.Data; + void ConfigureServices(IServiceCollection services, IConfiguration config) { // JWT authentication — Entra External ID (CIAM) - // Validates Bearer tokens issued by usimclients.ciamlogin.com. - // AzureAd config comes from local.settings.json (Functions) or - // appsettings.json / environment variables (AspNetCore). + // Validates Bearer tokens issued by PositiveSpendClients.ciamlogin.com. + // Config keys: AzureAd:Instance / TenantId / ClientId / Audience + // In ASP.NET Core mode: appsettings.json + env vars (AzureAd__*) + // In Functions mode: local.settings.json AzureAd section (dev only) services.AddAuthentication() .AddMicrosoftIdentityWebApi(config.GetSection("AzureAd")); services.AddAuthorization(); - // Data layer — swap between MockDataService (no DB) and SqlDataService (dbRegistration). + // Data layer — swap here to flip between mock (no DB) and real DB. // MockDataService: no connection string required, seeds 4 test applicants. - // SqlDataService: requires ConnectionStrings:Sql pointed at dbRegistration. + // SqlDataService: requires ConnectionStrings:Sql → dbRegistration on 10.10.99.212 // Calls dbo.spRegistration with @action/@rqst/@resp OUTPUT pattern. services.AddSingleton(); services.AddSingleton(); @@ -32,45 +28,51 @@ void ConfigureServices(IServiceCollection services, IConfiguration config) } // ═════════════════════════════════════════════════════════════════════════════ -// SWAP: Azure Functions host +// SWAP: ASP.NET Core host ◄ ACTIVE +// Running in docker-compose as registration:8080 behind nginx / Gateway. +// Dockerfile: mcr.microsoft.com/dotnet/aspnet:8.0 ← matches this mode. +// ═════════════════════════════════════════════════════════════════════════════ +var builder = WebApplication.CreateBuilder(args); + +ConfigureServices(builder.Services, builder.Configuration); +builder.Services.AddControllers(); + +// CORS — reads CORS:AllowedOrigins from appsettings.json or env var CORS__AllowedOrigins. +// Comma-separated list. Matches the value already in docker-compose .env: +// CORS__AllowedOrigins=https://client.positivespend.com,https://portal.positivespend.com,... +builder.Services.AddCors(options => options.AddDefaultPolicy(policy => + policy.WithOrigins( + (builder.Configuration["CORS:AllowedOrigins"] ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) + .AllowAnyHeader() + .AllowAnyMethod())); + +var app = builder.Build(); + +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +await app.RunAsync(); + +// ═════════════════════════════════════════════════════════════════════════════ +// SWAP: Azure Functions host ◄ INACTIVE — uncomment to restore // Active when deployed to Azure Functions or running via: func start -// ═════════════════════════════════════════════════════════════════════════════ -var host = new HostBuilder() - .ConfigureFunctionsWebApplication() - .ConfigureServices((ctx, services) => - { - services.AddApplicationInsightsTelemetryWorkerService(); - services.ConfigureFunctionsApplicationInsights(); - ConfigureServices(services, ctx.Configuration); - }) - .Build(); - -host.Run(); - -// ═════════════════════════════════════════════════════════════════════════════ -// SWAP: ASP.NET Core host -// Active when running in podman-compose or dotnet run (self-hosted). // Also update RegistrationFunctions.cs and Registration.csproj. // ═════════════════════════════════════════════════════════════════════════════ -// var builder = WebApplication.CreateBuilder(args); -// -// ConfigureServices(builder.Services, builder.Configuration); -// builder.Services.AddControllers(); -// -// // CORS — replace with actual client origin in production -// builder.Services.AddCors(options => options.AddDefaultPolicy(policy => -// policy.WithOrigins( -// builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:3001" -// ) -// .AllowAnyHeader() -// .AllowAnyMethod())); -// -// var app = builder.Build(); -// -// app.UseCors(); -// app.UseAuthentication(); -// app.UseAuthorization(); -// app.MapControllers(); -// -// await app.RunAsync(); -// ───────────────────────────────────────────────────────────────────────────── \ No newline at end of file +// using Microsoft.Azure.Functions.Worker; +// +// var host = new HostBuilder() +// .ConfigureFunctionsWebApplication() +// .ConfigureServices((ctx, services) => +// { +// services.AddApplicationInsightsTelemetryWorkerService(); +// services.ConfigureFunctionsApplicationInsights(); +// ConfigureServices(services, ctx.Configuration); +// }) +// .Build(); +// +// host.Run(); +// ───────────────────────────────────────────────────────────────────────────── diff --git a/Registration/README.md b/Registration/README.md index 37701da..fc803c3 100644 --- a/Registration/README.md +++ b/Registration/README.md @@ -1,91 +1,126 @@ -# Registration Function +# Registration Service -Azure Function (isolated worker, .NET 8) for managing prospect registration in AdPlatform. +ASP.NET Core (.NET 8) service for managing prospect registration in AdPlatform. +Self-hosted via docker-compose as `registration:8080` behind nginx / Gateway. ## Architecture ``` -Prospect → Registration Function → dbRegistration (future) -Admin Panel → Management API → Registration Function (proxy) - → spClientManagement (approve → dbAdPlatform) +Client SPA (client.positivespend.com) + └─► Gateway (portal.positivespend.com) + └─► Registration API (regapi.positivespend.com → registration:8080) + ├─► dbRegistration (10.10.99.212 — Registrations table, spRegistration) + └─► CIAM (PositiveSpendClients.ciamlogin.com — token validation) + +Management API (mgmt.positivespend.com) + └─► Registration API (admin endpoints, x-functions-key auth) + └─► dbRegistration ``` -Management validates admin sessions, then proxies registration calls to this Function. -The Function never touches `dbAdPlatform`. Management never touches `dbRegistration`. +The Registration service never touches `dbAdPlatform`. Management never touches `dbRegistration`. +Approval provisioning into `dbAdPlatform` is the Management API's responsibility, called after +the `complete` action marks a registration as Approved. ## Endpoints | Method | Route | Auth | Description | |--------|-------|------|-------------| -| GET | `/api/registration/pending` | Function Key | List pending applicants | -| GET | `/api/registration/{id}` | Function Key | Get single applicant | -| POST | `/api/registration/register` | Function Key | New prospect signup | -| POST | `/api/registration/{id}/reject` | Function Key | Reject applicant | -| POST | `/api/registration/{id}/complete` | Function Key | Mark approved (called after platform client created) | -| GET | `/api/registration/health` | Anonymous | Health check | +| `POST` | `/api/registration/register` | Bearer (CIAM JWT) | New prospect signup | +| `GET` | `/api/registration/pending` | x-functions-key | List pending applicants | +| `GET` | `/api/registration/item/{id}` | x-functions-key | Get single applicant | +| `POST` | `/api/registration/action/{id}/reject` | x-functions-key | Reject applicant | +| `POST` | `/api/registration/action/{id}/complete` | x-functions-key | Mark approved | +| `GET` | `/api/health` | Anonymous | Health check | -## Mock Mode (Current) +`entraSubjectId` is **never** read from the request body on `/register` — it is extracted +from the validated CIAM Bearer token (OID claim). The client cannot spoof it. -Starts with 4 realistic test applicants in memory. State persists within a Function host -lifecycle and resets on cold start. No database required. +## Authentication + +**Client-facing endpoint** (`/register`): Bearer token issued by +`PositiveSpendClients.ciamlogin.com` (tenant `cbf8b7d7`). Validated by +`Microsoft.Identity.Web` against the CIAM tenant. The Client SPA acquires this +token via MSAL after the user signs in with Google, Apple, or Microsoft. + +**Admin endpoints** (`pending` / `item` / `reject` / `complete`): `x-functions-key` header +validated by `ApiKeyAuthFilter`. Key must match `Registration__FunctionKey` env var. +These endpoints are called by the Management API, not the browser. + +## CIAM Tenant Reference + +| Field | Value | +|-------|-------| +| Tenant | Positive Spend Clients | +| Tenant ID | `cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b` | +| Client SPA App ID | `c426967f-bfcc-46af-b4e5-d69dc01cbf75` | +| Authority | `https://PositiveSpendClients.ciamlogin.com/cbf8b7d7.../` | + +This is the **client-facing CIAM tenant** — separate from the internal `positivespend.com` +org tenant (`f56a3c51`) used by Management and the Tech/Admin consoles. + +## Mock Mode + +Switch in `Program.cs` to run without a database (seeds 4 test applicants in memory): -To switch to mock mode, in `Program.cs`: ```csharp services.AddSingleton(); ``` -## Database Mode (Future) +State resets on container restart — by design. -When `dbRegistration` is ready: +## Database Setup -1. Create the database and run the `spRegistration` stored proc migration -2. Set `ConnectionStrings:Sql` to the registration database connection string -3. In `Program.cs`, swap DI registration: -```csharp -services.AddSingleton(); -services.AddSingleton(); -``` +Run `dbo.spRegistration.sql` against `dbRegistration` on `10.10.99.212` once. +It is idempotent (`CREATE OR ALTER PROCEDURE`, `IF OBJECT_ID ... IS NULL` guard on the table). -The `SqlDataService` calls `dbo.spRegistration` with the standard `@action/@rqst/@resp OUTPUT` -pattern used across all AdPlatform services. - -## Local Development +## docker-compose Environment Variables ```bash -# Requires Azure Functions Core Tools +# SQL Server +ConnectionStrings__Sql=Server=10.10.99.212;Database=dbRegistration;User Id=appAdPlatformReg;Password=...;TrustServerCertificate=True; + +# CIAM — already correct in .env +AzureAd__Instance=https://PositiveSpendClients.ciamlogin.com/ +AzureAd__TenantId=cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b +AzureAd__ClientId=c426967f-bfcc-46af-b4e5-d69dc01cbf75 + +# CORS — already in .env +CORS__AllowedOrigins=https://client.positivespend.com,https://portal.positivespend.com,... + +# Admin key — already in .env +Registration__FunctionKey=mra0B2boC5m36E7CUn-Urhwp7k3t3QvPZKjJvtNVEdVgAzFuuaAyRA== +``` + +## Health Check + +```bash +curl https://regapi.positivespend.com/api/health +# {"ok":true,"service":"registration","mode":"database","timestamp":"..."} +``` + +## SSL + +```bash +sudo certbot --nginx -d regapi.positivespend.com +``` + +--- + +## Host Swap — Restore Azure Functions + +Three files change. Everything else (Data layer, Mock layer, models, SQL) is identical. + +| File | Change | +|------|--------| +| `Registration.csproj` | Comment ASP.NET Core `ItemGroup`, uncomment Functions `ItemGroup`, restore `v4`, change `Sdk` to `Microsoft.NET.Sdk` | +| `Program.cs` | Comment ASP.NET Core block, uncomment Functions `HostBuilder` block | +| `Functions/RegistrationFunctions.cs` | Swap class declaration, swap each method signature (all marked `◄ INACTIVE`) | + +Client changes when restoring Functions: +- `authConfig.js`: set `API_BASE_URL` to the Azure Function App URL, set `API_FUNCTION_KEY` from Azure Portal → App Keys → default + +Run locally in Functions mode: +```bash func start +curl http://localhost:7071/api/health ``` - -Test with: -```bash -curl http://localhost:7071/api/registration/health -curl http://localhost:7071/api/registration/pending -``` - -## Deployment - -Deploy as an Azure Function App (Consumption or Flex Consumption plan). - -After deployment: -1. Copy the Function Key from Azure Portal → Function App → App Keys -2. Set in Management API config: - - `Registration:BaseUrl` = `https://your-function-app.azurewebsites.net/api` - - `Registration:FunctionKey` = `` - -These can be set as Azure Container App environment variables: -``` -Registration__BaseUrl=https://your-function-app.azurewebsites.net/api -Registration__FunctionKey= -``` - -## Mock Applicants - -The mock data includes 4 test applicants representing the target market -(small businesses with low ad spend thresholds): - -| Business | Category | Payment Verified | Days Waiting | -|----------|----------|-----------------|-------------| -| Bella's Boutique | Retail | Yes | 3 | -| Pacific Coast Plumbing | Home Services | Yes | 1 | -| Sunrise Dental Group | Healthcare | No | ~0.25 | -| FreshBite Meal Prep | Food & Beverage | Yes | ~0.08 | diff --git a/Registration/Registration.csproj b/Registration/Registration.csproj index 6d4a407..7c477ff 100644 --- a/Registration/Registration.csproj +++ b/Registration/Registration.csproj @@ -1,50 +1,54 @@ - + net8.0 - Exe enable enable Registration - - v4 + + + - - - - - - - @@ -56,4 +60,4 @@ - \ No newline at end of file + diff --git a/Registration/appsettings.json b/Registration/appsettings.json new file mode 100644 index 0000000..9edeee2 --- /dev/null +++ b/Registration/appsettings.json @@ -0,0 +1,56 @@ +{ + // ── Connection Strings ─────────────────────────────────────────────────── + // Override via docker-compose env var: + // ConnectionStrings__Sql=Server=10.10.99.212;Database=dbRegistration;... + "ConnectionStrings": { + "Sql": "Server=10.10.99.212;Database=dbRegistration;User Id=appAdPlatformReg;Password=CHANGE_ME;TrustServerCertificate=True;" + }, + + // ── Entra External ID (CIAM) ───────────────────────────────────────────── + // Tenant: Positive Spend Clients (cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b) + // SPA App: AdPlatform Client SPA (c426967f-bfcc-46af-b4e5-d69dc01cbf75) + // + // This is the CLIENT-facing CIAM tenant — NOT the internal positivespend.com + // org tenant used by the Management/Admin console (f56a3c51). + // + // Tokens are issued by PositiveSpendClients.ciamlogin.com. + // Microsoft.Identity.Web validates issuer, audience, and signature automatically. + // + // Override via env vars (docker-compose .env): + // AzureAd__Instance=https://PositiveSpendClients.ciamlogin.com/ + // AzureAd__TenantId=cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b + // AzureAd__ClientId=c426967f-bfcc-46af-b4e5-d69dc01cbf75 + "AzureAd": { + "Instance": "https://PositiveSpendClients.ciamlogin.com/", + "TenantId": "cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b", + "ClientId": "c426967f-bfcc-46af-b4e5-d69dc01cbf75", + "Audience": "c426967f-bfcc-46af-b4e5-d69dc01cbf75" + }, + + // ── CORS ───────────────────────────────────────────────────────────────── + // Comma-separated allowed origins. + // Override via env var: CORS__AllowedOrigins (already in docker-compose .env) + "CORS": { + "AllowedOrigins": "https://client.positivespend.com,https://portal.positivespend.com" + }, + + // ── Admin API Key ──────────────────────────────────────────────────────── + // Used by ApiKeyAuthFilter on admin endpoints (pending / item / reject / complete). + // Replaces Azure Functions AuthorizationLevel.Function in self-hosted mode. + // Must match Registration__FunctionKey in docker-compose .env. + // Override via env var: Registration__FunctionKey + "Registration": { + "FunctionKey": "" + }, + + // ── Logging ────────────────────────────────────────────────────────────── + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Identity": "Warning" + } + }, + + "AllowedHosts": "*" +}