diff --git a/Registration/Functions/RegistrationFunctions.cs b/Registration/Functions/RegistrationFunctions.cs index 8b1cbd4..03da4f7 100644 --- a/Registration/Functions/RegistrationFunctions.cs +++ b/Registration/Functions/RegistrationFunctions.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker; // ← SWAP: remove for AspNetCore using Microsoft.Extensions.Logging; using Registration.Data; using System.Security.Claims; @@ -17,13 +17,33 @@ namespace Registration.Functions; /// entraSubjectId is extracted from the validated /// token — the client never supplies it. /// -/// GET /api/registration/pending — Admin; requires Function key. -/// GET /api/registration/item/{id} — Admin; requires Function key. -/// POST /api/registration/action/{id}/reject — Admin; requires Function key. -/// POST /api/registration/action/{id}/complete — Admin; requires Function key. +/// 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/health — Anonymous. +/// +/// ═══════════════════════════════════════════════════════ +/// HOST SWAP — three files change, everything else stays: +/// 1. Functions/RegistrationFunctions.cs ← this file +/// 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 +/// ═══════════════════════════════════════════════════════ /// + +// ── SWAP: Azure Functions class declaration ─────────────────────────────── public class RegistrationFunctions +// ── SWAP: AspNetCore class declaration ──────────────────────────────────── +// [ApiController] +// [Route("api")] +// public class RegistrationFunctions : ControllerBase +// ───────────────────────────────────────────────────────────────────────── { private readonly IRegistrationDataService _data; private readonly ILogger _log; @@ -54,21 +74,21 @@ public class RegistrationFunctions // ── Public: Register ───────────────────────────────────────────────── - /// - /// Register a new prospect. - /// - /// AuthorizationLevel.Anonymous at the trigger allows any caller with a valid - /// Bearer token — no Function key required from the browser. - /// The [Authorize] attribute ensures the JWT is present and valid before - /// the function body runs. entraSubjectId is extracted from token claims only. - /// + // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("Register")] [Authorize] public async Task Register( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req, CancellationToken ct) + // ── SWAP: AspNetCore ────────────────────────────────────────────────── + // [HttpPost("registration/register")] + // [Authorize] + // public async Task Register(CancellationToken ct) + // ───────────────────────────────────────────────────────────────────── { - // Identity comes from the validated JWT — never from the request body + // AspNetCore only — uncomment when in AspNetCore mode: + // var req = HttpContext.Request; + var entraSubjectId = GetEntraSubjectId(req.HttpContext.User); if (string.IsNullOrEmpty(entraSubjectId)) @@ -90,7 +110,6 @@ public class RegistrationFunctions if (request == null || string.IsNullOrWhiteSpace(request.BusinessName)) return new BadRequestObjectResult(new { ok = false, error = "businessName is required" }); - // Stamp the server-validated identity — client-supplied value is ignored request.EntraSubjectId = entraSubjectId; _log.LogInformation("[Registration] POST register: {Name} by entra={EntraId}", @@ -106,11 +125,15 @@ public class RegistrationFunctions // ── Admin: List pending ─────────────────────────────────────────────── - /// List all pending registrations. Called by Management API with Function key. + // ── 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) + // ───────────────────────────────────────────────────────────────────── { _log.LogInformation("[Registration] GET pending"); var result = await _data.GetPendingAsync(ct); @@ -119,12 +142,16 @@ public class RegistrationFunctions // ── Admin: Get by ID ───────────────────────────────────────────────── - /// Get a single applicant by registration ID. Called by Management API. + // ── 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) + // ───────────────────────────────────────────────────────────────────── { _log.LogInformation("[Registration] GET {Id}", registrationId); @@ -137,13 +164,20 @@ public class RegistrationFunctions // ── Admin: Reject ──────────────────────────────────────────────────── - /// Reject a pending applicant. Called by Management after admin clicks 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) + // ───────────────────────────────────────────────────────────────────── { + // AspNetCore only — uncomment when in AspNetCore mode: + // var req = HttpContext.Request; + RejectBody? body = null; try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } catch { /* optional body */ } @@ -159,19 +193,22 @@ public class RegistrationFunctions return new OkObjectResult(result); } - // ── Admin: Complete (approved) ──────────────────────────────────────── + // ── Admin: Complete ─────────────────────────────────────────────────── - /// - /// Mark a registration as approved/completed. - /// Called by Management after spClientManagement.create succeeds. - /// Receives the platformClientId to link the registration to the platform record. - /// + // ── 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) + // ───────────────────────────────────────────────────────────────────── { + // AspNetCore only — uncomment when in AspNetCore mode: + // var req = HttpContext.Request; + CompleteBody? body = null; try { body = await JsonSerializer.DeserializeAsync(req.Body, JsonOpts, ct); } catch { /* optional body */ } @@ -189,10 +226,14 @@ public class RegistrationFunctions // ── Health ─────────────────────────────────────────────────────────── - /// Health check — anonymous, no auth required. + // ── SWAP: Azure Functions ───────────────────────────────────────────── [Function("Health")] public IActionResult Health( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequest req) + // ── SWAP: AspNetCore ────────────────────────────────────────────────── + // [HttpGet("health")] + // public IActionResult Health() + // ───────────────────────────────────────────────────────────────────── { return new OkObjectResult(new { @@ -215,4 +256,4 @@ internal sealed class RejectBody 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 ea40ddb..93dc26b 100644 --- a/Registration/Program.cs +++ b/Registration/Program.cs @@ -1,36 +1,76 @@ -using Microsoft.Azure.Functions.Worker; +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. +// ───────────────────────────────────────────────────────────────────────────── +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). + services.AddAuthentication() + .AddMicrosoftIdentityWebApi(config.GetSection("AzureAd")); + + services.AddAuthorization(); + + // Data layer — swap between MockDataService (no DB) and SqlDataService (dbRegistration). + // MockDataService: no connection string required, seeds 4 test applicants. + // SqlDataService: requires ConnectionStrings:Sql pointed at dbRegistration. + // Calls dbo.spRegistration with @action/@rqst/@resp OUTPUT pattern. + services.AddSingleton(); + services.AddSingleton(); + // ── swap data layer here if needed ────────────────────────────────────── + // services.AddSingleton(); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// SWAP: Azure Functions host +// Active when deployed to Azure Functions or running via: func start +// ═════════════════════════════════════════════════════════════════════════════ var host = new HostBuilder() .ConfigureFunctionsWebApplication() - .ConfigureServices((context, services) => + .ConfigureServices((ctx, services) => { services.AddApplicationInsightsTelemetryWorkerService(); services.ConfigureFunctionsApplicationInsights(); - - // ============================================================= - // JWT Authentication — Entra External ID (CIAM) - // Validates Bearer tokens issued by usimclients.ciamlogin.com. - // AzureAd config is in local.settings.json (dev) or Function App - // Configuration (production) using AzureAd__ prefix. - // ============================================================= - services.AddAuthentication() - .AddMicrosoftIdentityWebApi(context.Configuration.GetSection("AzureAd")); - - services.AddAuthorization(); - - // ============================================================= - // Data layer — SqlDataService backed by dbRegistration. - // Connection string: ConnectionStrings:Sql in local.settings.json - // or the "Sql" connection string in Function App Configuration. - // ============================================================= - services.AddSingleton(); - services.AddSingleton(); + 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 diff --git a/Registration/Registration.csproj b/Registration/Registration.csproj index 6d673e3..6d4a407 100644 --- a/Registration/Registration.csproj +++ b/Registration/Registration.csproj @@ -1,34 +1,59 @@ - - net8.0 - v4 - Exe - enable - enable - Registration - + + net8.0 + Exe + enable + enable + Registration + + v4 + + + + + + + + + + + + + + + - - - PreserveNewest - - - PreserveNewest - Never - - + + + + PreserveNewest + + + PreserveNewest + Never + + - + \ No newline at end of file