Add project files.
This commit is contained in:
37
AdPlatformServers.sln
Normal file
37
AdPlatformServers.sln
Normal 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
|
||||
382
Gateway/Controllers/AuthController.cs
Normal file
382
Gateway/Controllers/AuthController.cs
Normal 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; }
|
||||
}
|
||||
25
Gateway/Controllers/ExecutionController.cs
Normal file
25
Gateway/Controllers/ExecutionController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
28
Gateway/Controllers/TestController.cs
Normal file
28
Gateway/Controllers/TestController.cs
Normal 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
14
Gateway/Data/SqlNames.cs
Normal 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
92
Gateway/Data/SqlServer.cs
Normal 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
28
Gateway/Gateway.csproj
Normal 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
6
Gateway/Gateway.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Gateway_HostAddress = http://localhost:5255
|
||||
|
||||
GET {{Gateway_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
11
Gateway/Models/CampaignDto.cs
Normal file
11
Gateway/Models/CampaignDto.cs
Normal 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; } = "";
|
||||
}
|
||||
17
Gateway/Models/CreateCampaignRequest.cs
Normal file
17
Gateway/Models/CreateCampaignRequest.cs
Normal 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; }
|
||||
}
|
||||
10
Gateway/Models/CreateCampaignResult.cs
Normal file
10
Gateway/Models/CreateCampaignResult.cs
Normal 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; }
|
||||
}
|
||||
22
Gateway/Models/ExecutionRequest.cs
Normal file
22
Gateway/Models/ExecutionRequest.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
14
Gateway/Models/ExecutionResponse.cs
Normal file
14
Gateway/Models/ExecutionResponse.cs
Normal 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; }
|
||||
}
|
||||
9
Gateway/Models/ProviderRequest.cs
Normal file
9
Gateway/Models/ProviderRequest.cs
Normal 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; }
|
||||
}
|
||||
16
Gateway/Models/ProviderResponse.cs
Normal file
16
Gateway/Models/ProviderResponse.cs
Normal 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
80
Gateway/Program.cs
Normal 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();
|
||||
52
Gateway/Properties/launchSettings.json
Normal file
52
Gateway/Properties/launchSettings.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Gateway/ProviderClients/GoogleProviderClient.cs
Normal file
60
Gateway/ProviderClients/GoogleProviderClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
169
Gateway/Security/AccessLogMiddleware.cs
Normal file
169
Gateway/Security/AccessLogMiddleware.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
415
Gateway/Security/ClientAuthMiddleware.cs
Normal file
415
Gateway/Security/ClientAuthMiddleware.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Gateway/Security/ClientContext.cs
Normal file
60
Gateway/Security/ClientContext.cs
Normal 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);
|
||||
}
|
||||
512
Gateway/Security/MultiProviderAuthMiddleware.cs
Normal file
512
Gateway/Security/MultiProviderAuthMiddleware.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
375
Gateway/Services/ExecutionService.cs
Normal file
375
Gateway/Services/ExecutionService.cs
Normal 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"] ?? ""
|
||||
};
|
||||
}
|
||||
}
|
||||
12
Gateway/appsettings.Development.json
Normal file
12
Gateway/appsettings.Development.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
|
||||
"Auth": {
|
||||
"AllowDevBypass": true
|
||||
}
|
||||
}
|
||||
18
Gateway/appsettings.json
Normal file
18
Gateway/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Gateway/appsettings.multiprovider.json
Normal file
22
Gateway/appsettings.multiprovider.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
87
GoogleApi/Configuration/GoogleAdsConfig.cs
Normal file
87
GoogleApi/Configuration/GoogleAdsConfig.cs
Normal 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; }
|
||||
}
|
||||
83
GoogleApi/Controllers/InternalController.cs
Normal file
83
GoogleApi/Controllers/InternalController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
259
GoogleApi/GOOGLE_ADS_SETUP.md
Normal file
259
GoogleApi/GOOGLE_ADS_SETUP.md
Normal 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
|
||||
23
GoogleApi/GoogleApi.csproj
Normal file
23
GoogleApi/GoogleApi.csproj
Normal 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
6
GoogleApi/GoogleApi.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@GoogleApi_HostAddress = http://localhost:5023
|
||||
|
||||
GET {{GoogleApi_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
87
GoogleApi/Models/OperationPayloads.cs
Normal file
87
GoogleApi/Models/OperationPayloads.cs
Normal 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
|
||||
93
GoogleApi/Models/ProviderModels.cs
Normal file
93
GoogleApi/Models/ProviderModels.cs
Normal 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
101
GoogleApi/Program.cs
Normal 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();
|
||||
26
GoogleApi/Properties/launchSettings.json
Normal file
26
GoogleApi/Properties/launchSettings.json
Normal 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
180
GoogleApi/README.md
Normal 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
|
||||
58
GoogleApi/Security/InternalAuthFilter.cs
Normal file
58
GoogleApi/Security/InternalAuthFilter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
71
GoogleApi/Services/GoogleAdsClientFactory.cs
Normal file
71
GoogleApi/Services/GoogleAdsClientFactory.cs
Normal 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..]}";
|
||||
}
|
||||
}
|
||||
576
GoogleApi/Services/GoogleAdsService.cs
Normal file
576
GoogleApi/Services/GoogleAdsService.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
18
GoogleApi/appsettings.Development.json
Normal file
18
GoogleApi/appsettings.Development.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
22
GoogleApi/appsettings.json
Normal file
22
GoogleApi/appsettings.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
92
Management/Controllers/Admin/AdminClientsController.cs
Normal file
92
Management/Controllers/Admin/AdminClientsController.cs
Normal 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; }
|
||||
}
|
||||
58
Management/Controllers/Admin/AdminControllerBase.cs
Normal file
58
Management/Controllers/Admin/AdminControllerBase.cs
Normal 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 });
|
||||
}
|
||||
65
Management/Controllers/Admin/AdminSessionsController.cs
Normal file
65
Management/Controllers/Admin/AdminSessionsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
140
Management/Controllers/Admin/AdminUsersController.cs
Normal file
140
Management/Controllers/Admin/AdminUsersController.cs
Normal 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; }
|
||||
}
|
||||
76
Management/Controllers/MonitoringController.cs
Normal file
76
Management/Controllers/MonitoringController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
114
Management/Controllers/OnboardingController.cs
Normal file
114
Management/Controllers/OnboardingController.cs
Normal 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; }
|
||||
}
|
||||
37
Management/Controllers/TestController.cs
Normal file
37
Management/Controllers/TestController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Management/Controllers/WeatherForecastController.cs
Normal file
33
Management/Controllers/WeatherForecastController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Management/Data/SqlService.cs
Normal file
82
Management/Data/SqlService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Management/Management.csproj
Normal file
23
Management/Management.csproj
Normal 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>
|
||||
6
Management/Management.http
Normal file
6
Management/Management.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Management_HostAddress = http://localhost:5290
|
||||
|
||||
GET {{Management_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
61
Management/Program.cs
Normal file
61
Management/Program.cs
Normal 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();
|
||||
15
Management/Properties/launchSettings.json
Normal file
15
Management/Properties/launchSettings.json
Normal 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
90
Management/README.md
Normal 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)
|
||||
181
Management/SQL/spAdminClients.sql
Normal file
181
Management/SQL/spAdminClients.sql
Normal 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
|
||||
111
Management/SQL/spAdminSessions.sql
Normal file
111
Management/SQL/spAdminSessions.sql
Normal 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
|
||||
288
Management/SQL/spAdminUsers.sql
Normal file
288
Management/SQL/spAdminUsers.sql
Normal 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
|
||||
106
Management/SQL/spMonitoring.sql
Normal file
106
Management/SQL/spMonitoring.sql
Normal 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
|
||||
151
Management/SQL/spOnboarding.sql
Normal file
151
Management/SQL/spOnboarding.sql
Normal 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
|
||||
261
Management/Security/ClientAuthMiddleware.cs
Normal file
261
Management/Security/ClientAuthMiddleware.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Management/Security/ClientContext.cs
Normal file
20
Management/Security/ClientContext.cs
Normal 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);
|
||||
}
|
||||
13
Management/WeatherForecast.cs
Normal file
13
Management/WeatherForecast.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
11
Management/appsettings.Development.json
Normal file
11
Management/appsettings.Development.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"AllowDevBypass": true
|
||||
}
|
||||
}
|
||||
17
Management/appsettings.json
Normal file
17
Management/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user