219 lines
9.2 KiB
C#
219 lines
9.2 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Extensions.Logging;
|
|
using Registration.Data;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
|
|
namespace Registration.Functions;
|
|
|
|
/// <summary>
|
|
/// Registration HTTP endpoints.
|
|
///
|
|
/// ENDPOINTS:
|
|
/// POST /api/registration/register — Public; requires Bearer token (CIAM JWT).
|
|
/// 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/health — Anonymous.
|
|
/// </summary>
|
|
public class RegistrationFunctions
|
|
{
|
|
private readonly IRegistrationDataService _data;
|
|
private readonly ILogger<RegistrationFunctions> _log;
|
|
|
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
public RegistrationFunctions(IRegistrationDataService data, ILogger<RegistrationFunctions> log)
|
|
{
|
|
_data = data;
|
|
_log = log;
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Extract the Entra Object ID from a validated CIAM JWT.
|
|
/// The OID claim is stable across sessions and providers (Google, Apple, Microsoft).
|
|
/// </summary>
|
|
private static string? GetEntraSubjectId(ClaimsPrincipal user) =>
|
|
user.FindFirst("oid")?.Value
|
|
?? user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value
|
|
?? user.FindFirst("sub")?.Value;
|
|
|
|
// ── Public: Register ─────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Function("Register")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Register(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/register")] HttpRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
// Identity comes from the validated JWT — never from the request body
|
|
var entraSubjectId = GetEntraSubjectId(req.HttpContext.User);
|
|
|
|
if (string.IsNullOrEmpty(entraSubjectId))
|
|
{
|
|
_log.LogWarning("[Registration] Register called without a valid token OID claim");
|
|
return new UnauthorizedObjectResult(new { ok = false, error = "Valid authentication required" });
|
|
}
|
|
|
|
RegisterRequest? request;
|
|
try
|
|
{
|
|
request = await JsonSerializer.DeserializeAsync<RegisterRequest>(req.Body, JsonOpts, ct);
|
|
}
|
|
catch
|
|
{
|
|
return new BadRequestObjectResult(new { ok = false, error = "Invalid JSON body" });
|
|
}
|
|
|
|
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}",
|
|
request.BusinessName, entraSubjectId);
|
|
|
|
var result = await _data.RegisterAsync(request, ct);
|
|
|
|
if (!result.Ok)
|
|
return new BadRequestObjectResult(result);
|
|
|
|
return new OkObjectResult(result);
|
|
}
|
|
|
|
// ── Admin: List pending ───────────────────────────────────────────────
|
|
|
|
/// <summary>List all pending registrations. Called by Management API with Function key.</summary>
|
|
[Function("GetPending")]
|
|
public async Task<IActionResult> GetPending(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/pending")] HttpRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
_log.LogInformation("[Registration] GET pending");
|
|
var result = await _data.GetPendingAsync(ct);
|
|
return new OkObjectResult(result);
|
|
}
|
|
|
|
// ── Admin: Get by ID ─────────────────────────────────────────────────
|
|
|
|
/// <summary>Get a single applicant by registration ID. Called by Management API.</summary>
|
|
[Function("GetById")]
|
|
public async Task<IActionResult> GetById(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "registration/item/{registrationId}")] HttpRequest req,
|
|
string registrationId,
|
|
CancellationToken ct)
|
|
{
|
|
_log.LogInformation("[Registration] GET {Id}", registrationId);
|
|
|
|
var applicant = await _data.GetByIdAsync(registrationId, ct);
|
|
if (applicant == null)
|
|
return new NotFoundObjectResult(new { ok = false, error = "Registration not found" });
|
|
|
|
return new OkObjectResult(new { ok = true, applicant });
|
|
}
|
|
|
|
// ── Admin: Reject ────────────────────────────────────────────────────
|
|
|
|
/// <summary>Reject a pending applicant. Called by Management after admin clicks Reject.</summary>
|
|
[Function("Reject")]
|
|
public async Task<IActionResult> Reject(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/reject")] HttpRequest req,
|
|
string registrationId,
|
|
CancellationToken ct)
|
|
{
|
|
RejectBody? body = null;
|
|
try { body = await JsonSerializer.DeserializeAsync<RejectBody>(req.Body, JsonOpts, ct); }
|
|
catch { /* optional body */ }
|
|
|
|
_log.LogInformation("[Registration] POST reject: {Id} reason={Reason}",
|
|
registrationId, body?.Reason);
|
|
|
|
var result = await _data.RejectAsync(registrationId, body?.Reason, body?.RejectedBy, ct);
|
|
|
|
if (!result.Ok)
|
|
return new BadRequestObjectResult(result);
|
|
|
|
return new OkObjectResult(result);
|
|
}
|
|
|
|
// ── Admin: Complete (approved) ────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Mark a registration as approved/completed.
|
|
/// Called by Management after spClientManagement.create succeeds.
|
|
/// Receives the platformClientId to link the registration to the platform record.
|
|
/// </summary>
|
|
[Function("Complete")]
|
|
public async Task<IActionResult> Complete(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "registration/action/{registrationId}/complete")] HttpRequest req,
|
|
string registrationId,
|
|
CancellationToken ct)
|
|
{
|
|
CompleteBody? body = null;
|
|
try { body = await JsonSerializer.DeserializeAsync<CompleteBody>(req.Body, JsonOpts, ct); }
|
|
catch { /* optional body */ }
|
|
|
|
_log.LogInformation("[Registration] POST complete: {Id} → platform client {PlatformId}",
|
|
registrationId, body?.PlatformClientId);
|
|
|
|
var result = await _data.CompleteAsync(registrationId, body?.PlatformClientId, ct);
|
|
|
|
if (!result.Ok)
|
|
return new BadRequestObjectResult(result);
|
|
|
|
return new OkObjectResult(result);
|
|
}
|
|
|
|
// ── Health ───────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Health check — anonymous, no auth required.</summary>
|
|
[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",
|
|
timestamp = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Request / Response Bodies ─────────────────────────────────────────────
|
|
|
|
internal sealed class RejectBody
|
|
{
|
|
public string? Reason { get; set; }
|
|
public string? RejectedBy { get; set; }
|
|
}
|
|
|
|
internal sealed class CompleteBody
|
|
{
|
|
public string? PlatformClientId { get; set; }
|
|
}
|