forked from gongxuegit/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -2,6 +2,150 @@ namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class ArchitectureBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Production_authorization_does_not_depend_on_legacy_role_claims()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var authorizationFiles = Directory
|
||||
.EnumerateFiles(Path.Combine(root, "Tiku.Api", "Security"), "*.cs", SearchOption.AllDirectories)
|
||||
.Append(Path.Combine(root, "Tiku.Api", "Program.cs"));
|
||||
var forbidden = new[]
|
||||
{
|
||||
"TikuClaimTypes.TenantRole",
|
||||
"TenantRoleAuthorization",
|
||||
"PrimaryRole"
|
||||
};
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
authorizationFiles,
|
||||
forbidden,
|
||||
"Production authorization still depends on a legacy role claim or primary role");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backoffice_controllers_do_not_construct_platform_access_flags()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var controllerFiles = Directory.EnumerateFiles(
|
||||
Path.Combine(root, "Tiku.Api", "Controllers"),
|
||||
"*Backoffice*Controller.cs",
|
||||
SearchOption.AllDirectories);
|
||||
var forbidden = new[]
|
||||
{
|
||||
"new BackofficeActor(",
|
||||
"IsPlatformAdmin(",
|
||||
"IsPlatform ="
|
||||
};
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
controllerFiles,
|
||||
forbidden,
|
||||
"Backoffice controllers must use the resolved access context instead of constructing platform access flags");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackendAuthorizationDoesNotUseMembershipBusinessRoles()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var files = new[]
|
||||
{
|
||||
Path.Combine(root, "Tiku.Infrastructure", "Growth", "CommissionService.cs"),
|
||||
Path.Combine(root, "Tiku.Infrastructure", "Growth", "ReferralService.cs"),
|
||||
Path.Combine(root, "Tiku.Application", "TenantAdmin", "TenantAdminDirectModels.cs"),
|
||||
Path.Combine(root, "Tiku.Api", "Controllers", "TenantAdminDirectController.cs")
|
||||
};
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
files,
|
||||
new[]
|
||||
{
|
||||
"item.Role == TenantRole.TenantOwner",
|
||||
"item.Role == TenantRole.TenantAdmin",
|
||||
"TenantRole Role = TenantRole.TenantAdmin",
|
||||
"new TenantAdminActor("
|
||||
},
|
||||
"Backend authorization must use database role permissions rather than membership business roles or fabricated admin actors");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Auth_sessions_are_accessed_only_through_the_session_store()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure", "Tiku.Worker" };
|
||||
var allowedFiles = new[]
|
||||
{
|
||||
"TikuDbContext.cs",
|
||||
"AuthSessionStore.cs",
|
||||
"SessionStore.cs"
|
||||
};
|
||||
|
||||
var files = sourceRoots
|
||||
.SelectMany(directory => Directory.EnumerateFiles(
|
||||
Path.Combine(root, directory),
|
||||
"*.cs",
|
||||
SearchOption.AllDirectories))
|
||||
.Where(path => !path.Contains(
|
||||
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
|
||||
StringComparison.Ordinal))
|
||||
.Where(path => !path.Contains(
|
||||
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Configurations{Path.DirectorySeparatorChar}",
|
||||
StringComparison.Ordinal))
|
||||
.Where(path => !allowedFiles.Contains(Path.GetFileName(path), StringComparer.Ordinal));
|
||||
|
||||
AssertNoForbiddenSymbols(
|
||||
root,
|
||||
files,
|
||||
new[] { ".AuthSessions", "Set<AuthSession>" },
|
||||
"AuthSession DbSet access must be encapsulated by IAuthSessionStore");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Api_uses_an_authenticated_fallback_policy()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var program = File.ReadAllText(Path.Combine(root, "Tiku.Api", "Program.cs"));
|
||||
|
||||
Assert.Contains("FallbackPolicy", program, StringComparison.Ordinal);
|
||||
Assert.Contains("RequireAuthenticatedUser()", program, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_controller_action_declares_authorization_or_anonymous_access()
|
||||
{
|
||||
var controllerAssembly = typeof(Tiku.Api.Controllers.AuthController).Assembly;
|
||||
var violations = controllerAssembly
|
||||
.GetTypes()
|
||||
.Where(type => !type.IsAbstract && typeof(Microsoft.AspNetCore.Mvc.ControllerBase).IsAssignableFrom(type))
|
||||
.SelectMany(type => type
|
||||
.GetMethods(System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.Public |
|
||||
System.Reflection.BindingFlags.DeclaredOnly)
|
||||
.Where(method => method
|
||||
.GetCustomAttributes(inherit: true)
|
||||
.OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
|
||||
.Any())
|
||||
.Select(method => new
|
||||
{
|
||||
Controller = type,
|
||||
Action = method,
|
||||
Metadata = type.GetCustomAttributes(inherit: true)
|
||||
.Concat(method.GetCustomAttributes(inherit: true))
|
||||
}))
|
||||
.Where(candidate => !candidate.Metadata.Any(attribute =>
|
||||
attribute is Microsoft.AspNetCore.Authorization.IAuthorizeData or
|
||||
Microsoft.AspNetCore.Authorization.IAllowAnonymous))
|
||||
.Select(candidate => $"{candidate.Controller.FullName}.{candidate.Action.Name}")
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
violations.Length == 0,
|
||||
$"Controller actions without explicit authorization metadata were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Business_code_does_not_bypass_tenant_query_boundaries()
|
||||
{
|
||||
@@ -150,4 +294,23 @@ public sealed class ArchitectureBoundaryTests
|
||||
|
||||
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
|
||||
private static void AssertNoForbiddenSymbols(
|
||||
string root,
|
||||
IEnumerable<string> files,
|
||||
IReadOnlyCollection<string> forbidden,
|
||||
string failureMessage)
|
||||
{
|
||||
var violations = files
|
||||
.SelectMany(path => File.ReadLines(path)
|
||||
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
|
||||
.Where(candidate => forbidden.Any(symbol =>
|
||||
candidate.line.Contains(symbol, StringComparison.Ordinal)))
|
||||
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
violations.Length == 0,
|
||||
$"{failureMessage}:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user