Client security fixes (cmd/tailscale-tray/main.go): - SSRF protection in Add Server dialog (validateControlURL): reject private/loopback/link-local/cloud-metadata IPs via DNS resolution - RCE gate on AuthURL/BrowseToURL exec paths (validateAuthURL) - Sanitized URL logging (sanitizeURLForLog drops query auth tokens) - Error handling on exec.Command with user-facing showError() Admin panel security (web-admin): - Bcrypt password hashing (replaces SHA256) - Rate limiting: 5 failed logins → 15-min lockout - Session + login attempt cleanup goroutine (hourly) - url.QueryEscape / encodeURIComponent for all API params - Fail-hard startup when no TLS and non-loopback bind - ADMIN_PASSWORD required (no default), password min 12 chars - Username regex whitelist Installer hardening (Setup.wxs): - util:PermissionEx restricts SCM access: only Administrators + SYSTEM can start/stop/reconfigure service. Authenticated Users limited to QueryStatus/QueryConfig/Interrogate - Vital="yes" on ServiceInstall Docs & roadmap: - PRODUCTION_ROADMAP.md: 5-milestone plan (security + features + distribution + ops) with granular tasks, effort, done-when - CLIENT_SECURITY_AUDIT.md, SECURITY_FIXES.md, DEPLOYMENT.md - AI assistant rules (.cursorrules, .antigravityrules, etc.) Build & distribution: - build-msi.ps1, deploy-and-sign.ps1, sign-release.ps1 - redeploy.ps1, tray-deploy.ps1, test-msi.ps1 - installer/msi/ alternative WXS setup - Restored .github/workflows/ removed in mirror cleanup .gitignore hardened: *.pfx, *.p12, *.key, *.pem, .env*
22 KiB
Audit Service Platform Variants — Implementation Guide
Triển khai
IAuditServiceabstraction cho 3 nền tảng: Blazor WASM, Blazor Server, API-only
Kiến trúc tổng quan
graph TD
subgraph "Shared Contract (INS.Shared)"
I["IAuditService"]
end
subgraph "Blazor WASM"
W["WasmAuditService"]
W -->|"HTTP POST"| MC["Module Controller"]
MC -->|"gRPC"| GS["LoggingGrpcService"]
GS -->|"Publish"| RMQ
end
subgraph "Blazor Server"
S["ServerAuditService"]
S -->|"Direct inject"| PUB["IAuditLogPublisher"]
PUB -->|"Publish"| RMQ
end
subgraph "API Only"
A["ApiAuditService"]
A -->|"Direct inject"| PUB
end
I --> W
I --> S
I --> A
RMQ["RabbitMQ"] --> CON["AuditLogConsumerService"]
CON --> DB["SQL Server"]
File 1: IAuditService.cs — Shared Contract
Location:
lib/INS.Shared/Services/IAuditService.cs
namespace INS.Shared.Services
{
/// <summary>
/// Platform-agnostic audit logging interface.
/// Implementations: WasmAuditService, ServerAuditService, ApiAuditService
/// </summary>
public interface IAuditService
{
/// <summary>
/// Log INSERT operation
/// </summary>
Task LogInsertAsync(string tableName, string rowId, object? newData,
string? schemaName = null, string? functionName = null);
/// <summary>
/// Log UPDATE operation
/// </summary>
Task LogUpdateAsync(string tableName, string rowId,
object? oldData, object? newData,
string? schemaName = null, string? primaryKeyColumns = null,
string? functionName = null);
/// <summary>
/// Log DELETE operation
/// </summary>
Task LogDeleteAsync(string tableName, string rowId, object? oldData,
string? schemaName = null, string? functionName = null);
/// <summary>
/// Log custom operation (APPROVE, REJECT, EXPORT, etc.)
/// </summary>
Task LogCustomAsync(string tableName, string operation,
string? rowId = null, object? oldData = null, object? newData = null,
string? schemaName = null, string? functionName = null);
}
}
File 2: WasmAuditService.cs — Blazor WASM Variant
Location:
lib/INS.DXComponents/Logging/WasmAuditService.cs
Flow:HttpClient POST → Module Controller → gRPC → RabbitMQ
using System.Net.Http.Json;
using System.Text.Json;
using INS.Shared.Services;
using INS.Module.Shared.Models;
namespace INS.DXComponents.Logging;
/// <summary>
/// Blazor WASM implementation: gửi audit log qua HTTP tới Module Backend.
/// Module Backend forward qua gRPC tới INS.Server → RabbitMQ.
///
/// Dùng khi: Client-side rendering, không có direct access tới RabbitMQ.
/// DI Lifetime: Scoped (per-circuit)
/// </summary>
public class WasmAuditService : IAuditService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IAuditUserProvider _userProvider;
public WasmAuditService(
IHttpClientFactory httpClientFactory,
IAuditUserProvider userProvider)
{
_httpClientFactory = httpClientFactory;
_userProvider = userProvider;
}
public Task LogInsertAsync(string tableName, string rowId, object? newData,
string? schemaName = null, string? functionName = null)
=> SendAsync(tableName, "INSERT", rowId, null, newData, schemaName, functionName);
public Task LogUpdateAsync(string tableName, string rowId,
object? oldData, object? newData,
string? schemaName = null, string? primaryKeyColumns = null,
string? functionName = null)
=> SendAsync(tableName, "UPDATE", rowId, oldData, newData, schemaName, functionName);
public Task LogDeleteAsync(string tableName, string rowId, object? oldData,
string? schemaName = null, string? functionName = null)
=> SendAsync(tableName, "DELETE", rowId, oldData, null, schemaName, functionName);
public Task LogCustomAsync(string tableName, string operation,
string? rowId = null, object? oldData = null, object? newData = null,
string? schemaName = null, string? functionName = null)
=> SendAsync(tableName, operation, rowId ?? "", oldData, newData, schemaName, functionName);
private async Task SendAsync(string tableName, string operation, string rowId,
object? oldData, object? newData, string? schemaName, string? functionName)
{
try
{
var user = await _userProvider.GetCurrentUserAsync();
var dto = new RabbitMQAuditLogDto
{
TableName = tableName,
RowId = rowId,
Operation = operation,
OldData = Serialize(oldData),
NewData = Serialize(newData),
ChangedBy = user.Username ?? user.UserId ?? "System",
Timestamp = DateTime.UtcNow
};
using var client = _httpClientFactory.CreateClient("AuthenticatedClient");
await client.PostAsJsonAsync("/api/logging/audit", dto);
}
catch
{
// Swallow - audit failure không break UI
}
}
private static string? Serialize(object? data)
=> data is null ? null
: data is string s ? s
: JsonSerializer.Serialize(data);
}
File 3: ServerAuditService.cs — Blazor Server Variant
Location:
lib/INS.DXComponents/Logging/ServerAuditService.cs
Flow:Direct inject IAuditLogPublisher → RabbitMQ(KHÔNG qua HTTP)
using System.Text.Json;
using INS.Shared.Services;
namespace INS.DXComponents.Logging;
/// <summary>
/// Blazor Server implementation: inject trực tiếp IAuditLogPublisher.
/// KHÔNG gọi HTTP tới chính server (tránh circular call).
///
/// Dùng khi: Server-side rendering, có direct access tới RabbitMQ.
/// DI Lifetime: Scoped (per-circuit)
/// </summary>
public class ServerAuditService : IAuditService
{
private readonly IAuditLogPublisher _publisher;
private readonly IAuditUserProvider _userProvider;
private readonly ICircuitContextAccessor _circuitContext;
public ServerAuditService(
IAuditLogPublisher publisher,
IAuditUserProvider userProvider,
ICircuitContextAccessor circuitContext)
{
_publisher = publisher;
_userProvider = userProvider;
_circuitContext = circuitContext;
}
public Task LogInsertAsync(string tableName, string rowId, object? newData,
string? schemaName = null, string? functionName = null)
=> PublishAsync(schemaName ?? "dbo", tableName, rowId, "INSERT",
null, newData, null, functionName);
public Task LogUpdateAsync(string tableName, string rowId,
object? oldData, object? newData,
string? schemaName = null, string? primaryKeyColumns = null,
string? functionName = null)
=> PublishAsync(schemaName ?? "dbo", tableName, rowId, "UPDATE",
oldData, newData, primaryKeyColumns, functionName);
public Task LogDeleteAsync(string tableName, string rowId, object? oldData,
string? schemaName = null, string? functionName = null)
=> PublishAsync(schemaName ?? "dbo", tableName, rowId, "DELETE",
oldData, null, null, functionName);
public Task LogCustomAsync(string tableName, string operation,
string? rowId = null, object? oldData = null, object? newData = null,
string? schemaName = null, string? functionName = null)
=> PublishAsync(schemaName ?? "dbo", tableName, rowId ?? "", operation,
oldData, newData, null, functionName);
private async Task PublishAsync(string schemaName, string tableName,
string rowId, string operation,
object? oldData, object? newData,
string? primaryKeyColumns, string? functionName)
{
try
{
await _publisher.PublishAsync(
schemaName: schemaName,
tableName: tableName,
rowId: rowId,
operation: operation,
oldData: Serialize(oldData),
newData: Serialize(newData),
primaryKeyColumns: primaryKeyColumns,
functionName: functionName ?? "BlazorServer");
}
catch
{
// Swallow - audit failure không break UI
}
}
private static string? Serialize(object? data)
=> data is null ? null
: data is string s ? s
: JsonSerializer.Serialize(data);
}
File 4: ApiAuditService.cs — API-only Variant
Location:
src/INS.Backend/Services/ApiAuditService.cs
Flow:Direct inject IAuditLogPublisher → RabbitMQ
using System.Text.Json;
using INS.Shared.Services;
namespace INS.Backend.Services;
/// <summary>
/// API-only implementation: inject IAuditLogPublisher + IRequestContextAccessor.
/// Auto-enrich từ HTTP context (đã có CorrelationIdMiddleware).
///
/// Dùng khi: Pure API backend (không có Blazor component).
/// DI Lifetime: Scoped (per-request)
/// </summary>
public class ApiAuditService : IAuditService
{
private readonly IAuditLogPublisher _publisher;
public ApiAuditService(IAuditLogPublisher publisher)
{
_publisher = publisher;
}
public Task LogInsertAsync(string tableName, string rowId, object? newData,
string? schemaName = null, string? functionName = null)
=> _publisher.PublishAsync(schemaName ?? "dbo", tableName, rowId,
"INSERT", null, Serialize(newData), null, functionName);
public Task LogUpdateAsync(string tableName, string rowId,
object? oldData, object? newData,
string? schemaName = null, string? primaryKeyColumns = null,
string? functionName = null)
=> _publisher.PublishAsync(schemaName ?? "dbo", tableName, rowId,
"UPDATE", Serialize(oldData), Serialize(newData), primaryKeyColumns, functionName);
public Task LogDeleteAsync(string tableName, string rowId, object? oldData,
string? schemaName = null, string? functionName = null)
=> _publisher.PublishAsync(schemaName ?? "dbo", tableName, rowId,
"DELETE", Serialize(oldData), null, null, functionName);
public Task LogCustomAsync(string tableName, string operation,
string? rowId = null, object? oldData = null, object? newData = null,
string? schemaName = null, string? functionName = null)
=> _publisher.PublishAsync(schemaName ?? "dbo", tableName, rowId ?? "",
operation, Serialize(oldData), Serialize(newData), null, functionName);
private static string? Serialize(object? data)
=> data is null ? null
: data is string s ? s
: JsonSerializer.Serialize(data);
}
File 5: IAuditUserProvider.cs — User Context Abstraction
Location:
lib/INS.Shared/Services/IAuditUserProvider.cs
namespace INS.Shared.Services
{
/// <summary>
/// Abstraction để lấy thông tin user hiện tại, hoạt động trên cả 3 platform.
/// Blazor WASM/Server: từ AuthenticationStateProvider
/// API: từ HttpContext.User
/// </summary>
public interface IAuditUserProvider
{
Task<AuditUserInfo> GetCurrentUserAsync();
}
public record AuditUserInfo(
string? UserId,
string? Username,
string? IpAddress = null,
string? SessionId = null);
}
5a. Blazor Implementation (cả WASM và Server)
Location:
lib/INS.DXComponents/Logging/BlazorAuditUserProvider.cs
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;
using INS.Shared.Services;
namespace INS.DXComponents.Logging;
/// <summary>
/// Lấy user info từ AuthenticationStateProvider (hoạt động cả WASM + Server)
/// </summary>
public class BlazorAuditUserProvider : IAuditUserProvider
{
private readonly AuthenticationStateProvider _authState;
public BlazorAuditUserProvider(AuthenticationStateProvider authState)
{
_authState = authState;
}
public async Task<AuditUserInfo> GetCurrentUserAsync()
{
var state = await _authState.GetAuthenticationStateAsync();
var user = state.User;
if (user.Identity?.IsAuthenticated != true)
return new AuditUserInfo(null, "Anonymous");
return new AuditUserInfo(
UserId: user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
Username: user.FindFirst(ClaimTypes.Name)?.Value
?? user.FindFirst("preferred_username")?.Value
);
}
}
5b. API Implementation
Location:
src/INS.Backend/Services/ApiAuditUserProvider.cs
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
using INS.Shared.Services;
namespace INS.Backend.Services;
/// <summary>
/// Lấy user info từ HttpContext (API-only, có HTTP pipeline)
/// </summary>
public class ApiAuditUserProvider : IAuditUserProvider
{
private readonly IHttpContextAccessor _httpContext;
public ApiAuditUserProvider(IHttpContextAccessor httpContext)
{
_httpContext = httpContext;
}
public Task<AuditUserInfo> GetCurrentUserAsync()
{
var user = _httpContext.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return Task.FromResult(new AuditUserInfo(null, "System"));
var ip = _httpContext.HttpContext?.Connection?.RemoteIpAddress?.ToString();
return Task.FromResult(new AuditUserInfo(
UserId: user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
Username: user.FindFirst(ClaimTypes.Name)?.Value,
IpAddress: ip
));
}
}
File 6: ICircuitContextAccessor.cs — Blazor Server Context
Location:
lib/INS.DXComponents/Logging/ICircuitContextAccessor.cs
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.Http;
namespace INS.DXComponents.Logging;
/// <summary>
/// Blazor Server thay thế cho IRequestContextAccessor.
/// Capture HttpContext tại thời điểm kết nối SignalR (initial connection).
/// SAU ĐÓ SignalR không còn HttpContext → phải lưu lại.
/// </summary>
public interface ICircuitContextAccessor
{
string? CorrelationId { get; }
string? IpAddress { get; }
string? UserAgent { get; }
string? SessionId { get; }
string CircuitId { get; }
}
/// <summary>
/// Capture context từ HttpContext lúc circuit mở.
/// Registered: Scoped (per-circuit)
/// </summary>
public class CircuitContextAccessor : ICircuitContextAccessor
{
public string? CorrelationId { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? SessionId { get; set; }
public string CircuitId { get; set; } = "";
}
/// <summary>
/// CircuitHandler populate CircuitContextAccessor khi circuit mở.
/// Capture IP, UserAgent, SessionId từ initial HTTP handshake.
/// </summary>
public class AuditCircuitHandler : CircuitHandler
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly CircuitContextAccessor _circuitContext;
public AuditCircuitHandler(
IHttpContextAccessor httpContextAccessor,
CircuitContextAccessor circuitContext)
{
_httpContextAccessor = httpContextAccessor;
_circuitContext = circuitContext;
}
public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken ct)
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext != null)
{
_circuitContext.CircuitId = circuit.Id;
_circuitContext.IpAddress = httpContext.Connection.RemoteIpAddress?.ToString();
_circuitContext.UserAgent = httpContext.Request.Headers.UserAgent.ToString();
_circuitContext.SessionId = httpContext.Session?.Id;
_circuitContext.CorrelationId = httpContext.Request.Headers["X-Correlation-ID"]
.FirstOrDefault() ?? Guid.NewGuid().ToString("N");
}
return Task.CompletedTask;
}
}
File 7: AuditServiceExtensions.cs — DI Registration
Location:
lib/INS.DXComponents/Logging/AuditServiceExtensions.cs
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.Extensions.DependencyInjection;
using INS.Shared.Services;
namespace INS.DXComponents.Logging;
public static class AuditServiceExtensions
{
/// <summary>
/// Blazor WASM: audit log qua HTTP → Module Controller → gRPC → RabbitMQ
/// Yêu cầu: IHttpClientFactory đã cấu hình "AuthenticatedClient"
/// </summary>
public static IServiceCollection AddAuditService_BlazorWasm(this IServiceCollection services)
{
services.AddScoped<IAuditUserProvider, BlazorAuditUserProvider>();
services.AddScoped<IAuditService, WasmAuditService>();
return services;
}
/// <summary>
/// Blazor Server: audit log direct inject → IAuditLogPublisher → RabbitMQ
/// Yêu cầu: IAuditLogPublisher, IRabbitMQService đã đăng ký
/// </summary>
public static IServiceCollection AddAuditService_BlazorServer(this IServiceCollection services)
{
services.AddHttpContextAccessor();
// Context capture từ SignalR initial connection
services.AddScoped<CircuitContextAccessor>();
services.AddScoped<ICircuitContextAccessor>(sp => sp.GetRequiredService<CircuitContextAccessor>());
services.AddScoped<CircuitHandler, AuditCircuitHandler>();
// Audit services
services.AddScoped<IAuditUserProvider, BlazorAuditUserProvider>();
services.AddScoped<IAuditService, ServerAuditService>();
return services;
}
/// <summary>
/// API-only: audit log direct inject → IAuditLogPublisher → RabbitMQ
/// Yêu cầu: IAuditLogPublisher, IRequestContextAccessor đã đăng ký
/// </summary>
public static IServiceCollection AddAuditService_Api(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddScoped<IAuditUserProvider, ApiAuditUserProvider>();
services.AddScoped<IAuditService, ApiAuditService>();
return services;
}
}
File 8: INS_AuditLogger.razor — Updated Component (Multi-Platform)
Location:
lib/INS.DXComponents/Logging/INS_AuditLogger.razor
Thay đổi: Sử dụngIAuditServicethay vìLogServicetrực tiếp
@using INS.Shared.Services
@inject IAuditService AuditService
@code {
/// <summary>
/// Component ẩn để tự động log audit khi có thay đổi dữ liệu.
/// Hoạt động trên CẢ 3 platform (WASM, Server, API).
/// Sử dụng: <INS_AuditLogger @ref="auditLogger" />
/// </summary>
public Task LogInsertAsync(string tableName, string? rowId, object? newValue)
=> AuditService.LogInsertAsync(tableName, rowId ?? "", newValue);
public Task LogUpdateAsync(string tableName, string? rowId,
object? oldValue, object? newValue)
=> AuditService.LogUpdateAsync(tableName, rowId ?? "", oldValue, newValue);
public Task LogDeleteAsync(string tableName, string? rowId, object? oldValue)
=> AuditService.LogDeleteAsync(tableName, rowId ?? "", oldValue);
public Task LogCustomAsync(string tableName, string operation,
string? rowId = null, object? oldValue = null, object? newValue = null)
=> AuditService.LogCustomAsync(tableName, operation, rowId, oldValue, newValue);
}
Cấu hình Program.cs theo nền tảng
Blazor WASM
// Program.cs (Client project)
builder.Services.AddAuditService_BlazorWasm();
Blazor Server
// Program.cs (Server project)
// Prerequisites
builder.Services.AddScoped<IRequestContextAccessor, RequestContextAccessor>();
builder.Services.AddSingleton<IRollbackSqlGenerator, RollbackSqlGenerator>();
builder.Services.AddScoped<IAuditLogPublisher, AuditLogPublisher>();
// Audit service
builder.Services.AddAuditService_BlazorServer();
API-only
// Program.cs (API project)
// Prerequisites
builder.Services.AddScoped<IRequestContextAccessor, RequestContextAccessor>();
builder.Services.AddSingleton<IRollbackSqlGenerator, RollbackSqlGenerator>();
builder.Services.AddScoped<IAuditLogPublisher, AuditLogPublisher>();
// Middleware pipeline
app.UseCorrelationId();
app.UseActivityLogging();
// Audit service
builder.Services.AddAuditService_Api();
Usage Pattern — Giống nhau trên cả 3 platform
Blazor Page
<INS_AuditLogger @ref="auditLogger" />
@code {
private INS_AuditLogger auditLogger = default!;
async Task SaveUser(UserDto user)
{
var oldData = await LoadUser(user.Id);
await UpdateUserInDb(user);
await auditLogger.LogUpdateAsync("Users", user.Id.ToString(), oldData, user);
}
}
API Controller (inject trực tiếp)
[ApiController]
public class UsersController : ControllerBase
{
private readonly IAuditService _audit;
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, UserDto dto)
{
var old = await _repo.GetById(id);
await _repo.Update(dto);
await _audit.LogUpdateAsync("Users", id.ToString(), old, dto,
functionName: nameof(Update));
return Ok();
}
}
So sánh 3 variants
| Aspect | Blazor WASM | Blazor Server | API-only |
|---|---|---|---|
| Class | WasmAuditService |
ServerAuditService |
ApiAuditService |
| Transport | HTTP → gRPC → RMQ | Direct → RMQ | Direct → RMQ |
| Network hop | 2 (HTTP + gRPC) | 0 | 0 |
| Latency | ~10-50ms | ~1-5ms | ~1-5ms |
| User context | BlazorAuditUserProvider |
BlazorAuditUserProvider |
ApiAuditUserProvider |
| Request context | N/A (client-side) | ICircuitContextAccessor |
IRequestContextAccessor |
| CorrelationId | From server response header | From initial SignalR handshake | From CorrelationIdMiddleware |
| Requires RabbitMQ | ❌ (module backend has it) | ✅ Direct | ✅ Direct |
| Dependencies | IHttpClientFactory |
IAuditLogPublisher |
IAuditLogPublisher |
| Registration | AddAuditService_BlazorWasm() |
AddAuditService_BlazorServer() |
AddAuditService_Api() |