Add project files.

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

61
Management/Program.cs Normal file
View File

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

View File

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

90
Management/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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