从“上游分组授权”到“模型开放策略中心”:Grimoire Router 的权限架构重写

从“上游分组授权”到“模型开放策略中心”:Grimoire Router 的权限架构重写

这篇是一个很典型的“业务抽象错位”修复案例。
初始方案是按上游基础设施关系做权限:用户先按组找账号,再推断模型可见性。
但真实问题是:产品上真正要回答的是“这个模型是否对这个用户开放”,不是“用户是否处在某个上游组”。

image

先说为什么旧方案会卡住

把权限挂到上游分组会产生两个长期后果:

  1. 管理员认知负担高:配置时要先理解“组逻辑 + 账号分配 + 模型映射”三层关系;
  2. 语义不稳定:业务要求变成“模型白名单 / 黑名单”时,会被底层分组关系拖住;
  3. 风险边界扩散:日志和路由层需要额外推导,难以证明每条拒绝是正确的。

所以我们把权限移回模型本体。

设计迁移后的模型结构

ModelAlias 新增了两类字段:

  • AccessScopeAllUsers | SelectedUsers
  • AccessUsers:多对多的 ModelAliasAccessUser(显式授权用户)
// src/GrimoireRouter.Infrastructure/Persistence/Entities/ModelAlias.cs
public ModelAccessScope AccessScope { get; set; } = ModelAccessScope.AllUsers;
public ICollection<ModelAliasAccessUser> AccessUsers { get; set; } = [];

这看起来是个小改动,但它把授权语义从“基础设施维度”拉回到“业务维度”了。

两个运行时入口同时生效:文本 + 图片

文本链路在 ResponsesRelayService.ResolveContextAsync 里做:

if (!HasModelAccess(model, user.Id))
{
    return RelayRequestContext.WithError(RelayExecutionResult.Buffered(
        403,
        "application/json",
        SerializeError($"Model '{modelAlias}' is not enabled for this user.", "model_access_denied")));
}

图片链路也做了同样的判断,确保同一权限规则在统一执行:

// src/GrimoireRouter.Gateway/Images/ImagesRelayService.cs
if (!HasModelAccess(model, user.Id))
{
    return ImageRelayContext.WithError(RelayExecutionResult.Buffered(
        403,
        "application/json",
        SerializeError($"Model '{modelAlias}' is not enabled for this user.", "model_access_denied")));
}

你会看到这是关键:权限不再只在控制台显示成功,而是在路由执行前就阻断

管理端 API 如何落地

/api/admin/model-access-policies 的职责是让策略可视、可配置、可回放:

// src/GrimoireRouter.Api/Controllers/AdminModelAccessPoliciesController.cs
if (request.AccessScope == ModelAccessScope.SelectedUsers)
{
    dbContext.ModelAliasAccessUsers.RemoveRange(model.AccessUsers);
    model.AccessUsers.Clear();
    foreach (var userId in distinctUserIds)
    {
        model.AccessUsers.Add(new ModelAliasAccessUser { ModelAliasId = model.Id, UserId = userId });
    }
}
else
{
    dbContext.ModelAliasAccessUsers.RemoveRange(model.AccessUsers);
    model.AccessUsers.Clear();
}

model.AccessScope = request.AccessScope;

设计上是“每次更新都落成最终状态”,而不是“增量拼接”,可减少历史脏状态。

为什么旧 group 结构不删也可以继续保留

很多人会担心“迁移后旧结构怎么处理”。
我们的策略是:

  • 旧结构保留,避免一次性大规模改表/回填;
  • 运行时关键校验改用 ModelAliasAccess*
  • 旧 group 只作为历史兼容或查询参考,不再作为授权主路径。

这是现实工程中更稳的方式:少量并行期,少量兼容,保证主路径已经切完

实施顺序(可直接照着项目执行)

  1. 引入 ModelAccessScopeModelAliasAccessUser 映射。
  2. 增加管理端 API:GET /api/admin/model-access-policiesPATCH /api/admin/model-access-policies/{modelId}
  3. ResponsesRelayService.ResolveContextAsyncImagesRelayService.ResolveContextAsync 加入 HasModelAccess
  4. 保持上游候选构建(BuildUserScopedCandidates)只做路由范围,不再承担模型授权语义。
  5. 加回归测试覆盖 403 分支,确保拒绝码是 403model_access_denied
// src/GrimoireRouter.Gateway/Responses/ResponsesRelayService.cs
private static bool HasModelAccess(ModelAlias model, Guid userId)
{
    return model.AccessScope == ModelAccessScope.AllUsers
        || model.AccessUsers.Any(link => link.UserId == userId);
}

一句话总结

权限问题不是“加个分组条件”能长期解决的。
可观测、可推导、可复核的模型授权,必须是模型为中心的语义:ModelAlias -> AccessScope -> AccessUsers,并在文本与图片链路执行入口里统一阻断。
这样你才能在“谁能用哪个模型”这个问题上做正确决策。

posted @ 2026-06-22 00:05  初久的私房菜  阅读(4)  评论(0)    收藏  举报
作者:初久的私房菜
好好学习,天天向上
返回顶部小火箭
好友榜:
如果愿意,把你的博客地址放这里
张弛:https://blog.zhangchi.fun/