- AI 应用
- API网关
- LLM 网关
- 后端
- 前端
【免费下载链接】axonhub
⚡️ Open-source AI Gateway — Use any SDK to call 100+ LLMs. Built-in failover, load balancing, cost control & end-to-end tracing.
本文系统讲解 AxonHub 开源 AI Gateway 中 internal/authz 包的完整设计与实战用法:它基于 Ent Privacy 实现了单请求单一授权主体(Principal)、**受控权限绕过(Bypass)与Scope 授权决策(Scope-Gated Decision)**三大机制,并配套静态检查与审计日志。读完本文,你将掌握如何在业务层替代裸privacy.DecisionContext(ctx, privacy.Allow)调用、如何用闭包模式把放行范围压缩到最小操作区间,以及如何通过 lint 白名单保证权限治理不回归。
设计背景:为什么要引入 authz 包
AxonHub 使用 Ent Privacy 作为数据访问的最后防线,但在治理之前,业务层存在大量直接调用privacy.DecisionContext(ctx, privacy.Allow)的做法。根据仓库内的 Ent Privacy 权限治理方案,这种写法带来四类结构性风险:
- 绕过边界不清晰:
Allow是全局放行语义,容易把"局部系统动作"扩散成"整段链路放行"; - 主体混杂风险:同一请求上下文可能同时携带 User、API Key、Project 信息,放行后无法区分真实授权主体;
- 策略失效隐患:
internal/scopes的 default-deny 规则链会被上层Allow直接短路; - 可审计性不足:绕过动作缺少统一入口与理由记录,安全审计难以追踪。
治理方案统计显示,迁移前非测试代码中约有 73 处裸Allow调用、200+ 处测试调用,分布在配额检查、请求流水、系统配置读取等路径。internal/authz包正是这套治理方案的核心落地产物,其包级文档 doc.go 将其定位为"Ent Privacy 治理机制,提供受控隐私绕过与单一主体授权模型"。
四大设计目标
README 明确了 authz 包的四个设计目标,它们也是后续所有 API 与编码规范的总纲:
- 单一主体模型(Single Principal Model):一次请求只存在一个授权主体(System / User / APIKey);
- 受控绕过机制(Controlled Bypass):业务层禁止直接使用
privacy.Allow,所有绕过必须经过受控入口; - 绕过作用域隔离(Bypass Scope Isolation):bypass 上下文不能沿调用链无限扩散;
- 可审计、可回归(Auditable and Regression-testable):所有绕过动作可追踪,并配有防回归测试与静态检查。
核心组件一:Principal(授权主体)
Principal 定义在 principal.go,是整个 authz 包的身份基础。
主体类型
type PrincipalType int const ( PrincipalTypeUnknown PrincipalType = iota PrincipalTypeSystem // 系统主体:后台任务、内部操作 PrincipalTypeUser // 用户主体 PrincipalTypeAPIKey // API Key 主体 PrincipalTypeTest // 测试主体:仅测试环境使用 )从源码可见,除 README 提到的 System / User / APIKey 三种主体外,还额外提供了PrincipalTypeTest(测试环境专用)与PrincipalTypeUnknown(未识别兜底)。PrincipalType.String()分别返回"system"、"user"、"apikey"、"test"、"unknown",这一字符串形式会被审计日志直接使用。
Principal 结构体
type Principal struct { Type PrincipalType UserID *int APIKeyID *int ProjectID *int }其中ProjectID归属规则在治理方案中有明确约定:APIKey 主体的 ProjectID 从 APIKey 关联的 Project 获取,属于主体的派生属性;User 主体的 ProjectID 则是"选中的项目上下文",需在业务边界显式校验,不属于 Principal 本身。Principal.String()会生成用于审计的字符串,如user:123、apikey:456(ID 缺失时为user:unknown等)。
Set-once 语义与冲突检测
这是单一主体模型的关键实现。WithPrincipal使用独立的 context key(principalKey,unexported 类型防外部伪造),而非可变的contextContainer:
- 首次设置成功;再次设置完全相同的主体时幂等返回原 context;
- 设置不同的主体时返回错误
authz: principal conflict: existing=..., new=...; principalEqual会逐一比较 Type、UserID、APIKeyID、ProjectID 四个字段。
func WithPrincipal(ctx context.Context, p Principal) (context.Context, error) { if existing, ok := GetPrincipal(ctx); ok { if existing.Type != p.Type || !principalEqual(existing, p) { return ctx, fmt.Errorf("authz: principal conflict: existing=%s, new=%s", existing.String(), p.String()) } return ctx, nil // 相同主体,幂等 } return context.WithValue(ctx, principalKey{}, p), nil }配套的读取函数有GetPrincipal(ctx)(返回(Principal, bool))和MustGetPrincipal(ctx)(不存在时 panic,用于已确认有主体的链路)。测试文件 principal_test.go 中的TestWithPrincipal_SetOnce、TestWithPrincipal_ConflictDetection、TestMustGetPrincipal分别验证了幂等、冲突拒绝与 panic 行为。
便捷构造函数
除WithPrincipal外,源码还提供了三个快速构造入口,其中前两个在 README 中被引用:
NewSystemContext(ctx)(见 system.go):创建携带 System 主体的 context,供后台任务使用;NewUserContext(ctx, userID):创建 User 主体;NewAPIKeyContext(ctx, apiKeyID, projectID):创建 APIKey 主体(同时携带项目归属)。
测试 principal_test.go 的TestNewSystemContext、TestNewUserContext、TestNewAPIKeyContext对三个构造函数做了完整校验。
核心组件二:Bypass(受控绕过)
Bypass 机制定义在 bypass.go,它把"裸 Allow"收敛为两个带审计的受控入口。
两种入口
// 入口一:显式创建 bypass context(返回值必须命名 bypassCtx) func WithBypassPrivacy(ctx context.Context, reason string) (context.Context, error) // 入口二:闭包模式(推荐),自动完成创建 + 执行 func RunWithBypassT any (T, error)) (T, error) func RunWithBypassVoid(ctx context.Context, reason string, fn func(ctx context.Context) error) errorRunWithBypassVoid是为无返回值操作提供的闭包变体(源码注释中的示例见 bypass.go)。
准入校验:只有 System 或 Test 主体能绕过
WithBypassPrivacy的内部实现首先执行主体准入校验:
p, ok := GetPrincipal(ctx) if !ok { return nil, fmt.Errorf("authz: WithBypassPrivacy requires a principal in context") } if !p.IsSystem() && !p.IsTest() { return nil, fmt.Errorf("authz: WithBypassPrivacy requires system or test principal, got %s", p.String()) }也就是说,User 与 APIKey 主体无法触发 bypass,从机制上杜绝了"用户请求链路中借内部流程越权"的可能。测试 bypass_test.go 的TestBypassWithNonSystemPrincipal专门验证了 User 与 APIKey 主体调用会失败。
审计记录:每次绕过都可追踪
每次绕过都会写入bypassInfo(Reason、Timestamp、Principal),并调用recordBypassAudit生成审计记录:
type bypassAuditRecord struct { Timestamp time.Time Principal string Reason string Operation string // 固定为 "bypass" Entity string // 固定为 "privacy" Description string }默认使用internal/log输出authz: privacy bypass调试日志;也可以通过SetAuditLogger注入自定义审计器(测试TestSetAuditLogger验证了自定义 logger 能捕获 Reason、Principal、Operation 等字段)。同时提供GetBypassInfo(ctx)与IsBypassActive(ctx)用于调试与状态判断。注意:设计目标中"Bypass 作用域隔离"对应的测试是TestBypassScopeIsolation,它验证了闭包内部 context 继承外层值、但 bypass 状态不泄漏到闭包之外。
底层原理:唯一转换点
源码中只有WithBypassPrivacy一处将 capability 转换为 Ent 可识别的放行上下文(privacy.DecisionContext(ctx, privacy.Allow)),这是全仓库privacy.DecisionContext的唯一合法出口,也是静态检查脚本允许internal/authz/*直接注入DecisionContext(...Allow/Deny)的原因。
核心组件三:Scope Decision(Scope 授权决策)
Scope Decision 定义在 scope.go,它与 Bypass 的本质区别在于:Bypass 是无条件放行(适合系统内部操作),Scope Decision 是基于主体身份的授权判断(是业务授权行为,不是绕过)。
四个 API
func WithScopeDecision(ctx context.Context, requiredScope scopes.ScopeSlug) context.Context func RunWithScopeDecisionT any (T, error)) (T, error) func HasScope(ctx context.Context, requiredScope scopes.ScopeSlug) bool func RequireScope(ctx context.Context, requiredScope scopes.ScopeSlug) error其中HasScope是纯判断函数,不注入 DecisionContext,用于业务分支逻辑;RequireScope在主体不具备指定 scope 时返回错误;WithScopeDecision则根据判断结果注入privacy.Allow或privacy.Deny。三者分工明确,测试文件 scope_test.go 对其逐一覆盖。
三种主体的统一支持
HasScope的分派逻辑(scope.go)统一支持三类主体:
| 主体类型 | 判定逻辑 |
|---|---|
| System / Test | 始终返回true(系统主体拥有所有 scope) |
| User | userHasScope:逐级检查直接 scope → 系统级角色 scope → 项目成员 scope → 项目级角色 scope |
| APIKey | apiKeyHasScope:检查 APIKey 实体上挂载的 Scopes 列表 |
| Unknown / 未设置 | 一律false(default-deny) |
User scope 的完整判定链
从源码看,userHasScope的判定是分层递进的(scope.go),这也是它与旧scopes.WithUserScopeDecision(已删除)相比能力更完整的地方:
- context 中无 User 实体 → 直接返回 false;
user.IsOwner == true(系统所有者)→ 拥有全部 scope;- 用户的直接 scope(
user.Scopes)命中 → 放行; - 用户的系统级角色(
role.IsSystemRole())scope 命中 → 放行; - context 中存在 ProjectID 时,检查项目成员记录(
user.Edges.ProjectUsers)的IsOwner或成员 scope; - 再检查项目级角色(
role.ProjectID匹配且非系统角色)的 scope; - 均未命中 → 拒绝。
对应的测试用例覆盖了系统所有者、直接 scope、角色 scope、项目成员 scope、项目角色 scope、无项目成员资格时项目角色失效、跨项目 scope 不生效、context 无 ProjectID 时项目 scope 不生效等边界场景,详见 scope_test.go。
Scope 清单
requiredScope参数的类型是scopes.ScopeSlug,完整的 scope 定义在 internal/scopes/scopes.go,以read_/write_前缀区分读写权限,例如:
read_dashboard、read_channels/write_channelsread_users/write_users、read_roles/write_rolesread_settings/write_settings、read_api_keys/write_api_keysread_requests/write_requests、read_prompts/write_promptsread_projects/write_projects、read_data_storages/write_data_storages
每个 scope 还带有ScopeLevel(system或project)标识其生效层级,例如read_dashboard是 System 级,而read_users同时支持 System 与 Project 两级。
实战用法示例
以下代码均来自 README 并补充了源码级细节,可直接复制使用。
基础用法:后台任务 + 闭包绕过
// 创建 System principal context(后台任务) ctx = authz.NewSystemContext(ctx) // 使用闭包模式执行绕过操作,bypass 只覆盖 count 查询 result, err := authz.RunWithBypass(ctx, "quota-check", func(bypassCtx context.Context) (T, error) { return client.Request.Query().Count(bypassCtx) })注意两点:reason(此处为"quota-check")必须是稳定字符串(审计聚合依赖),不得拼接动态值(如fmt.Sprintf("quota-check-%d", userID)是规范明令禁止的写法,见 authz-coding-guidelines.md);闭包参数变量命名为bypassCtx,仅在该闭包内生效。
在中间件中设置 Principal
principal := authz.Principal{ Type: authz.PrincipalTypeUser, UserID: &user.ID, } ctx, err := authz.WithPrincipal(ctx, principal) if err != nil { return nil, fmt.Errorf("principal conflict: %w", err) }这是中间件层的标准写法:认证成功后写入主体,若 context 中已存在不同主体则返回冲突错误,中间件应据此直接拒绝请求(快速失败)。仓库中 internal/server/middleware/auth.go 已接入该模式。
Scope 授权决策
// 闭包模式(推荐):scope 决策只覆盖必要的查询 result, err := authz.RunWithScopeDecision(ctx, scopes.ScopeReadDashboard, func(scopeCtx context.Context) (T, error) { return client.Request.Query().All(scopeCtx) }) // 显式模式:决策注入整个 scopeCtx scopeCtx := authz.WithScopeDecision(ctx, scopes.ScopeReadDashboard) // 纯 scope 检查(不注入 privacy decision,用于业务分支) if authz.HasScope(ctx, scopes.ScopeWriteChannels) { // ... } // 要求主体必须拥有 scope,否则返回 error if err := authz.RequireScope(ctx, scopes.ScopeWriteSettings); err != nil { return err }仓库实际使用中,gql/dashboard.resolvers.go 是 Scope-Gated Decision 的集中使用方(约 10+ 处调用,用于 Dashboard 跨实体聚合查询的授权)。
后台任务的推荐模板
治理方案给出后台任务的标准改造模板(从"裸 Allow"到"显式 System 主体 + 受控 bypass"):
// 之前(直接 bypass,无主体声明) ctx = ent.NewContext(ctx, svc.db) ctx = privacy.DecisionContext(ctx, privacy.Allow) // 之后(显式 System 主体 + 受控 bypass) ctx = ent.NewContext(ctx, svc.db) ctx = authz.NewSystemContext(ctx) ctx, err := authz.WithBypassPrivacy(ctx, "provider-quota-check")[system.go](https://link.gitcode.com/i/6dedfe48d9c9c49862ee53c8bd653655)还提供了更便捷的封装:WithSystemBypass(ctx, reason)、RunWithSystemBypass、RunWithSystemBypassVoid,以及用于保护敏感后台操作的RequireSystemPrincipal(ctx)(非 System 主体返回错误)。
编码规范:十条红线
README 的 Coding Standards 定义了十条规范,配合静态检查强制执行:
- 禁止在
internal/authz之外直接使用privacy.DecisionContext(ctx, privacy.Allow)(含Deny); - 所有无条件 bypass 必须经过
authz.WithBypassPrivacy或authz.RunWithBypass; - 所有 scope 授权决策必须经过
authz.WithScopeDecision或authz.RunWithScopeDecision; - 纯 scope 判断使用
authz.HasScope(不注入 DecisionContext); - 优先使用闭包模式(
RunWithBypass/RunWithScopeDecision),把 Allow/Deny 限制在最小操作范围; - 必须使用
WithBypassPrivacy时,返回值变量命名为bypassCtx,严禁赋给ctx(否则 bypass 会沿调用链扩散); - 所有 bypass 必须携带简洁、稳定的
reason(用于审计聚合); - 中间件必须通过
authz.WithPrincipal保证 Principal 唯一; - 后台任务必须通过
authz.NewSystemContext显式声明 System 主体; - 禁止新增
scopes.WithUserScopeDecision调用,新代码统一使用authz.WithScopeDecision(旧 API 已从代码库删除)。
静态检查与 CI 落地:make lint-privacy
治理方案不只是文档约束,还落地成了可执行的静态检查。仓库 Makefile 中,make lint会先执行lint-all再执行lint-privacy;lint-privacy实际调用 scripts/lint/check-privacy-allow.sh。
该脚本检查三类违规:
- 白名单外注入
DecisionContext(...Allow/Deny):仅允许internal/authz/*(框架实现); - 白名单外调用 authz bypass API(
WithBypassPrivacy/RunWithBypass等):额外允许*_internal.go、internal/server/gql/*.resolvers.go、internal/server/biz/system_*.go、*_test.go、internal/ent/migrate/datamigrate/*,以及迁移期白名单文件(orchestrator.go、auth.go、quota.go、permission_validator.go、prompt.go、system.go); ctx = authz.WithBypassPrivacy(...)扩散写法:直接匹配ctx赋值模式,强制变量命名为bypassCtx。
脚本明确注释:RunWithScopeDecision/WithScopeDecision是 scope 授权函数而非 bypass,因此有意排除在检查之外。运行make lint-privacy即可在本地复现 CI 的权限治理检查。
测试与验收:核心不变量
authz 包自带三个测试文件,覆盖了治理方案定义的"核心不变量":
- principal_test.go:主体类型字符串化、
IsSystem/IsUser/IsAPIKey判断、set-once 幂等、混合主体拒绝、MustGetPrincipalpanic 行为; - bypass_test.go:bypass 信息写入与读取、闭包内外 bypass 状态隔离、错误传播、User/APIKey 主体绕过被拒绝、自定义审计 logger、System 主体绕过;
- scope_test.go:System 主体全 scope、无主体 default-deny、Owner 全放行、直接 scope / 角色 scope / 项目成员 scope / 项目角色 scope 的判定,以及跨项目隔离、无项目成员资格时项目角色失效等边界。
这些测试与静态检查共同构成"防回归"闭环:任何试图在业务层重新引入裸Allow、或让 bypass 沿调用链扩散的改动,都会在 CI 阶段被拦截。
相关文档索引
- 治理方案(架构与迁移路线):docs/zh/development/ent-privacy-governance-plan.md
- 使用规范(API 概览与场景示例):docs/zh/development/authz-coding-guidelines.md
- 核心源码:principal.go / bypass.go / scope.go / system.go / doc.go
- 测试源码:principal_test.go / bypass_test.go / scope_test.go
- Scope 定义:internal/scopes/scopes.go
- 静态检查:scripts/lint/check-privacy-allow.sh
- AI 应用
- API网关
- LLM 网关
- 后端
- 前端
【免费下载链接】axonhub
⚡️ Open-source AI Gateway — Use any SDK to call 100+ LLMs. Built-in failover, load balancing, cost control & end-to-end tracing.
相关推荐
AxonHub 权限治理实战:internal/authz 包实现受控隐私旁路与单一授权主体模型
AxonHub 权限治理实战:internal/authz 包实现受控隐私旁路与单一授权主体模型 本文基于 AxonHub 仓库中的开发规范文档 authz c
AI 应用API网关LLM 网关后端前端AxonHub 的 Ent Privacy 权限治理:从裸 `privacy.Allow` 到受控 `internal/authz` 的迁移实战
AxonHub 的 Ent Privacy 权限治理:从裸 privacy.Allow 到受控 internal/authz 的迁移实战 导读 本文以 Axon
AI 应用API网关LLM 网关后端前端axonhub 中 internal/authz 权限治理包使用规范:Principal、受控 Bypass 与 Scope 授权决策实战指南
axonhub 中 internal/authz 权限治理包使用规范:Principal、受控 Bypass 与 Scope 授权决策实战指南 本文基于 axo
AI 应用API网关LLM 网关后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考