|
@@ -43,7 +43,19 @@ public class S8AuthorizationGuardTests
|
|
|
return data;
|
|
return data;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private sealed record ActionInfo(Type Controller, MethodInfo Method, string Verb, string Template, string? Permission);
|
|
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// S8-ACTION-PERMISSION-1:一个 Action 现在可能挂两种门禁之一 ——
|
|
|
|
|
+ /// 配置面仍用能力码 <see cref="S8PermissionAttribute"/>,
|
|
|
|
|
+ /// 异常单动作改用 <see cref="S8ExceptionActionAttribute"/>(租户内 Action↔Role 判定)。
|
|
|
|
|
+ /// 两者<b>互斥</b>:同一个 Action 不允许同时挂,否则同一问题两个 authority。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ private sealed record ActionInfo(
|
|
|
|
|
+ Type Controller, MethodInfo Method, string Verb, string Template,
|
|
|
|
|
+ string? Permission, string? ExceptionAction)
|
|
|
|
|
+ {
|
|
|
|
|
+ /// <summary>是否已被任一门禁保护。未保护 = 退回平台「未登记路由默认放行」。</summary>
|
|
|
|
|
+ public bool Guarded => !string.IsNullOrWhiteSpace(Permission) || !string.IsNullOrWhiteSpace(ExceptionAction);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
private static IEnumerable<ActionInfo> AllActions()
|
|
private static IEnumerable<ActionInfo> AllActions()
|
|
|
{
|
|
{
|
|
@@ -57,7 +69,8 @@ public class S8AuthorizationGuardTests
|
|
|
var verb = http.HttpMethods.FirstOrDefault() ?? "GET";
|
|
var verb = http.HttpMethods.FirstOrDefault() ?? "GET";
|
|
|
var template = (http as IRouteTemplateProvider)?.Template ?? string.Empty;
|
|
var template = (http as IRouteTemplateProvider)?.Template ?? string.Empty;
|
|
|
var perm = m.GetCustomAttribute<S8PermissionAttribute>()?.Code;
|
|
var perm = m.GetCustomAttribute<S8PermissionAttribute>()?.Code;
|
|
|
- yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm);
|
|
|
|
|
|
|
+ var act = m.GetCustomAttribute<S8ExceptionActionAttribute>()?.Code;
|
|
|
|
|
+ yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm, act);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -88,12 +101,20 @@ public class S8AuthorizationGuardTests
|
|
|
public void EveryS8Action_DeclaresAPermission()
|
|
public void EveryS8Action_DeclaresAPermission()
|
|
|
{
|
|
{
|
|
|
var missing = AllActions()
|
|
var missing = AllActions()
|
|
|
- .Where(a => string.IsNullOrWhiteSpace(a.Permission))
|
|
|
|
|
|
|
+ .Where(a => !a.Guarded)
|
|
|
.Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb} {a.Template}]")
|
|
.Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb} {a.Template}]")
|
|
|
.ToList();
|
|
.ToList();
|
|
|
|
|
|
|
|
Assert.True(missing.Count == 0,
|
|
Assert.True(missing.Count == 0,
|
|
|
- "以下 S8 Action 未声明 [S8Permission],将退回平台『未登记路由默认放行』:\n" + string.Join('\n', missing));
|
|
|
|
|
|
|
+ "以下 S8 Action 未声明 [S8Permission] 或 [S8ExceptionAction],将退回平台『未登记路由默认放行』:\n"
|
|
|
|
|
+ + string.Join('\n', missing));
|
|
|
|
|
+
|
|
|
|
|
+ // S8-ACTION-PERMISSION-1:两种门禁互斥。同时挂 = 同一问题两个 authority,判据迟早分叉。
|
|
|
|
|
+ var doubled = AllActions()
|
|
|
|
|
+ .Where(a => !string.IsNullOrWhiteSpace(a.Permission) && !string.IsNullOrWhiteSpace(a.ExceptionAction))
|
|
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name}")
|
|
|
|
|
+ .ToList();
|
|
|
|
|
+ Assert.True(doubled.Count == 0, "以下 Action 同时挂了两种门禁:\n" + string.Join('\n', doubled));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>声明的权限码必须在正式目录内,杜绝写错字符串导致永远匹配不到。</summary>
|
|
/// <summary>声明的权限码必须在正式目录内,杜绝写错字符串导致永远匹配不到。</summary>
|
|
@@ -107,6 +128,13 @@ public class S8AuthorizationGuardTests
|
|
|
.Distinct().ToList();
|
|
.Distinct().ToList();
|
|
|
|
|
|
|
|
Assert.True(unknown.Count == 0, "以下权限码不在 S8PermissionCatalog 内:\n" + string.Join('\n', unknown));
|
|
Assert.True(unknown.Count == 0, "以下权限码不在 S8PermissionCatalog 内:\n" + string.Join('\n', unknown));
|
|
|
|
|
+
|
|
|
|
|
+ var unknownActions = AllActions()
|
|
|
|
|
+ .Where(a => a.ExceptionAction != null && !S8ExceptionActionCatalog.IsKnown(a.ExceptionAction))
|
|
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.ExceptionAction}")
|
|
|
|
|
+ .Distinct().ToList();
|
|
|
|
|
+ Assert.True(unknownActions.Count == 0,
|
|
|
|
|
+ "以下动作码不在 S8ExceptionActionCatalog 内:\n" + string.Join('\n', unknownActions));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ───────────────────────── ② 写动作不得落到只读能力 ─────────────────────────
|
|
// ───────────────────────── ② 写动作不得落到只读能力 ─────────────────────────
|
|
@@ -119,8 +147,13 @@ public class S8AuthorizationGuardTests
|
|
|
public void MutationActions_DoNotUseReadOnlyCapabilities()
|
|
public void MutationActions_DoNotUseReadOnlyCapabilities()
|
|
|
{
|
|
{
|
|
|
var offenders = AllActions()
|
|
var offenders = AllActions()
|
|
|
- .Where(a => a.Verb != "GET" && a.Permission != null && IsReadCapability(a.Permission))
|
|
|
|
|
- .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission}")
|
|
|
|
|
|
|
+ .Where(a => a.Verb != "GET"
|
|
|
|
|
+ && ((a.Permission != null && IsReadCapability(a.Permission))
|
|
|
|
|
+ // 新门禁同理:写动作不得落在"查看异常"上,
|
|
|
|
|
+ // 否则有查看权限的人就能改业务状态。
|
|
|
|
|
+ || a.ExceptionAction == S8ExceptionActionCode.View
|
|
|
|
|
+ || a.ExceptionAction == S8ExceptionActionCode.ViewAll))
|
|
|
|
|
+ .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission ?? a.ExceptionAction}")
|
|
|
.ToList();
|
|
.ToList();
|
|
|
|
|
|
|
|
Assert.True(offenders.Count == 0, "以下写动作落在只读能力上:\n" + string.Join('\n', offenders));
|
|
Assert.True(offenders.Count == 0, "以下写动作落在只读能力上:\n" + string.Join('\n', offenders));
|
|
@@ -170,15 +203,20 @@ public class S8AuthorizationGuardTests
|
|
|
|
|
|
|
|
/// <summary>复检通过 / 退回必须是 quality 能力;提交复检、认领、开始处理必须是 operator 能力。</summary>
|
|
/// <summary>复检通过 / 退回必须是 quality 能力;提交复检、认领、开始处理必须是 operator 能力。</summary>
|
|
|
[Theory]
|
|
[Theory]
|
|
|
- [InlineData("approve-verification", S8PermissionCatalog.VerificationApprove)]
|
|
|
|
|
- [InlineData("reject-verification", S8PermissionCatalog.VerificationReject)]
|
|
|
|
|
- [InlineData("submit-verification", S8PermissionCatalog.VerificationSubmit)]
|
|
|
|
|
- [InlineData("claim", S8PermissionCatalog.ExceptionClaim)]
|
|
|
|
|
- [InlineData("start-progress", S8PermissionCatalog.ExceptionStart)]
|
|
|
|
|
- [InlineData("transfer", S8PermissionCatalog.ExceptionAssign)]
|
|
|
|
|
- [InlineData("upgrade", S8PermissionCatalog.ExceptionUpgrade)]
|
|
|
|
|
- [InlineData("comment", S8PermissionCatalog.ExceptionComment)]
|
|
|
|
|
- public void ExceptionAction_MapsToExpectedCapability(string routeFragment, string expected)
|
|
|
|
|
|
|
+ // S8-ACTION-PERMISSION-1:映射对象由能力码改为**动作码**。
|
|
|
|
|
+ // 旧能力码在本批之后只剩一个用途:首次 Provisioning 从「本租户原本谁能做」推导默认授权;
|
|
|
|
|
+ // 运行期判定改由 S8ExceptionActionAttribute + 租户内 Action↔Role 回答,
|
|
|
|
|
+ // 原因是旧链路不校验角色的租户(实测 UATAdminA 经别家租户角色拿到 assign)。
|
|
|
|
|
+ // 守卫对象随之改变,守的仍是同一件事:这个路由挂对了门禁。
|
|
|
|
|
+ [InlineData("approve-verification", S8ExceptionActionCode.Verify)]
|
|
|
|
|
+ [InlineData("reject-verification", S8ExceptionActionCode.Verify)]
|
|
|
|
|
+ [InlineData("submit-verification", S8ExceptionActionCode.SubmitVerify)]
|
|
|
|
|
+ [InlineData("claim", S8ExceptionActionCode.Claim)]
|
|
|
|
|
+ [InlineData("start-progress", S8ExceptionActionCode.Start)]
|
|
|
|
|
+ [InlineData("transfer", S8ExceptionActionCode.Transfer)]
|
|
|
|
|
+ [InlineData("upgrade", S8ExceptionActionCode.Upgrade)]
|
|
|
|
|
+ [InlineData("comment", S8ExceptionActionCode.Comment)]
|
|
|
|
|
+ public void ExceptionAction_MapsToExpectedActionCode(string routeFragment, string expected)
|
|
|
{
|
|
{
|
|
|
var action = AllActions().SingleOrDefault(a =>
|
|
var action = AllActions().SingleOrDefault(a =>
|
|
|
a.Controller == typeof(AdoS8ExceptionsController)
|
|
a.Controller == typeof(AdoS8ExceptionsController)
|
|
@@ -186,7 +224,7 @@ public class S8AuthorizationGuardTests
|
|
|
&& a.Template.EndsWith(routeFragment, StringComparison.Ordinal));
|
|
&& a.Template.EndsWith(routeFragment, StringComparison.Ordinal));
|
|
|
|
|
|
|
|
Assert.NotNull(action);
|
|
Assert.NotNull(action);
|
|
|
- Assert.Equal(expected, action!.Permission);
|
|
|
|
|
|
|
+ Assert.Equal(expected, action!.ExceptionAction);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>「检验通过 / 退回」绝不能与「提交复检」共用能力码,否则处理人可自检自过。</summary>
|
|
/// <summary>「检验通过 / 退回」绝不能与「提交复检」共用能力码,否则处理人可自检自过。</summary>
|
|
@@ -232,8 +270,25 @@ public class S8AuthorizationGuardTests
|
|
|
var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
|
|
var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
|
|
|
|
|
|
|
|
Assert.Contains("GetOwnBtnPermList", src);
|
|
Assert.Contains("GetOwnBtnPermList", src);
|
|
|
- Assert.DoesNotContain("AdoS8RolePermissionConfig", src);
|
|
|
|
|
- Assert.DoesNotContain("PermissionCodes", src);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // S8-ACTION-PERMISSION-1:新授权链路一并纳入本守卫。新表 ado_s8_exception_action_role
|
|
|
|
|
+ // 取代了那张 0 消费方的死表,绝不能有人顺手把 permission_codes 接回运行链。
|
|
|
|
|
+ foreach (var f in new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "Infrastructure/S8/S8PermissionAttribute.cs",
|
|
|
|
|
+ "Infrastructure/S8/S8ExceptionActionAuthorizer.cs",
|
|
|
|
|
+ "Infrastructure/S8/S8ExceptionActionAttribute.cs",
|
|
|
|
|
+ "Infrastructure/S8/S8TenantRoleResolver.cs",
|
|
|
|
|
+ })
|
|
|
|
|
+ {
|
|
|
|
|
+ var one = File.ReadAllText(Path.Combine(root, f));
|
|
|
|
|
+ // 判据必须精确到「那张死表的实体 / 属性 / 列」。
|
|
|
|
|
+ // 裸子串 "PermissionCodes" 会误伤合法方法名(如按租户解析能力码的
|
|
|
|
|
+ // GetPermissionCodesAsync),把一条真守卫变成噪音,最后被人整条删掉。
|
|
|
|
|
+ Assert.DoesNotContain("AdoS8RolePermissionConfig", one);
|
|
|
|
|
+ Assert.DoesNotContain(".PermissionCodes", one);
|
|
|
|
|
+ Assert.DoesNotContain("permission_codes", one);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>权限门必须 fail-closed:拿不到权限服务 / 抛异常时一律 403,绝不放行。</summary>
|
|
/// <summary>权限门必须 fail-closed:拿不到权限服务 / 抛异常时一律 403,绝不放行。</summary>
|
|
@@ -247,6 +302,13 @@ public class S8AuthorizationGuardTests
|
|
|
Assert.Contains("catch", src);
|
|
Assert.Contains("catch", src);
|
|
|
// 空码构造必须抛,防止 [S8Permission("")] 变成静默放行。
|
|
// 空码构造必须抛,防止 [S8Permission("")] 变成静默放行。
|
|
|
Assert.Throws<ArgumentException>(() => new S8PermissionAttribute(""));
|
|
Assert.Throws<ArgumentException>(() => new S8PermissionAttribute(""));
|
|
|
|
|
+
|
|
|
|
|
+ // S8-ACTION-PERMISSION-1:新门禁同一条要求,且未知动作码同样在构造期就炸。
|
|
|
|
|
+ var actionSrc = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8ExceptionActionAttribute.cs"));
|
|
|
|
|
+ Assert.Contains("Status403Forbidden", actionSrc);
|
|
|
|
|
+ Assert.Contains("catch", actionSrc);
|
|
|
|
|
+ Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute(""));
|
|
|
|
|
+ Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_REAL"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
@@ -258,12 +320,23 @@ public class S8AuthorizationGuardTests
|
|
|
public void CatalogHasNoOrphanCapability()
|
|
public void CatalogHasNoOrphanCapability()
|
|
|
{
|
|
{
|
|
|
var used = AllActions().Where(a => a.Permission != null).Select(a => a.Permission!).ToHashSet(StringComparer.Ordinal);
|
|
var used = AllActions().Where(a => a.Permission != null).Select(a => a.Permission!).ToHashSet(StringComparer.Ordinal);
|
|
|
|
|
+
|
|
|
|
|
+ // S8-ACTION-PERMISSION-1:归属现在有两种 —— ① 仍被某个 Action 直接使用(配置面);
|
|
|
|
|
+ // ② 被 S8ExceptionActionCatalog 引为某动作的 LegacyPermissionCode。后者不是孤儿,
|
|
|
|
|
+ // 而是首次 Provisioning 推导默认授权的输入;删掉它会让「本租户原本谁能做这件事」失传。
|
|
|
|
|
+ var legacyMapped = S8ExceptionActionCatalog.All
|
|
|
|
|
+ .SelectMany(d => new[] { d.LegacyPermissionCode }.Concat(d.LegacyPermissionAliases))
|
|
|
|
|
+ .Where(c => !string.IsNullOrWhiteSpace(c))
|
|
|
|
|
+ .Select(c => c!)
|
|
|
|
|
+ .ToHashSet(StringComparer.Ordinal);
|
|
|
|
|
+
|
|
|
var orphans = S8PermissionCatalog.All
|
|
var orphans = S8PermissionCatalog.All
|
|
|
.Where(x => !x.Deprecated)
|
|
.Where(x => !x.Deprecated)
|
|
|
.Select(x => x.Code)
|
|
.Select(x => x.Code)
|
|
|
- .Where(c => !used.Contains(c))
|
|
|
|
|
|
|
+ .Where(c => !used.Contains(c) && !legacyMapped.Contains(c))
|
|
|
.ToList();
|
|
.ToList();
|
|
|
- Assert.True(orphans.Count == 0, "以下能力码在目录内但无任何 Action 使用:\n" + string.Join('\n', orphans));
|
|
|
|
|
|
|
+ Assert.True(orphans.Count == 0,
|
|
|
|
|
+ "以下能力码既无 Action 使用、也未被动作目录引为 Legacy 推导输入:\n" + string.Join('\n', orphans));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>已废弃能力码不得被任何 Action 引用(否则等于用一个没人维护的码当门禁)。</summary>
|
|
/// <summary>已废弃能力码不得被任何 Action 引用(否则等于用一个没人维护的码当门禁)。</summary>
|