Init Git
This commit is contained in:
26
AMREZ.EOP.API/AMREZ.EOP.API.csproj
Normal file
26
AMREZ.EOP.API/AMREZ.EOP.API.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AMREZ.EOP.Abstractions\AMREZ.EOP.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\AMREZ.EOP.Domain\AMREZ.EOP.Domain.csproj" />
|
||||
<ProjectReference Include="..\AMREZ.EOP.Infrastructures\AMREZ.EOP.Infrastructures.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
AMREZ.EOP.API/AMREZ.EOP.API.http
Normal file
6
AMREZ.EOP.API/AMREZ.EOP.API.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@AMREZ.EOP.API_HostAddress = http://localhost:5063
|
||||
|
||||
GET {{AMREZ.EOP.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
136
AMREZ.EOP.API/Controllers/AuthenticationController.cs
Normal file
136
AMREZ.EOP.API/Controllers/AuthenticationController.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Security.Claims;
|
||||
using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.Register;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail;
|
||||
using AMREZ.EOP.Domain.Shared.Contracts;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AMREZ.EOP.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthenticationController : ControllerBase
|
||||
{
|
||||
private readonly ILoginUseCase _login;
|
||||
private readonly IRegisterUseCase _register;
|
||||
private readonly IChangePasswordUseCase _changePassword;
|
||||
|
||||
private readonly IAddEmailIdentityUseCase _addEmail;
|
||||
private readonly IVerifyEmailUseCase _verifyEmail;
|
||||
|
||||
private readonly IEnableTotpUseCase _enableTotp;
|
||||
private readonly IDisableMfaUseCase _disableMfa;
|
||||
|
||||
private readonly ILogoutUseCase _logout;
|
||||
private readonly ILogoutAllUseCase _logoutAll;
|
||||
|
||||
public AuthenticationController(
|
||||
ILoginUseCase login,
|
||||
IRegisterUseCase register,
|
||||
IChangePasswordUseCase changePassword)
|
||||
{
|
||||
_login = login;
|
||||
_register = register;
|
||||
_changePassword = changePassword;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> PostLogin([FromBody] LoginRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _login.ExecuteAsync(body, ct);
|
||||
if (res is null) return Unauthorized(new { message = "Invalid credentials" });
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, res.UserId.ToString()),
|
||||
new(ClaimTypes.Name, string.IsNullOrWhiteSpace(res.DisplayName) ? res.Email : res.DisplayName),
|
||||
new(ClaimTypes.Email, res.Email),
|
||||
new("tenant", res.TenantId)
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthPolicies.Scheme));
|
||||
await HttpContext.SignInAsync(AuthPolicies.Scheme, principal);
|
||||
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _register.ExecuteAsync(body, ct);
|
||||
if (res is null)
|
||||
return Conflict(new { message = "Email already exists or tenant not found" });
|
||||
|
||||
return Created($"/api/authentication/users/{res.UserId}", res);
|
||||
}
|
||||
|
||||
[HttpPost("change-password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _changePassword.ExecuteAsync(body, ct);
|
||||
if (!ok) return BadRequest(new { message = "Change password failed" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(AuthPolicies.Scheme);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("email")]
|
||||
public async Task<IActionResult> AddEmail([FromBody] AddEmailIdentityRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _addEmail.ExecuteAsync(body, ct);
|
||||
if (!ok) return BadRequest(new { message = "Cannot add email identity" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("email/verify")]
|
||||
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _verifyEmail.ExecuteAsync(body, ct);
|
||||
if (!ok) return BadRequest(new { message = "Verify email failed" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("totp/enable")]
|
||||
public async Task<IActionResult> EnableTotp([FromBody] EnableTotpRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _enableTotp.ExecuteAsync(body, ct);
|
||||
if (res is null) return BadRequest(new { message = "Enable TOTP failed" });
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpPost("disable")]
|
||||
public async Task<IActionResult> DisableMfa([FromBody] DisableMfaRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _disableMfa.ExecuteAsync(body, ct);
|
||||
if (!ok) return BadRequest(new { message = "Disable MFA failed" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("revoke")]
|
||||
public async Task<IActionResult> Revoke([FromBody] LogoutRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _logout.ExecuteAsync(body, ct);
|
||||
if (!ok) return NotFound(new { message = "Session not found" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("revoke-all")]
|
||||
public async Task<IActionResult> RevokeAll([FromBody] LogoutAllRequest body, CancellationToken ct)
|
||||
{
|
||||
var n = await _logoutAll.ExecuteAsync(body, ct);
|
||||
return Ok(new { revoked = n });
|
||||
}
|
||||
}
|
||||
128
AMREZ.EOP.API/Controllers/HumanResourcesController.cs
Normal file
128
AMREZ.EOP.API/Controllers/HumanResourcesController.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact;
|
||||
using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AMREZ.EOP.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class HumanResourcesController : ControllerBase
|
||||
{
|
||||
private readonly IUpsertUserProfileUseCase _upsertProfile;
|
||||
private readonly IAddEmploymentUseCase _addEmployment;
|
||||
private readonly IEndEmploymentUseCase _endEmployment;
|
||||
private readonly IAddEmployeeAddressUseCase _addAddress;
|
||||
private readonly ISetPrimaryAddressUseCase _setPrimaryAddress;
|
||||
private readonly IAddEmergencyContactUseCase _addEmergencyContact;
|
||||
private readonly ISetPrimaryEmergencyContactUseCase _setPrimaryEmergencyContact;
|
||||
private readonly IAddEmployeeBankAccountUseCase _addBankAccount;
|
||||
private readonly ISetPrimaryBankAccountUseCase _setPrimaryBankAccount;
|
||||
|
||||
public HumanResourcesController(
|
||||
IUpsertUserProfileUseCase upsertProfile,
|
||||
IAddEmploymentUseCase addEmployment,
|
||||
IEndEmploymentUseCase endEmployment,
|
||||
IAddEmployeeAddressUseCase addAddress,
|
||||
ISetPrimaryAddressUseCase setPrimaryAddress,
|
||||
IAddEmergencyContactUseCase addEmergencyContact,
|
||||
ISetPrimaryEmergencyContactUseCase setPrimaryEmergencyContact,
|
||||
IAddEmployeeBankAccountUseCase addBankAccount,
|
||||
ISetPrimaryBankAccountUseCase setPrimaryBankAccount)
|
||||
{
|
||||
_upsertProfile = upsertProfile;
|
||||
_addEmployment = addEmployment;
|
||||
_endEmployment = endEmployment;
|
||||
_addAddress = addAddress;
|
||||
_setPrimaryAddress = setPrimaryAddress;
|
||||
_addEmergencyContact = addEmergencyContact;
|
||||
_setPrimaryEmergencyContact = setPrimaryEmergencyContact;
|
||||
_addBankAccount = addBankAccount;
|
||||
_setPrimaryBankAccount = setPrimaryBankAccount;
|
||||
}
|
||||
|
||||
// ===== User Profile =====
|
||||
[HttpPost("profiles/upsert")]
|
||||
public async Task<IActionResult> UpsertProfile([FromBody] UserProfileUpsertRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _upsertProfile.ExecuteAsync(body, ct);
|
||||
if (res is null) return BadRequest(new { message = "Upsert failed" });
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
// ===== Employment =====
|
||||
[HttpPost("employments")]
|
||||
public async Task<IActionResult> AddEmployment([FromBody] EmploymentAddRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _addEmployment.ExecuteAsync(body, ct);
|
||||
if (res is null) return BadRequest(new { message = "Add employment failed" });
|
||||
return Created($"/api/humanresources/employments/{res.Id}", res);
|
||||
}
|
||||
|
||||
[HttpPost("employments/{id:guid}/end")]
|
||||
public async Task<IActionResult> EndEmployment([FromRoute] Guid id, [FromBody] EmploymentEndRequest body, CancellationToken ct)
|
||||
{
|
||||
if (id != body.EmploymentId) return BadRequest(new { message = "Mismatched id" });
|
||||
var ok = await _endEmployment.ExecuteAsync(body, ct);
|
||||
if (!ok) return NotFound(new { message = "Employment not found" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===== Addresses =====
|
||||
[HttpPost("addresses")]
|
||||
public async Task<IActionResult> AddAddress([FromBody] EmployeeAddressAddRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _addAddress.ExecuteAsync(body, ct);
|
||||
if (res is null) return BadRequest(new { message = "Add address failed" });
|
||||
return Created($"/api/humanresources/addresses/{res.Id}", res);
|
||||
}
|
||||
|
||||
[HttpPost("addresses/set-primary")]
|
||||
public async Task<IActionResult> SetPrimaryAddress([FromBody] SetPrimaryAddressRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _setPrimaryAddress.ExecuteAsync(body, ct);
|
||||
if (!ok) return NotFound(new { message = "Address not found" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===== Emergency Contacts =====
|
||||
[HttpPost("emergency-contacts")]
|
||||
public async Task<IActionResult> AddEmergencyContact([FromBody] EmergencyContactAddRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _addEmergencyContact.ExecuteAsync(body, ct);
|
||||
if (res is null) return BadRequest(new { message = "Add emergency contact failed" });
|
||||
return Created($"/api/humanresources/emergency-contacts/{res.Id}", res);
|
||||
}
|
||||
|
||||
[HttpPost("emergency-contacts/set-primary")]
|
||||
public async Task<IActionResult> SetPrimaryEmergencyContact([FromBody] SetPrimaryEmergencyContactRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _setPrimaryEmergencyContact.ExecuteAsync(body, ct);
|
||||
if (!ok) return NotFound(new { message = "Emergency contact not found" });
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===== Bank Accounts =====
|
||||
[HttpPost("bank-accounts")]
|
||||
public async Task<IActionResult> AddBankAccount([FromBody] EmployeeBankAccountAddRequest body, CancellationToken ct)
|
||||
{
|
||||
var res = await _addBankAccount.ExecuteAsync(body, ct);
|
||||
if (res is null) return BadRequest(new { message = "Add bank account failed" });
|
||||
return Created($"/api/humanresources/bank-accounts/{res.Id}", res);
|
||||
}
|
||||
|
||||
[HttpPost("bank-accounts/set-primary")]
|
||||
public async Task<IActionResult> SetPrimaryBankAccount([FromBody] SetPrimaryBankAccountRequest body, CancellationToken ct)
|
||||
{
|
||||
var ok = await _setPrimaryBankAccount.ExecuteAsync(body, ct);
|
||||
if (!ok) return NotFound(new { message = "Bank account not found" });
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
192
AMREZ.EOP.API/Controllers/TenancyController.cs
Normal file
192
AMREZ.EOP.API/Controllers/TenancyController.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||
using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain;
|
||||
using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AMREZ.EOP.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class TenancyController : ControllerBase
|
||||
{
|
||||
private readonly ICreateTenantUseCase _createTenant;
|
||||
private readonly IUpdateTenantUseCase _updateTenant;
|
||||
private readonly IDeleteTenantUseCase _deleteTenant;
|
||||
private readonly IMapDomainUseCase _mapDomain;
|
||||
private readonly IUnmapDomainUseCase _unmapDomain;
|
||||
private readonly IAddBaseDomainUseCase _addBaseDomain;
|
||||
private readonly IRemoveBaseDomainUseCase _removeBaseDomain;
|
||||
private readonly IListTenantsUseCase _listTenants;
|
||||
private readonly IListDomainsUseCase _listDomains;
|
||||
private readonly ITenantResolver _resolver;
|
||||
|
||||
public TenancyController(
|
||||
ICreateTenantUseCase createTenant,
|
||||
IUpdateTenantUseCase updateTenant,
|
||||
IDeleteTenantUseCase deleteTenant,
|
||||
IMapDomainUseCase mapDomain,
|
||||
IUnmapDomainUseCase unmapDomain,
|
||||
IAddBaseDomainUseCase addBaseDomain,
|
||||
IRemoveBaseDomainUseCase removeBaseDomain,
|
||||
IListTenantsUseCase listTenants,
|
||||
IListDomainsUseCase listDomains,
|
||||
ITenantResolver resolver)
|
||||
{
|
||||
_createTenant = createTenant;
|
||||
_updateTenant = updateTenant;
|
||||
_deleteTenant = deleteTenant;
|
||||
_mapDomain = mapDomain;
|
||||
_unmapDomain = unmapDomain;
|
||||
_addBaseDomain = addBaseDomain;
|
||||
_removeBaseDomain = removeBaseDomain;
|
||||
_listTenants = listTenants;
|
||||
_listDomains = listDomains;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
private static object ErrTenantResolve => new
|
||||
{
|
||||
code = "TENANT_CONTEXT_NOT_RESOLVED",
|
||||
message = "Check X-Tenant (e.g., public) or permission scope."
|
||||
};
|
||||
|
||||
// Tenants
|
||||
// Create: อย่า feed body เข้า resolver (tenant ยังไม่เกิด) ให้ใช้ context ปัจจุบันเท่านั้น
|
||||
[HttpPost("tenants")]
|
||||
public async Task<IActionResult> CreateTenant([FromBody] CreateTenantRequest body, CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _createTenant.ExecuteAsync(body, ct);
|
||||
if (res is null)
|
||||
return Conflict(new { code = "TENANT_EXISTS", message = "Use PUT to update/reactivate." });
|
||||
|
||||
return Created($"/api/Tenancy/tenants/{res.TenantKey}", res);
|
||||
}
|
||||
|
||||
[HttpPut("tenants/{tenantKey}")]
|
||||
public async Task<IActionResult> UpdateTenant([FromRoute] string tenantKey, [FromBody] UpdateTenantRequest body, CancellationToken ct)
|
||||
{
|
||||
if (!string.Equals(tenantKey?.Trim(), body.TenantKey?.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new { code = "TENANT_KEY_MISMATCH", message = "Route tenantKey must match body.TenantKey" });
|
||||
|
||||
// การแก้ไข tenant ที่มีอยู่: ให้ resolver ใช้ tenantKey เป็น hint เพื่อบังคับ context ให้ถูกต้อง
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _updateTenant.ExecuteAsync(body, ct);
|
||||
if (res is null)
|
||||
return NotFound(new { code = "TENANT_UPDATE_FAILED", message = "Update failed (not found, precondition, or tenant not resolved)" });
|
||||
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpDelete("tenants/{tenantKey}")]
|
||||
public async Task<IActionResult> DeleteTenant([FromRoute] string tenantKey, CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var ok = await _deleteTenant.ExecuteAsync(tenantKey, ct);
|
||||
if (!ok)
|
||||
return NotFound(new { code = "TENANT_DELETE_FAILED", message = "Delete failed (not found or tenant not resolved)" });
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("tenants")]
|
||||
public async Task<IActionResult> ListTenants(CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _listTenants.ExecuteAsync(new ListTenantsRequest(), ct);
|
||||
if (res is null)
|
||||
return NotFound(new { code = "TENANT_LIST_FAILED", message = "Tenant resolve failed" });
|
||||
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
// Domains
|
||||
[HttpGet("domains")]
|
||||
public async Task<IActionResult> ListDomains([FromQuery] string? tenantKey, CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _listDomains.ExecuteAsync(new ListDomainsRequest { TenantKey = tenantKey }, ct);
|
||||
if (res is null)
|
||||
return NotFound(new { code = "DOMAIN_LIST_FAILED", message = "Tenant resolve failed" });
|
||||
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpPost("domains/map")]
|
||||
public async Task<IActionResult> MapDomain([FromBody] MapDomainRequest body, CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: body?.TenantKey);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _mapDomain.ExecuteAsync(body, ct);
|
||||
if (res is null)
|
||||
return Conflict(new { code = "DOMAIN_MAP_FAILED", message = "Map failed (tenant/domain invalid or tenant not resolved)" });
|
||||
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpDelete("domains/{domain}")]
|
||||
public async Task<IActionResult> UnmapDomain([FromRoute] string domain, [FromQuery] string? tenantKey, CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _unmapDomain.ExecuteAsync(new UnmapDomainRequest { Domain = domain, TenantKey = tenantKey }, ct);
|
||||
if (res is null)
|
||||
return NotFound(new { code = "DOMAIN_UNMAP_FAILED", message = "Unmap failed (not found or tenant not resolved)" });
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("domains/base")]
|
||||
public async Task<IActionResult> AddBaseDomain([FromBody] AddBaseDomainRequest body, CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _addBaseDomain.ExecuteAsync(body, ct);
|
||||
if (res is null)
|
||||
return Conflict(new { code = "BASE_DOMAIN_ADD_FAILED", message = "Add base domain failed (tenant not resolved or duplicate)" });
|
||||
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpDelete("domains/base/{baseDomain}")]
|
||||
public async Task<IActionResult> RemoveBaseDomain([FromRoute] string baseDomain, [FromQuery] string? tenantKey, CancellationToken ct)
|
||||
{
|
||||
var resolved = _resolver.Resolve(HttpContext, hint: null);
|
||||
if (resolved is null)
|
||||
return NotFound(ErrTenantResolve);
|
||||
|
||||
var res = await _removeBaseDomain.ExecuteAsync(new RemoveBaseDomainRequest { BaseDomain = baseDomain, TenantKey = tenantKey }, ct);
|
||||
if (res is null)
|
||||
return NotFound(new { code = "BASE_DOMAIN_REMOVE_FAILED", message = "Remove base domain failed (not found or tenant not resolved)" });
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
4
AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs
Normal file
4
AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace AMREZ.EOP.API.Filters;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
|
||||
public sealed class SkipTenantGuardAttribute : Attribute { }
|
||||
114
AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs
Normal file
114
AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using AMREZ.EOP.API.Filters;
|
||||
using AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
namespace AMREZ.EOP.API.Middleware;
|
||||
|
||||
public sealed class StrictTenantGuardOptions
|
||||
{
|
||||
public string PlatformSlug { get; set; } = "public";
|
||||
public string HeaderName { get; set; } = "X-Tenant";
|
||||
public string ClaimName { get; set; } = "tenant"; // ปรับเป็น tid/org ได้ตามระบบ
|
||||
public HashSet<(string method, string pathPrefix)> Allowlist { get; } = new()
|
||||
{
|
||||
("GET", "/health"),
|
||||
("GET", "/swagger"),
|
||||
("GET", "/swagger/index.html"),
|
||||
("GET", "/swagger/"),
|
||||
};
|
||||
public Regex SlugRegex { get; set; } = new(@"^[a-z0-9\-]{1,64}$", RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
public sealed class StrictTenantGuardMiddleware : IMiddleware
|
||||
{
|
||||
private readonly ILogger<StrictTenantGuardMiddleware> _log;
|
||||
private readonly StrictTenantGuardOptions _opts;
|
||||
private readonly TenantMap _map;
|
||||
|
||||
public StrictTenantGuardMiddleware(
|
||||
ILogger<StrictTenantGuardMiddleware> log,
|
||||
StrictTenantGuardOptions opts,
|
||||
TenantMap map)
|
||||
{ _log = log; _opts = opts; _map = map; }
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
// 1) allowlist แบบเร็ว
|
||||
var path = ctx.Request.Path.Value ?? string.Empty;
|
||||
if (_opts.Allowlist.Any(p =>
|
||||
ctx.Request.Method.Equals(p.method, StringComparison.OrdinalIgnoreCase) &&
|
||||
path.StartsWith(p.pathPrefix, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var endpoint = ctx.GetEndpoint();
|
||||
if (endpoint?.Metadata?.GetMetadata<SkipTenantGuardAttribute>() is not null)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) ดึง header
|
||||
var raw = ctx.Request.Headers[_opts.HeaderName].ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await ctx.Response.WriteAsJsonAsync(new { code = "MISSING_TENANT", message = $"{_opts.HeaderName} required" });
|
||||
return;
|
||||
}
|
||||
|
||||
var slug = raw.ToLowerInvariant();
|
||||
if (!_opts.SlugRegex.IsMatch(slug))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await ctx.Response.WriteAsJsonAsync(new { code = "INVALID_TENANT_FORMAT", message = "tenant slug invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) ตรวจ claim ให้ตรงกับ header
|
||||
// var claim = GetTenantClaim(ctx.User, _opts.ClaimName);
|
||||
// if (claim is null || !string.Equals(claim, slug, StringComparison.Ordinal))
|
||||
// {
|
||||
// ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
// await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_MISMATCH", message = "token tenant != header" });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// 5) ตรวจว่ามีอยู่จริงในระบบ (TenantMap/meta)
|
||||
if (!_map.TryGetBySlug(slug, out var ctxTenant))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_NOT_FOUND", message = "unknown tenant" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 6) สำหรับ /api/Tenancy/** → ใช้ platform DB แต่ยังต้องรู้ target tenant
|
||||
var isControlPlane = path.StartsWith("/api/Tenancy", StringComparison.OrdinalIgnoreCase);
|
||||
if (isControlPlane)
|
||||
{
|
||||
// เก็บ target tenant ให้ use case ใช้
|
||||
ctx.Items["TargetTenantKey"] = slug;
|
||||
|
||||
// บังคับให้ resolver ใช้ platform (ภายหลัง)
|
||||
ctx.Items["ForcePlatformContext"] = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// business-plane: ใช้ tenant context ปกติ (resolver จะอ่าน X-Tenant ตรง ๆ)
|
||||
}
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
|
||||
private static string? GetTenantClaim(ClaimsPrincipal user, string name)
|
||||
{
|
||||
if (user?.Identity?.IsAuthenticated != true) return null;
|
||||
return user.FindFirstValue(name)
|
||||
?? user.FindFirstValue("tid")
|
||||
?? user.FindFirstValue("org")
|
||||
?? user.FindFirstValue("org_id");
|
||||
}
|
||||
}
|
||||
141
AMREZ.EOP.API/Program.cs
Normal file
141
AMREZ.EOP.API/Program.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||
using AMREZ.EOP.API.Middleware;
|
||||
using AMREZ.EOP.Domain.Shared.Contracts;
|
||||
using AMREZ.EOP.Domain.Shared.Tenancy;
|
||||
using AMREZ.EOP.Infrastructures.Data;
|
||||
using AMREZ.EOP.Infrastructures.DependencyInjections;
|
||||
using AMREZ.EOP.Infrastructures.Options;
|
||||
using AMREZ.EOP.Infrastructures.Tenancy;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Npgsql;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Connections"));
|
||||
|
||||
builder.Services.AddInfrastructure();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "AMREZ.EOP API",
|
||||
Version = "v1"
|
||||
});
|
||||
|
||||
// Cookie auth (ชื่อคุกกี้ eop.auth)
|
||||
c.AddSecurityDefinition("cookieAuth", new OpenApiSecurityScheme
|
||||
{
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
In = ParameterLocation.Cookie,
|
||||
Name = "eop.auth",
|
||||
Description = "Sign in via /api/authentication/login to receive cookie."
|
||||
});
|
||||
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme{
|
||||
Reference = new OpenApiReference{ Type = ReferenceType.SecurityScheme, Id = "cookieAuth" }
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(AuthPolicies.Scheme)
|
||||
.AddCookie(AuthPolicies.Scheme, o => { o.LoginPath="/api/authentication/login"; o.Cookie.Name="eop.auth"; o.SlidingExpiration=true; });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddSingleton(new StrictTenantGuardOptions
|
||||
{
|
||||
PlatformSlug = "public",
|
||||
HeaderName = "X-Tenant",
|
||||
ClaimName = "tenant"
|
||||
});
|
||||
builder.Services.AddTransient<StrictTenantGuardMiddleware>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await ApplyMigrationsAsync(app.Services);
|
||||
|
||||
app.MapOpenApi();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(o =>
|
||||
{
|
||||
o.SwaggerEndpoint("/swagger/v1/swagger.json", "AMREZ.EOP API v1");
|
||||
o.RoutePrefix = "swagger";
|
||||
o.DisplayRequestDuration();
|
||||
});
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthentication();
|
||||
|
||||
app.UseMiddleware<StrictTenantGuardMiddleware>();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task ApplyMigrationsAsync(IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var sp = scope.ServiceProvider;
|
||||
|
||||
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuthOptions>>().Value;
|
||||
var factory = sp.GetRequiredService<ITenantDbContextFactory>();
|
||||
|
||||
if (!opts.UseSchemaPerTenant)
|
||||
{
|
||||
await using var db = factory.Create<AppDbContext>(new TenantContext { Id = "_shared_" });
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
var schemas = await GetAppSchemasAsync(opts.DefaultConnection);
|
||||
|
||||
foreach (var schema in schemas)
|
||||
{
|
||||
var t = new TenantContext { Id = schema, Schema = schema, Mode = TenantMode.Schema };
|
||||
await using var db = factory.Create<AppDbContext>(t);
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<List<string>> GetAppSchemasAsync(string connectionString)
|
||||
{
|
||||
var list = new List<string>();
|
||||
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
const string sql = @"
|
||||
SELECT nspname
|
||||
FROM pg_namespace
|
||||
WHERE nspname NOT LIKE 'pg_%'
|
||||
AND nspname <> 'information_schema'
|
||||
ORDER BY nspname;";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await using var rd = await cmd.ExecuteReaderAsync();
|
||||
while (await rd.ReadAsync())
|
||||
list.Add(rd.GetString(0));
|
||||
|
||||
if (list.Count == 0) list.Add("public");
|
||||
|
||||
return list;
|
||||
}
|
||||
23
AMREZ.EOP.API/Properties/launchSettings.json
Normal file
23
AMREZ.EOP.API/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5063",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7275;http://localhost:5063",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
AMREZ.EOP.API/appsettings.Development.json
Normal file
8
AMREZ.EOP.API/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
AMREZ.EOP.API/appsettings.json
Normal file
16
AMREZ.EOP.API/appsettings.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Connections": {
|
||||
"DefaultConnection": "Host=127.0.0.1;Port=15432;User ID=amrez;Password=Initial1!;Database=amrez-app-db;Search Path=public;",
|
||||
"UseSchemaPerTenant": false,
|
||||
"StorageBackend": "ef",
|
||||
"RedisConnection": "localhost:6379",
|
||||
"RedisDb": 0
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user