深入解析:Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行
Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行 ️⚙️
目录
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-namespace kubectl 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: eg
hostnames:
- "api.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /orders
backendRefs:
- name: abp-orders
port: 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.io
kind: HTTPRoute
name: orders
extAuth:
http:
backendRefs:
- name: http-ext-auth # 你的授权服务
port: 9002
headersToBackend:
- 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: eg
hostnames: ["api.example.com"]
rules:
- matches:
- path: { type: PathPrefix, value: /orders }
timeouts:
request: 5s # 端到端
backendRequest: 3s # 单次上游请求
backendRefs:
- name: abp-orders
port: 8080
推荐 request ≥ backendRequest,避免“阴影超时”。
4.4 灰度/重试/熔断(集中治理)
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: btp-orders
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: orders
retry:
numRetries: 2
circuitBreaker:
maxConnections: 1024
maxPendingRequests: 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.Core
return 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);字段裁剪优先查询阶段投影,避免把无用列拉回后再删。

浙公网安备 33010602011771号