Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行
Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行 🛡️⚙️
📚 目录
- Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行 🛡️⚙️
- 1) 背景 & 目标 🎯
- 2) 架构与职责边界 🧭
- 2.1 组件总览
- 2.2 判定顺序 + 头部去向
- 3) 环境与版本 🧪
- 4) 网关:路由 + 统一鉴权 + 头透传 🧱
- 4.1 业务入口:HTTPRoute
- 4.2 SecurityPolicy(ext_authz:HTTP 模式)
- 4.3 超时位置更正(⚠️重要)
- 4.4 灰度/重试/熔断(集中治理)
- 4.5 头部大小与风险
- 5) 授权服务(ext_authz)🧠
- 5.1 契约要点
- 5.2 参考实现(HTTP,.NET Minimal · 任意路径+方法)
- 6) ABP 集成:**只做“资源执行”**(行过滤 + 字段裁剪 + 审计)🧩
- 6.1 中间件:验签 + 注入访问上下文
- 6.2 绑定租户(ICurrentTenant)& 与 ABP 默认 `__tenant` 的协同
- 6.3 行级过滤(RowFilter → 动态表达式/全局过滤器)
- 6.4 字段裁剪(查询阶段投影)——优先避免拉回无用列
- 6.5 应用服务组合 🎛️
- 7) 失败与回退(Failure Mode)🧯
- 8) 可观测性与 SLO 📊
- 9) 压测(k6)🏎️
- 10) 版本/兼容性提示 🧩
- FAQ 💡
1) 背景 & 目标 🎯
- 现状痛点:服务内鉴权 → 重复实现、标准不一、高并发下抖动放大。
- 策略:PDP/PEP 分离,把判定(ext_authz)前移至网关,后端聚焦“执行”。
- 交付:YAML + .NET 代码 + k6 压测脚本 一把跑通;观测/灰度/回退全链路可操作。✅
2) 架构与职责边界 🧭
2.1 组件总览
2.2 判定顺序 + 头部去向
3) 环境与版本 🧪
-
Kubernetes:1.26+
-
Envoy Gateway v1.5.3(Helm 安装 + Quickstart)
helm install eg oci://docker.io/envoyproxy/gateway-helm \--version v1.5.3 \-n envoy-gateway-system --create-namespacekubectl apply -f \https://github.com/envoyproxy/gateway/releases/download/v1.5.3/quickstart.yaml \-n default
-
.NET 8/9(ABP vNext);EF Core 为数据访问
-
观测:使用官方 Addons Helm Chart 安装 Prometheus / Grafana / OTEL(版本与 EG 对齐)📈
4) 网关:路由 + 统一鉴权 + 头透传 🧱
4.1 业务入口:HTTPRoute
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:name: orders
spec:parentRefs:- name: eghostnames:- "api.example.com"rules:- matches:- path:type: PathPrefixvalue: /ordersbackendRefs:- name: abp-ordersport: 8080
4.2 SecurityPolicy(ext_authz:HTTP 模式)
关键:
headersToBackend
决定允许时授权响应中的哪些头会被透传给后端。
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:name: orders-ext-auth
spec:targetRefs:- group: gateway.networking.k8s.iokind: HTTPRoutename: ordersextAuth:http:backendRefs:- name: http-ext-auth # 你的授权服务port: 9002headersToBackend:- x-current-user- x-tenant-id- x-field-mask- x-row-filter- x-authz-signature
4.3 超时位置更正(⚠️重要)
- HTTPRoute 层配置超时(而非
BackendTrafficPolicy
):
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:name: orders
spec:parentRefs:- name: eghostnames: ["api.example.com"]rules:- matches:- path: { type: PathPrefix, value: /orders }timeouts:request: 5s # 端到端backendRequest: 3s # 单次上游请求backendRefs:- name: abp-ordersport: 8080
推荐 request ≥ backendRequest,避免“阴影超时”。
4.4 灰度/重试/熔断(集中治理)
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:name: btp-orders
spec:targetRefs:- group: gateway.networking.k8s.iokind: HTTPRoutename: ordersretry:numRetries: 2circuitBreaker:maxConnections: 1024maxPendingRequests: 1024
4.5 头部大小与风险
5) 授权服务(ext_authz)🧠
5.1 契约要点
- 允许:返回
200 OK
,在响应头放入x-tenant-id/x-field-mask/x-row-filter/x-authz-signature/...
。 - 拒绝:返回
401/403
。 - 转发到授权服务的请求头:与
headersToBackend
不同且依实现/版本而异(尤其 HTTP vs gRPC);对关键头(Authorization/Cookie/X-Forwarded-*
)请做集成测试或显式配置,确保到达授权服务。 - HTTP 模式路径与方法:网关通常沿用原请求的方法与路径去调用授权服务(不是固定
/check
),因此服务端需匹配任意路径与方法;或改用 gRPC ext_authz。
5.2 参考实现(HTTP,.NET Minimal · 任意路径+方法)
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();const string Secret = "change_me";// 显式匹配所有常见方法 + 任意路径
string[] verbs = new[] { "GET","POST","PUT","PATCH","DELETE","OPTIONS","HEAD" };
app.MapMethods("/{**path}", verbs, (HttpContext ctx) =>
{// 1) 检查凭证(示例)var auth = ctx.Request.Headers.Authorization.ToString();if (string.IsNullOrEmpty(auth) || !auth.StartsWith("Bearer "))return Results.StatusCode(403);// 2) 业务判定(示例)var user = "user1";var tenantId = "t-1001";var fieldMask = "Order:Id,No,Total;Item:Sku,Qty";var rowFilter = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("""{"TenantId":"t-1001"}"""));// 3) 允许时:把要透传给后端的头写在**响应头**中(需在 headersToBackend 白名单)ctx.Response.Headers.Append("x-current-user", user);ctx.Response.Headers.Append("x-tenant-id", tenantId);ctx.Response.Headers.Append("x-field-mask", fieldMask);ctx.Response.Headers.Append("x-row-filter", rowFilter);// 4) HMAC 签名(常量时间比较;可扩展 ts/nonce)var canonical = $"{ctx.Request.Method}\n{ctx.Request.Path}";using var mac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(Secret));var sig = Convert.ToHexString(mac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();ctx.Response.Headers.Append("x-authz-signature", sig);return Results.Ok();
});app.Run("http://0.0.0.0:9002");
6) ABP 集成:只做“资源执行”(行过滤 + 字段裁剪 + 审计)🧩
6.1 中间件:验签 + 注入访问上下文
public record AccessContext(string? TenantId, string? FieldMask, string? RowFilterBase64);
public interface IAccessContextAccessor { AccessContext Current { get; set; } }
public class AccessContextAccessor : IAccessContextAccessor
{ public AccessContext Current { get; set; } = new(null,null,null); }public class AuthzHeadersMiddleware
{private readonly RequestDelegate _next;private const string Secret = "change_me";public AuthzHeadersMiddleware(RequestDelegate next) => _next = next;public async Task Invoke(HttpContext ctx, IAccessContextAccessor accessor){var sig = ctx.Request.Headers["x-authz-signature"].ToString();var canonical = $"{ctx.Request.Method}\n{ctx.Request.Path}";using var h = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(Secret));var expected = Convert.ToHexString(h.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();var ok = !string.IsNullOrEmpty(sig) &&System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(Convert.FromHexString(sig), Convert.FromHexString(expected));if (!ok) { ctx.Response.StatusCode = 401; await ctx.Response.WriteAsync("Invalid authz signature"); return; }var tenantId = ctx.Request.Headers["x-tenant-id"].ToString();var fieldMask = ctx.Request.Headers["x-field-mask"].ToString();var rowFilter = ctx.Request.Headers["x-row-filter"].ToString();accessor.Current = new AccessContext(tenantId, fieldMask, rowFilter);await _next(ctx);}
}
6.2 绑定租户(ICurrentTenant)& 与 ABP 默认 __tenant
的协同
public class TenantBindingMiddleware
{private readonly RequestDelegate _next;public TenantBindingMiddleware(RequestDelegate next) => _next = next;public async Task Invoke(HttpContext ctx, IAccessContextAccessor accessor, ICurrentTenant currentTenant){// 以网关判定为准;如与 Token Claim 冲突,可拒绝或降级只读(在此处处理)using (currentTenant.Change(accessor.Current.TenantId)){await _next(ctx);}}
}// 注册
builder.Services.AddSingleton<IAccessContextAccessor, AccessContextAccessor>();
app.UseMiddleware<AuthzHeadersMiddleware>();
app.UseMiddleware<TenantBindingMiddleware>();
ABP 默认头名是
__tenant
;如果你使用x-tenant-id
,请自定义解析器或在中间件绑定到ICurrentTenant
。
6.3 行级过滤(RowFilter → 动态表达式/全局过滤器)
public static class RowFilterExtensions
{public static IQueryable<T> ApplyRowFilter<T>(this IQueryable<T> query, string? rowFilterBase64){if (string.IsNullOrWhiteSpace(rowFilterBase64)) return query;var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(rowFilterBase64));var dict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();var allow = new HashSet<string>(StringComparer.OrdinalIgnoreCase){ "TenantId", "CustomerId" }; // 白名单var p = Expression.Parameter(typeof(T), "e");Expression? body = null;foreach (var (k,v) in dict){if (!allow.Contains(k)) continue;var prop = Expression.PropertyOrField(p, k);var constant = Expression.Constant(Convert.ChangeType(v, prop.Type));var eq = Expression.Equal(prop, constant);body = body == null ? eq : Expression.AndAlso(body, eq);}if (body == null) return query;var lambda = Expression.Lambda<Func<T,bool>>(body, p);return query.Where(lambda);}
}
6.4 字段裁剪(查询阶段投影)——优先避免拉回无用列
public static class FieldMaskExtensions
{public static IQueryable<dynamic> ApplyFieldMask<T>(this IQueryable<T> query, string? mask){if (string.IsNullOrWhiteSpace(mask)) return query.Select(x => (dynamic)x)!;var parts = mask.Split(':', 2);if (parts.Length != 2) return query.Select(x => (dynamic)x)!;var fields = parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);var safe = fields.Where(f => typeof(T).GetProperty(f) != null).ToArray(); // 白名单校验if (safe.Length == 0) return query.Select(x => (dynamic)x)!;var select = $"new({string.Join(",", safe)})"; // System.Linq.Dynamic.Corereturn query.Select(select);}
}
6.5 应用服务组合 🎛️
public class OrderAppService : ApplicationService
{private readonly IRepository<Order, Guid> _repo;private readonly IAccessContextAccessor _access;public OrderAppService(IRepository<Order, Guid> repo, IAccessContextAccessor access)=> (_repo, _access) = (repo, access);public async Task<List<object>> GetListAsync(){var q = await _repo.GetQueryableAsync();q = q.ApplyRowFilter(_access.Current.RowFilterBase64); // 行过滤var projected = q.ApplyFieldMask<Order>(_access.Current.FieldMask); // 字段裁剪(查询阶段)return await projected.ToDynamicListAsync();}
}
7) 失败与回退(Failure Mode)🧯
- 严格:
failure_mode_allow=false
(默认),网关直接 4xx/5xx,并记录decision_id
; - 宽松:
true
时可“最低权限放行”(慎用),加x-authz-downgraded: true
标识; - 熔断/超时:用
BackendTrafficPolicy
(熔断/重试)+HTTPRoute.timeouts
(超时)对齐端到端参数。
8) 可观测性与 SLO 📊
- 重点:ext_authz 延迟/错误率、401/403/429、熔断打开率、各 Route P95/错误分布、请求头大小直方图。
- 审计:全链路打通
decision_id
(ext_authz → 网关 → ABP),支持回放。
9) 压测(k6)🏎️
// k6 run authz.js
import http from 'k6/http';
import { check, sleep } from 'k6';export let options = {vus: 100, duration: '60s',thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<200'] },
};export default function () {const host = __ENV.GATEWAY_HOST;const res = http.get(`http://${host}/orders`, {headers: { 'Host': 'api.example.com', 'Authorization': 'Bearer x' }});check(res, { 'status is 200': (r) => r.status === 200 || r.status === 304 });sleep(0.2);
}
- 场景:无鉴权 → 启用 ext_authz(缓存命中/未命中)→ 注入掩码/过滤 → 故障注入(超时/熔断)。
- 观测:P95/P99、拒绝率、熔断命中、头大小分布是否逼近 60KiB 门槛。
10) 版本/兼容性提示 🧩
- 仅在必要时开启“路由重算”(如果授权后新增/修改的请求头会影响路由匹配),否则不启用。
- 发往授权服务的请求头请做集成测试(Authorization/Cookie/X-Forwarded-*…),必要时显式配置以确保到达;与
headersToBackend
概念区分开。 - ABP 默认租户头是
__tenant
;若采用x-tenant-id
,需自定义解析器或在中间件绑定到ICurrentTenant
。 - 请求头体量:默认 60 KiB;尽量传引用/指纹,把大对象放后端缓存以按
decision_id
再取详情。
FAQ 💡
-
Q:HTTP 与 gRPC ext_authz 都能“允许时添加上游请求头”吗?
A:是。HTTP 模式依赖 ext_authz 过滤器把授权响应头合并进上游请求,再由headersToBackend
决定透传给后端;gRPC 按 proto 返回headers_to_add
等。 -
Q:是否开启失败放行(failure_mode_allow)?
A:默认不开。仅对读接口、可控风险的场合灰度开启,并在指标/日志中清晰打标与告警。 -
Q:行过滤与字段裁剪放哪一层?
A:行过滤优先 EF Core(全局过滤 + 动态 Where);字段裁剪优先查询阶段投影,避免把无用列拉回后再删。