From 8e7e03702e3eaf355acddb3d835694a4c2ea0a8f Mon Sep 17 00:00:00 2001 From: Grae Jones Date: Tue, 3 Feb 2026 15:04:37 -0800 Subject: [PATCH] Add project files. --- AdPlatformServers.sln | 37 ++ Gateway/Controllers/AuthController.cs | 382 ++++++++++++ Gateway/Controllers/ExecutionController.cs | 25 + Gateway/Controllers/TestController.cs | 28 + Gateway/Data/SqlNames.cs | 14 + Gateway/Data/SqlServer.cs | 92 +++ Gateway/Gateway.csproj | 28 + Gateway/Gateway.http | 6 + Gateway/Models/CampaignDto.cs | 11 + Gateway/Models/CreateCampaignRequest.cs | 17 + Gateway/Models/CreateCampaignResult.cs | 10 + Gateway/Models/ExecutionRequest.cs | 22 + Gateway/Models/ExecutionResponse.cs | 14 + Gateway/Models/ProviderRequest.cs | 9 + Gateway/Models/ProviderResponse.cs | 16 + Gateway/Program.cs | 80 +++ Gateway/Properties/launchSettings.json | 52 ++ .../ProviderClients/GoogleProviderClient.cs | 60 ++ Gateway/Security/AccessLogMiddleware.cs | 169 +++++ Gateway/Security/ClientAuthMiddleware.cs | 415 +++++++++++++ Gateway/Security/ClientContext.cs | 60 ++ .../Security/MultiProviderAuthMiddleware.cs | 512 ++++++++++++++++ Gateway/Services/ExecutionService.cs | 375 ++++++++++++ Gateway/appsettings.Development.json | 12 + Gateway/appsettings.json | 18 + Gateway/appsettings.multiprovider.json | 22 + GoogleApi/Configuration/GoogleAdsConfig.cs | 87 +++ GoogleApi/Controllers/InternalController.cs | 83 +++ GoogleApi/GOOGLE_ADS_SETUP.md | 259 ++++++++ GoogleApi/GoogleApi.csproj | 23 + GoogleApi/GoogleApi.http | 6 + GoogleApi/Models/OperationPayloads.cs | 87 +++ GoogleApi/Models/ProviderModels.cs | 93 +++ GoogleApi/Program.cs | 101 +++ GoogleApi/Properties/launchSettings.json | 26 + GoogleApi/README.md | 180 ++++++ GoogleApi/Security/InternalAuthFilter.cs | 58 ++ GoogleApi/Services/GoogleAdsClientFactory.cs | 71 +++ GoogleApi/Services/GoogleAdsService.cs | 576 ++++++++++++++++++ GoogleApi/appsettings.Development.json | 18 + GoogleApi/appsettings.json | 22 + .../Admin/AdminClientsController.cs | 92 +++ .../Controllers/Admin/AdminControllerBase.cs | 58 ++ .../Admin/AdminSessionsController.cs | 65 ++ .../Controllers/Admin/AdminUsersController.cs | 140 +++++ .../Controllers/MonitoringController.cs | 76 +++ .../Controllers/OnboardingController.cs | 114 ++++ Management/Controllers/TestController.cs | 37 ++ .../Controllers/WeatherForecastController.cs | 33 + Management/Data/SqlService.cs | 82 +++ Management/Management.csproj | 23 + Management/Management.http | 6 + Management/Program.cs | 61 ++ Management/Properties/launchSettings.json | 15 + Management/README.md | 90 +++ Management/SQL/spAdminClients.sql | 181 ++++++ Management/SQL/spAdminSessions.sql | 111 ++++ Management/SQL/spAdminUsers.sql | 288 +++++++++ Management/SQL/spMonitoring.sql | 106 ++++ Management/SQL/spOnboarding.sql | 151 +++++ Management/Security/ClientAuthMiddleware.cs | 261 ++++++++ Management/Security/ClientContext.cs | 20 + Management/WeatherForecast.cs | 13 + Management/appsettings.Development.json | 11 + Management/appsettings.json | 17 + 65 files changed, 6227 insertions(+) create mode 100644 AdPlatformServers.sln create mode 100644 Gateway/Controllers/AuthController.cs create mode 100644 Gateway/Controllers/ExecutionController.cs create mode 100644 Gateway/Controllers/TestController.cs create mode 100644 Gateway/Data/SqlNames.cs create mode 100644 Gateway/Data/SqlServer.cs create mode 100644 Gateway/Gateway.csproj create mode 100644 Gateway/Gateway.http create mode 100644 Gateway/Models/CampaignDto.cs create mode 100644 Gateway/Models/CreateCampaignRequest.cs create mode 100644 Gateway/Models/CreateCampaignResult.cs create mode 100644 Gateway/Models/ExecutionRequest.cs create mode 100644 Gateway/Models/ExecutionResponse.cs create mode 100644 Gateway/Models/ProviderRequest.cs create mode 100644 Gateway/Models/ProviderResponse.cs create mode 100644 Gateway/Program.cs create mode 100644 Gateway/Properties/launchSettings.json create mode 100644 Gateway/ProviderClients/GoogleProviderClient.cs create mode 100644 Gateway/Security/AccessLogMiddleware.cs create mode 100644 Gateway/Security/ClientAuthMiddleware.cs create mode 100644 Gateway/Security/ClientContext.cs create mode 100644 Gateway/Security/MultiProviderAuthMiddleware.cs create mode 100644 Gateway/Services/ExecutionService.cs create mode 100644 Gateway/appsettings.Development.json create mode 100644 Gateway/appsettings.json create mode 100644 Gateway/appsettings.multiprovider.json create mode 100644 GoogleApi/Configuration/GoogleAdsConfig.cs create mode 100644 GoogleApi/Controllers/InternalController.cs create mode 100644 GoogleApi/GOOGLE_ADS_SETUP.md create mode 100644 GoogleApi/GoogleApi.csproj create mode 100644 GoogleApi/GoogleApi.http create mode 100644 GoogleApi/Models/OperationPayloads.cs create mode 100644 GoogleApi/Models/ProviderModels.cs create mode 100644 GoogleApi/Program.cs create mode 100644 GoogleApi/Properties/launchSettings.json create mode 100644 GoogleApi/README.md create mode 100644 GoogleApi/Security/InternalAuthFilter.cs create mode 100644 GoogleApi/Services/GoogleAdsClientFactory.cs create mode 100644 GoogleApi/Services/GoogleAdsService.cs create mode 100644 GoogleApi/appsettings.Development.json create mode 100644 GoogleApi/appsettings.json create mode 100644 Management/Controllers/Admin/AdminClientsController.cs create mode 100644 Management/Controllers/Admin/AdminControllerBase.cs create mode 100644 Management/Controllers/Admin/AdminSessionsController.cs create mode 100644 Management/Controllers/Admin/AdminUsersController.cs create mode 100644 Management/Controllers/MonitoringController.cs create mode 100644 Management/Controllers/OnboardingController.cs create mode 100644 Management/Controllers/TestController.cs create mode 100644 Management/Controllers/WeatherForecastController.cs create mode 100644 Management/Data/SqlService.cs create mode 100644 Management/Management.csproj create mode 100644 Management/Management.http create mode 100644 Management/Program.cs create mode 100644 Management/Properties/launchSettings.json create mode 100644 Management/README.md create mode 100644 Management/SQL/spAdminClients.sql create mode 100644 Management/SQL/spAdminSessions.sql create mode 100644 Management/SQL/spAdminUsers.sql create mode 100644 Management/SQL/spMonitoring.sql create mode 100644 Management/SQL/spOnboarding.sql create mode 100644 Management/Security/ClientAuthMiddleware.cs create mode 100644 Management/Security/ClientContext.cs create mode 100644 Management/WeatherForecast.cs create mode 100644 Management/appsettings.Development.json create mode 100644 Management/appsettings.json diff --git a/AdPlatformServers.sln b/AdPlatformServers.sln new file mode 100644 index 0000000..c4eb886 --- /dev/null +++ b/AdPlatformServers.sln @@ -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 diff --git a/Gateway/Controllers/AuthController.cs b/Gateway/Controllers/AuthController.cs new file mode 100644 index 0000000..1c391ef --- /dev/null +++ b/Gateway/Controllers/AuthController.cs @@ -0,0 +1,382 @@ +using Gateway.Data; +using Gateway.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Gateway.Controllers; + +/// +/// Authentication endpoints for session management. +/// Sessions are created after Entra External ID authentication. +/// +[ApiController] +[Route("api/auth")] +public sealed class AuthController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly ILogger _log; + + public AuthController(SqlService sql, ClientContext client, ILogger log) + { + _sql = sql; + _client = client; + _log = log; + } + + /// + /// 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. + /// + [HttpPost("session")] + public async Task 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 }); + } + } + + /// + /// 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. + /// + [HttpPost("register")] + public async Task 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 }); + } + } + + /// + /// Sign off (invalidate session) + /// + [HttpPost("signoff")] + public async Task 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" }); + } + } + + /// + /// Refresh session (extend expiration) + /// + [HttpPost("refresh")] + public async Task 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" }); + } + } + + /// + /// Get current session info + /// + [HttpGet("me")] + public async Task 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" }); + } + } + + /// + /// Switch to a different client context (for multi-client users) + /// + [HttpPost("switch-client")] + public async Task 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; } +} diff --git a/Gateway/Controllers/ExecutionController.cs b/Gateway/Controllers/ExecutionController.cs new file mode 100644 index 0000000..7e3e914 --- /dev/null +++ b/Gateway/Controllers/ExecutionController.cs @@ -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 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"); + } +} diff --git a/Gateway/Controllers/TestController.cs b/Gateway/Controllers/TestController.cs new file mode 100644 index 0000000..d3dd0a4 --- /dev/null +++ b/Gateway/Controllers/TestController.cs @@ -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 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"); + } +} diff --git a/Gateway/Data/SqlNames.cs b/Gateway/Data/SqlNames.cs new file mode 100644 index 0000000..20a3fc1 --- /dev/null +++ b/Gateway/Data/SqlNames.cs @@ -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"; + } +} diff --git a/Gateway/Data/SqlServer.cs b/Gateway/Data/SqlServer.cs new file mode 100644 index 0000000..cd4e348 --- /dev/null +++ b/Gateway/Data/SqlServer.cs @@ -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 _logger; + + public SqlService(IConfiguration config, ILogger 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; + } + + /// + /// Executes a stored procedure using the standard signature: + /// @action varchar(..), + /// @rqst nvarchar(max), + /// @resp nvarchar(max) OUTPUT + /// Returns the output JSON string (resp). + /// + public async Task 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; + } + } +} diff --git a/Gateway/Gateway.csproj b/Gateway/Gateway.csproj new file mode 100644 index 0000000..328b8be --- /dev/null +++ b/Gateway/Gateway.csproj @@ -0,0 +1,28 @@ +ο»Ώ + + + net8.0 + enable + enable + + + True + mcr.microsoft.com/dotnet/aspnet:8.0 + gateway + 1fbe288a-4287-4931-8b37-9711665c35bb + + + + + + + + + + + + + + + + diff --git a/Gateway/Gateway.http b/Gateway/Gateway.http new file mode 100644 index 0000000..e4ae6dc --- /dev/null +++ b/Gateway/Gateway.http @@ -0,0 +1,6 @@ +@Gateway_HostAddress = http://localhost:5255 + +GET {{Gateway_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Gateway/Models/CampaignDto.cs b/Gateway/Models/CampaignDto.cs new file mode 100644 index 0000000..94543ce --- /dev/null +++ b/Gateway/Models/CampaignDto.cs @@ -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; } = ""; +} diff --git a/Gateway/Models/CreateCampaignRequest.cs b/Gateway/Models/CreateCampaignRequest.cs new file mode 100644 index 0000000..dd81c52 --- /dev/null +++ b/Gateway/Models/CreateCampaignRequest.cs @@ -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? Meta { get; set; } +} diff --git a/Gateway/Models/CreateCampaignResult.cs b/Gateway/Models/CreateCampaignResult.cs new file mode 100644 index 0000000..d064338 --- /dev/null +++ b/Gateway/Models/CreateCampaignResult.cs @@ -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; } +} diff --git a/Gateway/Models/ExecutionRequest.cs b/Gateway/Models/ExecutionRequest.cs new file mode 100644 index 0000000..79681de --- /dev/null +++ b/Gateway/Models/ExecutionRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace Gateway.Models +{ + public sealed class ExecutionRequest + { + /// Ad platform provider: google, meta, msads, etc. + public string Provider { get; set; } = "google"; + + /// Sub-module/microservice: system, campaigns, reporting, accounts, etc. + public string Service { get; set; } = "system"; + + /// Specific operation/action: ping, create, list, get, update, delete, etc. + public string Action { get; set; } = "ping"; + + /// Tenant/Customer ID for account context + public string? TenantId { get; set; } + + /// Raw JSON payload for the operation + public JsonElement Payload { get; set; } + } +} diff --git a/Gateway/Models/ExecutionResponse.cs b/Gateway/Models/ExecutionResponse.cs new file mode 100644 index 0000000..f89c62c --- /dev/null +++ b/Gateway/Models/ExecutionResponse.cs @@ -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; } +} diff --git a/Gateway/Models/ProviderRequest.cs b/Gateway/Models/ProviderRequest.cs new file mode 100644 index 0000000..724b6cc --- /dev/null +++ b/Gateway/Models/ProviderRequest.cs @@ -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? Payload { get; set; } +} diff --git a/Gateway/Models/ProviderResponse.cs b/Gateway/Models/ProviderResponse.cs new file mode 100644 index 0000000..6698bc7 --- /dev/null +++ b/Gateway/Models/ProviderResponse.cs @@ -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; } +} diff --git a/Gateway/Program.cs b/Gateway/Program.cs new file mode 100644 index 0000000..2c63ad7 --- /dev/null +++ b/Gateway/Program.cs @@ -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(); +builder.Services.AddScoped(); + +// Authentication context (scoped - one per request) +builder.Services.AddScoped(); + +// Provider clients +builder.Services.AddHttpClient(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(); + +// Standard middleware +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/Gateway/Properties/launchSettings.json b/Gateway/Properties/launchSettings.json new file mode 100644 index 0000000..7edeeaf --- /dev/null +++ b/Gateway/Properties/launchSettings.json @@ -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 + } + } +} \ No newline at end of file diff --git a/Gateway/ProviderClients/GoogleProviderClient.cs b/Gateway/ProviderClients/GoogleProviderClient.cs new file mode 100644 index 0000000..b9a6c0c --- /dev/null +++ b/Gateway/ProviderClients/GoogleProviderClient.cs @@ -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 _logger; + + public GoogleProviderClient(HttpClient http, IConfiguration config, ILogger 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(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); + } +} diff --git a/Gateway/Security/AccessLogMiddleware.cs b/Gateway/Security/AccessLogMiddleware.cs new file mode 100644 index 0000000..3be67aa --- /dev/null +++ b/Gateway/Security/AccessLogMiddleware.cs @@ -0,0 +1,169 @@ +using Gateway.Data; +using System.Diagnostics; +using System.Text.Json; + +namespace Gateway.Security; + +/// +/// 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. +/// +public sealed class AccessLogMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + // Paths to skip logging (health checks, static files, etc.) + private static readonly HashSet _skipPaths = new(StringComparer.OrdinalIgnoreCase) + { + "/health", + "/favicon.ico" + }; + + public AccessLogMiddleware(RequestDelegate next, ILogger 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; + } +} + +/// +/// Extension method for cleaner registration in Program.cs +/// +public static class AccessLogMiddlewareExtensions +{ + public static IApplicationBuilder UseAccessLogging(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/Gateway/Security/ClientAuthMiddleware.cs b/Gateway/Security/ClientAuthMiddleware.cs new file mode 100644 index 0000000..17e7850 --- /dev/null +++ b/Gateway/Security/ClientAuthMiddleware.cs @@ -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; + +/// +/// Tight auth contract: +/// 1) POST /api/auth/session -> MUST be Entra JWT (Authorization: Bearer ) +/// 2) All other /api/* -> MUST be valid session token (X-Session-Token OR Authorization: Bearer ) +/// 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). +/// +public sealed class ClientAuthMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IConfiguration _config; + + // Exact paths that do not require authentication + private static readonly HashSet _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? _oidcConfigManager; + private static readonly object _oidcLock = new(); + + public ClientAuthMiddleware(RequestDelegate next, ILogger 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= 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; + } + + /// + /// Session token authentication: Validate against our session database. + /// Accepts X-Session-Token header OR Authorization: Bearer . + /// + private async Task 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; + } + } + + /// + /// Development bypass: Accept X-Dev-ClientId header. + /// Only works when ASPNETCORE_ENVIRONMENT=Development or Auth:AllowDevBypass=true. + /// + private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId) + { + var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var allowBypass = _config.GetValue("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; + } + + /// + /// JWT authentication: Validate Entra ID Bearer token. + /// Used ONLY for /api/auth/session. + /// + private async Task 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 { 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 GetOrCreateConfigManager(string metadataAddress) + { + lock (_oidcLock) + { + _oidcConfigManager ??= new ConfigurationManager( + metadataAddress, + new OpenIdConnectConfigurationRetriever()); + + return _oidcConfigManager; + } + } +} \ No newline at end of file diff --git a/Gateway/Security/ClientContext.cs b/Gateway/Security/ClientContext.cs new file mode 100644 index 0000000..5bfb0b3 --- /dev/null +++ b/Gateway/Security/ClientContext.cs @@ -0,0 +1,60 @@ +namespace Gateway.Security; + +/// +/// Holds authenticated client information for the current request. +/// Populated by ClientAuthMiddleware. +/// +public sealed class ClientContext +{ + /// + /// Session ID from session-based auth. + /// + public string? SessionId { get; set; } + + /// + /// The authenticated client ID (from session, JWT sub claim, or dev header). + /// This identifies the client/organization in our platform. + /// + public string? ClientId { get; set; } + + /// + /// Optional tenant ID for the ad platform (e.g., Google Ads customer ID). + /// May be derived from ClientId mapping or passed in request. + /// + public string? TenantId { get; set; } + + /// + /// Display name from token or session (if available). + /// + public string? ClientName { get; set; } + + /// + /// User ID from session (if using session auth). + /// + public string? UserId { get; set; } + + /// + /// Email from token or session (if available). + /// + public string? Email { get; set; } + + /// + /// User role from session (admin, user, readonly). + /// + public string? Role { get; set; } + + /// + /// Whether this request was authenticated via dev bypass (vs real auth). + /// + public bool IsDevBypass { get; set; } + + /// + /// The authentication provider used (microsoft, google, etc.) + /// + public string? AuthProvider { get; set; } + + /// + /// True if we have a valid ClientId. + /// + public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId); +} diff --git a/Gateway/Security/MultiProviderAuthMiddleware.cs b/Gateway/Security/MultiProviderAuthMiddleware.cs new file mode 100644 index 0000000..41bdd22 --- /dev/null +++ b/Gateway/Security/MultiProviderAuthMiddleware.cs @@ -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; + +/// +/// 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 +/// +public sealed class MultiProviderAuthMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IConfiguration _config; + + // Paths that don't require auth + private static readonly HashSet _anonymousExact = new(StringComparer.OrdinalIgnoreCase) + { + "/", + "/health" + }; + + private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" }; + + // OIDC config managers (cached per provider) + private static readonly Dictionary> _oidcManagers = new(); + private static readonly object _oidcLock = new(); + + public MultiProviderAuthMiddleware(RequestDelegate next, ILogger 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 + }); + } + + /// + /// Try to validate JWT from multiple providers. + /// Returns (success, providerName). + /// + 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(); + + 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); + } + + /// + /// Validate Microsoft Entra ID JWT + /// + private async Task 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; + } + } + + /// + /// Validate Google ID token + /// + private async Task 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; + } + } + + /// + /// Extract standard claims into ClientContext + /// + 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; + } + + /// + /// Session token validation (unchanged from original) + /// + private async Task 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; + } + } + + /// + /// Development bypass + /// + private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId) + { + var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var allowBypass = _config.GetValue("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 GetOrCreateOidcManager(string provider, string metadataAddress) + { + lock (_oidcLock) + { + if (!_oidcManagers.TryGetValue(provider, out var mgr)) + { + mgr = new ConfigurationManager( + metadataAddress, + new OpenIdConnectConfigurationRetriever()); + _oidcManagers[provider] = mgr; + } + return mgr; + } + } +} diff --git a/Gateway/Services/ExecutionService.cs b/Gateway/Services/ExecutionService.cs new file mode 100644 index 0000000..223ecf7 --- /dev/null +++ b/Gateway/Services/ExecutionService.cs @@ -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 _logger; + + // Operations that don't require a linked account (health checks, etc.) + private static readonly HashSet AccountOptionalOperations = new(StringComparer.OrdinalIgnoreCase) + { + "Ping", "TestPing", "ListAccessibleCustomers" + }; + + public ExecutionService( + SqlService sql, + IHttpClientFactory http, + IConfiguration cfg, + ClientContext client, + ILogger logger) + { + _sql = sql; + _http = http; + _cfg = cfg; + _client = client; + _logger = logger; + } + + public async Task 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; + } + + /// + /// Validate that a Google Ads customer ID is linked in the database. + /// Returns loginCustomerId if account is found. + /// + private async Task 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"); + } + } + + /// + /// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider. + /// + private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId) + { + using var doc = JsonDocument.Parse(original.GetRawText()); + var dict = JsonSerializer.Deserialize>(doc.RootElement.GetRawText()) + ?? new Dictionary(); + + // 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); + } + + /// + /// Wrap provider response with Gateway metadata. + /// + 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 + }); + } + } + + /// + /// Result of account validation. + /// + 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 + }; + } + + /// + /// Get provider URL based on provider type. + /// + 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('/') ?? "" + }; + } + + /// + /// Get internal API key for provider. + /// + 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"] ?? "" + }; + } +} \ No newline at end of file diff --git a/Gateway/appsettings.Development.json b/Gateway/appsettings.Development.json new file mode 100644 index 0000000..89e875b --- /dev/null +++ b/Gateway/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + + "Auth": { + "AllowDevBypass": true + } +} diff --git a/Gateway/appsettings.json b/Gateway/appsettings.json new file mode 100644 index 0000000..c447d54 --- /dev/null +++ b/Gateway/appsettings.json @@ -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" + } + } +} diff --git a/Gateway/appsettings.multiprovider.json b/Gateway/appsettings.multiprovider.json new file mode 100644 index 0000000..b4c3c48 --- /dev/null +++ b/Gateway/appsettings.multiprovider.json @@ -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": "" + } + } +} diff --git a/GoogleApi/Configuration/GoogleAdsConfig.cs b/GoogleApi/Configuration/GoogleAdsConfig.cs new file mode 100644 index 0000000..6d59e9f --- /dev/null +++ b/GoogleApi/Configuration/GoogleAdsConfig.cs @@ -0,0 +1,87 @@ +namespace GoogleApi.Configuration; + +/// +/// Root configuration for Google Ads API integration. +/// Bind to the "GoogleAds" section in appsettings.json or environment variables. +/// +public sealed class GoogleAdsConfig +{ + public const string SectionName = "GoogleAds"; + + /// + /// Enable/disable real API calls. When false, the provider returns emulated responses. + /// + public bool EnableRealApi { get; set; } = false; + + /// + /// 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. + /// + public string ApiVersion { get; set; } = "v22"; + + /// + /// Developer token from your Google Ads manager account. + /// + public string DeveloperToken { get; set; } = string.Empty; + + /// + /// 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. + /// + public GoogleOAuthConfig OAuth { get; set; } = new(); + + /// + /// Default login customer ID (manager account / MCC) if not specified per request. + /// Format: 1234567890 (no dashes) + /// + public string? DefaultLoginCustomerId { get; set; } + + /// + /// Request timeout in seconds. + /// + public int TimeoutSeconds { get; set; } = 60; +} + +/// +/// OAuth configuration for Google Ads API. +/// This provider uses the "refresh token" (offline) flow for non-interactive server-to-server calls. +/// +public sealed class GoogleOAuthConfig +{ + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + + /// + /// Platform refresh token used to obtain access tokens without user interaction. + /// Store in Key Vault / secret store; inject via environment variables in prod. + /// + public string? RefreshToken { get; set; } +} + +/// +/// Per-request Google Ads context, populated from request and/or database. +/// +public sealed class GoogleAdsContext +{ + /// + /// Target Google Ads customer ID for this request. + /// Format: 1234567890 (no dashes) + /// + public required string CustomerId { get; set; } + + /// + /// Login customer ID (manager account / MCC). + /// Required when accessing client accounts under a manager account. + /// + public string? LoginCustomerId { get; set; } + + /// + /// Optional override refresh token for a specific account (if you ever store per-account tokens). + /// If null, the platform token from config is used. + /// + public string? RefreshToken { get; set; } +} diff --git a/GoogleApi/Controllers/InternalController.cs b/GoogleApi/Controllers/InternalController.cs new file mode 100644 index 0000000..1d2b570 --- /dev/null +++ b/GoogleApi/Controllers/InternalController.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using GoogleApi.Models; +using GoogleApi.Security; +using GoogleApi.Services; + +namespace GoogleApi.Controllers; + +/// +/// Internal API endpoint called by Gateway. +/// Protected by X-Internal-Key header validation. +/// +[ApiController] +[Route("internal")] +public sealed class InternalController : ControllerBase +{ + private readonly GoogleAdsService _googleAds; + private readonly ILogger _logger; + + public InternalController(GoogleAdsService googleAds, ILogger logger) + { + _googleAds = googleAds; + _logger = logger; + } + + /// + /// Health check - no auth required. + /// + [HttpGet("health")] + public IActionResult Health() + { + _logger.LogDebug("[InternalController] Health check"); + return Ok(new + { + ok = true, + service = "GoogleApi", + timestamp = DateTimeOffset.UtcNow + }); + } + + /// + /// Main execution endpoint - Gateway calls this. + /// Protected by InternalAuthFilter. + /// + [ServiceFilter(typeof(InternalAuthFilter))] + [HttpPost("execute")] + public async Task 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); + } + } +} diff --git a/GoogleApi/GOOGLE_ADS_SETUP.md b/GoogleApi/GOOGLE_ADS_SETUP.md new file mode 100644 index 0000000..e205b05 --- /dev/null +++ b/GoogleApi/GOOGLE_ADS_SETUP.md @@ -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 diff --git a/GoogleApi/GoogleApi.csproj b/GoogleApi/GoogleApi.csproj new file mode 100644 index 0000000..b19c8a3 --- /dev/null +++ b/GoogleApi/GoogleApi.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + True + mcr.microsoft.com/dotnet/aspnet:8.0 + googleapi + + + + + + + + + + + + diff --git a/GoogleApi/GoogleApi.http b/GoogleApi/GoogleApi.http new file mode 100644 index 0000000..45d3ab9 --- /dev/null +++ b/GoogleApi/GoogleApi.http @@ -0,0 +1,6 @@ +@GoogleApi_HostAddress = http://localhost:5023 + +GET {{GoogleApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/GoogleApi/Models/OperationPayloads.cs b/GoogleApi/Models/OperationPayloads.cs new file mode 100644 index 0000000..d1b230b --- /dev/null +++ b/GoogleApi/Models/OperationPayloads.cs @@ -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 diff --git a/GoogleApi/Models/ProviderModels.cs b/GoogleApi/Models/ProviderModels.cs new file mode 100644 index 0000000..a092388 --- /dev/null +++ b/GoogleApi/Models/ProviderModels.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GoogleApi.Models; + +/// +/// Request from Gateway to GoogleApi. +/// +public sealed class ProviderRequest +{ + /// + /// Operation to execute (e.g., "Ping", "CreateCampaign", "GetCampaignStats") + /// + public string Operation { get; set; } = string.Empty; + + /// + /// Tenant/customer ID - maps to Google Ads customer ID (the subaccount) + /// + public string? TenantId { get; set; } + + /// + /// 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. + /// + public string? LoginCustomerId { get; set; } + + /// + /// Correlation ID for request tracing + /// + public string? RequestId { get; set; } + + /// + /// Operation-specific payload + /// + public JsonElement? Payload { get; set; } + + /// + /// Deserialize payload to strongly-typed object + /// + public T GetPayload() where T : new() + { + if (Payload == null || Payload.Value.ValueKind == JsonValueKind.Null || Payload.Value.ValueKind == JsonValueKind.Undefined) + return new T(); + + try + { + return JsonSerializer.Deserialize(Payload.Value.GetRawText(), JsonOptions.Default) ?? new T(); + } + catch + { + return new T(); + } + } +} + +/// +/// Response from GoogleApi to Gateway. +/// +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 + }; +} diff --git a/GoogleApi/Program.cs b/GoogleApi/Program.cs new file mode 100644 index 0000000..66edc9a --- /dev/null +++ b/GoogleApi/Program.cs @@ -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( + builder.Configuration.GetSection(GoogleAdsConfig.SectionName)); + +// Log startup info +var googleConfig = builder.Configuration.GetSection(GoogleAdsConfig.SectionName).Get(); +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(); +builder.Services.AddSingleton(); + +// Auth filter for internal calls from Gateway +builder.Services.AddScoped(); + +// ============================================================ +// 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(); + + 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(); diff --git a/GoogleApi/Properties/launchSettings.json b/GoogleApi/Properties/launchSettings.json new file mode 100644 index 0000000..12579ae --- /dev/null +++ b/GoogleApi/Properties/launchSettings.json @@ -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 + } + } +} diff --git a/GoogleApi/README.md b/GoogleApi/README.md new file mode 100644 index 0000000..fd75d8d --- /dev/null +++ b/GoogleApi/README.md @@ -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: +``` + +### 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 diff --git a/GoogleApi/Security/InternalAuthFilter.cs b/GoogleApi/Security/InternalAuthFilter.cs new file mode 100644 index 0000000..3b095a2 --- /dev/null +++ b/GoogleApi/Security/InternalAuthFilter.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace GoogleApi.Security; + +/// +/// Validates the X-Internal-Key header for internal service-to-service calls. +/// Gateway must provide the correct key to call GoogleApi endpoints. +/// +public sealed class InternalAuthFilter : IAsyncActionFilter +{ + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public InternalAuthFilter(IConfiguration config, ILogger 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(); + } +} diff --git a/GoogleApi/Services/GoogleAdsClientFactory.cs b/GoogleApi/Services/GoogleAdsClientFactory.cs new file mode 100644 index 0000000..38a55f1 --- /dev/null +++ b/GoogleApi/Services/GoogleAdsClientFactory.cs @@ -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 _logger; + + public GoogleAdsClientFactory( + IOptions config, + ILogger 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..]}"; + } +} diff --git a/GoogleApi/Services/GoogleAdsService.cs b/GoogleApi/Services/GoogleAdsService.cs new file mode 100644 index 0000000..37d7924 --- /dev/null +++ b/GoogleApi/Services/GoogleAdsService.cs @@ -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 _logger; + + public GoogleAdsService( + IOptions config, + GoogleAdsClientFactory clientFactory, + ILogger logger) + { + _config = config.Value; + _clientFactory = clientFactory; + _logger = logger; + } + + public async Task 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 CreateCampaignAsync( + ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + 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 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 GetCampaignAsync( + ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + 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 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 UpdateCampaignAsync( + ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct) + { + var payload = request.GetPayload(); + + 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 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 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(); + + 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(); + + 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(); + + _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 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(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 + }); + } +} diff --git a/GoogleApi/appsettings.Development.json b/GoogleApi/appsettings.Development.json new file mode 100644 index 0000000..7a8d7af --- /dev/null +++ b/GoogleApi/appsettings.Development.json @@ -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": "" + } + } +} \ No newline at end of file diff --git a/GoogleApi/appsettings.json b/GoogleApi/appsettings.json new file mode 100644 index 0000000..c5a5f52 --- /dev/null +++ b/GoogleApi/appsettings.json @@ -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": "" + } + } +} \ No newline at end of file diff --git a/Management/Controllers/Admin/AdminClientsController.cs b/Management/Controllers/Admin/AdminClientsController.cs new file mode 100644 index 0000000..96e6cd3 --- /dev/null +++ b/Management/Controllers/Admin/AdminClientsController.cs @@ -0,0 +1,92 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; + +namespace Management.Controllers.Admin; + +/// +/// 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 +/// +[ApiController] +[Route("api/admin/clients")] +public sealed class AdminClientsController : AdminControllerBase +{ + public AdminClientsController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + /// + /// List all clients with optional filtering. + /// + [HttpGet] + public Task List( + [FromQuery] string? status, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + CancellationToken ct = default) + => CallProc("spAdminClients", "list", new { status, page, pageSize }, ct); + + /// + /// Get client by ID. + /// + [HttpGet("{clientId}")] + public Task Get(string clientId, CancellationToken ct) + => CallProc("spAdminClients", "get", new { clientId }, ct); + + /// + /// Create a new client. + /// + [HttpPost] + public Task 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); + } + + /// + /// Update client. + /// + [HttpPut("{clientId}")] + public Task 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); + } + + /// + /// Deactivate client (soft delete). + /// + [HttpDelete("{clientId}")] + public Task 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; } +} diff --git a/Management/Controllers/Admin/AdminControllerBase.cs b/Management/Controllers/Admin/AdminControllerBase.cs new file mode 100644 index 0000000..35eba2b --- /dev/null +++ b/Management/Controllers/Admin/AdminControllerBase.cs @@ -0,0 +1,58 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers.Admin; + +/// +/// Base class for admin controllers with shared functionality. +/// +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; + } + + /// + /// Execute stored procedure and return appropriate IActionResult. + /// + protected async Task 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 }); + } + } + + /// + /// Return BadRequest for validation failures. + /// + protected IActionResult ValidationError(string error) + => BadRequest(new { ok = false, error }); +} diff --git a/Management/Controllers/Admin/AdminSessionsController.cs b/Management/Controllers/Admin/AdminSessionsController.cs new file mode 100644 index 0000000..f600bfd --- /dev/null +++ b/Management/Controllers/Admin/AdminSessionsController.cs @@ -0,0 +1,65 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; + +namespace Management.Controllers.Admin; + +/// +/// 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 +/// +[ApiController] +[Route("api/admin/sessions")] +public sealed class AdminSessionsController : AdminControllerBase +{ + public AdminSessionsController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + /// + /// List sessions with optional filtering. + /// + [HttpGet] + public Task 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); + + /// + /// Revoke a session. + /// + [HttpPost("{sessionId}/revoke")] + public Task Revoke(string sessionId, CancellationToken ct) + { + Logger.LogWarning("[Admin] RevokeSession | SessionId={SessionId} | By={User}", sessionId, Client.Email); + return CallProc("spAdminSessions", "revoke", new { sessionId }, ct); + } + + /// + /// Revoke all sessions for a user. + /// + [HttpPost("~/api/admin/users/{userId}/revoke-sessions")] + public Task RevokeAllForUser(string userId, CancellationToken ct) + { + Logger.LogWarning("[Admin] RevokeAllSessions | UserId={UserId} | By={User}", userId, Client.Email); + return CallProc("spAdminSessions", "revokeAllForUser", new { userId }, ct); + } + + /// + /// Cleanup expired sessions. + /// + [HttpPost("cleanup")] + public Task 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); + } +} diff --git a/Management/Controllers/Admin/AdminUsersController.cs b/Management/Controllers/Admin/AdminUsersController.cs new file mode 100644 index 0000000..728cf7f --- /dev/null +++ b/Management/Controllers/Admin/AdminUsersController.cs @@ -0,0 +1,140 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; + +namespace Management.Controllers.Admin; + +/// +/// 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 +/// +[ApiController] +[Route("api/admin/users")] +public sealed class AdminUsersController : AdminControllerBase +{ + public AdminUsersController(SqlService sql, ClientContext client, ILogger log) + : base(sql, client, log) { } + + /// + /// List users with optional filtering. + /// + [HttpGet] + public Task 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); + + /// + /// Get user by ID. + /// + [HttpGet("{userId}")] + public Task Get(string userId, CancellationToken ct) + => CallProc("spAdminUsers", "get", new { userId }, ct); + + /// + /// Create a new user. + /// + [HttpPost] + public Task 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); + } + + /// + /// Update user. + /// + [HttpPut("{userId}")] + public Task 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); + } + + /// + /// Deactivate user (soft delete). + /// + [HttpDelete("{userId}")] + public Task Delete(string userId, CancellationToken ct) + { + Logger.LogWarning("[Admin] DeleteUser | Id={Id} | By={User}", userId, Client.Email); + return CallProc("spAdminUsers", "delete", new { userId }, ct); + } + + /// + /// Link user to client with role. + /// + [HttpPost("{userId}/clients")] + public Task 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); + } + + /// + /// Unlink user from client. + /// + [HttpDelete("{userId}/clients/{clientId}")] + public Task 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; } +} diff --git a/Management/Controllers/MonitoringController.cs b/Management/Controllers/MonitoringController.cs new file mode 100644 index 0000000..80f474a --- /dev/null +++ b/Management/Controllers/MonitoringController.cs @@ -0,0 +1,76 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers; + +/// +/// Monitoring endpoints for system health and stats. +/// Requires Admin session. +/// +/// ENDPOINTS: +/// GET /api/monitoring/health - System health overview +/// GET /api/monitoring/stats - Detailed statistics +/// +[ApiController] +[Route("api/monitoring")] +public sealed class MonitoringController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly ILogger _log; + + public MonitoringController(SqlService sql, ClientContext client, ILogger log) + { + _sql = sql; + _client = client; + _log = log; + } + + /// + /// System health overview. + /// + [HttpGet("health")] + public async Task 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 }); + } + } + + /// + /// Detailed system statistics. + /// + [HttpGet("stats")] + public async Task 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 }); + } + } +} diff --git a/Management/Controllers/OnboardingController.cs b/Management/Controllers/OnboardingController.cs new file mode 100644 index 0000000..6f799d9 --- /dev/null +++ b/Management/Controllers/OnboardingController.cs @@ -0,0 +1,114 @@ +using Management.Data; +using Management.Security; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace Management.Controllers; + +/// +/// 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 +/// +[ApiController] +[Route("api/onboarding")] +public sealed class OnboardingController : ControllerBase +{ + private readonly SqlService _sql; + private readonly ClientContext _client; + private readonly ILogger _log; + + public OnboardingController(SqlService sql, ClientContext client, ILogger log) + { + _sql = sql; + _client = client; + _log = log; + } + + /// + /// Check registration status for authenticated user. + /// + [HttpGet("status")] + public async Task 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 }); + } + } + + /// + /// Register a new organization. + /// + [HttpPost("register")] + public async Task 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; } +} diff --git a/Management/Controllers/TestController.cs b/Management/Controllers/TestController.cs new file mode 100644 index 0000000..4427454 --- /dev/null +++ b/Management/Controllers/TestController.cs @@ -0,0 +1,37 @@ +using Management.Data; +using Microsoft.AspNetCore.Mvc; + +namespace Management.Controllers; + +/// +/// Test endpoints (anonymous, no auth required). +/// +[ApiController] +[Route("api/test")] +public class TestController : ControllerBase +{ + private readonly SqlService _sql; + + public TestController(SqlService sql) + { + _sql = sql; + } + + /// + /// Database connectivity test. + /// + [HttpGet("ping")] + public async Task 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 }); + } + } +} diff --git a/Management/Controllers/WeatherForecastController.cs b/Management/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..02a5169 --- /dev/null +++ b/Management/Controllers/WeatherForecastController.cs @@ -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 _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable 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(); + } + } +} diff --git a/Management/Data/SqlService.cs b/Management/Data/SqlService.cs new file mode 100644 index 0000000..fa6ecfc --- /dev/null +++ b/Management/Data/SqlService.cs @@ -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 _logger; + + public SqlService(IConfiguration config, ILogger 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; + } + + /// + /// Execute stored procedure with standard signature: + /// @action varchar, @rqst nvarchar(max), @resp nvarchar(max) OUTPUT + /// + public async Task 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; + } + } +} diff --git a/Management/Management.csproj b/Management/Management.csproj new file mode 100644 index 0000000..cdcf535 --- /dev/null +++ b/Management/Management.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + mcr.microsoft.com/dotnet/aspnet:8.0 + management + + + + + + + + + + + + + + + diff --git a/Management/Management.http b/Management/Management.http new file mode 100644 index 0000000..d9faf6c --- /dev/null +++ b/Management/Management.http @@ -0,0 +1,6 @@ +@Management_HostAddress = http://localhost:5290 + +GET {{Management_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Management/Program.cs b/Management/Program.cs new file mode 100644 index 0000000..bedbb67 --- /dev/null +++ b/Management/Program.cs @@ -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(); +builder.Services.AddScoped(); +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(); + +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/Management/Properties/launchSettings.json b/Management/Properties/launchSettings.json new file mode 100644 index 0000000..a56b404 --- /dev/null +++ b/Management/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/Management/README.md b/Management/README.md new file mode 100644 index 0000000..11dca52 --- /dev/null +++ b/Management/README.md @@ -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) diff --git a/Management/SQL/spAdminClients.sql b/Management/SQL/spAdminClients.sql new file mode 100644 index 0000000..96d9f87 --- /dev/null +++ b/Management/SQL/spAdminClients.sql @@ -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 diff --git a/Management/SQL/spAdminSessions.sql b/Management/SQL/spAdminSessions.sql new file mode 100644 index 0000000..2ace4e4 --- /dev/null +++ b/Management/SQL/spAdminSessions.sql @@ -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 diff --git a/Management/SQL/spAdminUsers.sql b/Management/SQL/spAdminUsers.sql new file mode 100644 index 0000000..3a2bebb --- /dev/null +++ b/Management/SQL/spAdminUsers.sql @@ -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 diff --git a/Management/SQL/spMonitoring.sql b/Management/SQL/spMonitoring.sql new file mode 100644 index 0000000..27f174b --- /dev/null +++ b/Management/SQL/spMonitoring.sql @@ -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 diff --git a/Management/SQL/spOnboarding.sql b/Management/SQL/spOnboarding.sql new file mode 100644 index 0000000..0faef06 --- /dev/null +++ b/Management/SQL/spOnboarding.sql @@ -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 diff --git a/Management/Security/ClientAuthMiddleware.cs b/Management/Security/ClientAuthMiddleware.cs new file mode 100644 index 0000000..42b9f87 --- /dev/null +++ b/Management/Security/ClientAuthMiddleware.cs @@ -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; + +/// +/// 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 +/// +public sealed class ClientAuthMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IConfiguration _config; + + private static readonly HashSet _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? _oidcConfigManager; + private static readonly object _oidcLock = new(); + + public ClientAuthMiddleware(RequestDelegate next, ILogger 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("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 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 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 GetOrCreateConfigManager(string metadataAddress) + { + lock (_oidcLock) + { + _oidcConfigManager ??= new ConfigurationManager( + metadataAddress, new OpenIdConnectConfigurationRetriever()); + return _oidcConfigManager; + } + } +} diff --git a/Management/Security/ClientContext.cs b/Management/Security/ClientContext.cs new file mode 100644 index 0000000..5efa023 --- /dev/null +++ b/Management/Security/ClientContext.cs @@ -0,0 +1,20 @@ +namespace Management.Security; + +/// +/// Request-scoped authentication context. +/// Populated by ClientAuthMiddleware. +/// +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); +} diff --git a/Management/WeatherForecast.cs b/Management/WeatherForecast.cs new file mode 100644 index 0000000..dc323e3 --- /dev/null +++ b/Management/WeatherForecast.cs @@ -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; } + } +} diff --git a/Management/appsettings.Development.json b/Management/appsettings.Development.json new file mode 100644 index 0000000..956f61d --- /dev/null +++ b/Management/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Warning" + } + }, + "Auth": { + "AllowDevBypass": true + } +} diff --git a/Management/appsettings.json b/Management/appsettings.json new file mode 100644 index 0000000..bec8785 --- /dev/null +++ b/Management/appsettings.json @@ -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" + } + } +}