forked from gongxuegit/tiku-backend.net
feat: add runtime logging cors and zlinq foundation
This commit is contained in:
@@ -29,11 +29,17 @@
|
|||||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||||
|
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.2" />
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.2" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageVersion>
|
</PackageVersion>
|
||||||
|
<PackageVersion Include="ZLinq" Version="1.5.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -27,6 +27,8 @@
|
|||||||
- Entity Framework Core
|
- Entity Framework Core
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- Npgsql
|
- Npgsql
|
||||||
|
- Serilog
|
||||||
|
- ZLinq
|
||||||
- xUnit
|
- xUnit
|
||||||
|
|
||||||
项目分层:
|
项目分层:
|
||||||
@@ -42,6 +44,43 @@ Tiku.UnitTests # 单元测试
|
|||||||
Tiku.IntegrationTests # EF 模型/持久化约束测试
|
Tiku.IntegrationTests # EF 模型/持久化约束测试
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 运行时地基
|
||||||
|
|
||||||
|
这次迁移不只是把 Node/Nest/Supabase 换成 C#,而是把旧系统没有认真处理的运行时基础补起来。
|
||||||
|
|
||||||
|
### 数据库连接
|
||||||
|
|
||||||
|
- API 通过单例 `NpgsqlDataSource` 管理 PostgreSQL 连接池。
|
||||||
|
- EF Core 使用 `AddDbContextPool<TikuDbContext>`,避免每个请求重复构造完整 DbContext 依赖图。
|
||||||
|
- 连接池参数继续交给 PostgreSQL/Npgsql connection string 配置,例如 `Maximum Pool Size`、`Minimum Pool Size`、`Timeout`、`Command Timeout`。
|
||||||
|
- 业务代码不直接 new 连接,不绕过统一的 EF / Npgsql 配置入口。
|
||||||
|
|
||||||
|
### 日志
|
||||||
|
|
||||||
|
- API 接入 Serilog 结构化日志。
|
||||||
|
- 启动阶段使用 bootstrap logger,避免应用启动失败时完全没有日志。
|
||||||
|
- 请求日志统一记录 HTTP method、path、status code、elapsed 等基础字段。
|
||||||
|
- 请求日志会补充当前用户、租户、Session、租户角色、TraceId 等上下文,后续排查“某个机构某个用户某次请求”会比旧方案清楚很多。
|
||||||
|
- EF SQL、Microsoft 框架日志默认降噪;开发环境可提高 EF command 日志等级。
|
||||||
|
|
||||||
|
### 跨域
|
||||||
|
|
||||||
|
- CORS 作为 API 安全边界配置,不在 Controller 里散写。
|
||||||
|
- 生产默认不放行任何 Origin,避免开发便利配置意外带到线上。
|
||||||
|
- 开发环境默认允许本地前端常用端口:
|
||||||
|
- `http://localhost:5173`
|
||||||
|
- `http://127.0.0.1:5173`
|
||||||
|
- `http://localhost:3000`
|
||||||
|
- `http://127.0.0.1:3000`
|
||||||
|
- 不默认允许 credentials;如果后续需要 cookie 模式或管理后台单独域名,需要显式配置。
|
||||||
|
- 多租户正式域名确定后,可以把 CORS 白名单从静态配置升级为“租户域名 + 平台管理域名”的集中策略。
|
||||||
|
|
||||||
|
### 热路径集合处理
|
||||||
|
|
||||||
|
- API 项目引入 ZLinq,作为低分配集合处理工具。
|
||||||
|
- 当前先在小范围权限判断里落模板,后续题库筛选、权限集合、菜单/内容树投影等热路径再逐步使用。
|
||||||
|
- 不是为了炫技替换所有 LINQ;只在明确高频、低收益分配明显的路径使用。
|
||||||
|
|
||||||
## 数据库策略
|
## 数据库策略
|
||||||
|
|
||||||
当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储:
|
当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储:
|
||||||
|
|||||||
45
Tiku.Api/Logging/SerilogRequestLogging.cs
Normal file
45
Tiku.Api/Logging/SerilogRequestLogging.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Serilog.AspNetCore;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Logging;
|
||||||
|
|
||||||
|
public static class SerilogRequestLogging
|
||||||
|
{
|
||||||
|
public static void ConfigureRequestLogging(RequestLoggingOptions options)
|
||||||
|
{
|
||||||
|
options.GetLevel = (httpContext, elapsed, exception) =>
|
||||||
|
exception is not null || httpContext.Response.StatusCode >= StatusCodes.Status500InternalServerError
|
||||||
|
? LogEventLevel.Error
|
||||||
|
: elapsed > 1000 || httpContext.Response.StatusCode >= StatusCodes.Status400BadRequest
|
||||||
|
? LogEventLevel.Warning
|
||||||
|
: LogEventLevel.Information;
|
||||||
|
|
||||||
|
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||||
|
{
|
||||||
|
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
|
||||||
|
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
|
||||||
|
diagnosticContext.Set("TraceId", httpContext.TraceIdentifier);
|
||||||
|
|
||||||
|
var user = httpContext.User;
|
||||||
|
SetClaim(diagnosticContext, "UserId", user, TikuClaimTypes.UserId);
|
||||||
|
SetClaim(diagnosticContext, "TenantId", user, TikuClaimTypes.TenantId);
|
||||||
|
SetClaim(diagnosticContext, "SessionId", user, TikuClaimTypes.SessionId);
|
||||||
|
SetClaim(diagnosticContext, "TenantRole", user, TikuClaimTypes.TenantRole);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetClaim(
|
||||||
|
IDiagnosticContext diagnosticContext,
|
||||||
|
string propertyName,
|
||||||
|
System.Security.Claims.ClaimsPrincipal principal,
|
||||||
|
string claimType)
|
||||||
|
{
|
||||||
|
var value = principal.FindFirst(claimType)?.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
diagnosticContext.Set(propertyName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Tiku.Api/Options/CorsOptions.cs
Normal file
12
Tiku.Api/Options/CorsOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Tiku.Api.Options;
|
||||||
|
|
||||||
|
public sealed class CorsOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Cors";
|
||||||
|
public const string PolicyName = "TikuCors";
|
||||||
|
|
||||||
|
public string[] AllowedOrigins { get; set; } = [];
|
||||||
|
public string[] AllowedHeaders { get; set; } = ["Authorization", "Content-Type", "x-tenant-code"];
|
||||||
|
public string[] AllowedMethods { get; set; } = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||||
|
public bool AllowCredentials { get; set; }
|
||||||
|
}
|
||||||
@@ -2,125 +2,185 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Tiku.Api.Logging;
|
||||||
using Tiku.Api.Middleware;
|
using Tiku.Api.Middleware;
|
||||||
using Tiku.Api.OpenApi;
|
using Tiku.Api.OpenApi;
|
||||||
|
using Tiku.Api.Options;
|
||||||
using Tiku.Api.Security;
|
using Tiku.Api.Security;
|
||||||
using Tiku.Application;
|
using Tiku.Application;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Infrastructure;
|
using Tiku.Infrastructure;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.CreateBootstrapLogger();
|
||||||
|
|
||||||
builder.Services.AddControllers()
|
try
|
||||||
.AddJsonOptions(options =>
|
|
||||||
{
|
|
||||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
|
||||||
});
|
|
||||||
builder.Services.AddOpenApi(options =>
|
|
||||||
{
|
{
|
||||||
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
|
Log.Information("Starting TIKU API");
|
||||||
});
|
|
||||||
builder.Services.AddProblemDetails();
|
|
||||||
builder.Services.AddApplication();
|
|
||||||
|
|
||||||
var connectionString =
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Configuration.GetConnectionString("Database") ??
|
builder.Services.AddSerilog((services, configuration) => configuration
|
||||||
builder.Configuration["DATABASE_URL"] ??
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
"Host=localhost;Database=tiku;Username=postgres";
|
.ReadFrom.Services(services)
|
||||||
|
.Enrich.FromLogContext(),
|
||||||
|
preserveStaticLogger: true);
|
||||||
|
|
||||||
builder.Services.AddInfrastructure(connectionString);
|
builder.Services.AddControllers()
|
||||||
|
.AddJsonOptions(options =>
|
||||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
|
{
|
||||||
var jwtOptions = builder.Configuration
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
.GetSection("Security:Jwt")
|
});
|
||||||
.Get<JwtOptions>() ?? new JwtOptions();
|
builder.Services.AddOpenApi(options =>
|
||||||
|
|
||||||
builder.Services
|
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
||||||
.AddJwtBearer(options =>
|
|
||||||
{
|
{
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
|
||||||
|
});
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddApplication();
|
||||||
|
builder.Services.Configure<CorsOptions>(builder.Configuration.GetSection(CorsOptions.SectionName));
|
||||||
|
var corsOptions = builder.Configuration
|
||||||
|
.GetSection(CorsOptions.SectionName)
|
||||||
|
.Get<CorsOptions>() ?? new CorsOptions();
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(CorsOptions.PolicyName, policy =>
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
var origins = corsOptions.AllowedOrigins
|
||||||
ValidIssuer = jwtOptions.Issuer,
|
.Where(origin => !string.IsNullOrWhiteSpace(origin))
|
||||||
ValidateAudience = true,
|
.Select(origin => origin.Trim().TrimEnd('/'))
|
||||||
ValidAudience = jwtOptions.Audience,
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
ValidateIssuerSigningKey = true,
|
.ToArray();
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
|
||||||
ValidateLifetime = true,
|
if (origins.Length > 0)
|
||||||
ClockSkew = TimeSpan.FromMinutes(1)
|
|
||||||
};
|
|
||||||
options.Events = new JwtBearerEvents
|
|
||||||
{
|
|
||||||
OnTokenValidated = async context =>
|
|
||||||
{
|
{
|
||||||
if (!jwtOptions.ValidateSessions)
|
policy.WithOrigins(origins);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
|
|
||||||
if (!Guid.TryParse(sessionIdValue, out var sessionId))
|
|
||||||
{
|
|
||||||
context.Fail("Missing session claim.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
|
|
||||||
session =>
|
|
||||||
session.Id == sessionId &&
|
|
||||||
session.RevokedAt == null &&
|
|
||||||
session.ExpiresAt > now);
|
|
||||||
|
|
||||||
if (!isSessionActive)
|
|
||||||
{
|
|
||||||
context.Fail("Session has been revoked or expired.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
policy
|
||||||
|
.WithHeaders(corsOptions.AllowedHeaders)
|
||||||
|
.WithMethods(corsOptions.AllowedMethods);
|
||||||
|
|
||||||
|
if (corsOptions.AllowCredentials)
|
||||||
|
{
|
||||||
|
policy.AllowCredentials();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthorization(options =>
|
var connectionString =
|
||||||
{
|
builder.Configuration.GetConnectionString("Database") ??
|
||||||
options.AddPolicy(
|
builder.Configuration["DATABASE_URL"] ??
|
||||||
TikuPolicies.AuthenticatedUser,
|
"Host=localhost;Database=tiku;Username=postgres";
|
||||||
policy => policy.RequireAuthenticatedUser());
|
|
||||||
options.AddPolicy(
|
|
||||||
TikuPolicies.CurrentTenantMember,
|
|
||||||
policy => policy
|
|
||||||
.RequireAuthenticatedUser()
|
|
||||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
|
|
||||||
options.AddPolicy(
|
|
||||||
TikuPolicies.TenantAdmin,
|
|
||||||
policy => policy
|
|
||||||
.RequireAuthenticatedUser()
|
|
||||||
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
builder.Services.AddInfrastructure(connectionString);
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Security:Jwt"));
|
||||||
|
var jwtOptions = builder.Configuration
|
||||||
|
.GetSection("Security:Jwt")
|
||||||
|
.Get<JwtOptions>() ?? new JwtOptions();
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = jwtOptions.Issuer,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = jwtOptions.Audience,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1)
|
||||||
|
};
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnTokenValidated = async context =>
|
||||||
|
{
|
||||||
|
if (!jwtOptions.ValidateSessions)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionIdValue = context.Principal?.FindFirst(TikuClaimTypes.SessionId)?.Value;
|
||||||
|
if (!Guid.TryParse(sessionIdValue, out var sessionId))
|
||||||
|
{
|
||||||
|
context.Fail("Missing session claim.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbContext = context.HttpContext.RequestServices.GetRequiredService<TikuDbContext>();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var isSessionActive = await dbContext.AuthSessions.AnyAsync(
|
||||||
|
session =>
|
||||||
|
session.Id == sessionId &&
|
||||||
|
session.RevokedAt == null &&
|
||||||
|
session.ExpiresAt > now);
|
||||||
|
|
||||||
|
if (!isSessionActive)
|
||||||
|
{
|
||||||
|
context.Fail("Session has been revoked or expired.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(
|
||||||
|
TikuPolicies.AuthenticatedUser,
|
||||||
|
policy => policy.RequireAuthenticatedUser());
|
||||||
|
options.AddPolicy(
|
||||||
|
TikuPolicies.CurrentTenantMember,
|
||||||
|
policy => policy
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.RequireAssertion(context => TenantRoleAuthorization.IsTenantMember(context.User)));
|
||||||
|
options.AddPolicy(
|
||||||
|
TikuPolicies.TenantAdmin,
|
||||||
|
policy => policy
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.RequireAssertion(context => TenantRoleAuthorization.IsTenantAdmin(context.User)));
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
app.MapScalarApiReference(options => options
|
||||||
|
.WithTitle("TIKU Backend API")
|
||||||
|
.AddPreferredSecuritySchemes("BearerAuth")
|
||||||
|
.EnablePersistentAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
||||||
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseCors(CorsOptions.PolicyName);
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
Log.Fatal(exception, "TIKU API terminated unexpectedly");
|
||||||
app.MapScalarApiReference(options => options
|
throw;
|
||||||
.WithTitle("TIKU Backend API")
|
}
|
||||||
.AddPreferredSecuritySchemes("BearerAuth")
|
finally
|
||||||
.EnablePersistentAuthentication());
|
{
|
||||||
|
Log.CloseAndFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
using ZLinq;
|
||||||
|
|
||||||
namespace Tiku.Api.Security;
|
namespace Tiku.Api.Security;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ internal static class TenantRoleAuthorization
|
|||||||
public static bool IsTenantAdmin(ClaimsPrincipal principal)
|
public static bool IsTenantAdmin(ClaimsPrincipal principal)
|
||||||
{
|
{
|
||||||
return IsTenantMember(principal) &&
|
return IsTenantMember(principal) &&
|
||||||
principal.Claims
|
principal.Claims.AsValueEnumerable()
|
||||||
.Where(claim => claim.Type == TikuClaimTypes.TenantRole)
|
.Where(claim => claim.Type == TikuClaimTypes.TenantRole)
|
||||||
.Select(claim => claim.Value)
|
.Select(claim => claim.Value)
|
||||||
.Any(role => AdminRoles.Contains(role));
|
.Any(role => AdminRoles.Contains(role));
|
||||||
|
|||||||
@@ -16,7 +16,13 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="Microsoft.OpenApi" />
|
<PackageReference Include="Microsoft.OpenApi" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" />
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||||
|
<PackageReference Include="Serilog.Enrichers.Thread" />
|
||||||
|
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
|
<PackageReference Include="ZLinq" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Debug",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": {
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||||
|
"System.Net.Http.HttpClient": "Warning"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000"
|
||||||
|
],
|
||||||
|
"AllowCredentials": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,48 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": {
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
||||||
|
"System.Net.Http.HttpClient": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Enrich": [
|
||||||
|
"FromLogContext",
|
||||||
|
"WithMachineName",
|
||||||
|
"WithThreadId"
|
||||||
|
],
|
||||||
|
"WriteTo": [
|
||||||
|
{
|
||||||
|
"Name": "Console",
|
||||||
|
"Args": {
|
||||||
|
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Properties": {
|
||||||
|
"Application": "Tiku.Api"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [],
|
||||||
|
"AllowedHeaders": [
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"x-tenant-code"
|
||||||
|
],
|
||||||
|
"AllowedMethods": [
|
||||||
|
"GET",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
"PATCH",
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
],
|
||||||
|
"AllowCredentials": false
|
||||||
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "tiku-backend",
|
"Issuer": "tiku-backend",
|
||||||
|
|||||||
@@ -95,6 +95,21 @@ public sealed class TenantPublicEndpointTests
|
|||||||
Assert.Equal("ok", body.GetProperty("status").GetString());
|
Assert.Equal("ok", body.GetProperty("status").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cors_preflight_allows_configured_development_origin()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Options, "/api/health");
|
||||||
|
request.Headers.Add("Origin", "http://localhost:5173");
|
||||||
|
request.Headers.Add("Access-Control-Request-Method", "GET");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
Assert.Equal("http://localhost:5173", response.Headers.GetValues("Access-Control-Allow-Origin").Single());
|
||||||
|
}
|
||||||
|
|
||||||
private static Task SeedTenantAsync(ApiTestFactory factory, Guid tenantId)
|
private static Task SeedTenantAsync(ApiTestFactory factory, Guid tenantId)
|
||||||
{
|
{
|
||||||
return factory.SeedAsync(
|
return factory.SeedAsync(
|
||||||
|
|||||||
Reference in New Issue
Block a user