Add project files.
This commit is contained in:
92
Management/Controllers/Admin/AdminClientsController.cs
Normal file
92
Management/Controllers/Admin/AdminClientsController.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for client (organization) management.
|
||||
/// Requires Admin role.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/clients - List clients
|
||||
/// GET /api/admin/clients/{id} - Get client
|
||||
/// POST /api/admin/clients - Create client
|
||||
/// PUT /api/admin/clients/{id} - Update client
|
||||
/// DELETE /api/admin/clients/{id} - Deactivate client
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/clients")]
|
||||
public sealed class AdminClientsController : AdminControllerBase
|
||||
{
|
||||
public AdminClientsController(SqlService sql, ClientContext client, ILogger<AdminClientsController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
/// <summary>
|
||||
/// List all clients with optional filtering.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken ct = default)
|
||||
=> CallProc("spAdminClients", "list", new { status, page, pageSize }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get client by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{clientId}")]
|
||||
public Task<IActionResult> Get(string clientId, CancellationToken ct)
|
||||
=> CallProc("spAdminClients", "get", new { clientId }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new client.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public Task<IActionResult> Create([FromBody] CreateClientRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.ClientName))
|
||||
return Task.FromResult(ValidationError("clientName is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] CreateClient | Name={Name} | By={User}", request.ClientName, Client.Email);
|
||||
return CallProc("spAdminClients", "create", new { clientName = request.ClientName.Trim() }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update client.
|
||||
/// </summary>
|
||||
[HttpPut("{clientId}")]
|
||||
public Task<IActionResult> Update(string clientId, [FromBody] UpdateClientRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateClient | Id={Id} | By={User}", clientId, Client.Email);
|
||||
return CallProc("spAdminClients", "update", new
|
||||
{
|
||||
clientId,
|
||||
clientName = request?.ClientName?.Trim(),
|
||||
status = request?.Status
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate client (soft delete).
|
||||
/// </summary>
|
||||
[HttpDelete("{clientId}")]
|
||||
public Task<IActionResult> Delete(string clientId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteClient | Id={Id} | By={User}", clientId, Client.Email);
|
||||
return CallProc("spAdminClients", "delete", new { clientId }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed class CreateClientRequest
|
||||
{
|
||||
public string? ClientName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateClientRequest
|
||||
{
|
||||
public string? ClientName { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
58
Management/Controllers/Admin/AdminControllerBase.cs
Normal file
58
Management/Controllers/Admin/AdminControllerBase.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for admin controllers with shared functionality.
|
||||
/// </summary>
|
||||
public abstract class AdminControllerBase : ControllerBase
|
||||
{
|
||||
protected readonly SqlService Sql;
|
||||
protected readonly ClientContext Client;
|
||||
protected readonly ILogger Logger;
|
||||
|
||||
protected AdminControllerBase(SqlService sql, ClientContext client, ILogger logger)
|
||||
{
|
||||
Sql = sql;
|
||||
Client = client;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute stored procedure and return appropriate IActionResult.
|
||||
/// </summary>
|
||||
protected async Task<IActionResult> CallProc(string proc, string action, object rqst, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(rqst);
|
||||
var resp = await Sql.ExecProcAsync($"dbo.{proc}", action, json, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
return Content(resp, "application/json");
|
||||
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Operation failed";
|
||||
return BadRequest(new { ok = false, error });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[Admin] {Proc}.{Action} error", proc, action);
|
||||
return StatusCode(500, new { ok = false, error = "Operation failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return BadRequest for validation failures.
|
||||
/// </summary>
|
||||
protected IActionResult ValidationError(string error)
|
||||
=> BadRequest(new { ok = false, error });
|
||||
}
|
||||
65
Management/Controllers/Admin/AdminSessionsController.cs
Normal file
65
Management/Controllers/Admin/AdminSessionsController.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for session management.
|
||||
/// Requires Admin role.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/sessions - List sessions
|
||||
/// POST /api/admin/sessions/{id}/revoke - Revoke session
|
||||
/// POST /api/admin/users/{id}/revoke-sessions - Revoke all user sessions
|
||||
/// POST /api/admin/sessions/cleanup - Cleanup expired sessions
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/sessions")]
|
||||
public sealed class AdminSessionsController : AdminControllerBase
|
||||
{
|
||||
public AdminSessionsController(SqlService sql, ClientContext client, ILogger<AdminSessionsController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
/// <summary>
|
||||
/// List sessions with optional filtering.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public Task<IActionResult> List(
|
||||
[FromQuery] string? clientId,
|
||||
[FromQuery] string? userId,
|
||||
[FromQuery] bool activeOnly = true,
|
||||
[FromQuery] int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
=> CallProc("spAdminSessions", "list", new { clientId, userId, activeOnly, limit }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a session.
|
||||
/// </summary>
|
||||
[HttpPost("{sessionId}/revoke")]
|
||||
public Task<IActionResult> Revoke(string sessionId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] RevokeSession | SessionId={SessionId} | By={User}", sessionId, Client.Email);
|
||||
return CallProc("spAdminSessions", "revoke", new { sessionId }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke all sessions for a user.
|
||||
/// </summary>
|
||||
[HttpPost("~/api/admin/users/{userId}/revoke-sessions")]
|
||||
public Task<IActionResult> RevokeAllForUser(string userId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] RevokeAllSessions | UserId={UserId} | By={User}", userId, Client.Email);
|
||||
return CallProc("spAdminSessions", "revokeAllForUser", new { userId }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup expired sessions.
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public Task<IActionResult> Cleanup([FromQuery] int daysOld = 30, CancellationToken ct = default)
|
||||
{
|
||||
Logger.LogWarning("[Admin] CleanupSessions | DaysOld={DaysOld} | By={User}", daysOld, Client.Email);
|
||||
return CallProc("spAdminSessions", "cleanup", new { daysOld }, ct);
|
||||
}
|
||||
}
|
||||
140
Management/Controllers/Admin/AdminUsersController.cs
Normal file
140
Management/Controllers/Admin/AdminUsersController.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for user management.
|
||||
/// Requires Admin role.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/admin/users - List users
|
||||
/// GET /api/admin/users/{id} - Get user
|
||||
/// POST /api/admin/users - Create user
|
||||
/// PUT /api/admin/users/{id} - Update user
|
||||
/// DELETE /api/admin/users/{id} - Deactivate user
|
||||
/// POST /api/admin/users/{id}/clients - Link user to client
|
||||
/// DELETE /api/admin/users/{id}/clients/{cltId} - Unlink user from client
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin/users")]
|
||||
public sealed class AdminUsersController : AdminControllerBase
|
||||
{
|
||||
public AdminUsersController(SqlService sql, ClientContext client, ILogger<AdminUsersController> log)
|
||||
: base(sql, client, log) { }
|
||||
|
||||
/// <summary>
|
||||
/// List users with optional filtering.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public Task<IActionResult> List(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? clientId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken ct = default)
|
||||
=> CallProc("spAdminUsers", "list", new { status, clientId, page, pageSize }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get user by ID.
|
||||
/// </summary>
|
||||
[HttpGet("{userId}")]
|
||||
public Task<IActionResult> Get(string userId, CancellationToken ct)
|
||||
=> CallProc("spAdminUsers", "get", new { userId }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new user.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public Task<IActionResult> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Email))
|
||||
return Task.FromResult(ValidationError("email is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] CreateUser | Email={Email} | By={User}", request.Email, Client.Email);
|
||||
return CallProc("spAdminUsers", "create", new
|
||||
{
|
||||
email = request.Email.Trim(),
|
||||
displayName = request.DisplayName?.Trim(),
|
||||
clientId = request.ClientId,
|
||||
role = request.Role ?? "User"
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update user.
|
||||
/// </summary>
|
||||
[HttpPut("{userId}")]
|
||||
public Task<IActionResult> Update(string userId, [FromBody] UpdateUserRequest request, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UpdateUser | Id={Id} | By={User}", userId, Client.Email);
|
||||
return CallProc("spAdminUsers", "update", new
|
||||
{
|
||||
userId,
|
||||
displayName = request?.DisplayName?.Trim(),
|
||||
status = request?.Status
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate user (soft delete).
|
||||
/// </summary>
|
||||
[HttpDelete("{userId}")]
|
||||
public Task<IActionResult> Delete(string userId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] DeleteUser | Id={Id} | By={User}", userId, Client.Email);
|
||||
return CallProc("spAdminUsers", "delete", new { userId }, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link user to client with role.
|
||||
/// </summary>
|
||||
[HttpPost("{userId}/clients")]
|
||||
public Task<IActionResult> LinkToClient(string userId, [FromBody] LinkUserRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.ClientId))
|
||||
return Task.FromResult(ValidationError("clientId is required"));
|
||||
|
||||
Logger.LogWarning("[Admin] LinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
|
||||
userId, request.ClientId, Client.Email);
|
||||
return CallProc("spAdminUsers", "linkToClient", new
|
||||
{
|
||||
userId,
|
||||
clientId = request.ClientId,
|
||||
role = request.Role ?? "User"
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlink user from client.
|
||||
/// </summary>
|
||||
[HttpDelete("{userId}/clients/{clientId}")]
|
||||
public Task<IActionResult> UnlinkFromClient(string userId, string clientId, CancellationToken ct)
|
||||
{
|
||||
Logger.LogWarning("[Admin] UnlinkUser | UserId={UserId} ClientId={ClientId} | By={User}",
|
||||
userId, clientId, Client.Email);
|
||||
return CallProc("spAdminUsers", "unlinkFromClient", new { userId, clientId }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed class CreateUserRequest
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserRequest
|
||||
{
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LinkUserRequest
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
76
Management/Controllers/MonitoringController.cs
Normal file
76
Management/Controllers/MonitoringController.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Monitoring endpoints for system health and stats.
|
||||
/// Requires Admin session.
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/monitoring/health - System health overview
|
||||
/// GET /api/monitoring/stats - Detailed statistics
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/monitoring")]
|
||||
public sealed class MonitoringController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<MonitoringController> _log;
|
||||
|
||||
public MonitoringController(SqlService sql, ClientContext client, ILogger<MonitoringController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System health overview.
|
||||
/// </summary>
|
||||
[HttpGet("health")]
|
||||
public async Task<IActionResult> Health(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spMonitoring", "health", "{}", ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Monitoring] Health error");
|
||||
return StatusCode(500, new { ok = false, error = "Health check failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed system statistics.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> Stats([FromQuery] int hours = 24, CancellationToken ct = default)
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { hours });
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spMonitoring", "stats", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Monitoring] Stats error");
|
||||
return StatusCode(500, new { ok = false, error = "Stats failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
114
Management/Controllers/OnboardingController.cs
Normal file
114
Management/Controllers/OnboardingController.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Onboarding endpoints for new user/client registration.
|
||||
/// Requires JWT authentication (user may not have session yet).
|
||||
///
|
||||
/// ENDPOINTS:
|
||||
/// GET /api/onboarding/status - Check registration status
|
||||
/// POST /api/onboarding/register - Register new organization
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/onboarding")]
|
||||
public sealed class OnboardingController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<OnboardingController> _log;
|
||||
|
||||
public OnboardingController(SqlService sql, ClientContext client, ILogger<OnboardingController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check registration status for authenticated user.
|
||||
/// </summary>
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status(CancellationToken ct)
|
||||
{
|
||||
if (!_client.IsAuthenticated)
|
||||
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "status", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Service unavailable" });
|
||||
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Onboarding] Status error");
|
||||
return StatusCode(500, new { ok = false, error = "Status check failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a new organization.
|
||||
/// </summary>
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!_client.IsAuthenticated)
|
||||
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request?.ClientName))
|
||||
return BadRequest(new { ok = false, error = "clientName is required" });
|
||||
|
||||
_log.LogWarning("[Onboarding] Register | Subject={Subject} ClientName={ClientName}",
|
||||
_client.ClientId, request.ClientName);
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email,
|
||||
displayName = _client.ClientName,
|
||||
clientName = request.ClientName.Trim()
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "register", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return StatusCode(500, new { ok = false, error = "Registration service unavailable" });
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
return Content(resp, "application/json");
|
||||
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Registration failed";
|
||||
return BadRequest(new { ok = false, error });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Onboarding] Register error");
|
||||
return StatusCode(500, new { ok = false, error = "Registration failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
public string? ClientName { get; set; }
|
||||
}
|
||||
37
Management/Controllers/TestController.cs
Normal file
37
Management/Controllers/TestController.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Management.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Test endpoints (anonymous, no auth required).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/test")]
|
||||
public class TestController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
|
||||
public TestController(SqlService sql)
|
||||
{
|
||||
_sql = sql;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Database connectivity test.
|
||||
/// </summary>
|
||||
[HttpGet("ping")]
|
||||
public async Task<IActionResult> Ping(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spTemplate", "ping",
|
||||
"""{ "clientId":"00000000-0000-0000-0000-000000000001" }""", ct: ct);
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { ok = false, error = "Database connection failed", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Management/Controllers/WeatherForecastController.cs
Normal file
33
Management/Controllers/WeatherForecastController.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Management.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class WeatherForecastController : ControllerBase
|
||||
{
|
||||
private static readonly string[] Summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
private readonly ILogger<WeatherForecastController> _logger;
|
||||
|
||||
public WeatherForecastController(ILogger<WeatherForecastController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet(Name = "GetWeatherForecast")]
|
||||
public IEnumerable<WeatherForecast> Get()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Management/Data/SqlService.cs
Normal file
82
Management/Data/SqlService.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Management.Data;
|
||||
|
||||
public class SqlService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<SqlService> _logger;
|
||||
|
||||
public SqlService(IConfiguration config, ILogger<SqlService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetConnectionString()
|
||||
{
|
||||
var cs = _config.GetConnectionString("Sql");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
throw new InvalidOperationException("Missing ConnectionStrings:Sql");
|
||||
return cs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute stored procedure with standard signature:
|
||||
/// @action varchar, @rqst nvarchar(max), @resp nvarchar(max) OUTPUT
|
||||
/// </summary>
|
||||
public async Task<string> ExecProcAsync(
|
||||
string procName,
|
||||
string action,
|
||||
string rqstJson,
|
||||
int commandTimeoutSeconds = 60,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(procName))
|
||||
throw new ArgumentException("procName is required.", nameof(procName));
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
throw new ArgumentException("action is required.", nameof(action));
|
||||
if (string.IsNullOrWhiteSpace(rqstJson))
|
||||
rqstJson = "{}";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new SqlConnection(GetConnectionString());
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand(procName, conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = commandTimeoutSeconds
|
||||
};
|
||||
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = action });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqstJson });
|
||||
|
||||
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
cmd.Parameters.Add(pResp);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
var resp = pResp.Value as string ?? "";
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation("SQL ok: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
|
||||
|
||||
return resp;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogError(ex, "SQL error: {Proc}.{Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Management/Management.csproj
Normal file
23
Management/Management.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
|
||||
<ContainerRepository>management</ContainerRepository>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ContainerPort Include="8080" Type="tcp" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
Management/Management.http
Normal file
6
Management/Management.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Management_HostAddress = http://localhost:5290
|
||||
|
||||
GET {{Management_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
61
Management/Program.cs
Normal file
61
Management/Program.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Container-friendly HTTP binding
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// Services
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "AdPlatform Management API", Version = "v1" });
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<SqlService>();
|
||||
builder.Services.AddScoped<ClientContext>();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Middleware pipeline
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Health check (before auth)
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "Management",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
// Root endpoint
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "AdPlatform Management API",
|
||||
version = "1.0.0",
|
||||
status = "running",
|
||||
endpoints = new
|
||||
{
|
||||
onboarding = new[] { "GET /api/onboarding/status", "POST /api/onboarding/register" },
|
||||
monitoring = new[] { "GET /api/monitoring/health", "GET /api/monitoring/stats" },
|
||||
admin = new
|
||||
{
|
||||
clients = new[] { "GET/POST /api/admin/clients", "GET/PUT/DELETE /api/admin/clients/{id}" },
|
||||
users = new[] { "GET/POST /api/admin/users", "GET/PUT/DELETE /api/admin/users/{id}" },
|
||||
sessions = new[] { "GET /api/admin/sessions", "POST /api/admin/sessions/{id}/revoke" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Authentication middleware
|
||||
app.UseMiddleware<ClientAuthMiddleware>();
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
15
Management/Properties/launchSettings.json
Normal file
15
Management/Properties/launchSettings.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5100",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Management/README.md
Normal file
90
Management/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# AdPlatform Management API
|
||||
|
||||
.NET 8 API for platform administration: onboarding, user/client management, and monitoring.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Management/
|
||||
├── Controllers/
|
||||
│ ├── Admin/
|
||||
│ │ ├── AdminControllerBase.cs # Shared base class
|
||||
│ │ ├── AdminClientsController.cs # /api/admin/clients
|
||||
│ │ ├── AdminUsersController.cs # /api/admin/users
|
||||
│ │ └── AdminSessionsController.cs# /api/admin/sessions
|
||||
│ ├── OnboardingController.cs # /api/onboarding
|
||||
│ ├── MonitoringController.cs # /api/monitoring
|
||||
│ └── TestController.cs # /api/test
|
||||
├── Data/
|
||||
│ └── SqlService.cs # Database access
|
||||
├── Security/
|
||||
│ ├── ClientContext.cs # Request auth context
|
||||
│ └── ClientAuthMiddleware.cs # Auth middleware
|
||||
├── SQL/
|
||||
│ ├── spAdminClients.sql
|
||||
│ ├── spAdminUsers.sql
|
||||
│ ├── spAdminSessions.sql
|
||||
│ ├── spOnboarding.sql
|
||||
│ └── spMonitoring.sql
|
||||
└── Program.cs
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Onboarding (JWT auth)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | /api/onboarding/status | Check registration status |
|
||||
| POST | /api/onboarding/register | Register new organization |
|
||||
|
||||
### Admin - Clients (Session + Admin role)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | /api/admin/clients | List clients |
|
||||
| GET | /api/admin/clients/{id} | Get client |
|
||||
| POST | /api/admin/clients | Create client |
|
||||
| PUT | /api/admin/clients/{id} | Update client |
|
||||
| DELETE | /api/admin/clients/{id} | Deactivate client |
|
||||
|
||||
### Admin - Users (Session + Admin role)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | /api/admin/users | List users |
|
||||
| GET | /api/admin/users/{id} | Get user |
|
||||
| POST | /api/admin/users | Create user |
|
||||
| PUT | /api/admin/users/{id} | Update user |
|
||||
| DELETE | /api/admin/users/{id} | Deactivate user |
|
||||
| POST | /api/admin/users/{id}/clients | Link user to client |
|
||||
| DELETE | /api/admin/users/{id}/clients/{cid} | Unlink user |
|
||||
|
||||
### Admin - Sessions (Session + Admin role)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | /api/admin/sessions | List sessions |
|
||||
| POST | /api/admin/sessions/{id}/revoke | Revoke session |
|
||||
| POST | /api/admin/users/{id}/revoke-sessions | Revoke all user sessions |
|
||||
| POST | /api/admin/sessions/cleanup | Cleanup expired |
|
||||
|
||||
### Monitoring (Session + Admin role)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | /api/monitoring/health | System health |
|
||||
| GET | /api/monitoring/stats | Detailed stats |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Run SQL scripts in `SQL/` folder against dbAdPlatform
|
||||
2. Deploy to Azure Container Apps
|
||||
3. Set environment variables:
|
||||
- `ConnectionStrings__Sql`
|
||||
- `Auth__EntraId__TenantId`
|
||||
- `Auth__EntraId__ClientId`
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
# Open http://localhost:5100/swagger
|
||||
```
|
||||
|
||||
Dev bypass: Add `X-Dev-ClientId: test` header (Development environment only)
|
||||
181
Management/SQL/spAdminClients.sql
Normal file
181
Management/SQL/spAdminClients.sql
Normal file
@@ -0,0 +1,181 @@
|
||||
-- ============================================================
|
||||
-- spAdminClients: Client (organization) management
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spAdminClients]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: create
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'create'
|
||||
BEGIN
|
||||
DECLARE @cName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
|
||||
|
||||
IF @cName IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientName is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltName = @cName)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client name already exists"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
DECLARE @cId UNIQUEIDENTIFIER = NEWID();
|
||||
INSERT INTO dbo.tbClient (cltId, cltName, cltStatus)
|
||||
VALUES (@cId, @cName, 'Active');
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@cId AS clientId,
|
||||
@cName AS clientName,
|
||||
'Active' AS status
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: get
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'get'
|
||||
BEGIN
|
||||
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
|
||||
IF @gId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @gId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
c.cltStatus AS status,
|
||||
c.cltCreatedUtc AS createdAt,
|
||||
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount,
|
||||
(SELECT COUNT(*) FROM dbo.tbAdAccount WHERE accCltId = c.cltId) AS accountCount
|
||||
FROM dbo.tbClient c WHERE c.cltId = @gId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: list
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
|
||||
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
|
||||
|
||||
DECLARE @clients NVARCHAR(MAX);
|
||||
SELECT @clients = (
|
||||
SELECT
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
c.cltStatus AS status,
|
||||
c.cltCreatedUtc AS createdAt,
|
||||
(SELECT COUNT(*) FROM dbo.tbUserClientRole WHERE ucrCltId = c.cltId) AS userCount
|
||||
FROM dbo.tbClient c
|
||||
WHERE @lStatus IS NULL OR c.cltStatus = @lStatus
|
||||
ORDER BY c.cltName
|
||||
OFFSET (@lPage - 1) * @lPageSize ROWS
|
||||
FETCH NEXT @lPageSize ROWS ONLY
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
DECLARE @lTotal INT;
|
||||
SELECT @lTotal = COUNT(*) FROM dbo.tbClient WHERE @lStatus IS NULL OR cltStatus = @lStatus;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@clients, '[]')) AS clients,
|
||||
@lTotal AS totalCount,
|
||||
@lPage AS page,
|
||||
@lPageSize AS pageSize
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: update
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'update'
|
||||
BEGIN
|
||||
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @uName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
|
||||
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
|
||||
IF @uId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @uId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbClient
|
||||
SET cltName = ISNULL(@uName, cltName),
|
||||
cltStatus = ISNULL(@uStatus, cltStatus)
|
||||
WHERE cltId = @uId;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
cltId AS clientId,
|
||||
cltName AS clientName,
|
||||
cltStatus AS status
|
||||
FROM dbo.tbClient WHERE cltId = @uId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: delete (soft delete)
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
|
||||
IF @dId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbClient SET cltStatus = 'Inactive' WHERE cltId = @dId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
111
Management/SQL/spAdminSessions.sql
Normal file
111
Management/SQL/spAdminSessions.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- ============================================================
|
||||
-- spAdminSessions: Session management
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spAdminSessions]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: list
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @lUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @lActiveOnly BIT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.activeOnly') AS BIT), 1);
|
||||
DECLARE @lLimit INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.limit') AS INT), 100);
|
||||
|
||||
DECLARE @sessions NVARCHAR(MAX);
|
||||
SELECT @sessions = (
|
||||
SELECT TOP (@lLimit)
|
||||
s.sesId AS sessionId,
|
||||
u.usrId AS userId,
|
||||
u.usrEmail AS userEmail,
|
||||
u.usrDisplayName AS displayName,
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
s.sesCreatedUtc AS createdAt,
|
||||
s.sesExpiresUtc AS expiresAt,
|
||||
s.sesLastActivityUtc AS lastActivity,
|
||||
s.sesIpAddress AS ipAddress,
|
||||
s.sesIsRevoked AS isRevoked
|
||||
FROM dbo.tbSession s
|
||||
JOIN dbo.tbUser u ON u.usrId = s.sesUsrId
|
||||
JOIN dbo.tbClient c ON c.cltId = s.sesCltId
|
||||
WHERE (@lClientId IS NULL OR c.cltId = @lClientId)
|
||||
AND (@lUserId IS NULL OR u.usrId = @lUserId)
|
||||
AND (@lActiveOnly = 0 OR (s.sesIsRevoked = 0 AND s.sesExpiresUtc > SYSUTCDATETIME()))
|
||||
ORDER BY s.sesLastActivityUtc DESC
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@sessions, '[]')) AS sessions
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: revoke
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'revoke'
|
||||
BEGIN
|
||||
DECLARE @rSessionId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.sessionId'));
|
||||
|
||||
IF @rSessionId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"sessionId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesId = @rSessionId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: revokeAllForUser
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'revokeAllForUser'
|
||||
BEGIN
|
||||
DECLARE @raUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
|
||||
IF @raUserId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbSession SET sesIsRevoked = 1 WHERE sesUsrId = @raUserId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: cleanup
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'cleanup'
|
||||
BEGIN
|
||||
DECLARE @daysOld INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.daysOld') AS INT), 30);
|
||||
|
||||
DELETE FROM dbo.tbSession
|
||||
WHERE sesExpiresUtc < DATEADD(DAY, -@daysOld, SYSUTCDATETIME());
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsDeleted FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
288
Management/SQL/spAdminUsers.sql
Normal file
288
Management/SQL/spAdminUsers.sql
Normal file
@@ -0,0 +1,288 @@
|
||||
-- ============================================================
|
||||
-- spAdminUsers: User management
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spAdminUsers]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: create
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'create'
|
||||
BEGIN
|
||||
DECLARE @cEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
|
||||
DECLARE @cDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
|
||||
DECLARE @cClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @cRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
|
||||
|
||||
IF @cEmail IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"email is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrEmail = @cEmail)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User with this email already exists"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @cClientId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @cClientId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
DECLARE @cUserId UNIQUEIDENTIFIER = NEWID();
|
||||
DECLARE @cEntraSub NVARCHAR(100) = 'pending-' + CAST(@cUserId AS NVARCHAR(50));
|
||||
|
||||
INSERT INTO dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
|
||||
VALUES (@cUserId, @cEntraSub, 'Pending', @cEntraSub, @cEmail, @cDisplayName, 'Active');
|
||||
|
||||
IF @cClientId IS NOT NULL
|
||||
BEGIN
|
||||
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
|
||||
VALUES (@cUserId, @cClientId, @cRole);
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@cUserId AS userId,
|
||||
@cEmail AS email,
|
||||
@cDisplayName AS displayName,
|
||||
@cClientId AS clientId,
|
||||
@cRole AS [role]
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: get
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'get'
|
||||
BEGIN
|
||||
DECLARE @gId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
|
||||
IF @gId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @gId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
u.usrId AS userId,
|
||||
u.usrEmail AS email,
|
||||
u.usrDisplayName AS displayName,
|
||||
u.usrStatus AS status,
|
||||
u.usrCreatedUtc AS createdAt,
|
||||
(
|
||||
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
|
||||
FROM dbo.tbUserClientRole r
|
||||
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
|
||||
WHERE r.ucrUsrId = u.usrId
|
||||
FOR JSON PATH
|
||||
) AS clients
|
||||
FROM dbo.tbUser u WHERE u.usrId = @gId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: list
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'list'
|
||||
BEGIN
|
||||
DECLARE @lStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
DECLARE @lClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @lPage INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.page') AS INT), 1);
|
||||
DECLARE @lPageSize INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.pageSize') AS INT), 50);
|
||||
|
||||
DECLARE @users NVARCHAR(MAX);
|
||||
SELECT @users = (
|
||||
SELECT
|
||||
u.usrId AS userId,
|
||||
u.usrEmail AS email,
|
||||
u.usrDisplayName AS displayName,
|
||||
u.usrStatus AS status,
|
||||
u.usrCreatedUtc AS createdAt,
|
||||
(
|
||||
SELECT c.cltId AS clientId, c.cltName AS clientName, r.ucrRole AS [role]
|
||||
FROM dbo.tbUserClientRole r
|
||||
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId
|
||||
WHERE r.ucrUsrId = u.usrId
|
||||
FOR JSON PATH
|
||||
) AS clients
|
||||
FROM dbo.tbUser u
|
||||
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
|
||||
AND (@lClientId IS NULL OR EXISTS (
|
||||
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
|
||||
))
|
||||
ORDER BY u.usrEmail
|
||||
OFFSET (@lPage - 1) * @lPageSize ROWS
|
||||
FETCH NEXT @lPageSize ROWS ONLY
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
DECLARE @lTotal INT;
|
||||
SELECT @lTotal = COUNT(*)
|
||||
FROM dbo.tbUser u
|
||||
WHERE (@lStatus IS NULL OR u.usrStatus = @lStatus)
|
||||
AND (@lClientId IS NULL OR EXISTS (
|
||||
SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = u.usrId AND ucrCltId = @lClientId
|
||||
));
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@users, '[]')) AS users,
|
||||
@lTotal AS totalCount,
|
||||
@lPage AS page,
|
||||
@lPageSize AS pageSize
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: update
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'update'
|
||||
BEGIN
|
||||
DECLARE @uId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @uDisplayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
|
||||
DECLARE @uStatus VARCHAR(20) = NULLIF(JSON_VALUE(@j, '$.status'), '');
|
||||
|
||||
IF @uId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @uId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbUser
|
||||
SET usrDisplayName = ISNULL(@uDisplayName, usrDisplayName),
|
||||
usrStatus = ISNULL(@uStatus, usrStatus)
|
||||
WHERE usrId = @uId;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
usrId AS userId,
|
||||
usrEmail AS email,
|
||||
usrDisplayName AS displayName,
|
||||
usrStatus AS status
|
||||
FROM dbo.tbUser WHERE usrId = @uId
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: delete (soft delete)
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'delete'
|
||||
BEGIN
|
||||
DECLARE @dId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
|
||||
IF @dId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.tbUser SET usrStatus = 'Inactive' WHERE usrId = @dId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: linkToClient
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'linkToClient'
|
||||
BEGIN
|
||||
DECLARE @luUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @luClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
DECLARE @luRole VARCHAR(30) = ISNULL(NULLIF(JSON_VALUE(@j, '$.role'), ''), 'User');
|
||||
|
||||
IF @luUserId IS NULL OR @luClientId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbUser WHERE usrId = @luUserId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.tbClient WHERE cltId = @luClientId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"Client not found"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId)
|
||||
BEGIN
|
||||
UPDATE dbo.tbUserClientRole
|
||||
SET ucrRole = @luRole
|
||||
WHERE ucrUsrId = @luUserId AND ucrCltId = @luClientId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'updated' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
INSERT INTO dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
|
||||
VALUES (@luUserId, @luClientId, @luRole);
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, 'created' AS action, @luRole AS [role] FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: unlinkFromClient
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'unlinkFromClient'
|
||||
BEGIN
|
||||
DECLARE @ruUserId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.userId'));
|
||||
DECLARE @ruClientId UNIQUEIDENTIFIER = TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(@j, '$.clientId'));
|
||||
|
||||
IF @ruUserId IS NULL OR @ruClientId IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"userId and clientId are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
DELETE FROM dbo.tbUserClientRole
|
||||
WHERE ucrUsrId = @ruUserId AND ucrCltId = @ruClientId;
|
||||
|
||||
SET @resp = (SELECT CAST(1 AS BIT) AS ok, @@ROWCOUNT AS rowsAffected FOR JSON PATH, WITHOUT_ARRAY_WRAPPER);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
106
Management/SQL/spMonitoring.sql
Normal file
106
Management/SQL/spMonitoring.sql
Normal file
@@ -0,0 +1,106 @@
|
||||
-- ============================================================
|
||||
-- spMonitoring: System health and statistics
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spMonitoring]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: health
|
||||
-- System health overview
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'health'
|
||||
BEGIN
|
||||
DECLARE @clientCount INT, @userCount INT, @sessionCount INT, @logCount24h INT;
|
||||
|
||||
SELECT @clientCount = COUNT(*) FROM dbo.tbClient WHERE cltStatus = 'Active';
|
||||
SELECT @userCount = COUNT(*) FROM dbo.tbUser WHERE usrStatus = 'Active';
|
||||
SELECT @sessionCount = COUNT(*) FROM dbo.tbSession WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
|
||||
|
||||
-- Check if tbAdpApiLog exists (may not be in all installations)
|
||||
IF OBJECT_ID('dbo.tbAdpApiLog', 'U') IS NOT NULL
|
||||
EXEC sp_executesql N'SELECT @cnt = COUNT(*) FROM dbo.tbAdpApiLog WHERE createdUtc > DATEADD(HOUR, -24, SYSUTCDATETIME())',
|
||||
N'@cnt INT OUTPUT', @cnt = @logCount24h OUTPUT;
|
||||
ELSE
|
||||
SET @logCount24h = 0;
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@clientCount AS activeClients,
|
||||
@userCount AS activeUsers,
|
||||
@sessionCount AS activeSessions,
|
||||
@logCount24h AS apiCalls24h,
|
||||
SYSUTCDATETIME() AS serverTimeUtc
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: stats
|
||||
-- Detailed statistics
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'stats'
|
||||
BEGIN
|
||||
DECLARE @hours INT = ISNULL(TRY_CAST(JSON_VALUE(@j, '$.hours') AS INT), 24);
|
||||
|
||||
-- Clients by status
|
||||
DECLARE @clientsByStatus NVARCHAR(MAX);
|
||||
SELECT @clientsByStatus = (
|
||||
SELECT cltStatus AS status, COUNT(*) AS [count]
|
||||
FROM dbo.tbClient
|
||||
GROUP BY cltStatus
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
-- Users by status
|
||||
DECLARE @usersByStatus NVARCHAR(MAX);
|
||||
SELECT @usersByStatus = (
|
||||
SELECT usrStatus AS status, COUNT(*) AS [count]
|
||||
FROM dbo.tbUser
|
||||
GROUP BY usrStatus
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
-- Sessions stats
|
||||
DECLARE @activeSessions INT, @expiredSessions INT, @revokedSessions INT;
|
||||
SELECT @activeSessions = COUNT(*) FROM dbo.tbSession
|
||||
WHERE sesIsRevoked = 0 AND sesExpiresUtc > SYSUTCDATETIME();
|
||||
SELECT @expiredSessions = COUNT(*) FROM dbo.tbSession
|
||||
WHERE sesIsRevoked = 0 AND sesExpiresUtc <= SYSUTCDATETIME();
|
||||
SELECT @revokedSessions = COUNT(*) FROM dbo.tbSession
|
||||
WHERE sesIsRevoked = 1;
|
||||
|
||||
-- Recent registrations (last 7 days)
|
||||
DECLARE @recentClients INT, @recentUsers INT;
|
||||
SELECT @recentClients = COUNT(*) FROM dbo.tbClient
|
||||
WHERE cltCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
|
||||
SELECT @recentUsers = COUNT(*) FROM dbo.tbUser
|
||||
WHERE usrCreatedUtc > DATEADD(DAY, -7, SYSUTCDATETIME());
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
JSON_QUERY(ISNULL(@clientsByStatus, '[]')) AS clientsByStatus,
|
||||
JSON_QUERY(ISNULL(@usersByStatus, '[]')) AS usersByStatus,
|
||||
@activeSessions AS activeSessions,
|
||||
@expiredSessions AS expiredSessions,
|
||||
@revokedSessions AS revokedSessions,
|
||||
@recentClients AS newClientsLast7Days,
|
||||
@recentUsers AS newUsersLast7Days,
|
||||
SYSUTCDATETIME() AS serverTimeUtc
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
151
Management/SQL/spOnboarding.sql
Normal file
151
Management/SQL/spOnboarding.sql
Normal file
@@ -0,0 +1,151 @@
|
||||
-- ============================================================
|
||||
-- spOnboarding: User/Client registration
|
||||
-- ============================================================
|
||||
CREATE OR ALTER PROCEDURE [dbo].[spOnboarding]
|
||||
@action VARCHAR(50),
|
||||
@rqst NVARCHAR(MAX),
|
||||
@resp NVARCHAR(MAX) OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @j NVARCHAR(MAX) = ISNULL(@rqst, N'{}');
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: status
|
||||
-- Check if user is registered and has client access
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'status'
|
||||
BEGIN
|
||||
DECLARE @sSubject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
|
||||
DECLARE @sEmail NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
|
||||
|
||||
DECLARE @sUserId UNIQUEIDENTIFIER;
|
||||
DECLARE @sUserEmail NVARCHAR(256);
|
||||
|
||||
SELECT @sUserId = usrId, @sUserEmail = usrEmail
|
||||
FROM dbo.tbUser
|
||||
WHERE usrEntraSub = @sSubject;
|
||||
|
||||
-- User doesn't exist
|
||||
IF @sUserId IS NULL
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
CAST(0 AS BIT) AS isRegistered,
|
||||
@sEmail AS email
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- Check for client access
|
||||
DECLARE @clients NVARCHAR(MAX);
|
||||
SELECT @clients = (
|
||||
SELECT
|
||||
c.cltId AS clientId,
|
||||
c.cltName AS clientName,
|
||||
r.ucrRole AS [role]
|
||||
FROM dbo.tbUserClientRole r
|
||||
JOIN dbo.tbClient c ON c.cltId = r.ucrCltId AND c.cltStatus = 'Active'
|
||||
WHERE r.ucrUsrId = @sUserId
|
||||
FOR JSON PATH
|
||||
);
|
||||
|
||||
IF @clients IS NULL OR @clients = '[]'
|
||||
BEGIN
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
CAST(0 AS BIT) AS isRegistered,
|
||||
@sUserId AS userId,
|
||||
@sUserEmail AS email
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
CAST(1 AS BIT) AS isRegistered,
|
||||
@sUserId AS userId,
|
||||
@sUserEmail AS email,
|
||||
JSON_QUERY(@clients) AS clients
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- ACTION: register
|
||||
-- Creates client + links user as Admin
|
||||
------------------------------------------------------------------------
|
||||
IF @action = 'register'
|
||||
BEGIN
|
||||
DECLARE @provider VARCHAR(30) = NULLIF(JSON_VALUE(@j, '$.provider'), '');
|
||||
DECLARE @subject NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.subject'), '');
|
||||
DECLARE @email NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.email'), '');
|
||||
DECLARE @displayName NVARCHAR(256) = NULLIF(JSON_VALUE(@j, '$.displayName'), '');
|
||||
DECLARE @clientName NVARCHAR(200) = NULLIF(JSON_VALUE(@j, '$.clientName'), '');
|
||||
|
||||
-- Validation
|
||||
IF @provider IS NULL OR @subject IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"provider and subject are required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @clientName IS NULL
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"clientName is required"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- Find or create user
|
||||
DECLARE @userId UNIQUEIDENTIFIER;
|
||||
|
||||
SELECT @userId = usrId
|
||||
FROM dbo.tbUser
|
||||
WHERE usrEntraSub = @subject;
|
||||
|
||||
IF @userId IS NULL
|
||||
BEGIN
|
||||
SET @userId = NEWID();
|
||||
INSERT dbo.tbUser (usrId, usrEntraSub, usrProvider, usrSubject, usrEmail, usrDisplayName, usrStatus)
|
||||
VALUES (@userId, @subject, @provider, @subject, @email, @displayName, 'Active');
|
||||
END
|
||||
|
||||
-- Check if user already has client access
|
||||
IF EXISTS (SELECT 1 FROM dbo.tbUserClientRole WHERE ucrUsrId = @userId)
|
||||
BEGIN
|
||||
SET @resp = N'{"ok":false,"error":"User is already registered"}';
|
||||
RETURN;
|
||||
END
|
||||
|
||||
-- Create client
|
||||
DECLARE @clientId UNIQUEIDENTIFIER = NEWID();
|
||||
INSERT dbo.tbClient (cltId, cltName, cltStatus)
|
||||
VALUES (@clientId, @clientName, 'Active');
|
||||
|
||||
-- Link user as Admin
|
||||
INSERT dbo.tbUserClientRole (ucrUsrId, ucrCltId, ucrRole)
|
||||
VALUES (@userId, @clientId, 'Admin');
|
||||
|
||||
-- Return success
|
||||
SET @resp = (
|
||||
SELECT
|
||||
CAST(1 AS BIT) AS ok,
|
||||
@userId AS userId,
|
||||
@clientId AS clientId,
|
||||
@clientName AS clientName,
|
||||
'Admin' AS [role]
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SET @resp = N'{"ok":false,"error":"Unknown action"}';
|
||||
END
|
||||
GO
|
||||
261
Management/Security/ClientAuthMiddleware.cs
Normal file
261
Management/Security/ClientAuthMiddleware.cs
Normal file
@@ -0,0 +1,261 @@
|
||||
using Management.Data;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication middleware for Management API.
|
||||
///
|
||||
/// Auth paths:
|
||||
/// - /api/onboarding/* → JWT (user may not have session yet)
|
||||
/// - /api/admin/* → Session + Admin role
|
||||
/// - /api/monitoring/* → Session + Admin role
|
||||
/// - /api/test/* → Anonymous
|
||||
/// </summary>
|
||||
public sealed class ClientAuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ClientAuthMiddleware> _logger;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
private static readonly HashSet<string> _anonymousExact = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/", "/health"
|
||||
};
|
||||
|
||||
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
|
||||
private static readonly string[] _jwtOnlyPrefixes = { "/api/onboarding" };
|
||||
private static readonly string[] _adminRequiredPrefixes = { "/api/monitoring", "/api/admin" };
|
||||
|
||||
private static ConfigurationManager<OpenIdConnectConfiguration>? _oidcConfigManager;
|
||||
private static readonly object _oidcLock = new();
|
||||
|
||||
public ClientAuthMiddleware(RequestDelegate next, ILogger<ClientAuthMiddleware> logger, IConfiguration config)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
|
||||
{
|
||||
var path = (context.Request.Path.Value ?? "").ToLowerInvariant();
|
||||
var corrId = EnsureCorrelationId(context);
|
||||
|
||||
// Anonymous paths
|
||||
if (IsAnonymousPath(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dev bypass
|
||||
if (TryDevBypass(context, clientContext))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// JWT-only paths (onboarding)
|
||||
if (IsJwtOnlyPath(path))
|
||||
{
|
||||
if (await TryJwtAuthAsync(context, clientContext))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid Entra authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin-required paths
|
||||
if (IsAdminRequiredPath(path))
|
||||
{
|
||||
if (await TrySessionAuthAsync(context, clientContext, sql))
|
||||
{
|
||||
if (!clientContext.IsAdmin)
|
||||
{
|
||||
context.Response.StatusCode = 403;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Admin access required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin session required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: require session
|
||||
if (await TrySessionAuthAsync(context, clientContext, sql))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid session required" });
|
||||
}
|
||||
|
||||
private static bool IsAnonymousPath(string path) =>
|
||||
_anonymousExact.Contains(path) || _anonymousPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsJwtOnlyPath(string path) =>
|
||||
_jwtOnlyPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsAdminRequiredPath(string path) =>
|
||||
_adminRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string EnsureCorrelationId(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-Correlation-Id", out var existing) || string.IsNullOrWhiteSpace(existing.FirstOrDefault()))
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
context.Request.Headers["X-Correlation-Id"] = id;
|
||||
return id;
|
||||
}
|
||||
return existing.First()!;
|
||||
}
|
||||
|
||||
private bool TryDevBypass(HttpContext context, ClientContext clientContext)
|
||||
{
|
||||
var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
var allowBypass = _config.GetValue<bool>("Auth:AllowDevBypass");
|
||||
|
||||
if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase) && !allowBypass)
|
||||
return false;
|
||||
|
||||
if (!context.Request.Headers.TryGetValue("X-Dev-ClientId", out var devClientId))
|
||||
return false;
|
||||
|
||||
var clientId = devClientId.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
return false;
|
||||
|
||||
clientContext.ClientId = clientId;
|
||||
clientContext.IsDevBypass = true;
|
||||
clientContext.Role = "Admin";
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql)
|
||||
{
|
||||
string? token = context.Request.Headers["X-Session-Token"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
token = authHeader["Bearer ".Length..].Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return false;
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
|
||||
|
||||
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
|
||||
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
clientContext.PlatformClientId = clientContext.ClientId;
|
||||
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
|
||||
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
|
||||
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Session validation error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryJwtAuthAsync(HttpContext context, ClientContext clientContext)
|
||||
{
|
||||
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var token = authHeader["Bearer ".Length..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return false;
|
||||
|
||||
var tenantId = _config["Auth:EntraId:TenantId"];
|
||||
var clientId = _config["Auth:EntraId:ClientId"];
|
||||
var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
|
||||
var metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
|
||||
var mgr = GetOrCreateConfigManager(metadataAddress);
|
||||
var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = new[] { clientId, $"api://{clientId}" },
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = handler.ValidateToken(token, validationParams, out _);
|
||||
|
||||
clientContext.ClientId = principal.FindFirstValue("oid") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
clientContext.Email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email);
|
||||
clientContext.ClientName = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("JWT validation failed: {Message}", ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ConfigurationManager<OpenIdConnectConfiguration> GetOrCreateConfigManager(string metadataAddress)
|
||||
{
|
||||
lock (_oidcLock)
|
||||
{
|
||||
_oidcConfigManager ??= new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress, new OpenIdConnectConfigurationRetriever());
|
||||
return _oidcConfigManager;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Management/Security/ClientContext.cs
Normal file
20
Management/Security/ClientContext.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Management.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Request-scoped authentication context.
|
||||
/// Populated by ClientAuthMiddleware.
|
||||
/// </summary>
|
||||
public sealed class ClientContext
|
||||
{
|
||||
public string? SessionId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? PlatformClientId { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public bool IsDevBypass { get; set; }
|
||||
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
|
||||
public bool IsAdmin => string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
13
Management/WeatherForecast.cs
Normal file
13
Management/WeatherForecast.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Management
|
||||
{
|
||||
public class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
}
|
||||
11
Management/appsettings.Development.json
Normal file
11
Management/appsettings.Development.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"AllowDevBypass": true
|
||||
}
|
||||
}
|
||||
17
Management/appsettings.json
Normal file
17
Management/appsettings.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Auth": {
|
||||
"AllowDevBypass": false,
|
||||
"EntraId": {
|
||||
"Instance": "https://login.microsoftonline.com/",
|
||||
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
|
||||
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user