Add project files.

This commit is contained in:
Grae Jones
2026-02-03 15:04:37 -08:00
parent a4838b594d
commit 8e7e03702e
65 changed files with 6227 additions and 0 deletions

37
AdPlatformServers.sln Normal file
View File

@@ -0,0 +1,37 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36915.13
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateway", "Gateway\Gateway.csproj", "{2CADB68C-FB3B-D474-56C8-AE901F365B9C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoogleApi", "GoogleApi\GoogleApi.csproj", "{2A8EDC1B-88FA-CA30-1668-6E3204889388}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Management", "Management\Management.csproj", "{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2CADB68C-FB3B-D474-56C8-AE901F365B9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2CADB68C-FB3B-D474-56C8-AE901F365B9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2CADB68C-FB3B-D474-56C8-AE901F365B9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2CADB68C-FB3B-D474-56C8-AE901F365B9C}.Release|Any CPU.Build.0 = Release|Any CPU
{2A8EDC1B-88FA-CA30-1668-6E3204889388}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A8EDC1B-88FA-CA30-1668-6E3204889388}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A8EDC1B-88FA-CA30-1668-6E3204889388}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A8EDC1B-88FA-CA30-1668-6E3204889388}.Release|Any CPU.Build.0 = Release|Any CPU
{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B40D6912-CD8C-9B8D-8CC3-3F3B26B70D21}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4B799533-53B7-40FD-BBF1-FFF614C13DC9}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,382 @@
using Gateway.Data;
using Gateway.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Gateway.Controllers;
/// <summary>
/// Authentication endpoints for session management.
/// Sessions are created after Entra External ID authentication.
/// </summary>
[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly ILogger<AuthController> _log;
public AuthController(SqlService sql, ClientContext client, ILogger<AuthController> log)
{
_sql = sql;
_client = client;
_log = log;
}
/// <summary>
/// Exchange Entra JWT for a session token.
/// Call this after successful Entra login to get a session for API calls.
/// The JWT must be validated by middleware before this endpoint.
/// </summary>
[HttpPost("session")]
public async Task<IActionResult> CreateSession([FromBody] CreateSessionRequest? request, CancellationToken ct)
{
_log.LogWarning("[Session] CreateSession called");
// ClientContext is populated by middleware after JWT validation
if (!_client.IsAuthenticated)
{
_log.LogWarning("[Session] Not authenticated - ClientId is null/empty");
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
}
_log.LogWarning("[Session] Authenticated: ClientId={ClientId}, Email={Email}",
_client.ClientId, _client.Email);
var rqst = JsonSerializer.Serialize(new
{
provider = _client.AuthProvider ?? "EntraExternalId",
subject = _client.ClientId,
email = _client.Email,
displayName = _client.ClientName,
clientId = request?.PreferredClientId,
ipAddress = GetClientIp(),
userAgent = Request.Headers.UserAgent.FirstOrDefault(),
sessionDurationHours = request?.SessionDurationHours ?? 24
});
_log.LogWarning("[Session] Calling spSession with: {Rqst}", rqst);
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "createFromIdentity", rqst, ct: ct);
_log.LogWarning("[Session] spSession response: {Resp}", resp ?? "(null)");
if (string.IsNullOrWhiteSpace(resp))
{
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
{
_log.LogWarning("[Session] Success - returning session");
return Content(resp, "application/json");
}
else
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Session creation failed";
_log.LogWarning("[Session] Proc returned error: {Error}", error);
return BadRequest(new { ok = false, error });
}
}
catch (Exception ex)
{
_log.LogError(ex, "[Session] EXCEPTION in CreateSession: {Message}", ex.Message);
return StatusCode(500, new { ok = false, error = "Session service error", detail = ex.Message });
}
}
/// <summary>
/// Register a new client/organization for the authenticated user.
/// JWT must be validated by middleware before this endpoint.
/// Called from the registration portal after CIAM sign-in.
/// </summary>
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken ct)
{
_log.LogWarning("[Register] Register called");
if (!_client.IsAuthenticated)
{
_log.LogWarning("[Register] Not authenticated");
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
}
if (string.IsNullOrWhiteSpace(request?.CompanyName))
{
return BadRequest(new { ok = false, error = "companyName is required" });
}
_log.LogWarning("[Register] Authenticated: Subject={Subject}, Email={Email}, Company={Company}",
_client.ClientId, _client.Email, request.CompanyName);
var rqst = JsonSerializer.Serialize(new
{
provider = _client.AuthProvider ?? "EntraExternalId",
subject = _client.ClientId,
email = _client.Email,
displayName = _client.ClientName,
companyName = request.CompanyName,
industry = request.Industry,
website = request.Website
});
try
{
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "register", rqst, ct: ct);
_log.LogWarning("[Register] spOnboarding response: {Resp}", resp ?? "(null)");
if (string.IsNullOrWhiteSpace(resp))
{
return StatusCode(500, new { ok = false, error = "Onboarding service unavailable" });
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
{
_log.LogWarning("[Register] Success");
return Content(resp, "application/json");
}
else
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Registration failed";
_log.LogWarning("[Register] Error: {Error}", error);
return BadRequest(new { ok = false, error });
}
}
catch (Exception ex)
{
_log.LogError(ex, "[Register] EXCEPTION: {Message}", ex.Message);
return StatusCode(500, new { ok = false, error = "Registration service error", detail = ex.Message });
}
}
/// <summary>
/// Sign off (invalidate session)
/// </summary>
[HttpPost("signoff")]
public async Task<IActionResult> SignOff(CancellationToken ct)
{
var token = ExtractSessionToken();
if (string.IsNullOrWhiteSpace(token))
{
return BadRequest(new { ok = false, error = "No session token provided" });
}
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
try
{
await _sql.ExecProcAsync("dbo.spSession", "signoff", rqst, ct: ct);
return Ok(new { ok = true, message = "Signed out successfully" });
}
catch (Exception ex)
{
_log.LogError(ex, "SignOff error");
return StatusCode(500, new { ok = false, error = "Sign off failed" });
}
}
/// <summary>
/// Refresh session (extend expiration)
/// </summary>
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest? request, CancellationToken ct)
{
var token = ExtractSessionToken();
if (string.IsNullOrWhiteSpace(token))
{
return BadRequest(new { ok = false, error = "No session token provided" });
}
var rqst = JsonSerializer.Serialize(new
{
sessionToken = token,
sessionDurationHours = request?.SessionDurationHours ?? 24
});
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "refresh", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
{
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
{
return Content(resp, "application/json");
}
else
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Refresh failed";
return Unauthorized(new { ok = false, error });
}
}
catch (Exception ex)
{
_log.LogError(ex, "Refresh error");
return StatusCode(500, new { ok = false, error = "Session refresh failed" });
}
}
/// <summary>
/// Get current session info
/// </summary>
[HttpGet("me")]
public async Task<IActionResult> Me(CancellationToken ct)
{
var token = ExtractSessionToken();
if (string.IsNullOrWhiteSpace(token))
{
return Unauthorized(new { ok = false, error = "No session token provided" });
}
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
{
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
{
return Content(resp, "application/json");
}
else
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Invalid session";
return Unauthorized(new { ok = false, error });
}
}
catch (Exception ex)
{
_log.LogError(ex, "Me error");
return StatusCode(500, new { ok = false, error = "Session validation failed" });
}
}
/// <summary>
/// Switch to a different client context (for multi-client users)
/// </summary>
[HttpPost("switch-client")]
public async Task<IActionResult> SwitchClient([FromBody] SwitchClientRequest request, CancellationToken ct)
{
var token = ExtractSessionToken();
if (string.IsNullOrWhiteSpace(token))
{
return Unauthorized(new { ok = false, error = "No session token provided" });
}
if (string.IsNullOrWhiteSpace(request.ClientId))
{
return BadRequest(new { ok = false, error = "clientId is required" });
}
var rqst = JsonSerializer.Serialize(new
{
sessionToken = token,
clientId = request.ClientId
});
try
{
var resp = await _sql.ExecProcAsync("dbo.spSession", "switchClient", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
{
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
{
return Content(resp, "application/json");
}
else
{
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Switch failed";
return BadRequest(new { ok = false, error });
}
}
catch (Exception ex)
{
_log.LogError(ex, "SwitchClient error");
return StatusCode(500, new { ok = false, error = "Client switch failed" });
}
}
private string? ExtractSessionToken()
{
// Check X-Session-Token header first
var token = Request.Headers["X-Session-Token"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(token))
return token;
// Check Authorization header (for session tokens, not JWTs)
var auth = Request.Headers.Authorization.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
{
return auth.Substring(8).Trim();
}
return null;
}
private string? GetClientIp()
{
var forwarded = Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(forwarded))
return forwarded.Split(',')[0].Trim();
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
}
public sealed class CreateSessionRequest
{
public string? PreferredClientId { get; set; }
public int? SessionDurationHours { get; set; }
}
public sealed class RefreshRequest
{
public int? SessionDurationHours { get; set; }
}
public sealed class SwitchClientRequest
{
public string? ClientId { get; set; }
}
public sealed class RegisterRequest
{
public string? CompanyName { get; set; }
public string? Industry { get; set; }
public string? Website { get; set; }
}

View File

@@ -0,0 +1,25 @@
using Gateway.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Gateway.Controllers;
[ApiController]
[Route("api/execution")]
public sealed class ExecutionController : ControllerBase
{
private readonly ExecutionService _svc;
public ExecutionController(ExecutionService svc) => _svc = svc;
[HttpPost("request")]
public async Task<IActionResult> Execute([FromBody] JsonElement body)
{
if (body.ValueKind == JsonValueKind.Undefined || body.ValueKind == JsonValueKind.Null)
return BadRequest(new { ok = false, error = "Missing request body" });
var resp = await _svc.ExecuteAsync(body, HttpContext.RequestAborted);
// resp is JsonElement / JsonDocument / string json — you decide.
return Content(resp, "application/json");
}
}

View File

@@ -0,0 +1,28 @@
using Gateway.Data;
using Microsoft.AspNetCore.Mvc;
namespace Gateway.Controllers;
[ApiController]
[Route("api/test")]
public class TestController : ControllerBase
{
private readonly SqlService _sql;
public TestController(SqlService sql)
{
_sql = sql;
}
[HttpGet("ping")]
public async Task<IActionResult> Ping(CancellationToken ct)
{
// Use a real clientId that exists in tbClient (or you'll hit your FK check)
var rqst = """
{ "clientId":"00000000-0000-0000-0000-000000000001" }
""";
var resp = await _sql.ExecProcAsync("dbo.spTemplate", "ping", rqst, ct: ct);
return Content(resp, "application/json");
}
}

14
Gateway/Data/SqlNames.cs Normal file
View File

@@ -0,0 +1,14 @@
namespace Gateway.Data;
public static class SqlNames
{
public static class Procs
{
public const string Client = "dbo.spClient";
public const string User = "dbo.spUser";
public const string UserClientRole = "dbo.spUserClientRole";
public const string AdAccount = "dbo.spAdAccount";
public const string AdCampaign = "dbo.spAdCampaign";
public const string Invoice = "dbo.spInvoice";
}
}

92
Gateway/Data/SqlServer.cs Normal file
View File

@@ -0,0 +1,92 @@
using System.Data;
using System.Diagnostics;
using Microsoft.Data.SqlClient;
namespace Gateway.Data;
public class SqlService
{
private readonly IConfiguration _config;
private readonly ILogger<SqlService> _logger;
public SqlService(IConfiguration config, ILogger<SqlService> logger)
{
_config = config;
_logger = logger;
}
private string GetConnectionString()
{
// expects env var: ConnectionStrings__Sql
var cs = _config.GetConnectionString("Sql");
if (string.IsNullOrWhiteSpace(cs))
throw new InvalidOperationException("Missing connection string: ConnectionStrings:Sql (env var ConnectionStrings__Sql).");
return cs;
}
/// <summary>
/// Executes a stored procedure using the standard signature:
/// @action varchar(..),
/// @rqst nvarchar(max),
/// @resp nvarchar(max) OUTPUT
/// Returns the output JSON string (resp).
/// </summary>
public async Task<string> ExecProcAsync(
string procName,
string action,
string rqstJson,
int commandTimeoutSeconds = 60,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(procName))
throw new ArgumentException("procName is required.", nameof(procName));
if (string.IsNullOrWhiteSpace(action))
throw new ArgumentException("action is required.", nameof(action));
if (string.IsNullOrWhiteSpace(rqstJson))
rqstJson = "{}";
var sw = Stopwatch.StartNew();
try
{
await using var conn = new SqlConnection(GetConnectionString());
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(procName, conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = commandTimeoutSeconds
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = action });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqstJson });
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
{
Direction = ParameterDirection.Output
};
cmd.Parameters.Add(pResp);
await cmd.ExecuteNonQueryAsync(ct);
var resp = pResp.Value as string ?? "";
sw.Stop();
_logger.LogInformation("SQL ok: {Proc} action={Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
return resp;
}
catch (SqlException ex)
{
sw.Stop();
_logger.LogError(ex, "SQL error: {Proc} action={Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
throw;
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "App error calling SQL: {Proc} action={Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
throw;
}
}
}

28
Gateway/Gateway.csproj Normal file
View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Container Settings -->
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
<ContainerRepository>gateway</ContainerRepository>
<UserSecretsId>1fbe288a-4287-4931-8b37-9711665c35bb</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.Text.Json" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
</Project>

6
Gateway/Gateway.http Normal file
View File

@@ -0,0 +1,6 @@
@Gateway_HostAddress = http://localhost:5255
GET {{Gateway_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,11 @@
namespace Gateway.Models;
public class CampaignDto
{
public string Network { get; set; } = "";
public string ExternalAccountId { get; set; } = "";
public string CampaignId { get; set; } = "";
public string Name { get; set; } = "";
public string Status { get; set; } = "";
public string ChannelType { get; set; } = "";
}

View File

@@ -0,0 +1,17 @@
namespace Gateway.Models;
public class CreateCampaignRequest
{
// Campaign name
public string Name { get; set; } = "";
// For Google: budget uses micros (1,000,000 micros = 1 currency unit)
// e.g. $50/day => 50_000_000 micros
public long DailyBudgetMicros { get; set; }
// Optional: for future (Search/Display/PMax)
public string ChannelType { get; set; } = "Search";
// Optional: where your UI can store draft settings
public Dictionary<string, string>? Meta { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace Gateway.Models;
public class CreateCampaignResult
{
public string Network { get; set; } = "";
public string ExternalAccountId { get; set; } = "";
public bool Ok { get; set; }
public string? CampaignId { get; set; }
public string? Error { get; set; }
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
namespace Gateway.Models
{
public sealed class ExecutionRequest
{
/// <summary>Ad platform provider: google, meta, msads, etc.</summary>
public string Provider { get; set; } = "google";
/// <summary>Sub-module/microservice: system, campaigns, reporting, accounts, etc.</summary>
public string Service { get; set; } = "system";
/// <summary>Specific operation/action: ping, create, list, get, update, delete, etc.</summary>
public string Action { get; set; } = "ping";
/// <summary>Tenant/Customer ID for account context</summary>
public string? TenantId { get; set; }
/// <summary>Raw JSON payload for the operation</summary>
public JsonElement Payload { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
namespace Gateway.Models;
public sealed class ExecutionResponse
{
public bool Ok { get; set; }
public int? LogId { get; set; }
public string Provider { get; set; } = "";
public string Service { get; set; } = "";
public string RequestId { get; set; } = "";
public int ProviderStatus { get; set; }
public long ElapsedMs { get; set; }
public object? Result { get; set; }
public object? Error { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Gateway.Models;
public sealed class ProviderRequest
{
public string Operation { get; set; } = string.Empty;
public string? TenantId { get; set; }
public string? RequestId { get; set; }
public Dictionary<string, object>? Payload { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Gateway.Models;
public sealed class ProviderResponse
{
public bool Ok { get; set; }
public string? RequestId { get; set; }
public object? Data { get; set; }
public ProviderError? Error { get; set; }
}
public sealed class ProviderError
{
public string Code { get; set; } = "ERROR";
public string Message { get; set; } = "Unknown error";
public object? Detail { get; set; }
}

80
Gateway/Program.cs Normal file
View File

@@ -0,0 +1,80 @@
using Gateway.Data;
using Gateway.ProviderClients;
using Gateway.Security;
using Gateway.Services;
var builder = WebApplication.CreateBuilder(args);
// --------------------
// Container-friendly HTTP binding
// --------------------
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
// --------------------
// Services
// --------------------
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Data & business services
builder.Services.AddScoped<SqlService>();
builder.Services.AddScoped<ExecutionService>();
// Authentication context (scoped - one per request)
builder.Services.AddScoped<ClientContext>();
// Provider clients
builder.Services.AddHttpClient<GoogleProviderClient>(client =>
{
var baseUrl = builder.Configuration["Provider:Google:BaseUrl"]
?? Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL")
?? "";
if (!string.IsNullOrWhiteSpace(baseUrl))
client.BaseAddress = new Uri(baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/");
});
// HTTP client factory for ExecutionService
builder.Services.AddHttpClient();
var app = builder.Build();
// --------------------
// Middleware pipeline
// --------------------
// Swagger (enabled for all environments in containers)
app.UseSwagger();
app.UseSwaggerUI();
// Health check endpoint (before auth & logging)
app.MapGet("/health", () => Results.Ok(new
{
ok = true,
service = "Gateway",
timestamp = DateTimeOffset.UtcNow
}));
// Root endpoint
app.MapGet("/", () => Results.Ok(new
{
service = "Gateway API",
version = "1.0.0",
status = "Application Gateway running"
}));
// Access logging middleware (captures all requests)
// Placed BEFORE auth so we log even failed auth attempts
app.UseAccessLogging();
// Client authentication middleware (multi-provider)
// - Validates JWTs from Microsoft, Google, etc.
// - Accepts X-Dev-ClientId header (development)
app.UseMiddleware<MultiProviderAuthMiddleware>();
// Standard middleware
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,52 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5255"
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7252;http://localhost:5255"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Container (.NET SDK)": {
"commandName": "SdkContainer",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:52388",
"sslPort": 44375
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Net.Http.Json;
using System.Text.Json;
using Gateway.Models;
namespace Gateway.ProviderClients;
public sealed class GoogleProviderClient
{
private readonly HttpClient _http;
private readonly IConfiguration _config;
private readonly ILogger<GoogleProviderClient> _logger;
public GoogleProviderClient(HttpClient http, IConfiguration config, ILogger<GoogleProviderClient> logger)
{
_http = http;
_config = config;
_logger = logger;
}
private string GetInternalKey()
=> _config["Provider:Google:InternalKey"]
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY")
?? "";
public async Task<(int status, ProviderResponse body)> ExecuteAsync(ProviderRequest req, CancellationToken ct)
{
var key = GetInternalKey();
if (string.IsNullOrWhiteSpace(key))
return (500, new ProviderResponse { Ok = false, RequestId = req.RequestId, Error = new ProviderError { Code = "CONFIG", Message = "Missing Google internal key" } });
using var msg = new HttpRequestMessage(HttpMethod.Post, "internal/execute");
msg.Headers.Add("X-Internal-Key", key);
msg.Content = JsonContent.Create(req, options: new JsonSerializerOptions(JsonSerializerDefaults.Web));
var res = await _http.SendAsync(msg, ct);
ProviderResponse? body = null;
try
{
body = await res.Content.ReadFromJsonAsync<ProviderResponse>(cancellationToken: ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse provider response as JSON");
}
body ??= new ProviderResponse
{
Ok = res.IsSuccessStatusCode,
RequestId = req.RequestId,
Error = res.IsSuccessStatusCode ? null : new ProviderError { Code = "PROVIDER", Message = "Non-JSON error from provider" }
};
// If provider returned a 4xx/5xx but body says Ok=true, normalize
if (!res.IsSuccessStatusCode && body.Ok)
body.Ok = false;
return ((int)res.StatusCode, body);
}
}

View File

@@ -0,0 +1,169 @@
using Gateway.Data;
using System.Diagnostics;
using System.Text.Json;
namespace Gateway.Security;
/// <summary>
/// Logs all HTTP requests to tbAccessLog for security monitoring and debugging.
/// Should be registered early in the pipeline (after routing, before auth).
/// Logs asynchronously to avoid impacting response time.
/// </summary>
public sealed class AccessLogMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AccessLogMiddleware> _logger;
// Paths to skip logging (health checks, static files, etc.)
private static readonly HashSet<string> _skipPaths = new(StringComparer.OrdinalIgnoreCase)
{
"/health",
"/favicon.ico"
};
public AccessLogMiddleware(RequestDelegate next, ILogger<AccessLogMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
{
var path = context.Request.Path.Value ?? "/";
// Skip logging for noisy endpoints
if (ShouldSkip(path))
{
await _next(context);
return;
}
var stopwatch = Stopwatch.StartNew();
string? errorCode = null;
string? errorMessage = null;
try
{
await _next(context);
}
catch (Exception ex)
{
errorCode = "unhandled-exception";
errorMessage = ex.Message;
throw; // Re-throw to let error handling middleware deal with it
}
finally
{
stopwatch.Stop();
// Capture error info from response headers if set by auth middleware
if (context.Response.Headers.TryGetValue("X-Auth-Fail", out var authFail))
{
errorCode = authFail.FirstOrDefault();
}
// Fire-and-forget logging (don't await)
_ = LogAccessAsync(sql, context, clientContext, stopwatch.ElapsedMilliseconds, errorCode, errorMessage);
}
}
private static bool ShouldSkip(string path)
{
if (_skipPaths.Contains(path))
return true;
// Skip swagger
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private async Task LogAccessAsync(
SqlService sql,
HttpContext context,
ClientContext clientContext,
long durationMs,
string? errorCode,
string? errorMessage)
{
try
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? context.Response.Headers["X-Correlation-Id"].FirstOrDefault();
var authPath = context.Response.Headers["X-Auth-Path"].FirstOrDefault();
var rqst = JsonSerializer.Serialize(new
{
correlationId,
method = context.Request.Method,
path = context.Request.Path.Value,
queryString = context.Request.QueryString.HasValue
? SanitizeQueryString(context.Request.QueryString.Value)
: null,
authPath,
userId = clientContext.UserId,
clientId = clientContext.ClientId,
sessionId = clientContext.SessionId,
statusCode = context.Response.StatusCode,
durationMs,
ipAddress = GetClientIp(context),
userAgent = context.Request.Headers.UserAgent.FirstOrDefault(),
errorCode,
errorMessage
});
await sql.ExecProcAsync("dbo.spAccessLog", "log", rqst);
}
catch (Exception ex)
{
// Don't let logging failures affect the response
_logger.LogError(ex, "Failed to write access log");
}
}
private static string? GetClientIp(HttpContext context)
{
// Check X-Forwarded-For first (for requests behind load balancer/proxy)
var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(forwarded))
{
return forwarded.Split(',')[0].Trim();
}
return context.Connection.RemoteIpAddress?.ToString();
}
private static string? SanitizeQueryString(string? queryString)
{
if (string.IsNullOrWhiteSpace(queryString))
return null;
// Remove sensitive params (add more as needed)
var sensitiveParams = new[] { "token", "key", "secret", "password", "apikey" };
foreach (var param in sensitiveParams)
{
// Simple regex-free approach: just note that sensitive data may be present
if (queryString.Contains(param, StringComparison.OrdinalIgnoreCase))
{
return "[REDACTED]";
}
}
// Truncate if too long
return queryString.Length > 1000 ? queryString[..1000] : queryString;
}
}
/// <summary>
/// Extension method for cleaner registration in Program.cs
/// </summary>
public static class AccessLogMiddlewareExtensions
{
public static IApplicationBuilder UseAccessLogging(this IApplicationBuilder builder)
{
return builder.UseMiddleware<AccessLogMiddleware>();
}
}

View File

@@ -0,0 +1,415 @@
using Gateway.Data;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
namespace Gateway.Security;
/// <summary>
/// Tight auth contract:
/// 1) POST /api/auth/session -> MUST be Entra JWT (Authorization: Bearer <entraJwt>)
/// 2) All other /api/* -> MUST be valid session token (X-Session-Token OR Authorization: Bearer <sessionToken>)
/// 3) Dev bypass -> optional (Development or Auth:AllowDevBypass=true)
///
/// Populates ClientContext for downstream services.
/// Emits Warning logs so Azure Log Stream shows request-level auth flow.
/// Adds debug headers (X-Correlation-Id, X-Auth-Path, X-Auth-Fail).
/// </summary>
public sealed class ClientAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ClientAuthMiddleware> _logger;
private readonly IConfiguration _config;
// Exact paths that do not require authentication
private static readonly HashSet<string> _anonymousExact = new(StringComparer.OrdinalIgnoreCase)
{
"/",
"/health"
};
// Prefix paths that do not require authentication
private static readonly string[] _anonymousPrefixes =
{
"/swagger",
"/api/test"
};
// Cache OpenID config manager (avoid fetching metadata every request)
private static ConfigurationManager<OpenIdConnectConfiguration>? _oidcConfigManager;
private static readonly object _oidcLock = new();
public ClientAuthMiddleware(RequestDelegate next, ILogger<ClientAuthMiddleware> logger, IConfiguration config)
{
_next = next;
_logger = logger;
_config = config;
}
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
{
var pathRaw = context.Request.Path.Value ?? "";
var path = pathRaw.ToLowerInvariant();
var corrId = EnsureCorrelationId(context);
// Always visible in ACA log stream
_logger.LogWarning("[Auth] HIT {Method} {Path} | Corr={Corr}", context.Request.Method, pathRaw, corrId);
// Anonymous paths
if (IsAnonymousPath(path))
{
SetAuthDebugHeaders(context, corrId, authPath: "anonymous", authFail: null);
await _next(context);
return;
}
// ---------------------------------------------------------------------
// 1) SESSION EXCHANGE: MUST be Entra JWT
// ---------------------------------------------------------------------
if (path.StartsWith("/api/auth/session", StringComparison.OrdinalIgnoreCase))
{
if (await TryJwtAuthAsync(context, clientContext, corrId))
{
SetAuthDebugHeaders(context, corrId, authPath: "jwt(session-exchange)", authFail: null);
_logger.LogWarning("[Auth] Session exchange authorized via JWT | Email={Email} | Corr={Corr}",
clientContext.Email, corrId);
await _next(context);
return;
}
SetAuthDebugHeaders(context, corrId, authPath: "jwt(session-exchange)", authFail: "jwt-required");
_logger.LogWarning("[Auth] Session exchange denied: valid Entra JWT required | Corr={Corr}", corrId);
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
error = "Valid Entra authentication required",
correlationId = corrId
});
return;
}
// ---------------------------------------------------------------------
// 2) ALL OTHER /api/auth/*: MUST be session (or dev bypass)
// (signoff, refresh, me, switch-client, etc.)
// ---------------------------------------------------------------------
if (path.StartsWith("/api/auth", StringComparison.OrdinalIgnoreCase))
{
if (TryDevBypass(context, clientContext, corrId))
{
SetAuthDebugHeaders(context, corrId, authPath: "dev-bypass(auth)", authFail: null);
await _next(context);
return;
}
if (await TrySessionAuthAsync(context, clientContext, sql, corrId))
{
SetAuthDebugHeaders(context, corrId, authPath: "session(auth)", authFail: null);
await _next(context);
return;
}
SetAuthDebugHeaders(context, corrId, authPath: "session(auth)", authFail: "session-required");
_logger.LogWarning("[Auth] /api/auth denied: valid session required | Corr={Corr}", corrId);
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
error = "Valid session required",
correlationId = corrId
});
return;
}
// ---------------------------------------------------------------------
// 3) ALL OTHER REQUESTS (typically /api/*): MUST be session (or dev bypass)
// NO JWT FALLBACK. Keeps Bearer=<sessionToken> unambiguous.
// ---------------------------------------------------------------------
if (TryDevBypass(context, clientContext, corrId))
{
SetAuthDebugHeaders(context, corrId, authPath: "dev-bypass", authFail: null);
await _next(context);
return;
}
if (await TrySessionAuthAsync(context, clientContext, sql, corrId))
{
SetAuthDebugHeaders(context, corrId, authPath: "session", authFail: null);
await _next(context);
return;
}
SetAuthDebugHeaders(context, corrId, authPath: "session", authFail: "session-required");
_logger.LogWarning("[Auth] UNAUTHORIZED: valid session required | {Path} | Corr={Corr}", pathRaw, corrId);
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
error = "Valid session required",
correlationId = corrId
});
}
private static bool IsAnonymousPath(string pathLower)
{
if (_anonymousExact.Contains(pathLower))
return true;
return _anonymousPrefixes.Any(p => pathLower.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
private static string EnsureCorrelationId(HttpContext context)
{
const string header = "X-Correlation-Id";
if (!context.Request.Headers.TryGetValue(header, out var existing) ||
string.IsNullOrWhiteSpace(existing.FirstOrDefault()))
{
var id = Guid.NewGuid().ToString("N");
context.Request.Headers[header] = id;
return id;
}
return existing.First()!;
}
private static void SetAuthDebugHeaders(HttpContext context, string corrId, string authPath, string? authFail)
{
context.Response.Headers["X-Correlation-Id"] = corrId;
context.Response.Headers["X-Auth-Path"] = authPath;
if (!string.IsNullOrWhiteSpace(authFail))
context.Response.Headers["X-Auth-Fail"] = authFail;
}
/// <summary>
/// Session token authentication: Validate against our session database.
/// Accepts X-Session-Token header OR Authorization: Bearer <sessionToken>.
/// </summary>
private async Task<bool> TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql, string corrId)
{
string? token = null;
if (context.Request.Headers.TryGetValue("X-Session-Token", out var sessionHeader))
token = sessionHeader.FirstOrDefault();
if (string.IsNullOrWhiteSpace(token) &&
context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
var headerValue = authHeader.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerValue) &&
headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
token = headerValue["Bearer ".Length..].Trim();
}
}
if (string.IsNullOrWhiteSpace(token))
{
_logger.LogWarning("[Auth] Session auth skipped (no token) | Corr={Corr}", corrId);
return false;
}
try
{
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
if (string.IsNullOrWhiteSpace(resp))
{
_logger.LogWarning("[Auth] Session validation failed: empty response | Corr={Corr}", corrId);
return false;
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True)
{
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
clientContext.IsDevBypass = false;
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
clientContext.ClientId, clientContext.Email, corrId);
return clientContext.IsAuthenticated;
}
_logger.LogWarning("[Auth] Session validation failed: ok=false | Corr={Corr}", corrId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "[Auth] Session validation error | Corr={Corr}", corrId);
return false;
}
}
/// <summary>
/// Development bypass: Accept X-Dev-ClientId header.
/// Only works when ASPNETCORE_ENVIRONMENT=Development or Auth:AllowDevBypass=true.
/// </summary>
private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId)
{
var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var allowBypass = _config.GetValue<bool>("Auth:AllowDevBypass");
if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase) && !allowBypass)
return false;
if (!context.Request.Headers.TryGetValue("X-Dev-ClientId", out var devClientId))
return false;
var clientId = devClientId.FirstOrDefault();
if (string.IsNullOrWhiteSpace(clientId))
return false;
clientContext.ClientId = clientId;
clientContext.IsDevBypass = true;
if (context.Request.Headers.TryGetValue("X-Dev-TenantId", out var devTenantId))
clientContext.TenantId = devTenantId.FirstOrDefault();
if (context.Request.Headers.TryGetValue("X-Dev-ClientName", out var devName))
clientContext.ClientName = devName.FirstOrDefault();
_logger.LogWarning("[Auth] Dev bypass OK | ClientId={ClientId} | Corr={Corr}", clientId, corrId);
return true;
}
/// <summary>
/// JWT authentication: Validate Entra ID Bearer token.
/// Used ONLY for /api/auth/session.
/// </summary>
private async Task<bool> TryJwtAuthAsync(HttpContext context, ClientContext clientContext, string corrId)
{
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
_logger.LogWarning("[Auth] JWT skipped (no Authorization) | Corr={Corr}", corrId);
return false;
}
var headerValue = authHeader.FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue) ||
!headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[Auth] JWT skipped (Authorization not Bearer) | Corr={Corr}", corrId);
return false;
}
var token = headerValue["Bearer ".Length..].Trim();
if (string.IsNullOrWhiteSpace(token))
{
_logger.LogWarning("[Auth] JWT skipped (empty bearer token) | Corr={Corr}", corrId);
return false;
}
var tenantId = _config["Auth:EntraId:TenantId"] ?? _config["ENTRA_TENANT_ID"];
var clientId = _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"];
var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/";
var audienceOverride = _config["Auth:EntraId:Audience"]; // optional (e.g. api://xxx or App ID URI)
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
{
_logger.LogWarning("[Auth] JWT disabled (missing TenantId/ClientId) | Corr={Corr}", corrId);
return false;
}
try
{
// Diagnostics (safe enough for logs; do NOT log full token)
var handler = new JwtSecurityTokenHandler();
if (handler.CanReadToken(token))
{
var jwt = handler.ReadJwtToken(token);
_logger.LogWarning("[Auth] JWT presented | iss={Iss} aud={Aud} sub={Sub} | Corr={Corr}",
jwt.Issuer, jwt.Audiences.FirstOrDefault(), jwt.Subject, corrId);
}
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
var metadataAddress = $"{authority}/.well-known/openid-configuration";
var mgr = GetOrCreateConfigManager(metadataAddress);
var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted);
var validAudiences = new List<string> { clientId, $"api://{clientId}" };
if (!string.IsNullOrWhiteSpace(audienceOverride))
validAudiences.Add(audienceOverride);
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
ValidateAudience = true,
ValidAudiences = validAudiences,
ValidateLifetime = true,
IssuerSigningKeys = openIdConfig.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(5)
};
var principal = handler.ValidateToken(token, validationParams, out _);
clientContext.ClientId =
principal.FindFirstValue("oid") ??
principal.FindFirstValue(ClaimTypes.NameIdentifier) ??
principal.FindFirstValue("sub");
clientContext.Email =
principal.FindFirstValue("preferred_username") ??
principal.FindFirstValue(ClaimTypes.Email) ??
principal.FindFirstValue("upn");
clientContext.ClientName =
principal.FindFirstValue("name") ??
principal.FindFirstValue(ClaimTypes.Name);
clientContext.IsDevBypass = false;
_logger.LogWarning("[Auth] JWT validated OK | oid={Oid} email={Email} | Corr={Corr}",
clientContext.ClientId, clientContext.Email, corrId);
return clientContext.IsAuthenticated;
}
catch (SecurityTokenException ex)
{
_logger.LogWarning("[Auth] JWT validation FAILED: {Msg} | Corr={Corr}", ex.Message, corrId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "[Auth] JWT validation ERROR | Corr={Corr}", corrId);
return false;
}
}
private static ConfigurationManager<OpenIdConnectConfiguration> GetOrCreateConfigManager(string metadataAddress)
{
lock (_oidcLock)
{
_oidcConfigManager ??= new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress,
new OpenIdConnectConfigurationRetriever());
return _oidcConfigManager;
}
}
}

View File

@@ -0,0 +1,60 @@
namespace Gateway.Security;
/// <summary>
/// Holds authenticated client information for the current request.
/// Populated by ClientAuthMiddleware.
/// </summary>
public sealed class ClientContext
{
/// <summary>
/// Session ID from session-based auth.
/// </summary>
public string? SessionId { get; set; }
/// <summary>
/// The authenticated client ID (from session, JWT sub claim, or dev header).
/// This identifies the client/organization in our platform.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Optional tenant ID for the ad platform (e.g., Google Ads customer ID).
/// May be derived from ClientId mapping or passed in request.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Display name from token or session (if available).
/// </summary>
public string? ClientName { get; set; }
/// <summary>
/// User ID from session (if using session auth).
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// Email from token or session (if available).
/// </summary>
public string? Email { get; set; }
/// <summary>
/// User role from session (admin, user, readonly).
/// </summary>
public string? Role { get; set; }
/// <summary>
/// Whether this request was authenticated via dev bypass (vs real auth).
/// </summary>
public bool IsDevBypass { get; set; }
/// <summary>
/// The authentication provider used (microsoft, google, etc.)
/// </summary>
public string? AuthProvider { get; set; }
/// <summary>
/// True if we have a valid ClientId.
/// </summary>
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
}

View File

@@ -0,0 +1,512 @@
using Gateway.Data;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
namespace Gateway.Security;
/// <summary>
/// Multi-provider authentication middleware.
/// Supports: Microsoft Entra ID, Google, and extensible for others.
///
/// For /api/auth/session: Validates JWT from any configured provider
/// For all other /api/*: Validates session token
/// </summary>
public sealed class MultiProviderAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<MultiProviderAuthMiddleware> _logger;
private readonly IConfiguration _config;
// Paths that don't require auth
private static readonly HashSet<string> _anonymousExact = new(StringComparer.OrdinalIgnoreCase)
{
"/",
"/health"
};
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
// OIDC config managers (cached per provider)
private static readonly Dictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> _oidcManagers = new();
private static readonly object _oidcLock = new();
public MultiProviderAuthMiddleware(RequestDelegate next, ILogger<MultiProviderAuthMiddleware> logger, IConfiguration config)
{
_next = next;
_logger = logger;
_config = config;
}
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
{
var pathRaw = context.Request.Path.Value ?? "";
var path = pathRaw.ToLowerInvariant();
var corrId = EnsureCorrelationId(context);
_logger.LogWarning("[Auth] HIT {Method} {Path} | Corr={Corr}", context.Request.Method, pathRaw, corrId);
// Anonymous paths
if (IsAnonymousPath(path))
{
SetAuthHeaders(context, corrId, "anonymous", null);
await _next(context);
return;
}
// ---------------------------------------------------------------------
// SESSION EXCHANGE: Accept JWT from any configured provider
// ---------------------------------------------------------------------
if (path.StartsWith("/api/auth/session", StringComparison.OrdinalIgnoreCase))
{
var (jwtValid, provider) = await TryMultiProviderJwtAsync(context, clientContext, corrId);
if (jwtValid)
{
SetAuthHeaders(context, corrId, $"jwt({provider})", null);
_logger.LogWarning("[Auth] Session exchange authorized via {Provider} JWT | Email={Email} | Corr={Corr}",
provider, clientContext.Email, corrId);
await _next(context);
return;
}
SetAuthHeaders(context, corrId, "jwt", "jwt-required");
_logger.LogWarning("[Auth] Session exchange denied: valid JWT required | Corr={Corr}", corrId);
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
error = "Valid authentication required from a supported provider",
correlationId = corrId
});
return;
}
// ---------------------------------------------------------------------
// ALL OTHER /api/* PATHS: Require session token (or dev bypass)
// ---------------------------------------------------------------------
if (TryDevBypass(context, clientContext, corrId))
{
SetAuthHeaders(context, corrId, "dev-bypass", null);
await _next(context);
return;
}
if (await TrySessionAuthAsync(context, clientContext, sql, corrId))
{
SetAuthHeaders(context, corrId, "session", null);
await _next(context);
return;
}
SetAuthHeaders(context, corrId, "session", "session-required");
_logger.LogWarning("[Auth] UNAUTHORIZED: valid session required | {Path} | Corr={Corr}", pathRaw, corrId);
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new
{
ok = false,
error = "Valid session required",
correlationId = corrId
});
}
/// <summary>
/// Try to validate JWT from multiple providers.
/// Returns (success, providerName).
/// </summary>
private async Task<(bool Success, string? Provider)> TryMultiProviderJwtAsync(
HttpContext context,
ClientContext clientContext,
string corrId)
{
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
_logger.LogWarning("[Auth] No Authorization header | Corr={Corr}", corrId);
return (false, null);
}
var headerValue = authHeader.FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue) || !headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[Auth] Invalid Authorization header format | Corr={Corr}", corrId);
return (false, null);
}
var token = headerValue["Bearer ".Length..].Trim();
if (string.IsNullOrWhiteSpace(token))
{
_logger.LogWarning("[Auth] Empty bearer token | Corr={Corr}", corrId);
return (false, null);
}
// Check for provider hint from frontend
var providerHint = context.Request.Headers["X-Auth-Provider"].FirstOrDefault()?.ToLowerInvariant();
// Read token to get issuer (for auto-detection)
var handler = new JwtSecurityTokenHandler();
JwtSecurityToken? jwt = null;
if (handler.CanReadToken(token))
{
jwt = handler.ReadJwtToken(token);
_logger.LogWarning("[Auth] JWT presented | iss={Iss} aud={Aud} | Corr={Corr}",
jwt.Issuer, jwt.Audiences.FirstOrDefault(), corrId);
}
// Try providers in order (hint first, then auto-detect)
var providersToTry = new List<string>();
if (!string.IsNullOrWhiteSpace(providerHint))
providersToTry.Add(providerHint);
// Auto-detect based on issuer
if (jwt != null)
{
if (jwt.Issuer.Contains("login.microsoftonline.com") || jwt.Issuer.Contains("sts.windows.net") || jwt.Issuer.Contains("ciamlogin.com"))
providersToTry.Add("microsoft");
else if (jwt.Issuer.Contains("accounts.google.com"))
providersToTry.Add("google");
}
// Fallback: try all configured providers
if (IsProviderConfigured("microsoft") && !providersToTry.Contains("microsoft"))
providersToTry.Add("microsoft");
if (IsProviderConfigured("google") && !providersToTry.Contains("google"))
providersToTry.Add("google");
foreach (var provider in providersToTry.Distinct())
{
var success = provider switch
{
"microsoft" => await TryValidateMicrosoftJwtAsync(token, clientContext, corrId),
"google" => await TryValidateGoogleJwtAsync(token, clientContext, corrId),
_ => false
};
if (success)
{
clientContext.AuthProvider = provider;
return (true, provider);
}
}
return (false, null);
}
/// <summary>
/// Validate Microsoft Entra ID JWT
/// </summary>
private async Task<bool> TryValidateMicrosoftJwtAsync(string token, ClientContext clientContext, string corrId)
{
var tenantId = _config["Auth:Microsoft:TenantId"] ?? _config["Auth:EntraId:TenantId"] ?? _config["ENTRA_TENANT_ID"];
var clientId = _config["Auth:Microsoft:ClientId"] ?? _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"];
var ciamDomain = _config["Auth:Microsoft:CiamDomain"] ?? _config["Auth:EntraId:CiamDomain"];
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
{
_logger.LogWarning("[Auth] Microsoft provider not configured | Corr={Corr}", corrId);
return false;
}
try
{
// Peek at the token issuer to determine if this is a CIAM token
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
var isCiam = jwt.Issuer.Contains("ciamlogin.com", StringComparison.OrdinalIgnoreCase);
// Build authority + valid issuers based on token type
string authority;
string metadataAddress;
string[] validIssuers;
if (isCiam)
{
// CIAM (External ID): derive domain from issuer or config
var domain = ciamDomain;
if (string.IsNullOrWhiteSpace(domain))
{
// Extract domain from issuer, e.g. "https://USIMClients.ciamlogin.com/{tenant}/v2.0"
var issuerUri = new Uri(jwt.Issuer);
domain = issuerUri.Host;
}
authority = $"https://{domain}/{tenantId}/v2.0";
metadataAddress = $"{authority}/.well-known/openid-configuration";
validIssuers = new[]
{
$"https://{domain}/{tenantId}/v2.0",
$"https://{domain}/{tenantId}"
};
_logger.LogWarning("[Auth] CIAM token detected | domain={Domain} | Corr={Corr}", domain, corrId);
}
else
{
// Standard Entra ID
authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
metadataAddress = $"{authority}/.well-known/openid-configuration";
validIssuers = new[]
{
$"https://login.microsoftonline.com/{tenantId}/v2.0",
$"https://sts.windows.net/{tenantId}/"
};
}
var mgr = GetOrCreateOidcManager(isCiam ? "microsoft-ciam" : "microsoft", metadataAddress);
var openIdConfig = await mgr.GetConfigurationAsync(CancellationToken.None);
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = validIssuers,
ValidateAudience = true,
ValidAudiences = new[] { clientId, $"api://{clientId}" },
ValidateLifetime = true,
IssuerSigningKeys = openIdConfig.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(5)
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, validationParams, out _);
ExtractClaims(principal, clientContext);
_logger.LogWarning("[Auth] Microsoft JWT validated ({Mode}) | sub={Sub} email={Email} | Corr={Corr}",
isCiam ? "CIAM" : "Entra", clientContext.ClientId, clientContext.Email, corrId);
return clientContext.IsAuthenticated;
}
catch (Exception ex)
{
_logger.LogWarning("[Auth] Microsoft JWT validation failed: {Msg} | Corr={Corr}", ex.Message, corrId);
return false;
}
}
/// <summary>
/// Validate Google ID token
/// </summary>
private async Task<bool> TryValidateGoogleJwtAsync(string token, ClientContext clientContext, string corrId)
{
var clientId = _config["Auth:Google:ClientId"] ?? _config["GOOGLE_CLIENT_ID"];
if (string.IsNullOrWhiteSpace(clientId))
{
_logger.LogWarning("[Auth] Google provider not configured | Corr={Corr}", corrId);
return false;
}
try
{
var metadataAddress = "https://accounts.google.com/.well-known/openid-configuration";
var mgr = GetOrCreateOidcManager("google", metadataAddress);
var openIdConfig = await mgr.GetConfigurationAsync(CancellationToken.None);
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { "https://accounts.google.com", "accounts.google.com" },
ValidateAudience = true,
ValidAudiences = new[] { clientId },
ValidateLifetime = true,
IssuerSigningKeys = openIdConfig.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(5)
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, validationParams, out _);
ExtractClaims(principal, clientContext);
_logger.LogWarning("[Auth] Google JWT validated | sub={Sub} email={Email} | Corr={Corr}",
clientContext.ClientId, clientContext.Email, corrId);
return clientContext.IsAuthenticated;
}
catch (Exception ex)
{
_logger.LogWarning("[Auth] Google JWT validation failed: {Msg} | Corr={Corr}", ex.Message, corrId);
return false;
}
}
/// <summary>
/// Extract standard claims into ClientContext
/// </summary>
private static void ExtractClaims(ClaimsPrincipal principal, ClientContext clientContext)
{
clientContext.ClientId =
principal.FindFirstValue("oid") ?? // Microsoft object ID
principal.FindFirstValue("sub") ?? // Standard subject
principal.FindFirstValue(ClaimTypes.NameIdentifier);
clientContext.Email =
principal.FindFirstValue("email") ??
principal.FindFirstValue("preferred_username") ??
principal.FindFirstValue(ClaimTypes.Email);
clientContext.ClientName =
principal.FindFirstValue("name") ??
principal.FindFirstValue(ClaimTypes.Name);
clientContext.IsDevBypass = false;
}
/// <summary>
/// Session token validation (unchanged from original)
/// </summary>
private async Task<bool> TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql, string corrId)
{
string? token = null;
// Check X-Session-Token header first
if (context.Request.Headers.TryGetValue("X-Session-Token", out var sessionHeader))
token = sessionHeader.FirstOrDefault();
// Fall back to Authorization: Bearer (session token, not JWT)
if (string.IsNullOrWhiteSpace(token) && context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
var auth = authHeader.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
token = auth["Bearer ".Length..].Trim();
}
if (string.IsNullOrWhiteSpace(token))
{
_logger.LogWarning("[Auth] No session token provided | Corr={Corr}", corrId);
return false;
}
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
try
{
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
if (string.IsNullOrWhiteSpace(resp))
{
_logger.LogWarning("[Auth] Session validation failed: empty response | Corr={Corr}", corrId);
return false;
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True)
{
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
clientContext.IsDevBypass = false;
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
clientContext.ClientId, clientContext.Email, corrId);
return clientContext.IsAuthenticated;
}
_logger.LogWarning("[Auth] Session validation failed: ok=false | Corr={Corr}", corrId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "[Auth] Session validation error | Corr={Corr}", corrId);
return false;
}
}
/// <summary>
/// Development bypass
/// </summary>
private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId)
{
var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var allowBypass = _config.GetValue<bool>("Auth:AllowDevBypass");
if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase) && !allowBypass)
return false;
if (!context.Request.Headers.TryGetValue("X-Dev-ClientId", out var devClientId))
return false;
var clientId = devClientId.FirstOrDefault();
if (string.IsNullOrWhiteSpace(clientId))
return false;
clientContext.ClientId = clientId;
clientContext.IsDevBypass = true;
if (context.Request.Headers.TryGetValue("X-Dev-TenantId", out var devTenantId))
clientContext.TenantId = devTenantId.FirstOrDefault();
_logger.LogWarning("[Auth] Dev bypass OK | ClientId={ClientId} | Corr={Corr}", clientId, corrId);
return true;
}
private bool IsProviderConfigured(string provider)
{
return provider switch
{
"microsoft" => !string.IsNullOrWhiteSpace(
_config["Auth:Microsoft:ClientId"] ?? _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"]),
"google" => !string.IsNullOrWhiteSpace(
_config["Auth:Google:ClientId"] ?? _config["GOOGLE_CLIENT_ID"]),
_ => false
};
}
private static bool IsAnonymousPath(string pathLower)
{
if (_anonymousExact.Contains(pathLower))
return true;
return _anonymousPrefixes.Any(p => pathLower.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
private static string EnsureCorrelationId(HttpContext context)
{
const string header = "X-Correlation-Id";
if (!context.Request.Headers.TryGetValue(header, out var existing) || string.IsNullOrWhiteSpace(existing.FirstOrDefault()))
{
var id = Guid.NewGuid().ToString("N");
context.Request.Headers[header] = id;
return id;
}
return existing.First()!;
}
private static void SetAuthHeaders(HttpContext context, string corrId, string authPath, string? authFail)
{
context.Response.Headers["X-Correlation-Id"] = corrId;
context.Response.Headers["X-Auth-Path"] = authPath;
if (!string.IsNullOrWhiteSpace(authFail))
context.Response.Headers["X-Auth-Fail"] = authFail;
}
private static ConfigurationManager<OpenIdConnectConfiguration> GetOrCreateOidcManager(string provider, string metadataAddress)
{
lock (_oidcLock)
{
if (!_oidcManagers.TryGetValue(provider, out var mgr))
{
mgr = new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress,
new OpenIdConnectConfigurationRetriever());
_oidcManagers[provider] = mgr;
}
return mgr;
}
}
}

View File

@@ -0,0 +1,375 @@
using Gateway.Data;
using Gateway.Security;
using System.Diagnostics;
using System.Text.Json;
namespace Gateway.Services;
public sealed class ExecutionService
{
private readonly SqlService _sql;
private readonly IHttpClientFactory _http;
private readonly IConfiguration _cfg;
private readonly ClientContext _client;
private readonly ILogger<ExecutionService> _logger;
// Operations that don't require a linked account (health checks, etc.)
private static readonly HashSet<string> AccountOptionalOperations = new(StringComparer.OrdinalIgnoreCase)
{
"Ping", "TestPing", "ListAccessibleCustomers"
};
public ExecutionService(
SqlService sql,
IHttpClientFactory http,
IConfiguration cfg,
ClientContext client,
ILogger<ExecutionService> logger)
{
_sql = sql;
_http = http;
_cfg = cfg;
_client = client;
_logger = logger;
}
public async Task<string> ExecuteAsync(JsonElement reqJson, CancellationToken ct)
{
var requestId = Guid.NewGuid().ToString("N");
var started = DateTimeOffset.UtcNow;
// Extract clientId from authenticated context
var clientId = _client.ClientId;
// Extract routing info: provider, service, action
var provider = reqJson.TryGetProperty("provider", out var pv) ? pv.GetString() ?? "google" : "google";
var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system";
var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping";
// Legacy support: if "operation" is provided, use it as action
string? operation = action;
if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String)
operation = opProp.GetString();
// TenantId priority: 1) request body, 2) ClientContext, 3) null
string? tenantId = null;
if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String)
tenantId = tid.GetString();
tenantId ??= _client.TenantId;
_logger.LogInformation(
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Action={Action} DevBypass={DevBypass}",
requestId, clientId, tenantId, provider, service, action, _client.IsDevBypass);
// ================================================================
// AGENCY MODEL: Validate account and get loginCustomerId
// ================================================================
string? loginCustomerId = null;
string? validatedClientName = null;
// Only validate if operation requires a linked account
bool requiresAccount = !string.IsNullOrEmpty(operation) &&
!AccountOptionalOperations.Contains(operation) &&
!string.IsNullOrEmpty(tenantId);
if (requiresAccount)
{
var validation = await ValidateGoogleAccountAsync(tenantId!, ct);
if (!validation.IsValid)
{
_logger.LogWarning(
"[Execution] Account validation failed | RequestId={RequestId} TenantId={TenantId} Error={Error}",
requestId, tenantId, validation.Error);
// Return error response without calling provider
return JsonSerializer.Serialize(new
{
ok = false,
status = 400,
elapsedMs = 0,
requestId,
clientId,
error = new
{
code = validation.ErrorCode ?? "VALIDATION_ERROR",
message = validation.Error
}
});
}
loginCustomerId = validation.LoginCustomerId;
validatedClientName = validation.ClientName;
_logger.LogInformation(
"[Execution] Account validated | RequestId={RequestId} TenantId={TenantId} LoginCustomerId={LoginCustomerId} Client={ClientName}",
requestId, tenantId, loginCustomerId, validatedClientName);
}
// Log start (now includes clientId and routing info)
int? logId = null;
var startRqst = JsonSerializer.Serialize(new
{
action = "start",
requestId,
clientId,
tenantId,
provider,
service,
operation = action,
loginCustomerId,
sessionId = _client.SessionId,
userId = _client.UserId,
isDevBypass = _client.IsDevBypass,
req = reqJson
});
var startResp = await _sql.ExecProcAsync("dbo.spAdpApiLog", "start", startRqst, ct: ct);
using (var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(startResp) ? "{}" : startResp))
{
if (doc.RootElement.TryGetProperty("logId", out var e) && e.ValueKind == JsonValueKind.Number)
logId = e.GetInt32();
}
// Inject/override fields in request before forwarding to provider
var enrichedRequest = EnrichRequest(reqJson, requestId, tenantId, loginCustomerId);
// Forward to provider (URL based on provider type)
var sw = Stopwatch.StartNew();
int providerStatus;
string providerResp;
try
{
var providerUrl = GetProviderUrl(provider);
var key = GetProviderKey(provider);
var httpClient = _http.CreateClient();
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
msg.Headers.Add("X-Internal-Key", key);
msg.Headers.Add("X-Request-Id", requestId);
msg.Content = new StringContent(enrichedRequest, System.Text.Encoding.UTF8, "application/json");
using var resp = await httpClient.SendAsync(msg, ct);
providerStatus = (int)resp.StatusCode;
providerResp = await resp.Content.ReadAsStringAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId}", requestId);
providerStatus = 500;
providerResp = JsonSerializer.Serialize(new { ok = false, requestId, error = ex.Message });
}
sw.Stop();
_logger.LogInformation(
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Status={Status} ElapsedMs={ElapsedMs}",
requestId, clientId, providerStatus, sw.ElapsedMilliseconds);
// Log finish (includes clientId and routing info for correlation)
var finishRqst = JsonSerializer.Serialize(new
{
action = "finish",
logId,
requestId,
clientId,
provider,
service,
operation = action,
providerStatus,
elapsedMs = sw.ElapsedMilliseconds,
resp = JsonDocument.Parse(providerResp).RootElement
});
_ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct);
// Wrap response with metadata
var wrappedResponse = WrapResponse(providerResp, providerStatus, sw.ElapsedMilliseconds, requestId, clientId);
return wrappedResponse;
}
/// <summary>
/// Validate that a Google Ads customer ID is linked in the database.
/// Returns loginCustomerId if account is found.
/// </summary>
private async Task<AccountValidation> ValidateGoogleAccountAsync(string customerId, CancellationToken ct)
{
try
{
var rqst = JsonSerializer.Serialize(new { customerId });
var resp = await _sql.ExecProcAsync("dbo.spGoogleAccount", "validate", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
{
return AccountValidation.Invalid("VALIDATION_ERROR", "Account validation failed");
}
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
// Check if validation succeeded
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
{
// Check account status
var accountStatus = root.TryGetProperty("accountStatus", out var accStat)
? accStat.GetString() : null;
var clientStatus = root.TryGetProperty("clientStatus", out var cltStat)
? cltStat.GetString() : null;
if (accountStatus != "Active")
{
return AccountValidation.Invalid("ACCOUNT_INACTIVE",
$"Google Ads account is {accountStatus?.ToLower() ?? "not active"}");
}
if (clientStatus != "Active")
{
return AccountValidation.Invalid("CLIENT_INACTIVE",
$"Client is {clientStatus?.ToLower() ?? "not active"}");
}
// Extract loginCustomerId (manager account)
var loginCustomerId = root.TryGetProperty("loginCustomerId", out var loginProp) &&
loginProp.ValueKind == JsonValueKind.String
? loginProp.GetString()
: null;
var clientName = root.TryGetProperty("clientName", out var nameProp) &&
nameProp.ValueKind == JsonValueKind.String
? nameProp.GetString()
: null;
return AccountValidation.Valid(loginCustomerId, clientName);
}
else
{
// Validation failed - account not found
var errorCode = root.TryGetProperty("errorCode", out var ecProp)
? ecProp.GetString() : "ACCOUNT_NOT_FOUND";
var error = root.TryGetProperty("error", out var errProp)
? errProp.GetString() : "Account not found";
return AccountValidation.Invalid(errorCode, error);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Account validation error for customerId={CustomerId}", customerId);
return AccountValidation.Invalid("VALIDATION_ERROR", "Account validation failed");
}
}
/// <summary>
/// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider.
/// </summary>
private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId)
{
using var doc = JsonDocument.Parse(original.GetRawText());
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(doc.RootElement.GetRawText())
?? new Dictionary<string, JsonElement>();
// Add/override requestId
dict["requestId"] = JsonDocument.Parse($"\"{requestId}\"").RootElement;
// Add tenantId if we have one
if (!string.IsNullOrWhiteSpace(tenantId))
{
dict["tenantId"] = JsonDocument.Parse($"\"{tenantId}\"").RootElement;
}
// Add loginCustomerId (manager account) if we have one
if (!string.IsNullOrWhiteSpace(loginCustomerId))
{
dict["loginCustomerId"] = JsonDocument.Parse($"\"{loginCustomerId}\"").RootElement;
}
return JsonSerializer.Serialize(dict);
}
/// <summary>
/// Wrap provider response with Gateway metadata.
/// </summary>
private static string WrapResponse(string providerResp, int status, long elapsedMs, string requestId, string? clientId)
{
try
{
var providerJson = JsonDocument.Parse(providerResp).RootElement;
var wrapped = new
{
ok = status >= 200 && status < 300,
status,
elapsedMs,
requestId,
clientId,
result = providerJson
};
return JsonSerializer.Serialize(wrapped, new JsonSerializerOptions { WriteIndented = false });
}
catch
{
// If provider response isn't valid JSON, wrap as string
return JsonSerializer.Serialize(new
{
ok = false,
status,
elapsedMs,
requestId,
clientId,
result = providerResp
});
}
}
/// <summary>
/// Result of account validation.
/// </summary>
private sealed class AccountValidation
{
public bool IsValid { get; init; }
public string? ErrorCode { get; init; }
public string? Error { get; init; }
public string? LoginCustomerId { get; init; }
public string? ClientName { get; init; }
public static AccountValidation Valid(string? loginCustomerId, string? clientName) => new()
{
IsValid = true,
LoginCustomerId = loginCustomerId,
ClientName = clientName
};
public static AccountValidation Invalid(string? errorCode, string? error) => new()
{
IsValid = false,
ErrorCode = errorCode,
Error = error
};
}
/// <summary>
/// Get provider URL based on provider type.
/// </summary>
private string GetProviderUrl(string provider)
{
return provider.ToLowerInvariant() switch
{
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
"msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "",
_ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? ""
};
}
/// <summary>
/// Get internal API key for provider.
/// </summary>
private string GetProviderKey(string provider)
{
return provider.ToLowerInvariant() switch
{
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
"msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "",
_ => _cfg["GOOGLE_INTERNAL_KEY"] ?? ""
};
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"AllowDevBypass": true
}
}

18
Gateway/appsettings.json Normal file
View File

@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Auth": {
"AllowDevBypass": false,
"EntraId": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
}
}
}

View File

@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Auth": {
"AllowDevBypass": false,
"Microsoft": {
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
},
"Google": {
"ClientId": ""
}
}
}

View File

@@ -0,0 +1,87 @@
namespace GoogleApi.Configuration;
/// <summary>
/// Root configuration for Google Ads API integration.
/// Bind to the "GoogleAds" section in appsettings.json or environment variables.
/// </summary>
public sealed class GoogleAdsConfig
{
public const string SectionName = "GoogleAds";
/// <summary>
/// Enable/disable real API calls. When false, the provider returns emulated responses.
/// </summary>
public bool EnableRealApi { get; set; } = false;
/// <summary>
/// Target Google Ads API version used by generated stubs (e.g. "v22").
/// NOTE: This value is informational; the compiled code targets a specific Vxx namespace.
/// </summary>
public string ApiVersion { get; set; } = "v22";
/// <summary>
/// Developer token from your Google Ads manager account.
/// </summary>
public string DeveloperToken { get; set; } = string.Empty;
/// <summary>
/// OAuth 2.0 application credentials used for server-to-server calls.
///
/// IMPORTANT:
/// - There is no interactive OAuth flow at runtime.
/// - A refresh token is generated once (out-of-band) and stored securely.
/// - This service uses that refresh token to obtain access tokens automatically.
/// </summary>
public GoogleOAuthConfig OAuth { get; set; } = new();
/// <summary>
/// Default login customer ID (manager account / MCC) if not specified per request.
/// Format: 1234567890 (no dashes)
/// </summary>
public string? DefaultLoginCustomerId { get; set; }
/// <summary>
/// Request timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 60;
}
/// <summary>
/// OAuth configuration for Google Ads API.
/// This provider uses the "refresh token" (offline) flow for non-interactive server-to-server calls.
/// </summary>
public sealed class GoogleOAuthConfig
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
/// <summary>
/// Platform refresh token used to obtain access tokens without user interaction.
/// Store in Key Vault / secret store; inject via environment variables in prod.
/// </summary>
public string? RefreshToken { get; set; }
}
/// <summary>
/// Per-request Google Ads context, populated from request and/or database.
/// </summary>
public sealed class GoogleAdsContext
{
/// <summary>
/// Target Google Ads customer ID for this request.
/// Format: 1234567890 (no dashes)
/// </summary>
public required string CustomerId { get; set; }
/// <summary>
/// Login customer ID (manager account / MCC).
/// Required when accessing client accounts under a manager account.
/// </summary>
public string? LoginCustomerId { get; set; }
/// <summary>
/// Optional override refresh token for a specific account (if you ever store per-account tokens).
/// If null, the platform token from config is used.
/// </summary>
public string? RefreshToken { get; set; }
}

View File

@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Mvc;
using GoogleApi.Models;
using GoogleApi.Security;
using GoogleApi.Services;
namespace GoogleApi.Controllers;
/// <summary>
/// Internal API endpoint called by Gateway.
/// Protected by X-Internal-Key header validation.
/// </summary>
[ApiController]
[Route("internal")]
public sealed class InternalController : ControllerBase
{
private readonly GoogleAdsService _googleAds;
private readonly ILogger<InternalController> _logger;
public InternalController(GoogleAdsService googleAds, ILogger<InternalController> logger)
{
_googleAds = googleAds;
_logger = logger;
}
/// <summary>
/// Health check - no auth required.
/// </summary>
[HttpGet("health")]
public IActionResult Health()
{
_logger.LogDebug("[InternalController] Health check");
return Ok(new
{
ok = true,
service = "GoogleApi",
timestamp = DateTimeOffset.UtcNow
});
}
/// <summary>
/// Main execution endpoint - Gateway calls this.
/// Protected by InternalAuthFilter.
/// </summary>
[ServiceFilter(typeof(InternalAuthFilter))]
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ProviderRequest request, CancellationToken ct)
{
_logger.LogInformation(
"[InternalController] Execute called | Operation={Operation} RequestId={RequestId}",
request?.Operation, request?.RequestId);
if (request == null)
{
return BadRequest(ProviderResponse.Fail(null, "VALIDATION", "Request body is required"));
}
if (string.IsNullOrWhiteSpace(request.Operation))
{
return BadRequest(ProviderResponse.Fail(request.RequestId, "VALIDATION", "Operation is required"));
}
var result = await _googleAds.ExecuteAsync(request, ct);
if (result.Ok)
{
return Ok(result);
}
else
{
// Use appropriate status codes based on error
var statusCode = result.Error?.Code switch
{
"VALIDATION" => 400,
"NOT_FOUND" => 404,
"UNAUTHORIZED" => 401,
"FORBIDDEN" => 403,
_ => 400
};
return StatusCode(statusCode, result);
}
}
}

View File

@@ -0,0 +1,259 @@
# Google Ads API Configuration Guide
## Overview
This document describes how to configure the GoogleApi service to connect to the real Google Ads API. The service supports both **emulated mode** (for testing without Google credentials) and **real API mode**.
## Configuration Levels
| Level | Storage | Examples |
|-------|---------|----------|
| **Platform secrets** | Azure Key Vault | Developer token, OAuth client secret |
| **Platform config** | App Settings / appsettings.json | API version, timeouts |
| **Per-account credentials** | Database (tbGoogleCredential) | Refresh tokens per linked account |
## Quick Start (Test Account)
1. Create a Google Ads test manager account
2. Get a developer token (works immediately for test accounts)
3. Set up OAuth credentials in Google Cloud Console
4. Configure the environment variables below
## Environment Variables for Azure Container Apps
> **Note:** This service runs **server-to-server**. There is **no interactive OAuth UI** at runtime.
> Generate the refresh token once (out-of-band) and store it securely (Key Vault / secrets).
### GoogleApi Service
```bash
# ==========================================
# Core Settings
# ==========================================
# Enable real Google Ads API calls (default: false)
GoogleAds__EnableRealApi=true
# API version (default: v22)
GoogleAds__ApiVersion=v22
# ==========================================
# Authentication - Developer Token
# Required for all API calls
# ==========================================
# Your developer token from Google Ads API Center
# Format: 22-character alphanumeric string
# Get from: https://ads.google.com/aw/apicenter
GoogleAds__DeveloperToken=YOUR_DEVELOPER_TOKEN_HERE
# ==========================================
# Authentication - OAuth 2.0
# Required for authenticating API requests
# ==========================================
# OAuth Client ID from Google Cloud Console
GoogleAds__OAuth__ClientId=YOUR_CLIENT_ID.apps.googleusercontent.com
# OAuth Client Secret from Google Cloud Console
# SENSITIVE - Use Key Vault reference in production
GoogleAds__OAuth__ClientSecret=YOUR_CLIENT_SECRET
# Refresh token for platform-level access
# Generated via OAuth flow or gcloud CLI
# SENSITIVE - Use Key Vault reference in production
GoogleAds__OAuth__RefreshToken=YOUR_REFRESH_TOKEN
# ==========================================
# Manager Account (Optional)
# Required if accessing client accounts under a manager
# ==========================================
# Default login customer ID (manager account)
# Format: 1234567890 (no dashes)
GoogleAds__DefaultLoginCustomerId=1234567890
# ==========================================
# Internal Authentication
# For Gateway -> GoogleApi communication
# ==========================================
# Shared secret for internal API authentication
# SENSITIVE - Use Key Vault reference
GOOGLE_INTERNAL_KEY=your-secure-internal-key
# ==========================================
# Optional Settings
# ==========================================
# HTTP timeout in seconds (default: 60)
GoogleAds__TimeoutSeconds=60
# Max retry attempts (default: 3)
GoogleAds__MaxRetries=3
```
### Azure Key Vault References
For sensitive values, use Key Vault references in Azure Container Apps:
```bash
# Instead of plain values:
GoogleAds__DeveloperToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsDeveloperToken/)
GoogleAds__OAuth__ClientSecret=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsClientSecret/)
GoogleAds__OAuth__RefreshToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsRefreshToken/)
GOOGLE_INTERNAL_KEY=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleInternalKey/)
```
## Step-by-Step Setup
### 1. Create Google Ads Manager Account
1. Go to https://ads.google.com/aw/apicenter
2. Sign in with a Google account NOT linked to production ads
3. Create a new manager account
4. For test accounts, click "Create a test manager account" link
### 2. Get Developer Token
1. In your manager account, go to **Tools & Settings > API Center**
2. Your developer token will be displayed
3. For test accounts: Token works immediately
4. For production: Apply for Basic Access (takes a few days)
### 3. Create Google Cloud Project
1. Go to https://console.cloud.google.com
2. Create a new project (or use existing)
3. Enable the **Google Ads API**:
- Go to APIs & Services > Library
- Search "Google Ads API"
- Click Enable
### 4. Create OAuth Credentials
1. Go to APIs & Services > Credentials
2. Click **Create Credentials > OAuth client ID**
3. Application type: **Desktop app** (for initial testing)
4. Download the JSON file
5. Note the Client ID and Client Secret
### 5. Generate Refresh Token
Option A: Using gcloud CLI
```bash
# Install gcloud CLI if not installed
gcloud auth login --cred-file=path/to/client_secret.json
gcloud auth print-access-token \
--scopes='https://www.googleapis.com/auth/adwords'
```
Option B: Using OAuth Playground
1. Go to https://developers.google.com/oauthplayground/
2. Click gear icon > Use your own credentials
3. Enter your Client ID and Secret
4. Select Google Ads API scope: `https://www.googleapis.com/auth/adwords`
5. Click Authorize APIs, sign in
6. Click "Exchange authorization code for tokens"
7. Copy the Refresh Token
### 6. Create Test Client Account
1. In your test manager account
2. Click Accounts > + > Create new account
3. This creates a test client account under your manager
4. Note the Customer ID (format: XXX-XXX-XXXX)
### 7. Configure Azure Container App
In Azure Portal > Container Apps > Your GoogleApi App > Settings > Environment Variables:
Add each variable from the list above, using Key Vault references for sensitive values.
## Testing the Configuration
### Check Health Endpoint
```bash
curl https://your-googleapi-url/health
```
Expected response:
```json
{
"service": "GoogleApi",
"status": "healthy",
"config": {
"realApiEnabled": true,
"apiVersion": "v18",
"developerTokenSet": true,
"oauthConfigured": true,
"defaultLoginCustomerId": "1234567890"
}
}
```
### Test API Call (via Gateway)
```bash
curl -X POST https://your-gateway-url/api/execution/request \
-H "Content-Type: application/json" \
-H "X-Dev-ClientId: test-client" \
-H "X-Dev-TenantId: 1234567890" \
-d '{
"operation": "ListAccessibleCustomers",
"payload": {}
}'
```
## Credential Flow Diagram
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Gateway │────▶│ GoogleApi │────▶│ Google Ads │
└─────────────┘ └─────────────┘ │ API │
│ └─────────────┘
┌──────┴──────┐
│ │
┌─────▼─────┐ ┌────▼────┐
│ Config │ │ Database │
│(env vars) │ │(per-acct)│
└───────────┘ └──────────┘
Config provides: Database provides:
- Developer Token - Per-account refresh tokens
- OAuth Client ID/Secret - Account-specific credentials
- Default refresh token - Linked customer IDs
```
## Troubleshooting
### "UNAUTHENTICATED" Error
- Check developer token is correct
- Verify OAuth credentials
- Ensure refresh token hasn't expired
### "PERMISSION_DENIED" Error
- Developer token may not be approved for production
- Verify account access permissions
- Check login-customer-id is correct
### "INVALID_CUSTOMER_ID" Error
- Customer ID format should be 10 digits, no dashes
- Verify account exists and is accessible
### Token Exchange Fails
- Client ID/Secret mismatch
- Refresh token was revoked
- OAuth consent was withdrawn
## Security Best Practices
1. **Never commit secrets** to source control
2. **Use Azure Key Vault** for all sensitive values
3. **Rotate refresh tokens** periodically
4. **Audit API access** via tbAdpApiLog
5. **Limit developer token access** - one token per application
6. **Use test accounts** for development and testing

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Container Settings -->
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
<ContainerRepository>googleapi</ContainerRepository>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Google.Ads.GoogleAds" Version="24.1.1" />
</ItemGroup>
<ItemGroup>
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
</Project>

6
GoogleApi/GoogleApi.http Normal file
View File

@@ -0,0 +1,6 @@
@GoogleApi_HostAddress = http://localhost:5023
GET {{GoogleApi_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,87 @@
using System.Text.Json.Serialization;
namespace GoogleApi.Models;
#region Campaign Payloads
public sealed class CreateCampaignPayload
{
public string Name { get; set; } = string.Empty;
public CampaignType Type { get; set; } = CampaignType.Search;
public long BudgetMicros { get; set; }
public BiddingStrategy BiddingStrategy { get; set; } = BiddingStrategy.MaximizeClicks;
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}
public sealed class GetCampaignPayload
{
public string CampaignId { get; set; } = string.Empty;
}
public sealed class UpdateCampaignPayload
{
public string CampaignId { get; set; } = string.Empty;
public string? Name { get; set; }
public long? BudgetMicros { get; set; }
public CampaignStatus? Status { get; set; }
}
public sealed class ListCampaignsPayload
{
public CampaignStatus? StatusFilter { get; set; }
public int PageSize { get; set; } = 50;
public string? PageToken { get; set; }
}
#endregion
#region Reporting Payloads
public sealed class CampaignStatsPayload
{
public string CampaignId { get; set; } = string.Empty;
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}
public sealed class AccountStatsPayload
{
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}
#endregion
#region Enums
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CampaignStatus
{
Unknown = 0,
Enabled = 1,
Paused = 2,
Removed = 3
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CampaignType
{
Search = 0,
Display = 1,
Shopping = 2,
Video = 3,
PerformanceMax = 4
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum BiddingStrategy
{
ManualCpc = 0,
MaximizeClicks = 1,
MaximizeConversions = 2,
TargetCpa = 3,
TargetRoas = 4
}
#endregion

View File

@@ -0,0 +1,93 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GoogleApi.Models;
/// <summary>
/// Request from Gateway to GoogleApi.
/// </summary>
public sealed class ProviderRequest
{
/// <summary>
/// Operation to execute (e.g., "Ping", "CreateCampaign", "GetCampaignStats")
/// </summary>
public string Operation { get; set; } = string.Empty;
/// <summary>
/// Tenant/customer ID - maps to Google Ads customer ID (the subaccount)
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Login customer ID - maps to Google Ads manager account (MCC)
/// Used in agency model where manager account accesses client subaccounts.
/// Populated by Gateway from tbAdAccount.accLoginAccountId.
/// </summary>
public string? LoginCustomerId { get; set; }
/// <summary>
/// Correlation ID for request tracing
/// </summary>
public string? RequestId { get; set; }
/// <summary>
/// Operation-specific payload
/// </summary>
public JsonElement? Payload { get; set; }
/// <summary>
/// Deserialize payload to strongly-typed object
/// </summary>
public T GetPayload<T>() where T : new()
{
if (Payload == null || Payload.Value.ValueKind == JsonValueKind.Null || Payload.Value.ValueKind == JsonValueKind.Undefined)
return new T();
try
{
return JsonSerializer.Deserialize<T>(Payload.Value.GetRawText(), JsonOptions.Default) ?? new T();
}
catch
{
return new T();
}
}
}
/// <summary>
/// Response from GoogleApi to Gateway.
/// </summary>
public sealed class ProviderResponse
{
public bool Ok { get; set; }
public string? RequestId { get; set; }
public object? Data { get; set; }
public ProviderError? Error { get; set; }
public static ProviderResponse Success(string? requestId, object? data = null)
=> new() { Ok = true, RequestId = requestId, Data = data };
public static ProviderResponse Fail(string? requestId, string code, string message, object? detail = null)
=> new()
{
Ok = false,
RequestId = requestId,
Error = new ProviderError { Code = code, Message = message, Detail = detail }
};
}
public sealed class ProviderError
{
public string Code { get; set; } = "ERROR";
public string Message { get; set; } = "Unknown error";
public object? Detail { get; set; }
}
internal static class JsonOptions
{
public static readonly JsonSerializerOptions Default = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}

101
GoogleApi/Program.cs Normal file
View File

@@ -0,0 +1,101 @@
using GoogleApi.Configuration;
using GoogleApi.Security;
using GoogleApi.Services;
var builder = WebApplication.CreateBuilder(args);
// ============================================================
// CRITICAL: Explicit port binding for Azure Container Apps
// ============================================================
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
// ============================================================
// Configuration
// ============================================================
// Bind GoogleAds configuration section
// Values can be overridden by environment variables:
// GoogleAds__EnableRealApi=true
// GoogleAds__DeveloperToken=xxx
// GoogleAds__OAuth__ClientId=xxx
// etc.
builder.Services.Configure<GoogleAdsConfig>(
builder.Configuration.GetSection(GoogleAdsConfig.SectionName));
// Log startup info
var googleConfig = builder.Configuration.GetSection(GoogleAdsConfig.SectionName).Get<GoogleAdsConfig>();
Console.WriteLine("===========================================");
Console.WriteLine($"[GoogleApi] Starting...");
Console.WriteLine($"[GoogleApi] Port: {port}");
Console.WriteLine($"[GoogleApi] Environment: {builder.Environment.EnvironmentName}");
Console.WriteLine($"[GoogleApi] GOOGLE_INTERNAL_KEY set: {!string.IsNullOrEmpty(builder.Configuration["InternalKey"] ?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY"))}");
Console.WriteLine($"[GoogleApi] Real API Enabled: {googleConfig?.EnableRealApi ?? false}");
Console.WriteLine($"[GoogleApi] API Version: {googleConfig?.ApiVersion ?? "not configured"}");
Console.WriteLine($"[GoogleApi] Developer Token Set: {!string.IsNullOrEmpty(googleConfig?.DeveloperToken)}");
Console.WriteLine("===========================================");
// ============================================================
// Services
// ============================================================
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "GoogleApi Provider", Version = "v1" });
});
// Core services
builder.Services.AddSingleton<GoogleAdsClientFactory>();
builder.Services.AddSingleton<GoogleAdsService>();
// Auth filter for internal calls from Gateway
builder.Services.AddScoped<InternalAuthFilter>();
// ============================================================
// Build & Configure
// ============================================================
var app = builder.Build();
Console.WriteLine("[GoogleApi] App built, configuring pipeline...");
// Always enable Swagger (helpful for debugging)
app.UseSwagger();
app.UseSwaggerUI();
app.UseRouting();
app.MapControllers();
// Root health check
app.MapGet("/", () => Results.Ok(new
{
service = "GoogleApi",
status = "healthy",
timestamp = DateTimeOffset.UtcNow
}));
// Detailed health check with config status
app.MapGet("/health", (IConfiguration config) =>
{
var googleConfig = config.GetSection(GoogleAdsConfig.SectionName).Get<GoogleAdsConfig>();
return Results.Ok(new
{
service = "GoogleApi",
status = "healthy",
timestamp = DateTimeOffset.UtcNow,
config = new
{
realApiEnabled = googleConfig?.EnableRealApi ?? false,
apiVersion = googleConfig?.ApiVersion ?? "not configured",
developerTokenSet = !string.IsNullOrEmpty(googleConfig?.DeveloperToken),
oauthConfigured = !string.IsNullOrEmpty(googleConfig?.OAuth?.ClientId),
defaultLoginCustomerId = googleConfig?.DefaultLoginCustomerId ?? "(not set)"
}
});
});
Console.WriteLine("[GoogleApi] Pipeline configured, starting listener...");
Console.WriteLine($"[GoogleApi] Listening on http://0.0.0.0:{port}");
app.Run();

View File

@@ -0,0 +1,26 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"GOOGLE_INTERNAL_KEY": "dev-test-key-12345"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5180"
},
"Container (.NET SDK)": {
"commandName": "SdkContainer",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"environmentVariables": {
"ASPNETCORE_HTTP_PORTS": "8080",
"GOOGLE_INTERNAL_KEY": "dev-test-key-12345"
},
"publishAllPorts": true
}
}
}

180
GoogleApi/README.md Normal file
View File

@@ -0,0 +1,180 @@
# GoogleApi Provider
Internal microservice that handles Google Ads API operations. Called by the Gateway via HTTP.
## Architecture
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Client │ ──────► │ Gateway │ ──────► │ GoogleApi │
│ │ │ (external) │ │ (internal) │
└──────────────┘ └──────────────┘ └──────────────────┘
│ │
│ X-Internal-Key │
│ header auth │
▼ ▼
POST /api/execute POST /internal/execute
```
## Local Development
```bash
# Run locally
cd GoogleApi
dotnet run
# Test health
curl http://localhost:5180/internal/health
# Test execute (with auth header)
curl -X POST http://localhost:5180/internal/execute \
-H "Content-Type: application/json" \
-H "X-Internal-Key: dev-test-key-12345" \
-d '{"operation": "Ping", "requestId": "test-123"}'
# Test create campaign
curl -X POST http://localhost:5180/internal/execute \
-H "Content-Type: application/json" \
-H "X-Internal-Key: dev-test-key-12345" \
-d '{
"operation": "CreateCampaign",
"tenantId": "1234567890",
"requestId": "test-456",
"payload": {
"name": "Test Campaign",
"budgetMicros": 10000000,
"type": "Search"
}
}'
```
## Supported Operations
| Operation | Description | Payload |
|-----------|-------------|---------|
| `Ping` | Health check | none |
| `CreateCampaign` | Create a campaign | `name`, `budgetMicros`, `type`, `biddingStrategy` |
| `GetCampaign` | Get campaign details | `campaignId` |
| `UpdateCampaign` | Update campaign | `campaignId`, `name?`, `budgetMicros?`, `status?` |
| `ListCampaigns` | List all campaigns | `statusFilter?`, `pageSize?`, `pageToken?` |
| `GetCampaignStats` | Get campaign metrics | `campaignId`, `startDate?`, `endDate?` |
| `GetAccountStats` | Get account metrics | `startDate?`, `endDate?` |
## Azure Deployment
### First-time setup
```bash
# Create the container app (internal ingress)
az containerapp create \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--environment AdPlatform-env-20260114160411 \
--image mcr.microsoft.com/dotnet/samples:aspnetapp \
--target-port 8080 \
--ingress internal \
--min-replicas 1 \
--max-replicas 3
# Set up managed identity for ACR (do this once)
az role assignment create \
--assignee $(az containerapp show -n usim-adp-googleapi -g RG-GraeJones --query identity.principalId -o tsv) \
--role AcrPull \
--scope /subscriptions/ad4c8963-6467-4ccf-bdf6-208a73b0a2af/resourceGroups/RG-GraeJones/providers/Microsoft.ContainerRegistry/registries/adplatform20260114160834
# Configure registry with managed identity
az containerapp registry set \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--server adplatform20260114160834.azurecr.io \
--identity system
# Set the internal key secret
az containerapp secret set \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--secrets google-internal-key="your-secret-key-here"
# Set environment variables
az containerapp update \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--set-env-vars "GOOGLE_INTERNAL_KEY=secretref:google-internal-key"
```
### Publish from Visual Studio
1. Right-click project → Publish
2. Select the `usim-adp-googleapi` profile
3. Click Publish
### Publish from CLI
```bash
# Build and push to ACR
dotnet publish -c Release
# Or manually
az acr build --registry adplatform20260114160834 --image googleapi:$(date +%Y%m%d%H%M%S) .
# Update container app
az containerapp update \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--image adplatform20260114160834.azurecr.io/googleapi:<tag>
```
### Verify deployment
```bash
# Check revision status
az containerapp revision list -n usim-adp-googleapi -g RG-GraeJones -o table
# Check logs
az containerapp logs show -n usim-adp-googleapi -g RG-GraeJones --type console
az containerapp logs show -n usim-adp-googleapi -g RG-GraeJones --type system
# Check env vars
az containerapp show -n usim-adp-googleapi -g RG-GraeJones --query "properties.template.containers[0].env"
```
## Gateway Configuration
Update Gateway's environment variables to point to GoogleApi:
```bash
az containerapp update \
--name usim-adp-gateway \
--resource-group RG-GraeJones \
--set-env-vars "GOOGLE_PROVIDER_URL=https://usim-adp-googleapi.internal.lemonbeach-1e8e273b.westus.azurecontainerapps.io"
```
## Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `PORT` | HTTP listen port | No (default: 8080) |
| `GOOGLE_INTERNAL_KEY` | Shared secret for Gateway auth | Yes |
| `ASPNETCORE_ENVIRONMENT` | Runtime environment | No |
## Troubleshooting
### Container stuck in "Activating"
1. Check system logs for image pull errors
2. Verify ACR credentials/managed identity
3. Verify image exists: `az acr repository show-tags --name adplatform20260114160834 --repository googleapi`
### No console output
Check that `Program.cs` has explicit port binding:
```csharp
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
```
### Auth failures
1. Verify `GOOGLE_INTERNAL_KEY` is set in both Gateway and GoogleApi
2. Check the secret reference is correct: `secretref:google-internal-key`
3. Test with curl using the `-H "X-Internal-Key: ..."` header

View File

@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace GoogleApi.Security;
/// <summary>
/// Validates the X-Internal-Key header for internal service-to-service calls.
/// Gateway must provide the correct key to call GoogleApi endpoints.
/// </summary>
public sealed class InternalAuthFilter : IAsyncActionFilter
{
private readonly IConfiguration _config;
private readonly ILogger<InternalAuthFilter> _logger;
public InternalAuthFilter(IConfiguration config, ILogger<InternalAuthFilter> logger)
{
_config = config;
_logger = logger;
}
public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var headerName = _config["InternalAuth:HeaderName"] ?? "X-Internal-Key";
// Try multiple sources for the key
var expectedKey = _config["InternalAuth:Key"]
?? _config["GOOGLE_INTERNAL_KEY"]
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY");
if (string.IsNullOrWhiteSpace(expectedKey))
{
_logger.LogError("[InternalAuth] No internal key configured - check GOOGLE_INTERNAL_KEY env var");
context.Result = new ObjectResult(new { error = "Internal auth key not configured" })
{
StatusCode = 500
};
return Task.CompletedTask;
}
if (!context.HttpContext.Request.Headers.TryGetValue(headerName, out var providedKey) ||
string.IsNullOrWhiteSpace(providedKey))
{
_logger.LogWarning("[InternalAuth] Missing {HeaderName} header", headerName);
context.Result = new UnauthorizedObjectResult(new { error = $"Missing {headerName} header" });
return Task.CompletedTask;
}
if (!string.Equals(providedKey.ToString(), expectedKey, StringComparison.Ordinal))
{
_logger.LogWarning("[InternalAuth] Invalid key provided");
context.Result = new UnauthorizedObjectResult(new { error = "Invalid internal auth key" });
return Task.CompletedTask;
}
_logger.LogDebug("[InternalAuth] Request authorized");
return next();
}
}

View File

@@ -0,0 +1,71 @@
using Google.Ads.Gax.Config;
using Google.Ads.GoogleAds.Config;
using Google.Ads.GoogleAds.Lib;
using GoogleApi.Configuration;
using Microsoft.Extensions.Options;
namespace GoogleApi.Services;
// ✅ Alias the Google library config type to avoid collision with your GoogleApi.Configuration.GoogleAdsConfig
using LibGoogleAdsConfig = Google.Ads.GoogleAds.Config.GoogleAdsConfig;
public sealed class GoogleAdsClientFactory
{
private readonly GoogleApi.Configuration.GoogleAdsConfig _cfg;
private readonly ILogger<GoogleAdsClientFactory> _logger;
public GoogleAdsClientFactory(
IOptions<GoogleApi.Configuration.GoogleAdsConfig> config,
ILogger<GoogleAdsClientFactory> logger)
{
_cfg = config.Value;
_logger = logger;
}
public bool IsRealApiEnabled =>
_cfg.EnableRealApi &&
!string.IsNullOrWhiteSpace(_cfg.DeveloperToken) &&
!string.IsNullOrWhiteSpace(_cfg.OAuth.ClientId) &&
!string.IsNullOrWhiteSpace(_cfg.OAuth.ClientSecret) &&
!string.IsNullOrWhiteSpace(_cfg.OAuth.RefreshToken);
public GoogleAdsClient CreateClient(GoogleAdsContext context)
{
var loginCustomerId = NormalizeCustomerId(
context.LoginCustomerId ?? _cfg.DefaultLoginCustomerId ?? string.Empty);
var libConfig = new LibGoogleAdsConfig
{
DeveloperToken = _cfg.DeveloperToken,
// ✅ Headless/server-to-server refresh-token flow
OAuth2Mode = OAuth2Flow.APPLICATION,
OAuth2ClientId = _cfg.OAuth.ClientId,
OAuth2ClientSecret = _cfg.OAuth.ClientSecret,
OAuth2RefreshToken = context.RefreshToken ?? _cfg.OAuth.RefreshToken,
// MCC/manager header
LoginCustomerId = string.IsNullOrWhiteSpace(loginCustomerId) ? null : loginCustomerId,
// ms
Timeout = Math.Max(1, _cfg.TimeoutSeconds) * 1000
};
_logger.LogDebug(
"[GoogleAds] CreateClient | RealApi={RealApi} LoginCustomerIdSet={LoginSet}",
IsRealApiEnabled,
!string.IsNullOrWhiteSpace(libConfig.LoginCustomerId));
return new GoogleAdsClient(libConfig);
}
public static string NormalizeCustomerId(string customerId)
=> (customerId ?? string.Empty).Replace("-", string.Empty).Trim();
public static string FormatCustomerId(string customerId)
{
var normalized = NormalizeCustomerId(customerId);
if (normalized.Length != 10) return normalized;
return $"{normalized[..3]}-{normalized[3..6]}-{normalized[6..]}";
}
}

View File

@@ -0,0 +1,576 @@
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
using Google.Ads.GoogleAds;
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Common;
using Google.Ads.GoogleAds.V22.Enums;
using Google.Ads.GoogleAds.V22.Errors;
using Google.Ads.GoogleAds.V22.Resources;
using Google.Ads.GoogleAds.V22.Services;
namespace GoogleApi.Services;
// ✅ IMPORTANT: force "Services" to mean Google.Ads.GoogleAds.Services (not GoogleApi.Services)
using GAdsServices = global::Google.Ads.GoogleAds.Services;
// ✅ Avoid name collision with Google.Ads.GoogleAds.V22.Resources.BiddingStrategy
using ModelBiddingStrategy = GoogleApi.Models.BiddingStrategy;
public sealed class GoogleAdsService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<GoogleAdsService> _logger;
public GoogleAdsService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<GoogleAdsService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
public async Task<ProviderResponse> ExecuteAsync(ProviderRequest request, CancellationToken ct)
{
var requestId = request.RequestId ?? Guid.NewGuid().ToString("N");
var operation = (request.Operation ?? string.Empty).Trim();
_logger.LogInformation(
"[GoogleAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}",
operation, requestId, request.TenantId, _clientFactory.IsRealApiEnabled);
try
{
var context = new GoogleAdsContext
{
CustomerId = GoogleAdsClientFactory.NormalizeCustomerId(request.TenantId ?? string.Empty),
LoginCustomerId = request.LoginCustomerId
};
var result = operation switch
{
"Ping" => Ping(requestId),
"TestPing" => Ping(requestId),
"CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct),
"GetCampaign" => await GetCampaignAsync(request, context, requestId, ct),
"UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct),
"ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct),
"GetCampaignStats" => GetCampaignStats(request, requestId),
"GetAccountStats" => GetAccountStats(request, requestId),
"ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct),
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
};
_logger.LogInformation(
"[GoogleAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}",
operation, requestId, result.Ok);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "[GoogleAds] Error in {Operation} | RequestId={RequestId}", operation, requestId);
return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message);
}
}
private ProviderResponse Ping(string requestId)
=> ProviderResponse.Success(requestId, new
{
message = "GoogleApi provider is healthy",
service = "GoogleApi",
realApiEnabled = _clientFactory.IsRealApiEnabled,
apiVersion = _config.ApiVersion,
timestamp = DateTimeOffset.UtcNow
});
private async Task<ProviderResponse> CreateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<CreateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.Name))
return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required");
if (payload.BudgetMicros <= 0)
return ProviderResponse.Fail(requestId, "VALIDATION", "BudgetMicros must be > 0");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await CreateCampaignRealAsync(payload, context, requestId, ct);
var externalId = $"customers/{context.CustomerId}/campaigns/{GenerateId()}";
_logger.LogInformation("[GoogleAds] EMULATED: Created campaign {CampaignName} => {CampaignId}", payload.Name, externalId);
return ProviderResponse.Success(requestId, new
{
externalId,
name = payload.Name,
type = payload.Type.ToString(),
status = "ENABLED",
budgetMicros = payload.BudgetMicros,
biddingStrategy = payload.BiddingStrategy.ToString(),
createdAt = DateTimeOffset.UtcNow,
emulated = true
});
}
private async Task<ProviderResponse> CreateCampaignRealAsync(
CreateCampaignPayload payload, GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
// 1) Budget
CampaignBudgetServiceClient budgetService =
client.GetService(GAdsServices.V22.CampaignBudgetService);
var budget = new CampaignBudget
{
Name = $"{payload.Name} Budget ({DateTime.UtcNow:yyyyMMddHHmmss})",
AmountMicros = payload.BudgetMicros,
DeliveryMethod = BudgetDeliveryMethodEnum.Types.BudgetDeliveryMethod.Standard,
ExplicitlyShared = false
};
var budgetResponse = await budgetService.MutateCampaignBudgetsAsync(
new MutateCampaignBudgetsRequest
{
CustomerId = context.CustomerId,
Operations = { new CampaignBudgetOperation { Create = budget } }
},
cancellationToken: ct);
var budgetResourceName = budgetResponse.Results.FirstOrDefault()?.ResourceName;
if (string.IsNullOrWhiteSpace(budgetResourceName))
return ProviderResponse.Fail(requestId, "API_ERROR", "Budget create returned no resource name");
// 2) Campaign
CampaignServiceClient campaignService =
client.GetService(GAdsServices.V22.CampaignService);
var campaign = new Campaign
{
Name = payload.Name,
Status = CampaignStatusEnum.Types.CampaignStatus.Enabled,
AdvertisingChannelType = MapChannelType(payload.Type),
CampaignBudget = budgetResourceName
};
// Dates must be yyyyMMdd for Google Ads API
if (!string.IsNullOrWhiteSpace(payload.StartDate)) campaign.StartDate = payload.StartDate;
if (!string.IsNullOrWhiteSpace(payload.EndDate)) campaign.EndDate = payload.EndDate;
// ✅ Apply bidding in a way that does NOT rely on Campaign.MaximizeClicks property existing
ApplyBiddingStrategySafe(campaign, payload.BiddingStrategy);
var campResponse = await campaignService.MutateCampaignsAsync(
new MutateCampaignsRequest
{
CustomerId = context.CustomerId,
Operations = { new CampaignOperation { Create = campaign } }
},
cancellationToken: ct);
var campaignResourceName = campResponse.Results.FirstOrDefault()?.ResourceName;
return ProviderResponse.Success(requestId, new
{
campaignResourceName,
budgetResourceName,
name = payload.Name,
type = payload.Type.ToString(),
status = "ENABLED",
budgetMicros = payload.BudgetMicros,
biddingStrategy = payload.BiddingStrategy.ToString(),
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error creating campaign | RequestId={RequestId}", requestId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create campaign via real API");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
private async Task<ProviderResponse> GetCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<GetCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await GetCampaignRealAsync(payload.CampaignId, context, requestId, ct);
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved campaign {CampaignId}", payload.CampaignId);
return ProviderResponse.Success(requestId, new
{
externalId = payload.CampaignId,
name = "Sample Campaign",
type = CampaignType.Search.ToString(),
status = "ENABLED",
budgetMicros = 10_000_000L,
// NOTE: GetCampaignPayload doesn't have BiddingStrategy — so don't reference it
biddingStrategy = ModelBiddingStrategy.MaximizeClicks.ToString(),
createdAt = DateTimeOffset.UtcNow.AddDays(-7),
emulated = true
});
}
private Task<ProviderResponse> GetCampaignRealAsync(
string campaignId, GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
GoogleAdsServiceClient googleAdsService =
client.GetService(GAdsServices.V22.GoogleAdsService);
var isResourceName = campaignId.Contains("/campaigns/", StringComparison.OrdinalIgnoreCase);
var where = isResourceName
? $"campaign.resource_name = '{campaignId}'"
: $"campaign.id = {campaignId}";
var query = $@"
SELECT
campaign.resource_name,
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign_budget.amount_micros
FROM campaign
WHERE {where}
LIMIT 1";
var resp = googleAdsService.Search(new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
});
var row = resp.FirstOrDefault();
if (row == null)
return Task.FromResult(ProviderResponse.Fail(requestId, "NOT_FOUND", "Campaign not found"));
return Task.FromResult(ProviderResponse.Success(requestId, new
{
campaign = new
{
resourceName = row.Campaign.ResourceName,
id = row.Campaign.Id,
name = row.Campaign.Name,
status = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
budgetMicros = row.CampaignBudget?.AmountMicros
},
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error getting campaign | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get campaign via real API");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private Task<ProviderResponse> UpdateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<UpdateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return Task.FromResult(ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"));
_logger.LogInformation("[GoogleAds] EMULATED: Updated campaign {CampaignId}", payload.CampaignId);
return Task.FromResult(ProviderResponse.Success(requestId, new
{
updated = true,
campaignId = payload.CampaignId,
updatedAt = DateTimeOffset.UtcNow,
emulated = true
}));
}
private async Task<ProviderResponse> ListCampaignsAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await ListCampaignsRealAsync(context, requestId, ct);
_logger.LogInformation("[GoogleAds] EMULATED: Listed campaigns for tenant {TenantId}", request.TenantId);
var campaigns = new[]
{
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Brand Campaign", status = "Enabled", budgetMicros = 5_000_000L },
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Product Campaign", status = "Enabled", budgetMicros = 10_000_000L },
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Retargeting", status = "Paused", budgetMicros = 3_000_000L }
};
return ProviderResponse.Success(requestId, new
{
campaigns,
totalCount = campaigns.Length,
emulated = true
});
}
private Task<ProviderResponse> ListCampaignsRealAsync(
GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
GoogleAdsServiceClient googleAdsService =
client.GetService(GAdsServices.V22.GoogleAdsService);
var query = @"
SELECT
campaign.resource_name,
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign_budget.amount_micros
FROM campaign
ORDER BY campaign.name";
var results = new List<object>();
var resp = googleAdsService.Search(new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
});
foreach (var row in resp)
{
ct.ThrowIfCancellationRequested();
results.Add(new
{
resourceName = row.Campaign.ResourceName,
id = row.Campaign.Id,
name = row.Campaign.Name,
status = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
budgetMicros = row.CampaignBudget?.AmountMicros
});
}
return Task.FromResult(ProviderResponse.Success(requestId, new
{
campaigns = results,
totalCount = results.Count,
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error listing campaigns | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list campaigns via real API");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private ProviderResponse GetCampaignStats(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<CampaignStatsPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved stats for campaign {CampaignId}", payload.CampaignId);
return ProviderResponse.Success(requestId, new
{
campaignId = payload.CampaignId,
dateRange = new { start = payload.StartDate ?? "2026-01-01", end = payload.EndDate ?? "2026-01-27" },
metrics = new
{
impressions = 15_234L,
clicks = 487L,
costMicros = 2_543_000L,
conversions = 23,
ctr = 0.032,
averageCpcMicros = 5_222L
},
emulated = true
});
}
private ProviderResponse GetAccountStats(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<AccountStatsPayload>();
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved account stats for tenant {TenantId}", request.TenantId);
return ProviderResponse.Success(requestId, new
{
tenantId = request.TenantId,
dateRange = new { start = payload.StartDate ?? "2026-01-01", end = payload.EndDate ?? "2026-01-27" },
metrics = new
{
totalCampaigns = 5,
activeCampaigns = 3,
totalImpressions = 152_340L,
totalClicks = 4_870L,
totalCostMicros = 25_430_000L,
totalConversions = 234
},
emulated = true
});
}
private Task<ProviderResponse> ListAccessibleCustomersAsync(
GoogleAdsContext context, string requestId, CancellationToken ct)
{
if (!_clientFactory.IsRealApiEnabled)
{
return Task.FromResult(ProviderResponse.Success(requestId, new
{
customers = new[] { "1234567890", "9876543210" },
emulated = true
}));
}
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
CustomerServiceClient customerService =
client.GetService(GAdsServices.V22.CustomerService);
var resp = customerService.ListAccessibleCustomers(new ListAccessibleCustomersRequest());
var customers = resp.ResourceNames
.Select(rn => rn.Split('/').LastOrDefault() ?? rn)
.ToArray();
return Task.FromResult(ProviderResponse.Success(requestId, new
{
customers,
rawResourceNames = resp.ResourceNames.ToArray(),
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error listing accessible customers | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list accessible customers");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private static string GenerateId() => Guid.NewGuid().ToString("N")[..12];
private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type)
=> type switch
{
CampaignType.Search => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Search,
CampaignType.Display => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Display,
CampaignType.Shopping => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Shopping,
CampaignType.Video => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Video,
CampaignType.PerformanceMax => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.PerformanceMax,
_ => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Search
};
// ✅ Strategy application that avoids Campaign.MaximizeClicks property dependency
private static void ApplyBiddingStrategySafe(Campaign campaign, ModelBiddingStrategy strategy)
{
// Try to set the enum safely without compile-time dependency on the member name.
// Different library/proto generations sometimes change the C# member casing.
static BiddingStrategyTypeEnum.Types.BiddingStrategyType ParseBst(params string[] names)
{
foreach (var n in names)
{
if (Enum.TryParse<BiddingStrategyTypeEnum.Types.BiddingStrategyType>(n, ignoreCase: true, out var v))
return v;
}
return BiddingStrategyTypeEnum.Types.BiddingStrategyType.Unspecified;
}
campaign.BiddingStrategyType = strategy switch
{
ModelBiddingStrategy.ManualCpc =>
ParseBst("ManualCpc", "MANUAL_CPC"),
ModelBiddingStrategy.MaximizeClicks =>
ParseBst("MaximizeClicks", "MAXIMIZE_CLICKS", "MaximizeClick"),
ModelBiddingStrategy.MaximizeConversions =>
ParseBst("MaximizeConversions", "MAXIMIZE_CONVERSIONS"),
ModelBiddingStrategy.TargetCpa =>
ParseBst("TargetCpa", "TARGET_CPA"),
ModelBiddingStrategy.TargetRoas =>
ParseBst("TargetRoas", "TARGET_ROAS"),
_ =>
ParseBst("MaximizeClicks", "MAXIMIZE_CLICKS", "Unspecified")
};
// Optional: set oneof objects ONLY when you know your generated Campaign has them.
// ManualCpc is the most consistently present.
if (strategy == ModelBiddingStrategy.ManualCpc)
{
campaign.ManualCpc = new ManualCpc();
}
// If your Campaign class DOES have these properties in your build, you can uncomment:
// if (strategy == ModelBiddingStrategy.MaximizeClicks) campaign.MaximizeClicks = new MaximizeClicks();
// if (strategy == ModelBiddingStrategy.MaximizeConversions) campaign.MaximizeConversions = new MaximizeConversions();
}
private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId)
{
var errorDetails = gex.Failure?.Errors?.Select(e => new
{
errorCode = e.ErrorCode?.ToString(),
message = e.Message,
trigger = e.Trigger?.StringValue,
location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray()
}).ToList();
return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new
{
googleRequestId = gex.RequestId,
errors = errorDetails
});
}
}

View File

@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"GoogleApi": "Debug"
}
},
"GOOGLE_INTERNAL_KEY": "dev-test-key-12345",
"GoogleAds": {
"ApiVersion": "v22",
"OAuth": {
"ClientId": "",
"ClientSecret": "",
"RefreshToken": ""
}
}
}

View File

@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"InternalKey": "",
"GoogleAds": {
"EnableRealApi": false,
"ApiVersion": "v22",
"DeveloperToken": "",
"DefaultLoginCustomerId": "",
"TimeoutSeconds": 60,
"OAuth": {
"ClientId": "",
"ClientSecret": "",
"RefreshToken": ""
}
}
}

View File

@@ -0,0 +1,92 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for client (organization) management.
/// Requires Admin role.
///
/// ENDPOINTS:
/// GET /api/admin/clients - List clients
/// GET /api/admin/clients/{id} - Get client
/// POST /api/admin/clients - Create client
/// PUT /api/admin/clients/{id} - Update client
/// DELETE /api/admin/clients/{id} - Deactivate client
/// </summary>
[ApiController]
[Route("api/admin/clients")]
public sealed class AdminClientsController : AdminControllerBase
{
public AdminClientsController(SqlService sql, ClientContext client, ILogger<AdminClientsController> log)
: base(sql, client, log) { }
/// <summary>
/// List all clients with optional filtering.
/// </summary>
[HttpGet]
public Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
=> CallProc("spAdminClients", "list", new { status, page, pageSize }, ct);
/// <summary>
/// Get client by ID.
/// </summary>
[HttpGet("{clientId}")]
public Task<IActionResult> Get(string clientId, CancellationToken ct)
=> CallProc("spAdminClients", "get", new { clientId }, ct);
/// <summary>
/// Create a new client.
/// </summary>
[HttpPost]
public Task<IActionResult> Create([FromBody] CreateClientRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.ClientName))
return Task.FromResult(ValidationError("clientName is required"));
Logger.LogWarning("[Admin] CreateClient | Name={Name} | By={User}", request.ClientName, Client.Email);
return CallProc("spAdminClients", "create", new { clientName = request.ClientName.Trim() }, ct);
}
/// <summary>
/// Update client.
/// </summary>
[HttpPut("{clientId}")]
public Task<IActionResult> Update(string clientId, [FromBody] UpdateClientRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateClient | Id={Id} | By={User}", clientId, Client.Email);
return CallProc("spAdminClients", "update", new
{
clientId,
clientName = request?.ClientName?.Trim(),
status = request?.Status
}, ct);
}
/// <summary>
/// Deactivate client (soft delete).
/// </summary>
[HttpDelete("{clientId}")]
public Task<IActionResult> Delete(string clientId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteClient | Id={Id} | By={User}", clientId, Client.Email);
return CallProc("spAdminClients", "delete", new { clientId }, ct);
}
}
// DTOs
public sealed class CreateClientRequest
{
public string? ClientName { get; set; }
}
public sealed class UpdateClientRequest
{
public string? ClientName { get; set; }
public string? Status { get; set; }
}

View File

@@ -0,0 +1,58 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers.Admin;
/// <summary>
/// Base class for admin controllers with shared functionality.
/// </summary>
public abstract class AdminControllerBase : ControllerBase
{
protected readonly SqlService Sql;
protected readonly ClientContext Client;
protected readonly ILogger Logger;
protected AdminControllerBase(SqlService sql, ClientContext client, ILogger logger)
{
Sql = sql;
Client = client;
Logger = logger;
}
/// <summary>
/// Execute stored procedure and return appropriate IActionResult.
/// </summary>
protected async Task<IActionResult> CallProc(string proc, string action, object rqst, CancellationToken ct)
{
try
{
var json = JsonSerializer.Serialize(rqst);
var resp = await Sql.ExecProcAsync($"dbo.{proc}", action, json, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
return Content(resp, "application/json");
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Operation failed";
return BadRequest(new { ok = false, error });
}
catch (Exception ex)
{
Logger.LogError(ex, "[Admin] {Proc}.{Action} error", proc, action);
return StatusCode(500, new { ok = false, error = "Operation failed", detail = ex.Message });
}
}
/// <summary>
/// Return BadRequest for validation failures.
/// </summary>
protected IActionResult ValidationError(string error)
=> BadRequest(new { ok = false, error });
}

View File

@@ -0,0 +1,65 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for session management.
/// Requires Admin role.
///
/// ENDPOINTS:
/// GET /api/admin/sessions - List sessions
/// POST /api/admin/sessions/{id}/revoke - Revoke session
/// POST /api/admin/users/{id}/revoke-sessions - Revoke all user sessions
/// POST /api/admin/sessions/cleanup - Cleanup expired sessions
/// </summary>
[ApiController]
[Route("api/admin/sessions")]
public sealed class AdminSessionsController : AdminControllerBase
{
public AdminSessionsController(SqlService sql, ClientContext client, ILogger<AdminSessionsController> log)
: base(sql, client, log) { }
/// <summary>
/// List sessions with optional filtering.
/// </summary>
[HttpGet]
public Task<IActionResult> List(
[FromQuery] string? clientId,
[FromQuery] string? userId,
[FromQuery] bool activeOnly = true,
[FromQuery] int limit = 100,
CancellationToken ct = default)
=> CallProc("spAdminSessions", "list", new { clientId, userId, activeOnly, limit }, ct);
/// <summary>
/// Revoke a session.
/// </summary>
[HttpPost("{sessionId}/revoke")]
public Task<IActionResult> Revoke(string sessionId, CancellationToken ct)
{
Logger.LogWarning("[Admin] RevokeSession | SessionId={SessionId} | By={User}", sessionId, Client.Email);
return CallProc("spAdminSessions", "revoke", new { sessionId }, ct);
}
/// <summary>
/// Revoke all sessions for a user.
/// </summary>
[HttpPost("~/api/admin/users/{userId}/revoke-sessions")]
public Task<IActionResult> RevokeAllForUser(string userId, CancellationToken ct)
{
Logger.LogWarning("[Admin] RevokeAllSessions | UserId={UserId} | By={User}", userId, Client.Email);
return CallProc("spAdminSessions", "revokeAllForUser", new { userId }, ct);
}
/// <summary>
/// Cleanup expired sessions.
/// </summary>
[HttpPost("cleanup")]
public Task<IActionResult> Cleanup([FromQuery] int daysOld = 30, CancellationToken ct = default)
{
Logger.LogWarning("[Admin] CleanupSessions | DaysOld={DaysOld} | By={User}", daysOld, Client.Email);
return CallProc("spAdminSessions", "cleanup", new { daysOld }, ct);
}
}

View File

@@ -0,0 +1,140 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers.Admin;
/// <summary>
/// Admin endpoints for user management.
/// Requires Admin role.
///
/// ENDPOINTS:
/// GET /api/admin/users - List users
/// GET /api/admin/users/{id} - Get user
/// POST /api/admin/users - Create user
/// PUT /api/admin/users/{id} - Update user
/// DELETE /api/admin/users/{id} - Deactivate user
/// POST /api/admin/users/{id}/clients - Link user to client
/// DELETE /api/admin/users/{id}/clients/{cltId} - Unlink user from client
/// </summary>
[ApiController]
[Route("api/admin/users")]
public sealed class AdminUsersController : AdminControllerBase
{
public AdminUsersController(SqlService sql, ClientContext client, ILogger<AdminUsersController> log)
: base(sql, client, log) { }
/// <summary>
/// List users with optional filtering.
/// </summary>
[HttpGet]
public Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] string? clientId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken ct = default)
=> CallProc("spAdminUsers", "list", new { status, clientId, page, pageSize }, ct);
/// <summary>
/// Get user by ID.
/// </summary>
[HttpGet("{userId}")]
public Task<IActionResult> Get(string userId, CancellationToken ct)
=> CallProc("spAdminUsers", "get", new { userId }, ct);
/// <summary>
/// Create a new user.
/// </summary>
[HttpPost]
public Task<IActionResult> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.Email))
return Task.FromResult(ValidationError("email is required"));
Logger.LogWarning("[Admin] CreateUser | Email={Email} | By={User}", request.Email, Client.Email);
return CallProc("spAdminUsers", "create", new
{
email = request.Email.Trim(),
displayName = request.DisplayName?.Trim(),
clientId = request.ClientId,
role = request.Role ?? "User"
}, ct);
}
/// <summary>
/// Update user.
/// </summary>
[HttpPut("{userId}")]
public Task<IActionResult> Update(string userId, [FromBody] UpdateUserRequest request, CancellationToken ct)
{
Logger.LogWarning("[Admin] UpdateUser | Id={Id} | By={User}", userId, Client.Email);
return CallProc("spAdminUsers", "update", new
{
userId,
displayName = request?.DisplayName?.Trim(),
status = request?.Status
}, ct);
}
/// <summary>
/// Deactivate user (soft delete).
/// </summary>
[HttpDelete("{userId}")]
public Task<IActionResult> Delete(string userId, CancellationToken ct)
{
Logger.LogWarning("[Admin] DeleteUser | Id={Id} | By={User}", userId, Client.Email);
return CallProc("spAdminUsers", "delete", new { userId }, ct);
}
/// <summary>
/// Link user to client with role.
/// </summary>
[HttpPost("{userId}/clients")]
public Task<IActionResult> LinkToClient(string userId, [FromBody] LinkUserRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request?.ClientId))
return Task.FromResult(ValidationError("clientId is required"));
Logger.LogWarning("[Admin] LinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
userId, request.ClientId, Client.Email);
return CallProc("spAdminUsers", "linkToClient", new
{
userId,
clientId = request.ClientId,
role = request.Role ?? "User"
}, ct);
}
/// <summary>
/// Unlink user from client.
/// </summary>
[HttpDelete("{userId}/clients/{clientId}")]
public Task<IActionResult> UnlinkFromClient(string userId, string clientId, CancellationToken ct)
{
Logger.LogWarning("[Admin] UnlinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
userId, clientId, Client.Email);
return CallProc("spAdminUsers", "unlinkFromClient", new { userId, clientId }, ct);
}
}
// DTOs
public sealed class CreateUserRequest
{
public string? Email { get; set; }
public string? DisplayName { get; set; }
public string? ClientId { get; set; }
public string? Role { get; set; }
}
public sealed class UpdateUserRequest
{
public string? DisplayName { get; set; }
public string? Status { get; set; }
}
public sealed class LinkUserRequest
{
public string? ClientId { get; set; }
public string? Role { get; set; }
}

View File

@@ -0,0 +1,76 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers;
/// <summary>
/// Monitoring endpoints for system health and stats.
/// Requires Admin session.
///
/// ENDPOINTS:
/// GET /api/monitoring/health - System health overview
/// GET /api/monitoring/stats - Detailed statistics
/// </summary>
[ApiController]
[Route("api/monitoring")]
public sealed class MonitoringController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly ILogger<MonitoringController> _log;
public MonitoringController(SqlService sql, ClientContext client, ILogger<MonitoringController> log)
{
_sql = sql;
_client = client;
_log = log;
}
/// <summary>
/// System health overview.
/// </summary>
[HttpGet("health")]
public async Task<IActionResult> Health(CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync("dbo.spMonitoring", "health", "{}", ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "[Monitoring] Health error");
return StatusCode(500, new { ok = false, error = "Health check failed", detail = ex.Message });
}
}
/// <summary>
/// Detailed system statistics.
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> Stats([FromQuery] int hours = 24, CancellationToken ct = default)
{
var rqst = JsonSerializer.Serialize(new { hours });
try
{
var resp = await _sql.ExecProcAsync("dbo.spMonitoring", "stats", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "[Monitoring] Stats error");
return StatusCode(500, new { ok = false, error = "Stats failed", detail = ex.Message });
}
}
}

View File

@@ -0,0 +1,114 @@
using Management.Data;
using Management.Security;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace Management.Controllers;
/// <summary>
/// Onboarding endpoints for new user/client registration.
/// Requires JWT authentication (user may not have session yet).
///
/// ENDPOINTS:
/// GET /api/onboarding/status - Check registration status
/// POST /api/onboarding/register - Register new organization
/// </summary>
[ApiController]
[Route("api/onboarding")]
public sealed class OnboardingController : ControllerBase
{
private readonly SqlService _sql;
private readonly ClientContext _client;
private readonly ILogger<OnboardingController> _log;
public OnboardingController(SqlService sql, ClientContext client, ILogger<OnboardingController> log)
{
_sql = sql;
_client = client;
_log = log;
}
/// <summary>
/// Check registration status for authenticated user.
/// </summary>
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken ct)
{
if (!_client.IsAuthenticated)
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
var rqst = JsonSerializer.Serialize(new
{
provider = "EntraExternalId",
subject = _client.ClientId,
email = _client.Email
});
try
{
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "status", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Service unavailable" });
return Content(resp, "application/json");
}
catch (Exception ex)
{
_log.LogError(ex, "[Onboarding] Status error");
return StatusCode(500, new { ok = false, error = "Status check failed", detail = ex.Message });
}
}
/// <summary>
/// Register a new organization.
/// </summary>
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken ct)
{
if (!_client.IsAuthenticated)
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
if (string.IsNullOrWhiteSpace(request?.ClientName))
return BadRequest(new { ok = false, error = "clientName is required" });
_log.LogWarning("[Onboarding] Register | Subject={Subject} ClientName={ClientName}",
_client.ClientId, request.ClientName);
var rqst = JsonSerializer.Serialize(new
{
provider = "EntraExternalId",
subject = _client.ClientId,
email = _client.Email,
displayName = _client.ClientName,
clientName = request.ClientName.Trim()
});
try
{
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "register", rqst, ct: ct);
if (string.IsNullOrWhiteSpace(resp))
return StatusCode(500, new { ok = false, error = "Registration service unavailable" });
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
return Content(resp, "application/json");
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Registration failed";
return BadRequest(new { ok = false, error });
}
catch (Exception ex)
{
_log.LogError(ex, "[Onboarding] Register error");
return StatusCode(500, new { ok = false, error = "Registration failed", detail = ex.Message });
}
}
}
public sealed class RegisterRequest
{
public string? ClientName { get; set; }
}

View File

@@ -0,0 +1,37 @@
using Management.Data;
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers;
/// <summary>
/// Test endpoints (anonymous, no auth required).
/// </summary>
[ApiController]
[Route("api/test")]
public class TestController : ControllerBase
{
private readonly SqlService _sql;
public TestController(SqlService sql)
{
_sql = sql;
}
/// <summary>
/// Database connectivity test.
/// </summary>
[HttpGet("ping")]
public async Task<IActionResult> Ping(CancellationToken ct)
{
try
{
var resp = await _sql.ExecProcAsync("dbo.spTemplate", "ping",
"""{ "clientId":"00000000-0000-0000-0000-000000000001" }""", ct: ct);
return Content(resp, "application/json");
}
catch (Exception ex)
{
return StatusCode(500, new { ok = false, error = "Database connection failed", detail = ex.Message });
}
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
namespace Management.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}

View File

@@ -0,0 +1,82 @@
using System.Data;
using System.Diagnostics;
using Microsoft.Data.SqlClient;
namespace Management.Data;
public class SqlService
{
private readonly IConfiguration _config;
private readonly ILogger<SqlService> _logger;
public SqlService(IConfiguration config, ILogger<SqlService> logger)
{
_config = config;
_logger = logger;
}
private string GetConnectionString()
{
var cs = _config.GetConnectionString("Sql");
if (string.IsNullOrWhiteSpace(cs))
throw new InvalidOperationException("Missing ConnectionStrings:Sql");
return cs;
}
/// <summary>
/// Execute stored procedure with standard signature:
/// @action varchar, @rqst nvarchar(max), @resp nvarchar(max) OUTPUT
/// </summary>
public async Task<string> ExecProcAsync(
string procName,
string action,
string rqstJson,
int commandTimeoutSeconds = 60,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(procName))
throw new ArgumentException("procName is required.", nameof(procName));
if (string.IsNullOrWhiteSpace(action))
throw new ArgumentException("action is required.", nameof(action));
if (string.IsNullOrWhiteSpace(rqstJson))
rqstJson = "{}";
var sw = Stopwatch.StartNew();
try
{
await using var conn = new SqlConnection(GetConnectionString());
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(procName, conn)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = commandTimeoutSeconds
};
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = action });
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqstJson });
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
{
Direction = ParameterDirection.Output
};
cmd.Parameters.Add(pResp);
await cmd.ExecuteNonQueryAsync(ct);
var resp = pResp.Value as string ?? "";
sw.Stop();
_logger.LogInformation("SQL ok: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
return resp;
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "SQL error: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
throw;
}
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
<ContainerRepository>management</ContainerRepository>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@Management_HostAddress = http://localhost:5290
GET {{Management_HostAddress}}/weatherforecast/
Accept: application/json
###

61
Management/Program.cs Normal file
View File

@@ -0,0 +1,61 @@
using Management.Data;
using Management.Security;
var builder = WebApplication.CreateBuilder(args);
// Container-friendly HTTP binding
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
// Services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "AdPlatform Management API", Version = "v1" });
});
builder.Services.AddScoped<SqlService>();
builder.Services.AddScoped<ClientContext>();
builder.Services.AddHttpClient();
var app = builder.Build();
// Middleware pipeline
app.UseSwagger();
app.UseSwaggerUI();
// Health check (before auth)
app.MapGet("/health", () => Results.Ok(new
{
ok = true,
service = "Management",
timestamp = DateTimeOffset.UtcNow
}));
// Root endpoint
app.MapGet("/", () => Results.Ok(new
{
service = "AdPlatform Management API",
version = "1.0.0",
status = "running",
endpoints = new
{
onboarding = new[] { "GET /api/onboarding/status", "POST /api/onboarding/register" },
monitoring = new[] { "GET /api/monitoring/health", "GET /api/monitoring/stats" },
admin = new
{
clients = new[] { "GET/POST /api/admin/clients", "GET/PUT/DELETE /api/admin/clients/{id}" },
users = new[] { "GET/POST /api/admin/users", "GET/PUT/DELETE /api/admin/users/{id}" },
sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" }
}
}
}));
// Authentication middleware
app.UseMiddleware<ClientAuthMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5100",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

90
Management/README.md Normal file
View File

@@ -0,0 +1,90 @@
# AdPlatform Management API
.NET 8 API for platform administration: onboarding, user/client management, and monitoring.
## Project Structure
```
Management/
├── Controllers/
│ ├── Admin/
│ │ ├── AdminControllerBase.cs # Shared base class
│ │ ├── AdminClientsController.cs # /api/admin/clients
│ │ ├── AdminUsersController.cs # /api/admin/users
│ │ └── AdminSessionsController.cs# /api/admin/sessions
│ ├── OnboardingController.cs # /api/onboarding
│ ├── MonitoringController.cs # /api/monitoring
│ └── TestController.cs # /api/test
├── Data/
│ └── SqlService.cs # Database access
├── Security/
│ ├── ClientContext.cs # Request auth context
│ └── ClientAuthMiddleware.cs # Auth middleware
├── SQL/
│ ├── spAdminClients.sql
│ ├── spAdminUsers.sql
│ ├── spAdminSessions.sql
│ ├── spOnboarding.sql
│ └── spMonitoring.sql
└── Program.cs
```
## API Endpoints
### Onboarding (JWT auth)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/onboarding/status | Check registration status |
| POST | /api/onboarding/register | Register new organization |
### Admin - Clients (Session + Admin role)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/admin/clients | List clients |
| GET | /api/admin/clients/{id} | Get client |
| POST | /api/admin/clients | Create client |
| PUT | /api/admin/clients/{id} | Update client |
| DELETE | /api/admin/clients/{id} | Deactivate client |
### Admin - Users (Session + Admin role)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/admin/users | List users |
| GET | /api/admin/users/{id} | Get user |
| POST | /api/admin/users | Create user |
| PUT | /api/admin/users/{id} | Update user |
| DELETE | /api/admin/users/{id} | Deactivate user |
| POST | /api/admin/users/{id}/clients | Link user to client |
| DELETE | /api/admin/users/{id}/clients/{cid} | Unlink user |
### Admin - Sessions (Session + Admin role)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/admin/sessions | List sessions |
| POST | /api/admin/sessions/{id}/revoke | Revoke session |
| POST | /api/admin/users/{id}/revoke-sessions | Revoke all user sessions |
| POST | /api/admin/sessions/cleanup | Cleanup expired |
### Monitoring (Session + Admin role)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/monitoring/health | System health |
| GET | /api/monitoring/stats | Detailed stats |
## Setup
1. Run SQL scripts in `SQL/` folder against dbAdPlatform
2. Deploy to Azure Container Apps
3. Set environment variables:
- `ConnectionStrings__Sql`
- `Auth__EntraId__TenantId`
- `Auth__EntraId__ClientId`
## Local Development
```bash
dotnet run
# Open http://localhost:5100/swagger
```
Dev bypass: Add `X-Dev-ClientId: test` header (Development environment only)

View File

@@ -0,0 +1,181 @@
-- ============================================================
-- spAdminClients: Client (organization) management
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spAdminClients]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: create
------------------------------------------------------------------------
IF @action = 'create'
BEGIN
DECLARE @cName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
IF @cName IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientName is required"}';
RETURN;
END
IF EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltName = @cName)
BEGIN
SET @resp = N'{"ok":false,"error":"Client name already exists"}';
RETURN;
END
DECLARE @cId UNIQUEIDENTIFIER = NEWID();
INSERT INTO dbo.tbClient (cltId, cltName, cltStatus)
VALUES (@cId, @cName, 'Active');
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@cId AS clientId,
@cName AS clientName,
'Active' AS status
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: get
------------------------------------------------------------------------
IF @action = 'get'
BEGIN
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
IF @gId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @gId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
c.cltId AS clientId,
c.cltName AS clientName,
c.cltStatus AS status,
c.cltCreatedUtc AS createdAt,
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount,
(SELECT COUNT(*) FROM dbo.tbAdAccount WHERE accCltId = c.cltId) AS accountCount
FROM dbo.tbClient c WHERE c.cltId = @gId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: list
------------------------------------------------------------------------
IF @action = 'list'
BEGIN
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
DECLARE @clients NVARCHAR(MAX);
SELECT @clients = (
SELECT
c.cltId AS clientId,
c.cltName AS clientName,
c.cltStatus AS status,
c.cltCreatedUtc AS createdAt,
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount
FROM dbo.tbClient c
WHERE @lStatus IS NULL OR c.cltStatus = @lStatus
ORDER BY c.cltName
OFFSET (@lPage - 1) * @lPageSize ROWS
FETCH NEXT @lPageSize ROWS ONLY
FOR JSON PATH
);
DECLARE @lTotal INT;
SELECT @lTotal = COUNT(*) FROM dbo.tbClient WHERE @lStatus IS NULL OR cltStatus = @lStatus;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@clients, '[]')) AS clients,
@lTotal AS totalCount,
@lPage AS page,
@lPageSize AS pageSize
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: update
------------------------------------------------------------------------
IF @action = 'update'
BEGIN
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @uName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
IF @uId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @uId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
UPDATE dbo.tbClient
SET cltName = ISNULL(@uName, cltName),
cltStatus = ISNULL(@uStatus, cltStatus)
WHERE cltId = @uId;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
cltId AS clientId,
cltName AS clientName,
cltStatus AS status
FROM dbo.tbClient WHERE cltId = @uId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: delete (soft delete)
------------------------------------------------------------------------
IF @action = 'delete'
BEGIN
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
IF @dId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientId is required"}';
RETURN;
END
UPDATE dbo.tbClient SET cltStatus = 'Inactive' WHERE cltId = @dId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -0,0 +1,111 @@
-- ============================================================
-- spAdminSessions: Session management
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spAdminSessions]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: list
------------------------------------------------------------------------
IF @action = 'list'
BEGIN
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @lUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @lActiveOnly BIT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.activeOnly') AS BIT), 1);
DECLARE @lLimit INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.limit') AS INT), 100);
DECLARE @sessions NVARCHAR(MAX);
SELECT @sessions = (
SELECT TOP (@lLimit)
s.sesId AS sessionId,
u.usrId AS userId,
u.usrEmail AS userEmail,
u.usrDisplayName AS displayName,
c.cltId AS clientId,
c.cltName AS clientName,
s.sesCreatedUtc AS createdAt,
s.sesExpiresUtc AS expiresAt,
s.sesLastActivityUtc AS lastActivity,
s.sesIpAddress AS ipAddress,
s.sesIsRevoked AS isRevoked
FROM dbo.tbSession s
JOIN dbo.tbUser u ON u.usrId = s.sesUsrId
JOIN dbo.tbClient c ON c.cltId = s.sesCltId
WHERE (@lClientId IS NULL OR c.cltId = @lClientId)
AND (@lUserId IS NULL OR u.usrId = @lUserId)
AND (@lActiveOnly = 0 OR (s.sesIsRevoked = 0 AND s.sesExpiresUtc > SYSUTCDATETIME()))
ORDER BY s.sesLastActivityUtc DESC
FOR JSON PATH
);
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@sessions, '[]')) AS sessions
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: revoke
------------------------------------------------------------------------
IF @action = 'revoke'
BEGIN
DECLARE @rSessionId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.sessionId'));
IF @rSessionId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"sessionId is required"}';
RETURN;
END
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesId = @rSessionId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: revokeAllForUser
------------------------------------------------------------------------
IF @action = 'revokeAllForUser'
BEGIN
DECLARE @raUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
IF @raUserId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesUsrId = @raUserId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: cleanup
------------------------------------------------------------------------
IF @action = 'cleanup'
BEGIN
DECLARE @daysOld INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.daysOld') AS INT), 30);
DELETE FROM dbo.tbSession
WHERE sesExpiresUtc < DATEADD(DAY, -@daysOld, SYSUTCDATETIME());
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsDeleted FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -0,0 +1,288 @@
-- ============================================================
-- spAdminUsers: User management
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spAdminUsers]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: create
------------------------------------------------------------------------
IF @action = 'create'
BEGIN
DECLARE @cEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
DECLARE @cDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
DECLARE @cClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @cRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
IF @cEmail IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"email is required"}';
RETURN;
END
IF EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrEmail = @cEmail)
BEGIN
SET @resp = N'{"ok":false,"error":"User with this email already exists"}';
RETURN;
END
IF @cClientId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @cClientId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
DECLARE @cUserId UNIQUEIDENTIFIER = NEWID();
DECLARE @cEntraSub NVARCHAR(100) = 'pending-' + CAST(@cUserId AS NVARCHAR(50));
INSERT INTO dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
VALUES (@cUserId, @cEntraSub, 'Pending', @cEntraSub, @cEmail, @cDisplayName, 'Active');
IF @cClientId IS NOT NULL
BEGIN
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
VALUES (@cUserId, @cClientId, @cRole);
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@cUserId AS userId,
@cEmail AS email,
@cDisplayName AS displayName,
@cClientId AS clientId,
@cRole AS [role]
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: get
------------------------------------------------------------------------
IF @action = 'get'
BEGIN
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
IF @gId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @gId)
BEGIN
SET @resp = N'{"ok":false,"error":"User not found"}';
RETURN;
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
u.usrId AS userId,
u.usrEmail AS email,
u.usrDisplayName AS displayName,
u.usrStatus AS status,
u.usrCreatedUtc AS createdAt,
(
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
FROM dbo.tbUserClientRole r
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
WHERE r.ucrUsrId = u.usrId
FOR JSON PATH
) AS clients
FROM dbo.tbUser u WHERE u.usrId = @gId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: list
------------------------------------------------------------------------
IF @action = 'list'
BEGIN
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
DECLARE @users NVARCHAR(MAX);
SELECT @users = (
SELECT
u.usrId AS userId,
u.usrEmail AS email,
u.usrDisplayName AS displayName,
u.usrStatus AS status,
u.usrCreatedUtc AS createdAt,
(
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
FROM dbo.tbUserClientRole r
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
WHERE r.ucrUsrId = u.usrId
FOR JSON PATH
) AS clients
FROM dbo.tbUser u
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
AND (@lClientId IS NULL OR EXISTS (
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
))
ORDER BY u.usrEmail
OFFSET (@lPage - 1) * @lPageSize ROWS
FETCH NEXT @lPageSize ROWS ONLY
FOR JSON PATH
);
DECLARE @lTotal INT;
SELECT @lTotal = COUNT(*)
FROM dbo.tbUser u
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
AND (@lClientId IS NULL OR EXISTS (
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
));
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@users, '[]')) AS users,
@lTotal AS totalCount,
@lPage AS page,
@lPageSize AS pageSize
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: update
------------------------------------------------------------------------
IF @action = 'update'
BEGIN
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @uDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
IF @uId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @uId)
BEGIN
SET @resp = N'{"ok":false,"error":"User not found"}';
RETURN;
END
UPDATE dbo.tbUser
SET usrDisplayName = ISNULL(@uDisplayName, usrDisplayName),
usrStatus = ISNULL(@uStatus, usrStatus)
WHERE usrId = @uId;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
usrId AS userId,
usrEmail AS email,
usrDisplayName AS displayName,
usrStatus AS status
FROM dbo.tbUser WHERE usrId = @uId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: delete (soft delete)
------------------------------------------------------------------------
IF @action = 'delete'
BEGIN
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
IF @dId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId is required"}';
RETURN;
END
UPDATE dbo.tbUser SET usrStatus = 'Inactive' WHERE usrId = @dId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: linkToClient
------------------------------------------------------------------------
IF @action = 'linkToClient'
BEGIN
DECLARE @luUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @luClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
DECLARE @luRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
IF @luUserId IS NULL OR @luClientId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @luUserId)
BEGIN
SET @resp = N'{"ok":false,"error":"User not found"}';
RETURN;
END
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @luClientId)
BEGIN
SET @resp = N'{"ok":false,"error":"Client not found"}';
RETURN;
END
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId)
BEGIN
UPDATE dbo.tbUserClientRole
SET ucrRole = @luRole
WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'updated' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
VALUES (@luUserId, @luClientId, @luRole);
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'created' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: unlinkFromClient
------------------------------------------------------------------------
IF @action = 'unlinkFromClient'
BEGIN
DECLARE @ruUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
DECLARE @ruClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
IF @ruUserId IS NULL OR @ruClientId IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
RETURN;
END
DELETE FROM dbo.tbUserClientRole
WHERE ucrUsrId = @ruUserId AND ucrCltId = @ruClientId;
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -0,0 +1,106 @@
-- ============================================================
-- spMonitoring: System health and statistics
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spMonitoring]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: health
-- System health overview
------------------------------------------------------------------------
IF @action = 'health'
BEGIN
DECLARE @clientCount INT, @userCount INT, @sessionCount INT, @logCount24h INT;
SELECT @clientCount = COUNT(*) FROM dbo.tbClient WHERE cltStatus = 'Active';
SELECT @userCount = COUNT(*) FROM dbo.tbUser WHERE usrStatus = 'Active';
SELECT @sessionCount = COUNT(*) FROM dbo.tbSession WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
-- Check if tbAdpApiLog exists (may not be in all installations)
IF OBJECT_ID('dbo.tbAdpApiLog', 'U') IS NOT NULL
EXEC sp_executesql N'SELECT @cnt = COUNT(*) FROM dbo.tbAdpApiLog WHERE createdUtc > DATEADD(HOUR, -24, SYSUTCDATETIME())',
N'@cnt INT OUTPUT', @cnt = @logCount24h OUTPUT;
ELSE
SET @logCount24h = 0;
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@clientCount AS activeClients,
@userCount AS activeUsers,
@sessionCount AS activeSessions,
@logCount24h AS apiCalls24h,
SYSUTCDATETIME() AS serverTimeUtc
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: stats
-- Detailed statistics
------------------------------------------------------------------------
IF @action = 'stats'
BEGIN
DECLARE @hours INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.hours') AS INT), 24);
-- Clients by status
DECLARE @clientsByStatus NVARCHAR(MAX);
SELECT @clientsByStatus = (
SELECT cltStatus AS status, COUNT(*) AS [count]
FROM dbo.tbClient
GROUP BY cltStatus
FOR JSON PATH
);
-- Users by status
DECLARE @usersByStatus NVARCHAR(MAX);
SELECT @usersByStatus = (
SELECT usrStatus AS status, COUNT(*) AS [count]
FROM dbo.tbUser
GROUP BY usrStatus
FOR JSON PATH
);
-- Sessions stats
DECLARE @activeSessions INT, @expiredSessions INT, @revokedSessions INT;
SELECT @activeSessions = COUNT(*) FROM dbo.tbSession
WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
SELECT @expiredSessions = COUNT(*) FROM dbo.tbSession
WHERE sesIsRevoked = 0 AND sesExpiresUtc <= SYSUTCDATETIME();
SELECT @revokedSessions = COUNT(*) FROM dbo.tbSession
WHERE sesIsRevoked = 1;
-- Recent registrations (last 7 days)
DECLARE @recentClients INT, @recentUsers INT;
SELECT @recentClients = COUNT(*) FROM dbo.tbClient
WHERE cltCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
SELECT @recentUsers = COUNT(*) FROM dbo.tbUser
WHERE usrCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
JSON_QUERY(ISNULL(@clientsByStatus, '[]')) AS clientsByStatus,
JSON_QUERY(ISNULL(@usersByStatus, '[]')) AS usersByStatus,
@activeSessions AS activeSessions,
@expiredSessions AS expiredSessions,
@revokedSessions AS revokedSessions,
@recentClients AS newClientsLast7Days,
@recentUsers AS newUsersLast7Days,
SYSUTCDATETIME() AS serverTimeUtc
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -0,0 +1,151 @@
-- ============================================================
-- spOnboarding: User/Client registration
-- ============================================================
CREATE OR ALTER PROCEDURE [dbo].[spOnboarding]
@action VARCHAR(50),
@rqst NVARCHAR(MAX),
@resp NVARCHAR(MAX) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
------------------------------------------------------------------------
-- ACTION: status
-- Check if user is registered and has client access
------------------------------------------------------------------------
IF @action = 'status'
BEGIN
DECLARE @sSubject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
DECLARE @sEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
DECLARE @sUserId UNIQUEIDENTIFIER;
DECLARE @sUserEmail NVARCHAR(256);
SELECT @sUserId = usrId, @sUserEmail = usrEmail
FROM dbo.tbUser
WHERE usrEntraSub = @sSubject;
-- User doesn't exist
IF @sUserId IS NULL
BEGIN
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
CAST(0 AS BIT) AS isRegistered,
@sEmail AS email
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
-- Check for client access
DECLARE @clients NVARCHAR(MAX);
SELECT @clients = (
SELECT
c.cltId AS clientId,
c.cltName AS clientName,
r.ucrRole AS [role]
FROM dbo.tbUserClientRole r
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId AND c.cltStatus = 'Active'
WHERE r.ucrUsrId = @sUserId
FOR JSON PATH
);
IF @clients IS NULL OR @clients = '[]'
BEGIN
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
CAST(0 AS BIT) AS isRegistered,
@sUserId AS userId,
@sUserEmail AS email
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
CAST(1 AS BIT) AS isRegistered,
@sUserId AS userId,
@sUserEmail AS email,
JSON_QUERY(@clients) AS clients
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
------------------------------------------------------------------------
-- ACTION: register
-- Creates client + links user as Admin
------------------------------------------------------------------------
IF @action = 'register'
BEGIN
DECLARE @provider VARCHAR(30) = NULLIF(JSON_VALUE(@j, '$.provider'), '');
DECLARE @subject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
DECLARE @email NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
DECLARE @displayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
DECLARE @clientName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
-- Validation
IF @provider IS NULL OR @subject IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"provider and subject are required"}';
RETURN;
END
IF @clientName IS NULL
BEGIN
SET @resp = N'{"ok":false,"error":"clientName is required"}';
RETURN;
END
-- Find or create user
DECLARE @userId UNIQUEIDENTIFIER;
SELECT @userId = usrId
FROM dbo.tbUser
WHERE usrEntraSub = @subject;
IF @userId IS NULL
BEGIN
SET @userId = NEWID();
INSERT dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
VALUES (@userId, @subject, @provider, @subject, @email, @displayName, 'Active');
END
-- Check if user already has client access
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @userId)
BEGIN
SET @resp = N'{"ok":false,"error":"User is already registered"}';
RETURN;
END
-- Create client
DECLARE @clientId UNIQUEIDENTIFIER = NEWID();
INSERT dbo.tbClient (cltId, cltName, cltStatus)
VALUES (@clientId, @clientName, 'Active');
-- Link user as Admin
INSERT dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
VALUES (@userId, @clientId, 'Admin');
-- Return success
SET @resp = (
SELECT
CAST(1 AS BIT) AS ok,
@userId AS userId,
@clientId AS clientId,
@clientName AS clientName,
'Admin' AS [role]
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);
RETURN;
END
SET @resp = N'{"ok":false,"error":"Unknown action"}';
END
GO

View File

@@ -0,0 +1,261 @@
using Management.Data;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
namespace Management.Security;
/// <summary>
/// Authentication middleware for Management API.
///
/// Auth paths:
/// - /api/onboarding/* → JWT (user may not have session yet)
/// - /api/admin/* → Session + Admin role
/// - /api/monitoring/* → Session + Admin role
/// - /api/test/* → Anonymous
/// </summary>
public sealed class ClientAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ClientAuthMiddleware> _logger;
private readonly IConfiguration _config;
private static readonly HashSet<string> _anonymousExact = new(StringComparer.OrdinalIgnoreCase)
{
"/", "/health"
};
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
private static readonly string[] _jwtOnlyPrefixes = { "/api/onboarding" };
private static readonly string[] _adminRequiredPrefixes = { "/api/monitoring", "/api/admin" };
private static ConfigurationManager<OpenIdConnectConfiguration>? _oidcConfigManager;
private static readonly object _oidcLock = new();
public ClientAuthMiddleware(RequestDelegate next, ILogger<ClientAuthMiddleware> logger, IConfiguration config)
{
_next = next;
_logger = logger;
_config = config;
}
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
{
var path = (context.Request.Path.Value ?? "").ToLowerInvariant();
var corrId = EnsureCorrelationId(context);
// Anonymous paths
if (IsAnonymousPath(path))
{
await _next(context);
return;
}
// Dev bypass
if (TryDevBypass(context, clientContext))
{
await _next(context);
return;
}
// JWT-only paths (onboarding)
if (IsJwtOnlyPath(path))
{
if (await TryJwtAuthAsync(context, clientContext))
{
await _next(context);
return;
}
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid Entra authentication required" });
return;
}
// Admin-required paths
if (IsAdminRequiredPath(path))
{
if (await TrySessionAuthAsync(context, clientContext, sql))
{
if (!clientContext.IsAdmin)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Admin access required" });
return;
}
await _next(context);
return;
}
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin session required" });
return;
}
// Default: require session
if (await TrySessionAuthAsync(context, clientContext, sql))
{
await _next(context);
return;
}
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid session required" });
}
private static bool IsAnonymousPath(string path) =>
_anonymousExact.Contains(path) || _anonymousPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
private static bool IsJwtOnlyPath(string path) =>
_jwtOnlyPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
private static bool IsAdminRequiredPath(string path) =>
_adminRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
private static string EnsureCorrelationId(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-Correlation-Id", out var existing) || string.IsNullOrWhiteSpace(existing.FirstOrDefault()))
{
var id = Guid.NewGuid().ToString("N");
context.Request.Headers["X-Correlation-Id"] = id;
return id;
}
return existing.First()!;
}
private bool TryDevBypass(HttpContext context, ClientContext clientContext)
{
var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var allowBypass = _config.GetValue<bool>("Auth:AllowDevBypass");
if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase) && !allowBypass)
return false;
if (!context.Request.Headers.TryGetValue("X-Dev-ClientId", out var devClientId))
return false;
var clientId = devClientId.FirstOrDefault();
if (string.IsNullOrWhiteSpace(clientId))
return false;
clientContext.ClientId = clientId;
clientContext.IsDevBypass = true;
clientContext.Role = "Admin";
return true;
}
private async Task<bool> TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql)
{
string? token = context.Request.Headers["X-Session-Token"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(token))
{
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
token = authHeader["Bearer ".Length..].Trim();
}
if (string.IsNullOrWhiteSpace(token))
return false;
try
{
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
if (string.IsNullOrWhiteSpace(resp))
return false;
using var doc = JsonDocument.Parse(resp);
var root = doc.RootElement;
if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True)
{
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
clientContext.PlatformClientId = clientContext.ClientId;
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
return clientContext.IsAuthenticated;
}
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Session validation error");
return false;
}
}
private async Task<bool> TryJwtAuthAsync(HttpContext context, ClientContext clientContext)
{
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return false;
var token = authHeader["Bearer ".Length..].Trim();
if (string.IsNullOrWhiteSpace(token))
return false;
var tenantId = _config["Auth:EntraId:TenantId"];
var clientId = _config["Auth:EntraId:ClientId"];
var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
return false;
try
{
var handler = new JwtSecurityTokenHandler();
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
var metadataAddress = $"{authority}/.well-known/openid-configuration";
var mgr = GetOrCreateConfigManager(metadataAddress);
var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted);
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
ValidateAudience = true,
ValidAudiences = new[] { clientId, $"api://{clientId}" },
ValidateLifetime = true,
IssuerSigningKeys = openIdConfig.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(5)
};
var principal = handler.ValidateToken(token, validationParams, out _);
clientContext.ClientId = principal.FindFirstValue("oid") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
clientContext.Email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email);
clientContext.ClientName = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name);
return clientContext.IsAuthenticated;
}
catch (Exception ex)
{
_logger.LogWarning("JWT validation failed: {Message}", ex.Message);
return false;
}
}
private static ConfigurationManager<OpenIdConnectConfiguration> GetOrCreateConfigManager(string metadataAddress)
{
lock (_oidcLock)
{
_oidcConfigManager ??= new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress, new OpenIdConnectConfigurationRetriever());
return _oidcConfigManager;
}
}
}

View File

@@ -0,0 +1,20 @@
namespace Management.Security;
/// <summary>
/// Request-scoped authentication context.
/// Populated by ClientAuthMiddleware.
/// </summary>
public sealed class ClientContext
{
public string? SessionId { get; set; }
public string? ClientId { get; set; }
public string? PlatformClientId { get; set; }
public string? ClientName { get; set; }
public string? UserId { get; set; }
public string? Email { get; set; }
public string? Role { get; set; }
public bool IsDevBypass { get; set; }
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
public bool IsAdmin => string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,13 @@
namespace Management
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"AllowDevBypass": true
}
}

View File

@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Auth": {
"AllowDevBypass": false,
"EntraId": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
}
}
}