深入解析:Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行

Envoy Gateway + ext_authz 做“入口统一鉴权”,ABP 只做资源执行 ️⚙️



1) 背景 & 目标

  • 现状痛点:服务内鉴权 → 重复实现标准不一、高并发下抖动放大
  • 策略:PDP/PEP 分离,把判定(ext_authz)前移至网关,后端聚焦“执行”。
  • 交付:YAML + .NET 代码 + k6 压测脚本 一把跑通;观测/灰度/回退全链路可操作。✅

2) 架构与职责边界

2.1 组件总览

HTTP
ext_authz 鉴权
允许: headersToBackend 透传
拒绝: 403/401
Client
Envoy Gateway
ext_authz Service (HTTP/gRPC)
ABP Service
DB: EF Core
Audit/Logs

2.2 判定顺序 + 头部去向

Client Envoy Gateway ext_authz (HTTP/gRPC) ABP Service DB 发往授权服务的头(HTTP/gRPC) ≠ 回传给后端的头 "headersToBackend" 仅决定**允许时**哪些授权响应头会透传到后端 HTTP /orders 1 Check(method/path/headers/claims...) 2 401/403 3 401/403 4 200 + 响应头(x-tenant-id/x-field-mask/x-row-filter/x-authz-signature) 5 上游请求 + 白名单透传(headersToBackend) 6 验签 + 绑定租户(ICurrentTenant) 7 EF(全局过滤+动态Where) + DTO投影(字段裁剪) 8 200 + 数据 9 alt [Deny] [Allow] Client Envoy Gateway ext_authz (HTTP/gRPC) ABP Service DB

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 头部大小与风险

base64后仍大
大JSON 掩码/过滤
请求头 > 60 KiB
HTTP 431
规避: 传引用ID/指纹 + 后端缓存
或调高 max_request_headers_kb(权衡)

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)

ext_authz 超时/异常
failure_mode_allow = false
failure_mode_allow = true
鉴权恢复(AuthHealthy)
故障恢复
Running
ExtAuthTimeout
StrictDeny
SoftAllow
  • 严格failure_mode_allow=false(默认),网关直接 4xx/5xx,并记录 decision_id
  • 宽松true 时可“最低权限放行”(慎用),加 x-authz-downgraded: true 标识;
  • 熔断/超时:用 BackendTrafficPolicy(熔断/重试)+ HTTPRoute.timeouts(超时)对齐端到端参数。

8) 可观测性与 SLO

Metrics
Proxy Metrics (Envoy)
Gateway Metrics
Prometheus
Grafana Dashboards
OTel Collector
APM/Tracing
  • 重点: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);字段裁剪优先查询阶段投影,避免把无用列拉回后再删。

posted @ 2025-11-12 11:10  yxysuanfa  阅读(23)  评论(0)    收藏  举报