Compare commits

...

8 Commits

Author SHA1 Message Date
Grae Jones
7ce747ce48 Reg Fix 1
All checks were successful
Registration / build-deploy (push) Successful in 1m13s
2026-03-23 14:02:58 -07:00
Grae Jones
b4fd0b6c9e Aligning CLient ID's
All checks were successful
Management / build-deploy (push) Successful in 31s
2026-03-23 13:03:08 -07:00
Grae Jones
0e17da63d0 Azure Setting
All checks were successful
Registration / build-deploy (push) Successful in 21s
2026-03-23 08:48:32 -07:00
Grae Jones
a6df344e80 Clean up
All checks were successful
Registration / build-deploy (push) Successful in 20s
2026-03-22 21:02:03 -07:00
Grae Jones
fae2226581 Revised Registration
All checks were successful
Registration / build-deploy (push) Successful in 9m8s
2026-03-22 09:37:28 -07:00
Grae Jones
8de463cd17 Revised Gateway
All checks were successful
Gateway / build-deploy (push) Successful in 2m34s
2026-03-22 07:50:04 -07:00
Grae Jones
866ab983c5 Merge branch 'master' of https://gitea.graejones.com/GraeJones01/AdPlatform-Server
All checks were successful
Management / build-deploy (push) Successful in 4m33s
2026-03-22 07:16:35 -07:00
Grae Jones
44764bc641 fix ClientAuthMiddleware 2026-03-22 07:15:18 -07:00
20 changed files with 428 additions and 250 deletions

View File

@@ -13,6 +13,28 @@ var builder = WebApplication.CreateBuilder(args);
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
// --------------------
// CORS — allowed origins from env var, comma-separated
// --------------------
var allowedOrigins = (builder.Configuration["CORS__AllowedOrigins"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
if (allowedOrigins.Length > 0)
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod();
else
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// --------------------
// Services
// --------------------
@@ -174,6 +196,9 @@ app.MapGet("/", () => Results.Ok(new
status = "Application Gateway running"
}));
// CORS — must be before auth middleware
app.UseCors();
// Access logging middleware (captures all requests)
// Placed BEFORE auth so we log even failed auth attempts
app.UseAccessLogging();

View File

@@ -250,8 +250,8 @@ public sealed class MultiProviderAuthMiddleware
{
// Standard Entra ID — could be CIAM tenant or Staff tenant (Tech, Admin)
// Detect by comparing issuer against configured Staff tenant ID
var staffTenantId = _config["Auth:Microsoft:StaffTenantId"];
var staffClientId = _config["Auth:Microsoft:StaffClientId"];
var staffTenantId = _config["Auth:Staff:TenantId"];
var staffClientId = _config["Auth:Staff:ClientId"];
var isStaff = !string.IsNullOrWhiteSpace(staffTenantId) &&
jwt.Issuer.Contains(staffTenantId, StringComparison.OrdinalIgnoreCase);

View File

@@ -36,7 +36,7 @@ public class ImageStorageService
_logger = logger;
_blobClient = blobClient;
_containerName = config["BlobStorage:ContainerName"] ?? "creative-images";
_blobBaseUrl = config["BlobStorage:BaseUrl"] ?? "https://usimadpcreatives.blob.core.windows.net";
_blobBaseUrl = config["BlobStorage:BaseUrl"] ?? string.Empty;
_isConfigured = blobClient != null;
if (!_isConfigured)

View File

@@ -6,41 +6,34 @@
}
},
"AllowedHosts": "*",
"Auth": {
"AllowDevBypass": false,
"Microsoft": {
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e",
"StaffTenantId": "0be4c23a-6941-4bdb-b397-a4faf88de4b3",
"StaffClientId": "b0f29246-91e7-4615-96db-5de9b6f8da2e"
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
},
"EntraId": {
"Instance": "https://login.microsoftonline.com/",
"Instance": "https://PositiveSpendClients.ciamlogin.com/",
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
}
},
"BlobStorage": {
"ConnectionString": "",
"ContainerName": "creative-images",
"BaseUrl": "https://usimadpcreatives.blob.core.windows.net"
"BaseUrl": ""
},
"MultiChannel": {
"Allocation": {
"MinMultiChannelMonthlyBudget": 500.00,
"MinMultiChannelMonthlyBudget": 500.0,
"MaxChannelsPerInitiative": 5,
"DefaultAllocationStrategy": "template",
"PerformanceEvalIntervalDays": 7,
"PerformanceLookbackDays": 14,
"PerformanceLearningPeriodDays": 14,
"MaxAllocationShiftPct": 15.00,
"MinChannelAllocationPct": 10.00,
"MaxChannelAllocationPct": 80.00
"MaxAllocationShiftPct": 15.0,
"MinChannelAllocationPct": 10.0,
"MaxChannelAllocationPct": 80.0
}
}
}
}

View File

@@ -6,17 +6,14 @@
}
},
"AllowedHosts": "*",
"Auth": {
"AllowDevBypass": false,
"Microsoft": {
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
},
"Google": {
"ClientId": ""
}
}
}
}

Binary file not shown.

View File

@@ -47,8 +47,23 @@
* 3. Grant admin consent
* 4. Create a client secret copy value set Graph__ClientSecret env var
*/
/*
* REGISTRATION API called by RegistrationClient (typed HttpClient).
* Management proxies /api/registration/* to this service.
*
* BaseUrl: Registration ASP.NET Core container, proxied via nginx.
* Set via env var: Registration__BaseUrl
* FunctionKey: Shared secret validated by ApiKeyAuthFilter on admin endpoints.
* Set via env var: Registration__FunctionKey
* Must match Registration:FunctionKey on the RegServer.
*/
"Registration": {
"BaseUrl": "https://portal.positivespend.com/api",
"FunctionKey": ""
},
"Graph": {
"TenantId": "0be4c23a-6941-4bdb-b397-a4faf88de4b3",
"TenantId": "f56a3c51-9b5c-4356-920f-b4dcf932a96b",
"ClientId": "b0f29246-91e7-4615-96db-5de9b6f8da2e",
"ClientSecret": ""
}

View File

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Registration.Auth;
/// <summary>
/// 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).
/// ─────────────────────────────────────────────────────────────────────────
/// </summary>
[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<IConfiguration>();
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) { }
}

View File

@@ -22,6 +22,8 @@ public sealed class Applicant
public string? WebsiteUrl { get; set; }
public string? BusinessCategory { get; set; }
public string? BusinessDescription { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? ContactName { get; set; }
public string? ContactEmail { get; set; }
public string? ContactPhone { get; set; }
@@ -42,7 +44,9 @@ public sealed class RegisterRequest
public string? WebsiteUrl { get; set; }
public string? BusinessCategory { get; set; }
public string? BusinessDescription { get; set; }
public string? ContactName { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? ContactName { get; set; } // Combined first + last, set by client
public string? ContactEmail { get; set; }
public string? ContactPhone { get; set; }
public string? EntraSubjectId { get; set; }

View File

@@ -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;
/// <summary>
@@ -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
/// ═══════════════════════════════════════════════════════
/// </summary>
// ── 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<RegistrationFunctions> log)
{
_data = data;
_log = log;
_log = log;
}
// ── Helpers ──────────────────────────────────────────────────────────
@@ -66,6 +78,7 @@ public class RegistrationFunctions
/// <summary>
/// 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
/// </summary>
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<IActionResult> Register(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req,
CancellationToken ct)
// ── SWAP: AspNetCore ──────────────────────────────────────────────────
// [HttpPost("registration/register")]
public async Task<IActionResult> Register(CancellationToken ct)
// ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
// [Function("Register")]
// [Authorize]
// public async Task<IActionResult> Register(CancellationToken ct)
// public async Task<IActionResult> 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<IActionResult> GetPending(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/pending")] HttpRequest req,
CancellationToken ct)
// ── SWAP: AspNetCore ──────────────────────────────────────────────────
// [HttpGet("registration/pending")]
// public async Task<IActionResult> GetPending(CancellationToken ct)
// ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
[HttpGet("registration/pending")]
[ApiKeyAuth]
public async Task<IActionResult> GetPending(CancellationToken ct)
// ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
// [Function("GetPending")]
// public async Task<IActionResult> 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<IActionResult> GetById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/item/{registrationId}")] HttpRequest req,
string registrationId,
CancellationToken ct)
// ── SWAP: AspNetCore ──────────────────────────────────────────────────
// [HttpGet("registration/item/{registrationId}")]
// public async Task<IActionResult> GetById(string registrationId, CancellationToken ct)
// ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
[HttpGet("registration/item/{registrationId}")]
[ApiKeyAuth]
public async Task<IActionResult> GetById(string registrationId, CancellationToken ct)
// ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
// [Function("GetById")]
// public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Reject(string registrationId, CancellationToken ct)
// ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
[HttpPost("registration/action/{registrationId}/reject")]
[ApiKeyAuth]
public async Task<IActionResult> Reject(string registrationId, CancellationToken ct)
// ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
// [Function("Reject")]
// public async Task<IActionResult> 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<RejectBody>(req.Body, JsonOpts, ct); }
@@ -195,19 +214,21 @@ public class RegistrationFunctions
// ── Admin: Complete ───────────────────────────────────────────────────
// ── SWAP: Azure Functions ─────────────────────────────────────────────
[Function("Complete")]
public async Task<IActionResult> 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<IActionResult> Complete(string registrationId, CancellationToken ct)
// ── SWAP: ASP.NET Core ◄ ACTIVE ─────────────────────────────────────
[HttpPost("registration/action/{registrationId}/complete")]
[ApiKeyAuth]
public async Task<IActionResult> Complete(string registrationId, CancellationToken ct)
// ── SWAP: Azure Functions ◄ INACTIVE — uncomment to restore
// [Function("Complete")]
// public async Task<IActionResult> 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<CompleteBody>(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; }
}
}

View File

@@ -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<SqlService>();
services.AddSingleton<IRegistrationDataService, SqlDataService>();
@@ -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();
// ─────────────────────────────────────────────────────────────────────────────
// using Microsoft.Azure.Functions.Worker;
//
// var host = new HostBuilder()
// .ConfigureFunctionsWebApplication()
// .ConfigureServices((ctx, services) =>
// {
// services.AddApplicationInsightsTelemetryWorkerService();
// services.ConfigureFunctionsApplicationInsights();
// ConfigureServices(services, ctx.Configuration);
// })
// .Build();
//
// host.Run();
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -36,7 +36,7 @@
},
{
"type": "Microsoft.Resources/deployments",
"name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('usim-adp-registration', subscription().subscriptionId)))]",
"name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('ps-adp-registration', subscription().subscriptionId)))]",
"resourceGroup": "[parameters('resourceGroupName')]",
"apiVersion": "2019-10-01",
"dependsOn": [
@@ -50,7 +50,7 @@
"resources": [
{
"kind": "web",
"name": "usim-adp-registration",
"name": "ps-adp-registration",
"type": "microsoft.insights/components",
"location": "[parameters('resourceLocation')]",
"properties": {},

View File

@@ -21,7 +21,7 @@
},
"resourceName": {
"type": "string",
"defaultValue": "usim-adp-registration",
"defaultValue": "ps-adp-registration",
"metadata": {
"description": "Name of the main resource to be created by this template."
}

View File

@@ -6,7 +6,7 @@
"connectionId": "AzureWebJobsStorage"
},
"appInsights1": {
"resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/microsoft.insights/components/usim-adp-registration",
"resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/microsoft.insights/components/ps-adp-registration",
"type": "appInsights.azure",
"connectionId": "APPLICATIONINSIGHTS_CONNECTION_STRING"
}

View File

@@ -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<IRegistrationDataService, MockDataService>();
```
## 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<SqlService>();
services.AddSingleton<IRegistrationDataService, SqlDataService>();
```
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 `<AzureFunctionsVersion>v4</AzureFunctionsVersion>`, 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` = `<key from portal>`
These can be set as Azure Container App environment variables:
```
Registration__BaseUrl=https://your-function-app.azurewebsites.net/api
Registration__FunctionKey=<key>
```
## 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 |

View File

@@ -1,50 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Registration</RootNamespace>
<!-- SWAP: Azure Functions only — remove for AspNetCore -->
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<!-- SWAP: AzureFunctionsVersion removed for ASP.NET Core mode. -->
<!-- To restore Azure Functions: add back <AzureFunctionsVersion>v4</AzureFunctionsVersion> -->
<!-- and change Sdk above to Microsoft.NET.Sdk -->
</PropertyGroup>
<!--
═══════════════════════════════════════════════════════════════════════════
SWAP: Azure Functions packages (active)
To switch to AspNetCore: comment this ItemGroup, uncomment the one below,
and remove <AzureFunctionsVersion> from PropertyGroup above.
SWAP: ASP.NET Core packages ◄ ACTIVE
Microsoft.NET.Sdk.Web pulls in Microsoft.AspNetCore.App automatically —
no explicit FrameworkReference needed here.
═══════════════════════════════════════════════════════════════════════════
-->
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.22.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.5.0" />
</ItemGroup>
<!--
═══════════════════════════════════════════════════════════════════════════
SWAP: ASP.NET Core packages
To switch to AspNetCore: uncomment this ItemGroup, comment the one above,
and remove <AzureFunctionsVersion> from PropertyGroup above.
SWAP: Azure Functions packages ◄ INACTIVE — uncomment to restore
To switch back to Azure Functions:
1. Uncomment this ItemGroup
2. Comment the ASP.NET Core ItemGroup above
3. Restore <AzureFunctionsVersion>v4</AzureFunctionsVersion> in PropertyGroup
4. Change Sdk to Microsoft.NET.Sdk
5. Uncomment the Functions block in Program.cs
6. Swap class declaration and method signatures in RegistrationFunctions.cs
═══════════════════════════════════════════════════════════════════════════
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.22.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.5.0" />
</ItemGroup>
-->
<!--
Azure Functions host files — present in both modes, ignored in AspNetCore.
host.json and local.settings.json are inert when running as a plain web app.
host.json and local.settings.json are inert in ASP.NET Core mode.
Kept in the repo so the Azure Functions SWAP path remains intact.
-->
<ItemGroup>
<None Update="host.json">
@@ -56,4 +60,4 @@
</None>
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,25 @@
{
"ConnectionStrings": {
"Sql": "Server=10.10.99.212;Database=dbRegistration;User Id=appAdPlatformReg;Password=REPLACE_ME;TrustServerCertificate=True;"
},
"AzureAd": {
"Instance": "https://positiveclients.ciamlogin.com/",
"TenantId": "cbf8b7d7-1e13-486d-b5b0-287ba79fdf0b",
"ClientId": "43c493e4-e1ed-4cd7-ab0a-e507e20af724",
"Audience": "43c493e4-e1ed-4cd7-ab0a-e507e20af724"
},
"CORS": {
"AllowedOrigins": "https://register.positivespend.com,https://client.positivespend.com,https://portal.positivespend.com"
},
"Registration": {
"FunctionKey": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.Identity": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -18,7 +18,8 @@
"routePrefix": "api",
"cors": {
"allowedOrigins": [
"https://adpregist.usimdev.com",
"https://register.positivespend.com",
"https://client.positivespend.com",
"http://localhost:3001"
],
"allowedHeaders": [ "*" ],

View File

@@ -5,12 +5,12 @@
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
},
"ConnectionStrings": {
"Sql": "Server=usimdev.database.windows.net;Database=dbRegistration;User Id=appAdPlatformReg;Password=YOUR_PASSWORD_HERE;TrustServerCertificate=True;"
"Sql": "Server=10.10.99.212;Database=dbRegistration;User Id=appAdPlatformReg;Password=REPLACE_ME;TrustServerCertificate=True;"
},
"AzureAd": {
"Instance": "https://usimclients.ciamlogin.com/",
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e",
"Audience": "154c9111-14a0-4c0f-8132-7bc68254a74e"
"Instance": "https://REPLACE_WITH_CIAM_SUBDOMAIN.ciamlogin.com/",
"TenantId": "REPLACE_WITH_CIAM_TENANT_ID",
"ClientId": "REPLACE_WITH_CIAM_CLIENT_ID",
"Audience": "REPLACE_WITH_CIAM_CLIENT_ID"
}
}