Gitea源码深度剖析-第5章 权限体系
第 1 章 1.5.6 节在拆解 RepoAssignment 时留下了一个钩子:repoAssignmentLegacy 调用 GetDoerRepoPermission 拿到权限后写入缓存,"第 5 章权限体系会展开这张权限表的构造"。本章就兑现这个承诺------把 Gitea 最复杂的核心之一(权限体系)从数据结构、计算链路、中间件拦截到 Git 协议访问面完整摊开。本章所有断言以 gitea-28.1.0 源码树(module 名 gitea.dev)为准,引用均给出文件路径与真实函数名,代码摘录忠实于原文(省略无关行以 ... 标注)。
读完本章,你应该能够回答这些问题:
- Gitea 的权限到底是"位掩码"还是"枚举"?
AccessModeOwner为什么不能被ParseAccessMode解析? - 一个组织成员对仓库某单元(unit)的读写权,是查表得来的还是每次现算的?
access表在其中扮演什么角色? - 匿名用户、站点管理员、deploy key、Actions 任务用户这四种"非典型身份"走的是同一条权限计算链吗?
- 仓库设置页里"公共访问"(匿名可读 / 所有人可写)如何注入到最终权限里?
RequireUnitReader拦截失败时返回 404 而不是 403,这是为什么?
在开始前必须先做一次"名词消毒"------社区文章与旧资料中常见的几个函数名在本版本中并不存在,本章一律采用源码中的真实名字:
| 常见误传 | 本版本真实符号 | 所在文件 |
|---|---|---|
GetUserRepoPerm / GetUserRepoPermission |
GetDoerRepoPermission、GetIndividualUserRepoPermission |
models/perm/access/repo_permission.go |
GetRepositoryAccesses |
RecalculateAccesses / refreshAccesses(accessMap 模式) |
models/perm/access/access.go |
getRepoUnitAccessMode |
Permission.UnitAccessMode、Team.UnitAccessModeEx |
repo_permission.go、models/organization/team.go |
requireRepoAdmin / requireRepoWriter(小写) |
context.RequireRepoAdmin()、context.RequireUnitWriter(unitTypes...) |
services/context/permission.go |
Mode 位掩码(`ModeRead |
ModeWrite`) | perm.AccessMode 整数枚举,无位运算 |
Team 角色 admin/developer/maintainer |
UI 三档 none/write/admin + 代码内 Owner |
routers/web/org/teams.go |
| "internal 仓库"(public/internal/private 三态) | repository 表只有 IsPrivate bool;internal 语义 = 非私有仓库 + owner 非 public 可见性 |
models/repo/repo.go、modules/structs/visible_type.go |
routers/common/auth.go |
该目录不存在;鉴权中间件在 routers/common/middleware.go,仓库权限在 services/context/permission.go |
--- |
带着这张表进入正文,可以避免"按记忆写代码"的坑。
5.1 权限模型全景:数据表与三层语义
5.1.1 五张核心表
Gitea 的仓库级权限落在五张表上(外加 deploy_key 与 access_token_scope 两张辅助表),它们各自只回答一个问题:
| 表 | 模型文件 | 回答的问题 | 关键字段 |
|---|---|---|---|
access |
models/perm/access/access.go |
"用户对仓库的最高访问级别是什么?" | user_id、repo_id(联合唯一)、mode |
collaboration |
models/repo/collaboration.go |
"用户是这个仓库的协作者吗?什么档位?" | repo_id、user_id、mode(默认 2 = write) |
team / team_repo / team_user / team_unit |
models/organization/team*.go |
"团队对哪些仓库、哪些单元、什么档位?" | team.authorize(团队整体档位)、team_unit.access_mode |
repo_unit |
models/repo/repo_unit.go |
"仓库启用了哪些单元?每个单元的配置和公共访问档位是什么?" | type、config、anonymous_access_mode、everyone_access_mode |
deploy_key |
models/deploykey/deploykey.go |
"这把部署密钥对这个仓库什么档位?" | repo_id、key_id、mode(默认 1 = read) |
access 表的结构注释直接点明了它与"真正 owner"的关系:
go
// Access represents the highest access level of a user to the repository. The only access type
// that is not in this table is the real owner of a repository. In case of an organization
// repository, the members of the owners team are in this table.
type Access struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"`
Mode perm.AccessMode
}
注意两点:个人仓库的 owner 不入 access 表 (计算时用 userID == repo.OwnerID 直接短路),而组织仓库的 Owners 团队成员入表 (因为组织仓库的"owner"是组织本身,成员需要一行为证)。access.mode 存的是"最高访问级别"------它是一个物化结果,由团队权限、协作者权限聚合而来,第 5.5 节展开它的重算逻辑。
5.1.2 仓库可见性:没有 "internal",只有两维组合
很多人期待 Gitea 有 GitLab 式的 public/internal/private 三态仓库,但 repository 表只有一个布尔量:
go
IsPrivate bool `xorm:"NOT NULL DEFAULT false"`
"internal"(登录可见、非成员不可见)的语义由两维组合 实现:仓库 IsPrivate == false 且 owner(用户/组织)的 Visibility != public,再叠加站点配置 setting.Service.RequireSignInViewStrict。owner 的可见性是三态的(modules/structs/visible_type.go):
go
const (
VisibleTypePublic VisibleType = iota // 0
VisibleTypeLimited // 1
VisibleTypePrivate // 2
)
这一组合在权限计算中反复出现,最典型的是 modeByOwnerAndAccess 的"完全公开"判定(models/perm/access/access.go):
go
repoIsFullyPublic := !setting.Service.RequireSignInViewStrict && repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate
if (restricted && repoIsFullyPublic) || (!restricted && !repo.IsPrivate) {
mode = perm.AccessModeRead
}
读法是:非私有仓库对普通登录用户保底 Read ;受限用户(IsRestricted)只有在"完全公开"(配置未强制登录、owner 公开、仓库非私有)时才保底 Read。这就是"internal 仓库"在 Gitea 中的真实成因------不是仓库有一个 internal 标志,而是 owner 可见性与 RequireSignInViewStrict 共同把保底 Read 收回了。
组织/用户的可见性判定集中在 ownerVisibilitySatisfiesDoer(models/organization/org.go):
go
func ownerVisibilitySatisfiesDoer(orgOrUser, user *user_model.User) bool {
// If user is nil, it's an anonymous user/request.
// The Ghost user is handled like an anonymous user.
if user == nil || user.IsGhost() {
return orgOrUser.Visibility == structs.VisibleTypePublic
}
if user.IsAdmin || orgOrUser.ID == user.ID {
return true
}
if !setting.Service.RequireSignInViewStrict && orgOrUser.Visibility == structs.VisibleTypePublic {
return true
}
return orgOrUser.Visibility != structs.VisibleTypePrivate && !user.IsRestricted
}
而 HasOrgOrUserVisible 在其上补了一条"成员豁免":
go
func HasOrgOrUserVisible(ctx context.Context, owner, doer *user_model.User) bool {
return ownerVisibilitySatisfiesDoer(owner, doer) ||
(doer != nil && OrgFromUser(owner).HasMemberWithUserID(ctx, doer.ID))
}
即:组织成员永远"看得见"自己的组织 ,哪怕组织是 private 可见性(对成员来说可见性只影响外人)。这条豁免在 GetIndividualUserRepoPermission 中被用作"陌生人防线":!organization.HasOrgOrUserVisible(...) && !isCollaborator 时直接返回 AccessModeNone------防止陌生人通过公开仓库探测私有组织的存在。
5.1.3 单元(Unit):权限的最小粒度
Gitea 把一个仓库切成最多 10 个"单元",权限判定的最小粒度就是单元。单元类型枚举在 models/unit/unit.go:
go
const (
TypeInvalid Type = iota // 0 invalid
TypeCode // 1 code
TypeIssues // 2 issues
TypePullRequests // 3 PRs
TypeReleases // 4 Releases
TypeWiki // 5 Wiki
TypeExternalWiki // 6 ExternalWiki
TypeExternalTracker // 7 ExternalTracker
TypeProjects // 8 Projects
TypePackages // 9 Packages
TypeActions // 10 Actions
)
每个单元在 unit.Units 注册表里声明自己的"权限天花板"(MaxAccessMode 字段),并且有一个关键方法:
go
// MaxPerm returns the max perms of this unit
func (u Unit) MaxPerm() perm.AccessMode {
if u.Type == TypeExternalTracker || u.Type == TypeExternalWiki {
return perm.AccessModeRead
}
return perm.AccessModeAdmin
}
外链 issue 追踪器与外链 wiki 只是跳转链接,永远最多 Read;其余单元天花板是 Admin(注意不是 Owner ------Owner 档只对仓库整体有意义,团队授予单元权限时会被 min(mode, MaxPerm()) 夹到 Admin)。
单元还有三层"启用"状态,容易混淆:
- 全局禁用 :
setting.Repository.DisabledRepoUnits在启动时经LoadUnitConfig存入disabledRepoUnitsAtomic,Type.UnitGlobalDisabled()查询;getUnitsByRepoID加载仓库单元时直接过滤掉全局禁用的类型------数据行还在,只是不加载。 - 仓库未启用 :仓库的
repo_unit表里没有该类型的行,repo.GetUnit返回ErrUnitTypeNotExist,页面表现为"该功能已禁用"。 - 团队未授权 :仓库启用了单元,但团队的
team_unit没给该单元授权,团队成员对它没有访问权。
三层分别由不同函数把守:UnitGlobalDisabled(数据层)、Repository.UnitEnabled(仓库层)、Team.UnitAccessMode > None(团队层),最终都汇聚进 Permission.UnitAccessMode(第 5.3 节)。
5.1.4 权限的三层语义
把上面的表与概念合起来,Gitea 的权限判定其实是三层漏斗:
scss
第一层:身份与可见性 ------ 你是谁?(匿名/用户/管理员/deploy key/Actions)
仓库和 owner 你看得见吗?(IsPrivate、Visibility、RequireSignInViewStrict)
第二层:仓库级档位 ------ 你是 owner / 协作者 / access 表用户 / 组织团队成员?
得到仓库整体 AccessMode(None/Read/Write/Admin/Owner)
第三层:单元级裁剪 ------ 对每个 unit 取 max(整体档位, 团队单元档位, 公共访问档位)
再被 unit.MaxPerm() 与 unitsMode 覆盖/夹逼
最终产物就是 models/perm/access/repo_permission.go 里的 Permission 结构体------一个请求内所有权限判断的唯一事实来源。第 5.3 节解剖它,第 5.4 节解剖它的生产过程。
5.2 models/perm 包详解:AccessMode 不是位掩码
5.2.1 AccessMode 枚举
models/perm/access_mode.go 全文只有 65 行,但它是整个权限体系的原子单位:
go
// AccessMode specifies the users access mode
type AccessMode int
const (
AccessModeNone AccessMode = iota // 0: no access
AccessModeRead // 1: read access
AccessModeWrite // 2: write access
AccessModeAdmin // 3: admin access
AccessModeOwner // 4: owner access
)
这是顺序枚举,不是位掩码 。所有比较都用 >=、max()、min(),源码中不存在 mode&ModeRead != 0 这类位运算。这带来两个直接后果:
- 权限聚合是天然的
max:updateUserAccess中ua.Mode = max(ua.Mode, mode),团队多档取并集就是取最大值。 - 权限裁剪是天然的
min:Team.UnitAccessModeEx中mode = min(mode, unitDef.MaxPerm())。
序列化与解析是不对称的,这一点源码注释说得很直白:
go
// ToString returns the string representation of the access mode, do not make it a Stringer, otherwise it's difficult to render in templates
func (mode AccessMode) ToString() string {
switch mode {
case perm.AccessModeRead: ...
(摘录省略了完整 switch,实际返回 "read"/"write"/"admin"/"owner"/"none"。)
go
// ParseAccessMode returns corresponding access mode to given permission string.
func ParseAccessMode(permission string, allowed ...AccessMode) AccessMode {
m := AccessModeNone
switch permission {
case "read":
m = AccessModeRead
case "write":
m = AccessModeWrite
case "admin":
m = AccessModeAdmin
default:
// the "owner" access is not really used for user input, it's mainly for checking access level in code, so don't parse it
}
if len(allowed) == 0 {
return m
}
return util.Iif(slices.Contains(allowed, m), m, AccessModeNone)
}
三行关键信息:
"owner"不可解析 ------它是纯代码内部档位,永远不会出现在表单或 URL 参数里(防止用户提交permission=owner提权)。allowed变参是白名单收紧 :ParseAccessMode("admin", AccessModeNone, AccessModeWrite, AccessModeAdmin)拿到 Admin,但ParseAccessMode("read", ...Write, Admin)返回 None(Read 不在白名单里)。- 任何解析失败一律归零为
AccessModeNone------fail closed。
ErrInvalidAccessMode 定义在文件末尾,用 util.NewInvalidArgumentErrorf 构造,供上层做错误分类。
5.2.2 一个容易被忽略的细节:doer 分型
GetDoerRepoPermission 的名字里 "Doer" 是理解整个体系的钥匙。Gitea 中"当前操作者"不只一种形态:
| doer 形态 | 识别方式 | 权限计算入口 |
|---|---|---|
| 匿名访问 | user == nil |
GetIndividualUserRepoPermission 走匿名分支 |
| 普通登录用户 | user_model.User 常规行 |
GetIndividualUserRepoPermission |
| 站点管理员 | user.IsAdmin == true |
同上,命中 AccessModeOwner 短路 |
| Actions 任务 | GetActionsUserTaskID(user) 返回 taskID |
GetActionsUserRepoPermission |
| Deploy key | GetDeployKeyUserDeployKeyID(user) 返回 keyID |
getDeployKeyRepoPermission |
分派逻辑只有八行(repo_permission.go):
go
func GetDoerRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (Permission, error) {
if taskID, ok := user_model.GetActionsUserTaskID(user); ok {
return GetActionsUserRepoPermission(ctx, repo, user, taskID)
}
if keyID, ok := user_model.GetDeployKeyUserDeployKeyID(user); ok {
return getDeployKeyRepoPermission(ctx, repo, keyID)
}
return GetIndividualUserRepoPermission(ctx, repo, user)
}
两个系统用户 ID 是负数占位(models/user/user_system.go):ActionsUserID = -2、DeployKeyUserID = -3。它们在数据库里没有真实用户行,但被构造成 *user_model.User 在请求上下文中流动,GetActionsUserTaskID/GetDeployKeyUserDeployKeyID 就是从这个内存对象里解出"它代表哪个任务/哪把钥匙"。
这一分派设计的动机是:Actions 任务与 deploy key 是"凭证级"身份,权限不由用户关系决定,而由任务令牌配置或密钥本身决定 。把它们塞进 GetIndividualUserRepoPermission 会让那条本已复杂的链路更加不可读,所以另起两条短链(第 5.9 节展开)。
5.2.3 权限单位表与默认单元集
unit.Units 注册表里每个 Unit 结构带一个 MaxAccessMode 字段(如 UnitCode 是 perm.AccessModeOwner、UnitPackages 是 perm.AccessModeRead)。注意这个字段与 MaxPerm() 不是一回事 :UnitCode.MaxAccessMode 写的是 Owner,但 MaxPerm() 对所有非外链单元一律返回 Admin。实际生效的是 MaxPerm()------UnitAccessModeEx、paresFormTeamUnits 都调它。MaxAccessMode 字段主要留给模板与 UI 展示,读代码时要认准调用点。
默认单元集有四套,随仓库形态选择:
go
// DefaultRepoUnits contains the default unit types
DefaultRepoUnits = []Type{TypeCode, TypeIssues, TypePullRequests, TypeReleases, TypeWiki, TypeProjects, TypePackages, TypeActions}
// DefaultForkRepoUnits contains the default unit types for forks
DefaultForkRepoUnits = []Type{TypeCode, TypePullRequests}
(另有 DefaultMirrorRepoUnits、DefaultTemplateRepoUnits,以及不允许作为默认单元的 NotAllowedDefaultRepoUnits = []Type{TypeExternalWiki, TypeExternalTracker}------外链单元必须由用户显式开启并填 URL。)
LoadUnitConfig 在启动时把 setting.Repository.DisabledRepoUnits 字符串解析成类型集合写入原子指针,并从各默认集中剔除 被全局禁用的单元;若剔完 DefaultRepoUnits 为空则返回错误 "no default repository units found" 拒绝启动------这是配置层面的防呆。
5.2.4 缓存键
同一请求内权限会被多次使用,repoAssignmentLegacy 计算一次后写入请求级缓存,键的构造集中在一个函数里(repo_permission.go):
go
// RepoUserPermissionCacheKey is the cachegroup.RepoUserPermission key of a doer's
// permission on a repository. Producers and consumers must agree on it, so it lives here.
func RepoUserPermissionCacheKey(repoID int64, doer *user_model.User) string {
var doerID int64
if doer != nil {
doerID = doer.ID
}
return fmt.Sprintf("%d-%d", repoID, doerID)
}
doer == nil(匿名)时 doerID 取 0,所以匿名权限的缓存键形如 17-0。缓存组名 cachegroup.RepoUserPermission = "repo_user_permission" 定义在 modules/cachegroup/cachegroup.go。生产者(repoAssignmentLegacy)与消费者(后续任何 ctx.Repo.Permission 读取方)通过这个共享函数对齐键格式------这是第 1 章讲过的"横切机制"在权限上的一个落点。
5.3 Permission 结构体与判定 API
5.3.1 结构体:五个字段各管一件事
models/perm/access/repo_permission.go 开头的结构体是全章的中心:
go
// Permission contains all the permissions related variables to a repository for a user
type Permission struct {
AccessMode perm_model.AccessMode
units []*repo_model.RepoUnit
unitsMode map[unit.Type]perm_model.AccessMode
everyoneAccessMode map[unit.Type]perm_model.AccessMode // the unit's minimal access mode for every signed-in user
anonymousAccessMode map[unit.Type]perm_model.AccessMode // the unit's minimal access mode for anonymous (non-signed-in) user
orgRepoTeams []*organization.Team
}
| 字段 | 含义 | 谁填充 |
|---|---|---|
AccessMode |
仓库整体档位(None/Read/Write/Admin/Owner) | 计算链主干 |
units |
仓库启用的单元列表(已被全局禁用过滤) | repo.LoadUnits |
unitsMode |
单元级 档位表;nil 表示"整体档位对所有单元生效" |
组织团队分支 / deploy key / Actions |
everyoneAccessMode |
每个登录用户在各单元的保底档位(公共访问设置) | finalProcessRepoUnitPermission |
anonymousAccessMode |
匿名用户在各单元的保底档位(公共访问设置) | finalProcessRepoUnitPermission |
orgRepoTeams |
用户在该仓库所属的组织团队列表(供 DangerZone 等复用) | GetUserRepoTeams |
unitsMode == nil 是一个重要的约定态 :表示"未做单元级细分,AccessMode 适用于全部单元"。代码中多处显式依赖它,比如 owner team 短路时 perm.unitsMode = nil(第 5.4 节),以及 finalProcessRepoUnitPermission 中 if perm.unitsMode == nil { return } 直接跳过裁剪。
5.3.2 UnitAccessMode:整个包最精妙的一函数
所有 CanRead/CanWrite 最终都落在 UnitAccessMode 上:
go
// UnitAccessMode returns current user access mode to the specify unit of the repository
// It also considers "public (anonymous/everyone) access mode"
func (p *Permission) UnitAccessMode(unitType unit.Type) perm_model.AccessMode {
// if the units map contains the access mode, use it, but admin/owner mode could override it
if m, ok := p.unitsMode[unitType]; ok {
return util.Iif(p.AccessMode >= perm_model.AccessModeAdmin, p.AccessMode, m)
}
// if the units map does not contain the access mode, return the default access mode if the unit exists
unitDefaultAccessMode := p.AccessMode
unitDefaultAccessMode = max(unitDefaultAccessMode, p.anonymousAccessMode[unitType])
unitDefaultAccessMode = max(unitDefaultAccessMode, p.everyoneAccessMode[unitType])
hasUnit := slices.ContainsFunc(p.units, func(u *repo_model.RepoUnit) bool { return u.Type == unitType })
return util.Iif(hasUnit, unitDefaultAccessMode, perm_model.AccessModeNone)
}
逐行拆解成四条规则:
- 有单元级记录时 :返回单元档位
m,但若整体AccessMode >= Admin(即仓库管理员/owner/站点管理员),用整体档位覆盖------管理员不会被团队的单元级限制降权。 - 无单元级记录时 :以整体档位为基底,
max上匿名/所有人的公共访问保底。 - 单元不存在时 (仓库没启用该单元,或被全局禁用过滤掉了):一律
AccessModeNone------这是"仓库未启用单元 ⇒ 无权访问"的实现点。 - 两个
max说明公共访问只能加权不能减权:设置页给 Wiki 开"所有人可写",只会让权限 >= Write 的人更多,不会把原本有权限的人降级。
配套的 CanAccess 系列只是语法糖:
go
func (p *Permission) CanAccess(mode perm_model.AccessMode, unitType unit.Type) bool {
return p.UnitAccessMode(unitType) >= mode
}
func (p *Permission) CanRead(unitType unit.Type) bool { return p.CanAccess(perm_model.AccessModeRead, unitType) }
func (p *Permission) CanWrite(unitType unit.Type) bool { return p.CanAccess(perm_model.AccessModeWrite, unitType) }
以及一对专用于 issue/PR 二义路由的辅助:CanReadIssuesOrPulls(isPull)、CanWriteIssuesOrPulls(isPull)------isPull 为真走 TypePullRequests,否则走 TypeIssues。
5.3.3 整体档位的快捷判定
go
func (p *Permission) IsOwner() bool { return p.AccessMode >= perm_model.AccessModeOwner }
func (p *Permission) IsAdmin() bool { return p.AccessMode >= perm_model.AccessModeAdmin }
它们只看整体 AccessMode,不看 unitsMode 。所以"仓库管理员"的定义是整体档位 >= Admin,而不是某个单元有权------这是 RequireRepoAdmin 中间件与 API reqAdmin 的判定依据。
"是否至少能看点什么"有一对方法,注意注释里的 TODO:
go
// HasAnyUnitAccess returns true if the user might have at least one access mode to any unit of this repository.
// It doesn't count the "public(anonymous/everyone) access mode".
// TODO: most calls to this function should be replaced with `HasAnyUnitAccessOrPublicAccess`
func (p *Permission) HasAnyUnitAccess() bool {
for _, v := range p.unitsMode {
if v >= perm_model.AccessModeRead {
return true
}
}
return p.AccessMode >= perm_model.AccessModeRead
}
func (p *Permission) HasAnyUnitAccessOrPublicAccess() bool {
return p.HasAnyUnitPublicAccess() || p.HasAnyUnitAccess()
}
HasAnyUnitAccess 不计公共访问 ,只看用户自己的档位;HasAnyUnitAccessOrPublicAccess 把匿名/所有人的保底也算进去。两者的区别在"仓库全单元只开了公共访问、用户自己没有任何权限"这种边缘场景下才有差异------RepoAssignment 的可见性闸门用的是后者(!HasAnyUnitAccessOrPublicAccess() && !canWriteAsMaintainer(ctx) 才 404),因为只要有任何可看的入口就该让请求继续。
5.3.4 公共访问的注入:applyPublicAccessPermission 与 finalProcessRepoUnitPermission
权限计算收尾时执行两个私有函数。先看单个单元的注入:
go
func applyPublicAccessPermission(unitType unit.Type, accessMode perm_model.AccessMode, modeMap *map[unit.Type]perm_model.AccessMode) {
if setting.Repository.ForcePrivate {
return
}
if accessMode >= perm_model.AccessModeRead && accessMode > (*modeMap)[unitType] {
if *modeMap == nil {
*modeMap = make(map[unit.Type]perm_model.AccessMode)
}
(*modeMap)[unitType] = accessMode
}
}
站点级 ForcePrivate 在这里一票否决 ------管理员强制全站私有时,任何仓库的"公共访问"设置都不生效(设置页也有对应的禁用提示,第 5.6 节)。accessMode > (*modeMap)[unitType] 保证只升不降。
总装函数:
go
func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) {
// apply public (anonymous) access permissions
for _, u := range perm.units {
applyPublicAccessPermission(u.Type, u.AnonymousAccessMode, &perm.anonymousAccessMode)
}
if user == nil || user.ID <= 0 {
// for anonymous access, it could be:
// AccessMode is None or Read, units has repo units, unitModes is nil
return
}
// apply public (everyone) access permissions
for _, u := range perm.units {
applyPublicAccessPermission(u.Type, u.EveryoneAccessMode, &perm.everyoneAccessMode)
}
if perm.unitsMode == nil {
// if unitsMode is not set, then it means that the default p.AccessMode applies to all units
return
}
// remove no permission units
origPermUnits := perm.units
perm.units = make([]*repo_model.RepoUnit, 0, len(perm.units))
for _, u := range origPermUnits {
shouldKeep := false
for t := range perm.unitsMode {
if shouldKeep = u.Type == t; shouldKeep { break }
}
for t := range perm.anonymousAccessMode {
if shouldKeep = shouldKeep || u.Type == t; shouldKeep { break }
}
for t := range perm.everyoneAccessMode {
if shouldKeep = shouldKeep || u.Type == t; shouldKeep { break }
}
if shouldKeep {
perm.units = append(perm.units, u)
}
}
}
三个行为要点:
- 匿名用户只拿
anonymousAccessMode,不拿everyoneAccessMode------user == nil || user.ID <= 0时提前返回。"所有人可读"对匿名者不生效,这是语义区分("everyone" 指所有登录用户)而非疏漏。 unitsMode == nil时不做裁剪------整体档位模式下units保持完整,模板按ReadableUnitTypes()渲染。- units 裁剪 :当存在单元级记录时,把既不在
unitsMode、也不在两个公共访问表里的单元从units中剔除。ReadableUnitTypes依赖这个裁剪结果渲染侧边栏,避免给用户展示无权访问的入口。
GetIndividualUserRepoPermission 通过 defer 挂钩确保只在成功时执行:
go
defer func() {
if err == nil {
finalProcessRepoUnitPermission(user, &perm)
}
log.Trace("Permission Loaded for user %-v in repo %-v, permissions: %-+v", user, repo, perm)
}()
错误路径不总装,防止半成品权限被误用;同时无条件打一条 Trace 日志------排查权限问题时打开 trace 级日志就能看到每次计算的完整结构(LogString 方法会把每个 unit 的 Config 序列化出来)。
5.4 权限计算链路逐行拆解
5.4.1 GetIndividualUserRepoPermission:主链的九个检查点
这是普通身份(含匿名)唯一的权限入口,位于 models/perm/access/repo_permission.go:426。整个函数是一个短路优先的漏斗------越早返回说明命中越"强"或越"绝"的规则。按执行顺序编号:
go
func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (perm Permission, err error) {
defer func() {
if err == nil {
finalProcessRepoUnitPermission(user, &perm)
}
log.Trace("Permission Loaded for user %-v in repo %-v, permissions: %-+v", user, repo, perm)
}()
if err = repo.LoadUnits(ctx); err != nil {
return perm, err
}
perm.units = repo.Units
// anonymous user visit private repo.
if user == nil && repo.IsPrivate {
perm.AccessMode = perm_model.AccessModeNone
return perm, nil
}
var isCollaborator bool
if user != nil {
isCollaborator, err = repo_model.IsCollaborator(ctx, repo.ID, user.ID)
if err != nil {
return perm, err
}
}
if err = repo.LoadOwner(ctx); err != nil {
return perm, err
}
// Prevent strangers from checking out public repo of private organization/users
// Allow user if they are a collaborator of a repo within a private user or a private organization but not a member of the organization itself
// TODO: rename it to "IsOwnerVisibleToDoer"
if !organization.HasOrgOrUserVisible(ctx, repo.Owner, user) && !isCollaborator {
perm.AccessMode = perm_model.AccessModeNone
return perm, nil
}
// anonymous visit public repo
if user == nil {
perm.AccessMode = perm_model.AccessModeRead
return perm, nil
}
// Admin or the owner has super access to the repository
if user.IsAdmin || user.ID == repo.OwnerID {
perm.AccessMode = perm_model.AccessModeOwner
return perm, nil
}
// plain user TODO: this check should be replaced, only need to check collaborator access mode
perm.AccessMode, err = modeByOwnerAndAccess(ctx, user, repo)
if err != nil {
return perm, err
}
if !repo.Owner.IsOrganization() {
return perm, nil
}
...
}
检查点 1(LoadUnits) :先加载单元。注意 repo.LoadUnits 内部走 getUnitsByRepoID,已过滤 UnitGlobalDisabled 的类型------所以后面所有遍历 perm.units 的逻辑天然看不到全局禁用的单元。这就是 5.1.3 说的"数据行还在,只是不加载"。
检查点 2(匿名 × 私有) :user == nil && repo.IsPrivate → None。匿名访问私有仓库在第一行就被掐断,且此时连 LoadOwner 都没做------最短路径。
检查点 3(协作者标记) :IsCollaborator 只是一个 db.Exist 查询(models/repo/collaboration.go),结果存下来供检查点 5 和组织分支复用。注意这里不读协作档位 ,档位由 modeByOwnerAndAccess 从 access 表统一取(协作者的档位已物化进 access 表,第 5.5 节)。
检查点 4(owner 可见性防线) :!HasOrgOrUserVisible && !isCollaborator → None。语义是"仓库本身公开,但它的 owner(私有组织/私有用户)对你不可见,且你也不是协作者"------Gitea 不允许通过公开仓库间接枚举私有组织的存在。协作者豁免这条防线,因为协作者关系本身就证明了他是"内部人"。
检查点 5(匿名 × 公开) :user == nil → Read。匿名能走到这里说明仓库非私有、owner 可见,且检查点 4 已把私有 owner 挡掉,故直接 Read。
检查点 6(管理员/个人 owner 短路) :user.IsAdmin || user.ID == repo.OwnerID → Owner 档 ,立即返回。注意站点管理员拿到的也是 AccessModeOwner------这解释了为什么 IsAdmin()(整体 >= Admin)对站点管理员永远为真。
检查点 7(modeByOwnerAndAccess) :普通用户的基线档位。对个人仓库,这是它的最终答案(检查点 8 短路返回);对组织仓库,它只是起点。源码在此处留了一个 TODO:"this check should be replaced, only need to check collaborator access mode"------暗示当前实现对协作者也查了 access 表(因为协作者档位物化在表里),设计上希望未来直接读 collaboration.mode。
检查点 8(个人仓库收尾) :!repo.Owner.IsOrganization() → 直接返回。个人仓库的权限到此为止:owner/admin 是 Owner,其他人是 modeByOwnerAndAccess 的结果(协作者 Write/Admin、公开保底 Read、私有无记录 None)。
5.4.2 modeByOwnerAndAccess:基线档位的三条来源
个人仓库与组织仓库都经过它,逻辑在 models/perm/access/access.go:38:
go
func modeByOwnerAndAccess(ctx context.Context, user *user_model.User, repo *repo_model.Repository) (perm.AccessMode, error) {
mode := perm.AccessModeNone
var userID int64
restricted := false
if user != nil {
userID = user.ID
restricted = user.IsRestricted
}
if err := repo.LoadOwner(ctx); err != nil {
return mode, err
}
repoIsFullyPublic := !setting.Service.RequireSignInViewStrict && repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate
if (restricted && repoIsFullyPublic) || (!restricted && !repo.IsPrivate) {
mode = perm.AccessModeRead
}
if userID == 0 {
return mode, nil
}
if userID == repo.OwnerID {
return perm.AccessModeOwner, nil
}
a, exist, err := db.Get[Access](ctx, builder.Eq{"user_id": userID, "repo_id": repo.ID})
if err != nil {
return mode, err
} else if !exist {
return mode, nil
}
return a.Mode, nil
}
三条来源,按序覆盖:
- 可见性保底 :非私有仓库给 Read(受限用户要"完全公开"才给)。匿名(
userID == 0)在此返回------所以匿名者的基线就是这条保底。 - 个人 owner :
userID == repo.OwnerID→ Owner(虽然主链在检查点 6 已短路过一次,IsUserRealRepoAdmin等其他调用者依赖这里的兜底)。 access表 :命中则返回表中档位 (不是max(保底, 表值),是直接替换)。这里有一个隐含约定:refreshAccesses保证表里的值不低于"该场景应有的保底"(第 5.5 节),所以直接替换不会降权。
函数名中的 "modeByOwnerAndAccess" 精确概括了输入:owner 的可见性设置(决定保底) + access 表(决定显式授权)。
5.4.3 组织仓库分支:团队档位聚合
回到主链,走完检查点 7 后若是组织仓库,继续(repo_permission.go:487-526):
go
// now: the owner is visible to doer, if the repo is public, then the min access mode is read
minAccessMode := util.Iif(!repo.IsPrivate && !user.IsRestricted, perm_model.AccessModeRead, perm_model.AccessModeNone)
perm.AccessMode = max(perm.AccessMode, minAccessMode)
// get units mode from teams
perm.orgRepoTeams, err = organization.GetUserRepoTeams(ctx, repo.OwnerID, user.ID, repo.ID)
if err != nil {
return perm, err
}
if len(perm.orgRepoTeams) == 0 {
return perm, nil
}
perm.unitsMode = make(map[unit.Type]perm_model.AccessMode)
// Collaborators on organization
if isCollaborator {
for _, u := range repo.Units {
perm.unitsMode[u.Type] = perm.AccessMode
}
}
// if user in an owner team
for _, team := range perm.orgRepoTeams {
if team.IsOwnerTeam() || team.AccessMode == perm_model.AccessModeOwner {
perm.AccessMode = perm_model.AccessModeOwner
perm.unitsMode = nil
return perm, nil
}
}
for _, u := range repo.Units {
for _, team := range perm.orgRepoTeams {
teamMode, _ := team.UnitAccessModeEx(ctx, u.Type)
unitAccessMode := max(perm.unitsMode[u.Type], minAccessMode, teamMode)
perm.unitsMode[u.Type] = unitAccessMode
}
}
return perm, err
}
四个动作:
(a)非私有保底 Read :组织的非私有仓库给成员/受限用户以外的可见者 Read 保底(minAccessMode),叠加到 perm.AccessMode 上------即使 access 表没有记录,公开的组织仓库也能看。
(b)GetUserRepoTeams :一条 JOIN 查询拿"用户在这个组织里、且关联到这个仓库的团队"(models/organization/team_list.go):
go
func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams TeamList, err error) {
return teams, db.GetEngine(ctx).
Join("INNER", "team_user", "team_user.team_id = team.id").
Join("INNER", "team_repo", "team_repo.team_id = team.id").
Where("team.org_id = ?", orgID).
And("team_user.uid=?", userID).
And("team_repo.repo_id=?", repoID).
Find(&teams)
}
结果存进 perm.orgRepoTeams,既用于本函数也留给 CanDoerManageRepoDangerZone 复用。没有团队就到此为止(组织成员但不在这仓库的任何团队里,权限 = (a) 的保底与基线的 max)。
(c)协作者铺底 :组织仓库的协作者(collaboration 行)先把基线档位铺到所有单元 上,保证协作者的单元访问不被后续团队聚合覆盖成更低值(max 的第一个参数)。
(d)Owner 团队短路 :任一团队是 Owners 团队(按名字 OwnerTeamName = "Owners" 判断)或 AccessMode == Owner → 整体升为 Owner 并把 unitsMode 置回 nil------回到"整体档位适用所有单元"的约定态。这是 5.3.1 提到的约定态的一次真实使用。
(e)逐单元聚合:对每个 unit、每个团队执行
go
teamMode, _ := team.UnitAccessModeEx(ctx, u.Type)
unitAccessMode := max(perm.unitsMode[u.Type], minAccessMode, teamMode)
即 单元档位 = max(协作者铺底, 非私有保底, 团队对这个单元的档位) 。UnitAccessModeEx 的实现(第 5.7 节)保证 teamMode 已被 unit.MaxPerm() 夹逼且考虑了团队整体档位。
5.4.4 一个完整例子:把漏斗跑一遍
场景:组织 acme 有公开仓库 acme/api(IsPrivate=false,owner Visibility=limited);用户 alice 是组织成员但不在 Owners 团队中,她在团队 acme/dev 里,团队 authorize=write、team_unit(code)=write;站点配置默认(RequireSignInViewStrict=false)。
- 检查点 1:
LoadUnits拿到 Code/Issues/PRs/Releases/Wiki/Projects/Packages/Actions 行。 - 检查点 2:alice 非匿名,跳过。
- 检查点 3:alice 非协作者,
isCollaborator=false。 - 检查点 4:
ownerVisibilitySatisfiesDoer(acme, alice)------alice 非管理员、非 owner 本人、RequireSignInViewStrict=false但Visibility != public(limited),走最后一条Visibility != private && !restricted→ true。防线通过。 - 检查点 5:alice 非匿名,跳过。
- 检查点 6:alice 非管理员、非 owner(owner 是组织),跳过。
- 检查点 7:
modeByOwnerAndAccess------非私有 → 基线 Read;access表若无 alice 记录则返回 Read。 - 检查点 8:owner 是组织,继续。
minAccessMode = Read(非私有且 alice 非受限),perm.AccessMode = max(Read, Read) = Read。GetUserRepoTeams返回[acme/dev]。- 非协作者,跳过铺底(
unitsMode是刚 make 的空 map)。 acme/dev非 Owners 团队,不短路。- 逐单元:对 Code 单元
teamMode = UnitAccessModeEx(code) = max(团队 unit 行 write, 团队整体 write) 夹到 maxPerm(admin) = write,unitsMode[code] = max(未设即 0, Read, Write) = Write;Issues 单元若团队没单独设 unit 行,UnitAccessModeEx返回max(0, 团队整体 write) = Write,故unitsMode[issues] = Write......所有单元最终至少 Write(团队整体档位兜底)。 finalProcessRepoUnitPermission:仓库没设公共访问则两个保底表为 nil;unitsMode非 nil → 裁剪units,保留所有出现在unitsMode中的单元(本例全部保留)。
结果:alice 对 acme/api 整体 Read,但每个单元 Write------整体档位低于单元档位 是组织仓库的常态,UnitAccessMode 分支一(unitsMode 命中)决定最终取单元档位。若 alice 同时是协作者(write),检查点 (c) 铺底与团队聚合的 max 结果不变;若团队是 Owners 团队,则在 (d) 短路为整体 Owner + unitsMode=nil。
5.4.5 IsUserRealRepoAdmin:不经过整体档位的"真管理员"
组织仓库的管理员判定不能只看 AccessMode(协作者 Admin 与团队 Admin 都该算),于是有了独立函数:
go
// IsUserRealRepoAdmin check if this user is real repo admin (but not a site admin who also has repo admin access)
func IsUserRealRepoAdmin(ctx context.Context, repo *repo_model.Repository, user *user_model.User) bool {
if user == nil || repo == nil {
return false
}
mode, err := modeByOwnerAndAccess(ctx, user, repo)
if err != nil {
return false
}
if mode >= perm_model.AccessModeAdmin {
return true
}
teams, err := organization.GetUserRepoTeams(ctx, repo.OwnerID, user.ID, repo.ID)
if err != nil {
return false
}
for _, team := range teams {
if team.AccessMode >= perm_model.AccessModeAdmin {
return true
}
}
return false
}
它绕开 GetIndividualUserRepoPermission 的完整计算,直接问两件事:access 表档位 >= Admin,或任一关联团队整体档位 >= Admin。IsUserRepoAdmin 在它前面再补站点管理员:
go
func IsUserRepoAdmin(ctx context.Context, repo *repo_model.Repository, user *user_model.User) bool {
return (user != nil && user.IsAdmin) || IsUserRealRepoAdmin(ctx, repo, user)
}
注意团队判定只看 team.AccessMode(团队整体档位),不看 team_unit ------某个团队即便被授予单元级 Admin,若整体档位是 Write,其成员不算"真仓库管理员"。这与 CanDoerManageRepoDangerZone 的口径一致(第 5.8 节),两者共同定义了"谁能删库"。
5.4.6 辅助判定函数群
主链之外还有一组直接复用计算结果的函数,全部在 repo_permission.go:
| 函数 | 语义 | 备注 |
|---|---|---|
AccessLevel(user, repo) |
用户对 TypeCode 单元的档位 |
历史兼容入口,内部转调 AccessLevelUnit |
AccessLevelUnit(user, repo, unitType) |
用户对指定单元的档位 | 走 GetIndividualUserRepoPermission(不解析 Actions 用户) |
HasAccessUnit(..., testMode) |
testMode <= AccessLevelUnit |
分支保护白名单判定用 |
CanBeAssigned(user, repo) |
能否被指派 issue/PR | 任一单元 Write,或 PR 单元 Read;组织不能被指派 |
HasAnyUnitAccess(userID, repo) |
至少一个单元可读 | 按 ID 取用户后走主链 |
CheckRepoUnitUser(repo, user, unitType) |
用户能否访问单元 | 站点管理员直接 true,否则 CanRead |
GetUserIDsWithAnyUnitAccess(repo, mode, unitTypes...) |
汇总有某档位的用户 ID | 不走主链 :直接查 access 表 + owner + GetTeamUserIDsWithAccessToAnyRepoUnit,用于批量场景 |
PermissionNoAccess() |
构造零权限 | 2FA 强制场景用(第 5.8 节) |
其中 GetUserIDsWithAnyUnitAccess 值得留意:它是"权限反查"(从权限到人)而不是"人查权限",实现上直接读 access 表 mode >= ? 再并入 owner/团队成员------性能上绕开了主链的多步查询 ,代价是它只反映物化结果(依赖 refreshAccesses 的及时性)。
5.5 Access 表的物化与重算(Recalculate*)
5.5.1 为什么要物化:一次查询换 N 次 JOIN
团队档位存在 team/team_repo/team_unit 三张表里,协作者档位存在 collaboration 里。如果不做物化,每次权限计算都要 JOIN 这四张表;仓库列表页、通知、@提及等批量场景更是要对成百上千个仓库做判定。Gitea 的选择是:把"用户对仓库的最高档位"物化到一张窄表 access 里,计算时只查这一行。
物化带来的必答题是"什么时候重算"。答案是一组 Recalculate* 函数,全部在 models/perm/access/access.go,它们共享一个 accessMap 模式:
go
type userAccess struct {
User *user_model.User
Mode perm.AccessMode
}
// updateUserAccess updates an access map so that user has at least mode
func updateUserAccess(accessMap map[int64]*userAccess, user *user_model.User, mode perm.AccessMode) {
if ua, ok := accessMap[user.ID]; ok {
ua.Mode = max(ua.Mode, mode)
} else {
accessMap[user.ID] = &userAccess{User: user, Mode: mode}
}
}
max 语义与 AccessMode 枚举配套:一个人同时是协作者(Write)和团队成员(Read),物化结果取 Write。
5.5.2 refreshAccesses:增量差异同步
refreshAccesses 不是"删光重插",而是与现有记录做三方对比,只执行必要的 delete/update/insert:
go
func refreshAccesses(ctx context.Context, repo *repo_model.Repository, accessMap map[int64]*userAccess) (err error) {
minModeToKeep := perm.AccessModeRead
if err := repo.LoadOwner(ctx); err != nil {
return fmt.Errorf("LoadOwner: %w", err)
}
// If the repo isn't private and isn't owned by an organization,
// increase the minMode to Write.
if !repo.IsPrivate && !repo.Owner.IsOrganization() {
minModeToKeep = perm.AccessModeWrite
}
// Query existing accesses for cross-comparison
var existingAccesses []Access
if err := db.GetEngine(ctx).Where(builder.Eq{"repo_id": repo.ID}).Find(&existingAccesses); err != nil {
return fmt.Errorf("find existing accesses: %w", err)
}
existingMap := make(map[int64]perm.AccessMode, len(existingAccesses))
for _, a := range existingAccesses {
existingMap[a.UserID] = a.Mode
}
var toDelete []int64
var toInsert, toUpdate []Access
// Determine changes
for userID, ua := range accessMap {
if ua.Mode < minModeToKeep && !ua.User.IsRestricted {
// No explicit access record needed (handled by default permissions, e.g., public repo access)
if _, exists := existingMap[userID]; exists {
toDelete = append(toDelete, userID)
}
} else {
desiredMode := ua.Mode
if existingMode, exists := existingMap[userID]; exists {
if existingMode != desiredMode {
toUpdate = append(toUpdate, Access{UserID: userID, RepoID: repo.ID, Mode: desiredMode})
}
} else {
toInsert = append(toInsert, Access{UserID: userID, RepoID: repo.ID, Mode: desiredMode})
}
}
delete(existingMap, userID)
}
// Remaining in existingMap should be deleted
for userID := range existingMap {
toDelete = append(toDelete, userID)
}
... // 批量执行三类 SQL
}
minModeToKeep 是这段逻辑的灵魂,它决定了哪些记录根本不值得存:
| 仓库形态 | minModeToKeep | 含义 |
|---|---|---|
| 组织仓库(任意可见性) | Read | 团队/协作关系给 Read 也存表(组织成员关系是显式授权,公开可见性保底之外还需要表来支撑后续聚合) |
| 个人非私有仓库 | Write | 公开仓库的 Read 由可见性保底提供(modeByOwnerAndAccess 第一条),只有 >= Write 的显式授权才需要存 |
| 个人私有仓库 | Read | 私有仓库无保底,任何显式授权都必须存 |
还有一个例外:ua.User.IsRestricted 的用户即使档位低于 minModeToKeep 也存表 ------因为受限用户的可见性保底被 RequireSignInViewStrict/受限规则收回(见 5.1.2),modeByOwnerAndAccess 对他们不会给出保底 Read,必须靠表来显式授权。
这解释了 5.4.2 埋下的伏笔:"直接返回表中档位不会降权"------因为低于保底的记录压根不会被写进表。
5.5.3 三个 Recalculate 入口与触发时机
go
// RecalculateTeamAccesses recalculates new accesses for teams of an organization
// except the team whose ID is given. It is used to assign a team ID when
// remove repository from that team.
func RecalculateTeamAccesses(ctx context.Context, repo *repo_model.Repository, ignTeamID int64) (err error)
它先铺协作者(refreshCollaboratorAccesses),再遍历组织全部团队:Owners 团队强制 t.AccessMode = AccessModeOwner,与本仓库无 team_repo 关联的团队跳过,其余团队把档位 updateUserAccess 给每个成员,最后 refreshAccesses 同步差异。ignTeamID 参数用于"把仓库从某团队移除"的场景------被移除的团队不再参与聚合。
go
// RecalculateUserAccess recalculates new access for a single user
// Usable if we know access only affected one user
func RecalculateUserAccess(ctx context.Context, repo *repo_model.Repository, uid int64) (err error)
只影响一个用户时的快路径:先取该用户的协作者档位,若是组织仓库再 JOIN team_repo+team_user 取其全部团队(Owners 团队强制 Owner),取 max;然后先删后插 (Delete(&Access{RepoID, UserID}) + 条件 accessMode >= minMode 才 Insert)。注意这里的 minMode 判定与 refreshAccesses 略有不同:私有仓库 Read、非私有 Write,且没有 IsRestricted 分支 ------受限用户的低档位记录在这条路径上不会被保留,这是一个细微的行为差异(主链重算路径用 refreshAccesses,不受影响)。
go
// RecalculateAccesses recalculates all accesses for repository.
func RecalculateAccesses(ctx context.Context, repo *repo_model.Repository) error {
if repo.Owner.IsOrganization() {
return RecalculateTeamAccesses(ctx, repo, 0)
}
accessMap := make(map[int64]*userAccess, 20)
if err := refreshCollaboratorAccesses(ctx, repo.ID, accessMap); err != nil {
return fmt.Errorf("refreshCollaboratorAccesses: %w", err)
}
return refreshAccesses(ctx, repo, accessMap)
}
总入口的分支很清楚:组织仓库走团队聚合(ignTeamID=0 即不排除任何团队),个人仓库只聚合协作者。
调用时机(grep 全库可核):协作者增删改档位(services/repository/collaboration.go)、团队仓库关联变更(services/organization)、仓库转让(services/repository/transfer.go)、仓库创建时的初始化(models/repo/create.go)以及仓库可见性切换。原则是:任何改变"谁对这仓库有什么档位"的数据变更,都要触发物化同步------权限计算读的是物化结果,写路径必须负责保持它一致。
refreshCollaboratorAccesses 是唯一的协作者入口:
go
func refreshCollaboratorAccesses(ctx context.Context, repoID int64, accessMap map[int64]*userAccess) error {
collaborators, _, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{RepoID: repoID})
if err != nil {
return fmt.Errorf("GetCollaborators: %w", err)
}
for _, c := range collaborators {
if c.User.IsGhost() {
continue
}
updateUserAccess(accessMap, c.User, c.Collaboration.Mode)
}
return nil
}
跳过 Ghost(被删除用户的占位符)------幽灵用户不该拥有权限记录。
5.5.4 物化结果的两个消费方向
access 表有两类消费:
- 单点判定 :
modeByOwnerAndAccess的db.Get[Access]------主链的核心一步。 - 批量筛选 :仓库列表的
AccessibleRepositoryCondition(models/repo/repo_list.go)把五类条件 OR 起来:
go
// UserAccessRepoCond returns a condition for selecting all repositories a user has unit independent access to
func UserAccessRepoCond(idStr string, userID int64) builder.Cond {
return builder.In(idStr, builder.Select("repo_id").
From("`access`").
Where(builder.And(
builder.Eq{"`access`.user_id": userID},
builder.Gt{"`access`.mode": int(perm.AccessModeNone)},
)),
)
}
五类条件分别是:非私有仓库且 owner 可见(repository.is_private = false + owner 可见性)、access 表命中、协作者表命中、团队仓库命中(含单元级过滤的 userOrgTeamUnitRepoCond)、直接拥有,以及"自己所在组织的公开仓库"。列表页与单点判定的口径并不完全相同 ------列表是 SQL 层的近似,单点是 Go 层的精确计算,两者共用同一批数据表但各自实现。搜索仓库时 opts.Private、AllLimited、AllPublic 这些开关再叠加 token 的 public-only 限制(ApplyPublicOnly)。
5.6 单元级权限:RepoUnit、公共访问与全局禁用
5.6.1 RepoUnit 结构与 Config 多态
models/repo/repo_unit.go 的核心结构:
go
// RepoUnit describes all units of a repository
type RepoUnit struct { //revive:disable-line:exported
ID int64
RepoID int64 `xorm:"INDEX(s)"`
Type unit.Type `xorm:"INDEX(s)"`
Config convert.Conversion `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
AnonymousAccessMode perm.AccessMode `xorm:"NOT NULL DEFAULT 0"`
EveryoneAccessMode perm.AccessMode `xorm:"NOT NULL DEFAULT 0"`
}
Config 是一个 convert.Conversion 接口字段(FromDB/ToDB),实际类型由 XORM 的 BeforeSet 钩子按 type 列实例化:
go
// BeforeSet is invoked from XORM before setting the value of a field of this object.
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
switch colName {
case "type":
var err error
r.Type, _, err = db.CellToInt(val, unit.TypeInvalid)
...
switch r.Type {
case unit.TypeExternalWiki:
r.Config = new(ExternalWikiConfig)
case unit.TypeExternalTracker:
r.Config = new(ExternalTrackerConfig)
case unit.TypePullRequests:
r.Config = new(PullRequestsConfig)
case unit.TypeIssues:
r.Config = new(IssuesConfig)
case unit.TypeActions:
r.Config = new(ActionsConfig)
case unit.TypeProjects:
r.Config = new(ProjectsConfig)
case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypePackages:
fallthrough
default:
r.Config = new(UnitConfig)
}
case "config":
if *val == nil {
// XROM doesn't call FromDB if the value is nil, but we need to set default values for the config fields
_ = r.Config.FromDB(nil)
}
}
}
这套多态 Config 是"单元配置"的统一载体:PR 合并策略(PullRequestsConfig 的 AllowMerge/AllowRebase/DefaultMergeStyle 等)、Issues 计时开关、Actions 的 token 权限配置(ActionsConfig,含 OverrideOwnerConfig、AllowedCrossRepoIDs、IsCollaborativeOwner------第 5.9 节用到)、Projects 的模式。取值时有一组强类型访问器(PullRequestsConfig()、ActionsConfig() 等),底层是泛型 unitConfig[T],类型不符直接 panic (repo unit %d of type %s has config type %T instead of %T)------因为 BeforeSet 已保证类型与 Type 一致,不符只能是数据损坏。
config 列为 NULL 时 FromDB(nil) 走默认值逻辑(如 PullRequestsConfig.FromDB 先赋 DefaultPullRequestsConfig() 再反序列化,且注释明确"don't let corrupted database value cause unnecessary 500 error")------数据库里的 NULL/损坏配置不会让页面 500,而是回落默认值。
5.6.2 加载时的全局禁用过滤
go
func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err error) {
var tmpUnits []*RepoUnit
if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&tmpUnits); err != nil {
return nil, err
}
for _, u := range tmpUnits {
if !u.Type.UnitGlobalDisabled() {
units = append(units, u)
}
}
return units, nil
}
单仓库加载与列表加载(RepositoryList.LoadUnits)都做同一道过滤。这意味着全局禁用是"视图层"的开关 :数据库行原样保留,setting.Repository.DisabledRepoUnits 改配置重启即生效,无需迁移数据。权限链路因此天然正确------perm.units 里没有该类型,UnitAccessMode 返回 None,路由中间件 404。
5.6.3 公共访问(Public Access)设置页
routers/web/repo/setting/public_access.go 是这套机制的管理面。每种单元可选的档位由一个静态表定义:
go
const (
paNotSet = "not-set"
paAnonymousRead = "anonymous-read"
paEveryoneRead = "everyone-read"
paEveryoneWrite = "everyone-write"
)
配置解析的核心防呆在 parsePublicAccessMode:
go
func parsePublicAccessMode(permission string, allowed []string) (ret struct {
AnonymousAccessMode, EveryoneAccessMode perm.AccessMode
},
) {
ret.AnonymousAccessMode = perm.AccessModeNone
ret.EveryoneAccessMode = perm.AccessModeNone
// if site admin forces repositories to be private, then do not allow any other access mode,
// otherwise the "force private" setting would be bypassed
if setting.Repository.ForcePrivate {
return ret
}
if !slices.Contains(allowed, permission) {
return ret
}
switch permission {
case paAnonymousRead:
ret.AnonymousAccessMode = perm.AccessModeRead
case paEveryoneRead:
ret.EveryoneAccessMode = perm.AccessModeRead
case paEveryoneWrite:
ret.EveryoneAccessMode = perm.AccessModeWrite
}
return ret
}
三层校验:ForcePrivate 短路(全站强制私有时任何提交都解析为 None,在写入层兜底 ,与 5.3.4 读取层的短路形成双保险);allowed 白名单(每单元可选档位不同);表单值不合法一律 None(fail closed)。
各单元的可选档位差异值得注意:只有 Wiki 提供 paEveryoneWrite (所有人可写 wiki,实现"公开共写文档"),其余单元最多到 everyone-read;Code/Issues/PRs/Releases/Projects/Packages/Actions 提供 anonymous-read 与 everyone-read 两档。repoUnitPublicAccesses 从 ctx.Repo.Repository.Units 反查当前值时按优先级判定:EveryoneAccessMode == Write → everyone-write,== Read → everyone-read,AnonymousAccessMode == Read → anonymous-read。
写入路径直接持久化两列:
go
func UpdateRepoUnitPublicAccess(ctx context.Context, unit *RepoUnit) error {
_, err := db.GetEngine(ctx).Where("repo_id=? AND `type`=?", unit.RepoID, unit.Type).
Cols("anonymous_access_mode", "everyone_access_mode").Update(unit)
return err
}
注意 Cols 限定只更新这两列------不会误清 config 。设置页路由 /settings/public_access 挂在 reqSignIn, context.RepoAssignment, reqRepoAdmin 组下(routers/web/web.go),即只有仓库管理员能改。
5.6.4 公共访问如何影响判定:一个反直觉点
组合 5.3.2 的 UnitAccessMode 与 5.3.4 的注入,可以推出几个非常规行为:
- 匿名 Read 与匿名 Write 不可兼得 :
anonymous_access_mode的 UI 只提供anonymous-read(paAnonymousRead只解析 Read),所以匿名者最高 Read;"所有人可写"需要登录(everyone语义排除匿名)。 - 公共访问不提升整体
AccessMode:IsAdmin()/IsOwner()只看整体档位,一个因公共访问能写 Wiki 的用户,其Permission.IsAdmin()仍是 false------RequireRepoAdmin不会放行。 ForcePrivate下设置页显示禁用提示 :PublicAccesshandler 把GlobalForcePrivate传给模板并Flash.Error(ctx.Tr("form.repository_force_private"))------用户能看到设置项但改了不生效(后端也会重置为 None)。
5.7 组织与 Team:三层档位的叠加
5.7.1 Team 结构与三档权限
models/organization/team.go:
go
// Team represents a organization team.
type Team struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"`
LowerName string
Name string
Description string
AccessMode perm.AccessMode `xorm:"'authorize'"`
Members []*user_model.User `xorm:"-"`
NumRepos int
NumMembers int
Units []*TeamUnit `xorm:"-"`
// All repos in the org are included in this team automatically
IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"`
// Any user with CanCreateOrgRepo permission can create a repository in the organization, regardless of team membership.
// And the user will become the repo's admin (via collaborator) after the creation.
CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"`
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 2"`
}
列名 authorize 是历史遗留(xorm:"'authorize'" 标签)------查 SQL 日志时会看到 team.authorize 而不是 team.access_mode。Members 与 Units 是 xorm:"-" 的内存字段,按需 LoadMembers/LoadUnits 加载。
IsOwnerTeam 的实现朴素到需要警惕:
go
// IsOwnerTeam returns true if team is owner team.
func (t *Team) IsOwnerTeam() bool {
return t.Name == OwnerTeamName
}
OwnerTeamName = "Owners",按名字判断------Owners 团队没有独立的类型标志位,改名即改变性质(Gitea 的 UI 不允许改 Owners 团队名,但这层保护在模型层是缺失的)。
5.7.2 UnitAccessModeEx:团队档位的真实算法
go
// UnitAccessMode returns the access mode for the given unit type, "none" for non-existent units
func (t *Team) UnitAccessMode(ctx context.Context, tp unit.Type) perm.AccessMode {
accessMode, _ := t.UnitAccessModeEx(ctx, tp)
return accessMode
}
func (t *Team) UnitAccessModeEx(ctx context.Context, tp unit.Type) (mode perm.AccessMode, exist bool) {
if err := t.LoadUnits(ctx); err != nil {
log.Error("Error loading team (ID: %d) units: %v", t.ID, err)
}
for _, u := range t.Units {
if u.Type == tp {
mode, exist = u.AccessMode, true
break
}
}
mode = max(mode, t.AccessMode)
if unitDef, ok := unit.Units[tp]; ok {
mode = min(mode, unitDef.MaxPerm())
}
return mode, exist || t.AccessMode > perm.AccessModeNone
}
三步:
- 找
team_unit行,命中则以行内档位为起点(找不到则从 0 开始)。 max(行档位, 团队整体档位)------团队整体档位是所有单元的下限 :authorize=write的团队对每个单元至少 Write,即使没有team_unit行。(UI 在整体档位非 none 时不显示单元选择,与此一致。)min(mode, unit.MaxPerm())------外链 wiki/tracker 夹到 Read,其余夹到 Admin(TeamUnit行存的值不会超过这个天花板,因为 UI 写入时已min(mode, Write, MaxPerm()))。
返回的 exist 第二值语义是"团队对该单元是否有任何权限"(行存在 或 整体档位 > None),供需要区分"明确无权"与"未配置"的调用者使用。
TeamUnit 表本身极简(models/organization/team_unit.go):
go
// TeamUnit describes all units of a repository
type TeamUnit struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"`
TeamID int64 `xorm:"UNIQUE(s)"`
Type unit.Type `xorm:"UNIQUE(s)"`
AccessMode perm.AccessMode
}
(team_id, type) 联合唯一------一个团队对一个单元至多一行。
5.7.3 UI 的三档权限与单元级覆盖
团队创建表单的处理在 routers/web/org/teams.go:
go
func paresFormTeamUnits(orgID int64, forms url.Values) (units []*org_model.TeamUnit) {
for _, ut := range unit_model.AllRepoUnitTypes {
v, ok := forms["unit_"+strconv.Itoa(ut.Value())]
if !ok {
continue
}
vv, _ := strconv.Atoi(v[0])
mode := perm.AccessMode(vv)
mode = min(mode, perm.AccessModeWrite, unit_model.Units[ut].MaxPerm())
units = append(units, &org_model.TeamUnit{OrgID: orgID, Type: ut, AccessMode: mode})
}
return units
}
// NewTeamPost response for create new team
func NewTeamPost(ctx *context.Context) {
form := web.GetForm[*forms.CreateTeamForm](ctx)
includesAllRepositories := form.RepoAccess == "all"
teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeWrite, perm.AccessModeAdmin)
...
if t.AccessMode == perm.AccessModeNone {
t.Units = paresFormTeamUnits(ctx.Org.Organization.ID, ctx.Req.Form)
if len(t.Units) == 0 {
ctx.RenderWithErrDeprecated(ctx.Tr("form.team_no_units_error"), tplTeamNew, &form)
return
}
}
...
}
三个要点:
- UI 只有三档 :
ParseAccessMode(form.Permission, None, Write, Admin)------白名单明确排除read(解析 read 会归 None,模板 radio 的 value 也只有none/write/admin)。团队要么整体 none(走单元级配置),要么整体 write/admin。 - 整体档位非 none 时不解析单元表单 ------
if t.AccessMode == AccessModeNone才调paresFormTeamUnits,这正是UnitAccessModeEx步骤 2(整体档位是下限)在 UI 侧的对应物。 - 单元级档位被夹到
min(提交值, Write, MaxPerm())------即使伪造表单提交unit_1=4(Owner),写库的是min(4, 2, Admin) = Write。单元级最高只能 Write (UI 层面),比MaxPerm()的 Admin 天花板更严。
而 Owners 团队在 CreateOrganization 里以特权初始化(models/organization/org.go):
go
// Create default owner team.
t := &Team{
OrgID: org.ID,
LowerName: strings.ToLower(OwnerTeamName),
Name: OwnerTeamName,
AccessMode: perm.AccessModeOwner,
NumMembers: 1,
IncludesAllRepositories: true,
CanCreateOrgRepo: true,
Visibility: structs.VisibleTypeLimited,
}
...
// insert units for team
units := make([]TeamUnit, 0, len(unit.AllRepoUnitTypes))
for _, tp := range unit.AllRepoUnitTypes {
up := perm.AccessModeOwner
if tp == unit.TypeExternalTracker || tp == unit.TypeExternalWiki {
up = perm.AccessModeRead
}
units = append(units, TeamUnit{OrgID: org.ID, TeamID: t.ID, Type: tp, AccessMode: up})
}
Owners 团队整体档位 Owner、包含全部仓库、可建仓库、可见性 limited,且每个单元显式写 Owner 行 (外链单元 Read)。注意这里给单元写了 Owner,但 UnitAccessModeEx 的 min(mode, MaxPerm()) 会把外的单元夹到 Admin------Owner 行在团队档位计算中实际被夹到 Admin ,Owner 语义真正生效的地方是主链 (d) 的 IsOwnerTeam() 短路(整体 Owner + unitsMode=nil)。这是"数据看着冗余但逻辑各司其职"的一个例子。
5.7.4 CanCreateOrgRepo 与组织创建限制
团队的 CanCreateOrgRepo 布尔量在 SQL 层的判定(models/organization/org_user.go):
go
func CanCreateOrgRepo(ctx context.Context, orgID, uid int64) (bool, error) {
return db.GetEngine(ctx).
Where(builder.Eq{"team.can_create_org_repo": true}.Or(builder.Eq{"team.authorize": perm.AccessModeOwner})).
Join("INNER", "team_user", "team_user.team_id = team.id").
And("team_user.uid = ?", uid).
And("team_user.org_id = ?", orgID).
Exist(new(Team))
}
Owners 团队成员隐含可建仓库 (authorize = owner 分支),或任何被显式勾选 can_create_org_repo 的团队。建完仓库后创建者会以 admin 协作者身份加入(Team.CanCreateOrgRepo 字段注释所言),这也解释了 CanDoerManageRepoDangerZone 里那段"特殊放行"(第 5.8 节)。
而"能否创建组织"是完全独立的另一条规则,属于站点级 权限(models/user/user.go):
go
// CanCreateOrganization returns true if user can create organisation.
func (u *User) CanCreateOrganization() bool {
return u.IsAdmin || (u.AllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation)
}
三级:站点管理员无条件可建;普通用户需个人设置 AllowCreateOrganization;站点配置 setting.Admin.DisableRegularOrgCreation 可整体关闭普通用户的组织创建(对应 UI 上"只有管理员能创建组织"的选项)。CreateOrganization 第一行就用它守门,失败返回 ErrUserNotAllowedCreateOrg(Unwrap() = util.ErrPermissionDenied)。
仓库创建数量限制是另一条独立规则------MaxCreationLimit/CanCreateRepoIn 只管数量不管权限(源码注释明确"ONLY checks the repo number LIMIT or whether owner user is real"),与本章的档位体系正交。
5.7.5 团队可见性与团队元数据读取
团队自身也有三档可见性(默认 VisibleTypePrivate = 2),决定非成员能看到多少:
go
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
// the team's metadata, based on the team's visibility tier and the parent org's
// visibility. Privileged callers (site admins, org owners, team members) are
// decided by the caller before reaching here.
func (t *Team) CanNonMemberReadMeta(ctx context.Context, org, doer *user_model.User) (bool, error) {
switch t.Visibility {
case structs.VisibleTypePublic:
return HasOrgOrUserVisible(ctx, org, doer), nil
case structs.VisibleTypeLimited:
return IsOrganizationMember(ctx, t.OrgID, doer.ID)
default:
return false, nil
}
}
public 团队元数据对"看得见组织"的人可见;limited 团队仅组织成员可见;private 团队一律不可见(调用方负责先放行特权身份)。团队列表的过滤进一步落实这一点:ApplyTeamListFilter 对站点管理员与组织 owner 直接放行,普通成员收窄到 {limited, public},非成员收窄到 {public}(VisibleTeamVisibilitiesFor)。API 侧对应的检查器是 reqTeamReadAccess(第 5.8 节)。
5.7.6 已知设计缺陷:ORG-TEAM-UNIT-MAX-PERMISSION
源码里有两处显式 FIXME 标注了这套聚合模型的缺陷,值得在"设计动机"层面记录:
models/organization/team_list.go 与 models/organization/org.go:278 各有一处:
go
func (t TeamList) AnyRepoUnitMaxAccess(ctx context.Context, tp unit.Type) perm.AccessMode {
// FIXME: ORG-TEAM-UNIT-MAX-PERMISSION: this function is not right, team can access repo1's code doesn't mean it can access repo2's code
maxAccess := perm.AccessModeNone
for _, team := range t {
if team.IsOwnerTeam() {
return perm.AccessModeOwner
}
maxAccess = max(maxAccess, team.UnitAccessMode(ctx, tp))
}
return maxAccess
}
这个函数回答的是"该用户在这个组织 的任一仓库上对单元 X 的最大权限",却被用于组织级路由的准入判定(reqAnyRepoUnitAccess,第 5.8 节)------团队对 repo1 的 Code 有权不代表对 repo2 有权 ,但组织级页面(如组织代码搜索入口)用这个近似值判定"是否值得渲染入口",属于有意接受的近似。真正落到具体仓库的判定仍然走 GetIndividualUserRepoPermission 的精确计算。类似的还有 CanDoerManageRepoDangerZone 里的 FIXME: ORG-REPO-ADMIN-DANGER-ZONE(团队 Admin 可删组织仓库的历史行为被保留,注释解释"ideally we need a new field like AdminManageDangerZone, but for now we keep the legacy logic")。
5.8 中间件与调用点:权限如何拦截请求
5.8.1 RepoAssignment:权限注入的唯一时机(Web 侧)
第 1 章已经走过 RepoAssignment 的十步流水线,这里聚焦权限注入点。services/context/repo.go:804:
go
// RepoAssignment returns a middleware to handle repository assignment
func RepoAssignment(ctx *Context) {
repoAssignmentPreCheck(ctx)
prepareData := repoAssignmentPrepareData(ctx)
funcs := []func(ctx *Context, data *repoAssignmentPrepareDataStruct){
repoAssignmentPrepareOwner,
repoAssignmentAutoRedirectWiki,
repoAssignmentPrepareRepo,
repoAssignmentLegacy,
repoAssignmentPrepareTemplateData,
repoAssignmentAutoRedirectNotReady,
repoAssignmentPrepareGitRepo,
repoAssignmentPrepareRepoTransfer,
repoAssignmentPrepareBranches,
repoAssignmentPreparePullRequests,
}
for _, f := range funcs {
f(ctx, prepareData)
if ctx.Written() {
return
}
}
}
第 4 步 repoAssignmentLegacy 是权限计算与可见性闸门(repo.go:442):
go
func repoAssignmentLegacy(ctx *Context, data *repoAssignmentPrepareDataStruct) {
var err error
repo := data.repo
if err = repo.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)
return
}
if ctx.DoerNeedTwoFactorAuth() {
ctx.Repo.Permission = access_model.PermissionNoAccess()
} else {
ctx.Repo.Permission, err = access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return
}
}
// publish it so code resolving the same permission later in this request reuses it
if c := cache.GetContextCache(ctx); c != nil {
c.Put(cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(repo.ID, ctx.Doer), ctx.Repo.Permission)
}
if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() && !canWriteAsMaintainer(ctx) {
if ctx.FormString("go-get") == "1" {
EarlyResponseForGoGetMeta(ctx)
return
}
ctx.NotFound(nil)
return
}
ctx.Data["Permission"] = &ctx.Repo.Permission
...
}
四个关键行为:
- 2FA 强制降权 :
DoerNeedTwoFactorAuth()为真时不计算权限,直接给PermissionNoAccess()------站点强制 2FA 而用户未启用时,他登录了但对任何仓库都"零权限",所有后续CanRead/CanWrite为 false、HasAnyUnitAccessOrPublicAccess为 false → 404。这是一种"半封禁"状态:账号能登录、能收到提示,但仓库内容一律不可见。 - 写缓存 :算完立刻
cache.Put,同请求后续的权限解析(如模板渲染、子资源加载)命中请求级缓存,避免重复走主链的多次 DB 查询。缓存键格式由 5.2.4 的RepoUserPermissionCacheKey统一。 - 可见性闸门 :
!HasAnyUnitAccessOrPublicAccess() && !canWriteAsMaintainer(ctx)→NotFound。注意canWriteAsMaintainer(PR 维护者可推分支的特例,第 5.9 节)可以豁免------一个对 Code 单元无权但拥有 PR 分支维护权的人,仍能进入仓库页面。go-get=1的例外是给 git 客户端 go-get 元数据的(仓库不存在也要回 meta 标签,否则 go 工具链报错而非 404 提示)。 - 404 而非 403 :整个中间件栈的拒绝语义统一是
NotFound------不暴露"仓库存在但你无权",这与 API 层的一致(见下文),是刻意的信息隐藏。
ctx.Data["Permission"] = &ctx.Repo.Permission 把权限指针交给模板:侧边栏是否渲染 Issues 入口、设置按钮是否显示,全靠模板里的 {{if .Permission.CanRead ...}} 判断。
5.8.2 Web 中间件三件套
services/context/permission.go 提供三个可组合的中间件(闭包工厂):
go
// RequireRepoAdmin returns a middleware for requiring repository admin permission
func RequireRepoAdmin() func(ctx *Context) {
return func(ctx *Context) {
if !ctx.IsSigned || !ctx.Repo.Permission.IsAdmin() {
ctx.NotFound(nil)
return
}
}
}
要求"登录 + 整体档位 >= Admin" ------站点管理员(检查点 6 短路为 Owner)、仓库 owner、协作者 Admin、组织团队 Admin 都能过。注意它不看单元,是仓库级判定。
go
// RequireUnitWriter returns a middleware for requiring repository write to one of the unit permission
func RequireUnitWriter(unitTypes ...unit.Type) func(ctx *Context) {
return func(ctx *Context) {
if slices.ContainsFunc(unitTypes, ctx.Repo.Permission.CanWrite) {
return
}
ctx.NotFound(nil)
}
}
任一单元可写即放行 (ContainsFunc 是 OR 语义)------RequireUnitWriter(unit.TypeIssues, unit.TypePullRequests) 意为"能写 Issues 或能写 PR 就行",正对"评论/关闭 issue 或 PR"这类跨单元操作。
go
// RequireUnitReader returns a middleware for requiring repository write to one of the unit permission
func RequireUnitReader(unitTypes ...unit.Type) func(ctx *Context) {
return func(ctx *Context) {
for _, unitType := range unitTypes {
if ctx.Repo.Permission.CanRead(unitType) {
return
}
if unitType == unit.TypeCode && canWriteAsMaintainer(ctx) {
return
}
}
ctx.NotFound(nil)
}
}
任一单元可读即放行 ,外加一个特判:待检单元是 TypeCode 时,canWriteAsMaintainer(ctx) 也能放行。canWriteAsMaintainer 的语义是"当前用户是本仓库某个 PR 的维护者、可以推到其 head 分支"------这类人需要看 Code 页面(比较 diff、进 CI),即使对 Code 单元本身没有读权(常见于外部贡献者场景:fork PR 作者在基仓库的权限)。
两个工厂在 routers/web/web.go:909 实例化:
go
reqRepoAdmin := context.RequireRepoAdmin()
reqRepoCodeWriter := context.RequireUnitWriter(unit.TypeCode)
reqRepoReleaseWriter := context.RequireUnitWriter(unit.TypeReleases)
reqRepoReleaseReader := context.RequireUnitReader(unit.TypeReleases)
reqRepoIssuesOrPullsWriter := context.RequireUnitWriter(unit.TypeIssues, unit.TypePullRequests)
reqRepoIssuesOrPullsReader := context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests)
...
// the legacy names "reqRepoXxx" should be renamed to the correct name "reqUnitXxx", these permissions are for units, not repos
reqUnitsWithMarkdown := context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki)
reqUnitCodeReader := context.RequireUnitReader(unit.TypeCode)
...
那行注释是代码自身的"历史欠账声明":一批 reqRepoXxx 变量名实际是单元级权限,正确的名字应该是 reqUnitXxx。挂载方式(web.go:1172 起):
go
m.Group("/{username}/{reponame}/settings", func() {
... // 各设置子路由
},
reqSignIn, context.RepoAssignment, reqRepoAdmin,
ctxDataSet(reqctx.ContextData{"PageIsRepoSettings": true, ...}),
)
中间件顺序即依赖顺序 :reqSignIn 先保证有 Doer → RepoAssignment 计算权限并注入 ctx.Repo.Permission → reqRepoAdmin 才能读它。这个顺序在整棵路由树上都是模板:所有带 reqUnit* 的组都以 context.RepoAssignment 为前置(如 web.go:1304 的 optSignIn, context.RepoAssignment, reqUnitCodeReader)。
另有一个组织级的闭包中间件(web.go:437),它对应的正是 5.7.6 的 FIXME 函数:
go
reqAnyRepoUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.Context) {
return func(ctx *context.Context) {
// only check global disabled units when ignoreGlobal is false
if !ignoreGlobal && unitType.UnitGlobalDisabled() {
ctx.NotFound(nil)
return
}
if ctx.ContextUser.IsOrganization() {
if ctx.Org.Organization.AnyRepoUnitPermission(ctx, ctx.Doer, unitType) < accessMode {
ctx.NotFound(nil)
return
}
}
}
}
注意它只对组织生效 (个人用户身份的可见性由 individualPermsChecker 另行处理),且可选忽略全局禁用(ignoreGlobal=true 的调用点用于用户/组织级的 projects 页这类"仓库无关"页面)。
5.8.3 API 层:repoAssignment 与 checkers
API 有自己的一套(routers/api/v1/api.go),因为它的拒绝语义与错误格式不同(JSON 错误体、可带 403/404 区分)。核心装载函数:
go
func repoAssignment() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
... // 解析 owner、repo(含 redirect 处理,略)
{
needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if needTwoFactor {
ctx.Repo.Permission = access_model.PermissionNoAccess()
} else {
ctx.Repo.Permission, err = access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
}
if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() {
ctx.APIErrorNotFound()
return
}
if !ctx.TokenCanAccessRepo(repo) {
ctx.APIErrorNotFound()
return
}
}
}
与 Web 版的差异:
- 2FA 判定用
doerNeedTwoFactorAuth(API 版私有函数),且只对个人用户生效 :if doer == nil || !doer.IsIndividual() { return false, nil }------注释解释"system doers like Actions tasks or deploy-keys can never enroll 2FA",系统身份不该被 2FA 规则误伤。 - 可见性闸门后多一层 token 检查
TokenCanAccessRepo:!ctx.PublicOnly || !publicOnlyTokenDeniedRepo(ctx, repo)------public-only 令牌(scope 只含public)不得访问私有仓库或 owner 非 public 的仓库(services/context/api.go:56)。 - 拒绝用
APIErrorNotFound()(404 JSON)而非 403------同样是信息隐藏,但具体 checkers 会用 403(见下)。
checkers 全家福(api.go:413-655),按档位从高到低:
| checkers | 判定 | 失败响应 |
|---|---|---|
reqSiteAdmin |
ctx.IsUserSiteAdmin()(登录且 Doer.IsAdmin) |
403 |
reqOwner |
`Permission.IsOwner() | |
reqRepoDangerZone |
CanDoerManageRepoDangerZone(...)(5.7.6/5.9 讨论的宽口径) |
403 |
reqAdmin |
`IsUserRepoAdmin() | |
reqRepoWriter(unitTypes...) |
`IsUserRepoWriter(unitTypes) | |
reqRepoReader(unitType) |
`Permission.CanRead(unitType) | |
reqAnyRepoReader |
`Permission.HasAnyUnitAccess() | |
reqOrgOwnership |
organization.IsOrganizationOwner(...) 或站点管理员 |
403(org)/404(team 未准备时) |
reqOrgMembership |
IsOrganizationMember(...) 或站点管理员 |
403/404 |
reqTeamMembership |
站点管理员或组织 owner 或团队成员 | 403(组织成员)/404(非成员) |
reqTeamReadAccess |
team.CanNonMemberReadMeta(...)(5.7.5) |
403/404 |
mustEnableIssues / mustAllowPulls / mustEnableWiki |
对应单元 Permission.CanRead(...)(外加仓库级开关) |
404 |
reqGitHook |
ctx.Doer.CanEditGitHook()(`!DisableGitHooks && (IsAdmin |
辅助方法本身极薄(services/context/api.go:323-336):
go
// IsUserSiteAdmin returns true if current user is a site admin
func (ctx *APIContext) IsUserSiteAdmin() bool {
return ctx.IsSigned && ctx.Doer.IsAdmin
}
// IsUserRepoAdmin returns true if current user is admin in current repo
func (ctx *APIContext) IsUserRepoAdmin() bool {
return ctx.Repo.Permission.IsAdmin()
}
// IsUserRepoWriter returns true if current user has "write" privilege in current repo
func (ctx *APIContext) IsUserRepoWriter(unitTypes []unit.Type) bool {
return slices.ContainsFunc(unitTypes, ctx.Repo.Permission.CanWrite)
}
注意 reqRepoWriter/reqRepoReader 显式给了 admin 与站点管理员兜底 (|| IsUserRepoAdmin() || IsUserSiteAdmin())------因为 UnitAccessMode 的分支一只在整体档位 >= Admin 时才覆盖 unitsMode,这里再兜一层确保管理员不被单元配置挡住。而 reqRepoReader 的兜底链是 CanRead(unitType) 在最前:能读单元的普通用户最先放行,读不到再看是否 admin。
403 与 404 的分工 是 API 层的一个设计约定:repoAssignment("你根本不该知道这个仓库")用 404;checkers("仓库你知道,但这个操作你的权限不够")用 403。组织/团队的 reqOrgOwnership/reqTeamMembership 则做了细分------对组织成员 说 403("你是成员但权限不够"),对非成员 说 404(denyNonTeamMember 的分支),避免向非成员泄露组织内资源的存在。
5.8.4 路由挂载实例:从表到代码
几组有代表性的挂载(routers/web/web.go):
go
// issues / pulls 阅读入口
m.Group("/{username}/{reponame}", func() {
... // tree-list、compare、pulls/new 等
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
go
// settings 组:仓库管理员专属
}, reqSignIn, context.RepoAssignment, reqRepoAdmin, ctxDataSet(...))
settings 组下的子路由密度很高,几乎每一个都是"仓库管理员才能碰"的面:
| 路径 | handler | 实际权限 |
|---|---|---|
/settings |
repo_setting.Settings |
整体 Admin(组级 reqRepoAdmin) |
/settings/public_access |
repo_setting.PublicAccess(Post) |
整体 Admin(组级) |
/settings/collaboration |
repo_setting.Collaboration 等 |
整体 Admin;团队子路由另有 CanDoerManageOrgRepoCollaboratorTeam 判定 |
/settings/branches |
ProtectedBranchRules 等 |
整体 Admin |
/settings/hooks/git |
GitHooks |
整体 Admin + GitHookService()(CanEditGitHook 的站点级开关) |
/settings/keys |
DeployKeys |
整体 Admin |
/settings/actions/secrets |
repo_setting.Secrets |
整体 Admin + actions.MustEnableActions |
/settings/actions/variables |
shared_actions.Variables |
整体 Admin + Actions 启用 |
其中 Actions secrets/variables 挂在 reqRepoAdmin 组内 ------管理 secrets 需要仓库管理员权限;而 secrets 的使用 (workflow 运行时注入)走第 14 章的 Actions 令牌权限体系(ComputeTaskTokenPermissions,本章 5.9 简述)。组织级与用户级也有各自的 settings 组,分别挂组织 owner / 个人的检查(web.go:716-723、web.go:1046-1053)。
组织侧的权限门(web.go:995-1005)分了三档:OrgAssignment(RequireMember: true) 放行成员可见页(仪表盘、issue 列表),RequireTeamMember: true 收紧到团队页,RequireOwner: true(/settings、团队新建/编辑)只给 Owners 团队------组织的"设置页 = owner only"惯例在路由声明上一目了然。
5.8.5 时序图:一次页面请求的权限判定全链
图里最容易被忽略的两点:一是权限计算发生在 handler 之前、中间件序列的中段 (RepoAssignment 本身就是中间件);二是同一请求内只算一次 (缓存写在 RA,后续 M 和 H 读的是 ctx.Repo.Permission 这个内存对象)。
5.9 特殊权限面:Deploy Key、协作者、Git 协议、分支保护、双人审核、Actions
5.9.1 Deploy Key:凭证即权限
models/deploykey/deploykey.go 的结构决定了一切:
go
type DeployKey struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"INDEX"`
RepoID int64 `xorm:"INDEX"`
Name string
KeyType KeyType `xorm:"NOT NULL DEFAULT 1"` // SSH=1, Token=2
Fingerprint string
PublicKey *asymkey.PublicKey `xorm:"-"`
TokenHash string `xorm:"INDEX"` // sha256 of the token, which carries enough entropy to need no salt
Token string `xorm:"-"` // only set when the token is created
Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"`
...
}
要点:RepoID 是一等字段------deploy key 天生绑定单仓库 (与用户 SSH key 不同,用户 key 属于人);Mode 默认 1(Read),可为 Write(勾选 write 权限的部署密钥);KeyType=2 是 28.x 新增的 token 型部署密钥(TokenHash 存 sha256,"carries enough entropy to need no salt")。
它的权限计算是三条链里最短的(repo_permission.go:389):
go
func getDeployKeyRepoPermission(ctx context.Context, repo *repo_model.Repository, keyID int64) (perm Permission, err error) {
key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, keyID)
if err != nil {
if deploykey_model.IsErrDeployKeyNotExist(err) {
return perm, nil // the key belongs to another repository, so it grants nothing here
}
return perm, err
}
if err = repo.LoadUnits(ctx); err != nil {
return perm, err
}
perm.units = repo.Units
perm.unitsMode = make(map[unit.Type]perm_model.AccessMode)
for _, u := range repo.Units {
if u.Type == unit.TypeCode || u.Type == unit.TypeWiki { // a deploy-key only ever reaches git data
perm.unitsMode[u.Type] = key.Mode
}
}
return perm, nil
}
只有 Code 与 Wiki 两个单元被写入 unitsMode (注释:"a deploy-key only ever reaches git data"),且 perm.AccessMode 保持零值 None------deploy key 对 issues/PR/仓库设置一律无权 (UnitAccessMode 分支一:unitsMode 命中且整体 < Admin,返回 key.Mode;未命中的单元因 unitsMode 非 nil 走不到保底逻辑......准确说分支二中 hasUnit 为 true 但 unitDefaultAccessMode = max(None, 0, 0) = None)。钥匙跨仓库时 GetDeployKeyByID(ctx, repo.ID, keyID) 按 repo 过滤,取不到就返回零权限而非报错------权限上等价于"这把钥匙对这个仓库不存在"。
SSH 链路上的两道检查。第一道在 cmd/serv.go,把 git 动词翻译成所需档位:
go
func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) {
switch verb {
case git.CmdVerbUploadPack, git.CmdVerbUploadArchive:
return perm.AccessModeRead, true
case git.CmdVerbReceivePack:
return perm.AccessModeWrite, true
case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer:
switch lfsVerb {
case git.CmdSubVerbLfsUpload:
return perm.AccessModeWrite, true
case git.CmdSubVerbLfsDownload:
return perm.AccessModeRead, true
}
}
return perm.AccessModeNone, false
}
git-upload-pack(clone/fetch)→ Read,git-receive-pack(push)→ Write,LFS 上传 → Write、下载 → Read------LFS 权限跟着协议动词走,不是独立维度。
第二道在 routers/private/serv.go 的 ServCommand(主进程侧复核),先处理 deploy key 分支:
go
if key.Type == asymkey_model.KeyTypeDeploy {
if repo == nil {
ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName)
return
}
deployKey, err = deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
if err != nil {
...
ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy-key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
return
}
user = user_model.NewDeployKeyUserWithKeyID(deployKey.ID)
}
NewDeployKeyUserWithKeyID 构造出带 keyID 的系统用户,后续 GetDoerRepoPermission 自动分派到 getDeployKeyRepoPermission。核心检查:
go
// Permissions checking:
if repo != nil &&
(mode > perm.AccessModeRead ||
repo.IsPrivate ||
owner.Visibility.IsPrivate() ||
user.IsRestricted ||
setting.Service.RequireSignInViewStrict) {
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
mode = perm.AccessModeRead
}
userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
...
userMode := userPerm.UnitAccessMode(unitType)
if userMode < mode {
ctx.PrivateUserErrorf(http.StatusUnauthorized, "User key %d:%s has no %q permission for %s", key.ID, key.Name, modeString, repoLogName)
return
}
}
两个精妙处:
- 短路优化 :只在"要求写 / 私有仓库 / owner 私有可见 / 受限用户 / 强制登录"时才做权限计算------公开仓库的匿名 fetch 直接跳过整个权限查询(省掉一次主链计算)。
- AGit 的写权限延迟 :支持
refs/for(proc-receive)时,receive-pack所需档位临时降为 Read------因为 AGit 流程要求"只有读权限的人也能推自己的 ref 来创建 PR"(真正的写检查推迟到HookPreReceive;wiki 与 LFS 不享受这个降级,因为它们没有 refs/for 语义)。
此外 ServCommand 还有一串前置守卫:镜像仓库 mode > Read 拒绝(只读)、archived 仓库写拒绝、owner 非激活拒绝、repo 创建中/损坏拒绝------这些是与权限正交的状态检查,但都在同一条判定路径上。
5.9.2 Git HTTP 链:githttp.go 的档位翻译
HTTP 协议侧的对应实现在 routers/web/repo/githttp.go 的 httpBase:
go
if isPull {
accessMode = perm.AccessModeRead
} else {
accessMode = perm.AccessModeWrite
}
...
// Only public pulls don't need auth: repo must exist, not require-sign-in
canAnonymousPull := false
if isPull && repoExist && !setting.Service.RequireSignInViewStrict {
// allow anonymous pulls if owner is public and repo is public (not private)
if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
canAnonymousPull = true
}
// then check "public anonymous access" permission
if !canAnonymousPull && ctx.Doer == nil {
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil)
...
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
}
}
匿名可拉的两层条件:"完全公开"快路径(owner public + repo 非私有 + 未强制登录)直接放行,否则用匿名权限精确判定(覆盖公共访问设置给匿名开了 Read 的情况)。不满足则要求认证:
go
context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode))
...
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && ctx.Doer.IsIndividual() {
_, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if err == nil {
ctx.PlainText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page")
return nil
}
...
}
三个随行检查:token scope 档位匹配 (Read 操作要 read scope、Write 要 write scope,GetScopeLevelFromAccessMode);2FA 用户禁明文密码(开了 2FA 的账号必须用 PAT,这是防密码泄漏的标准姿势,错误消息直接给出指引);账号激活/未封禁检查。
最终档位复核:
go
if repoExist {
// Only the main code repo accepts refs/for pushes, so wiki pushes must keep write checks.
if git.DefaultFeatures().SupportProcReceive && !isWiki {
accessMode = perm.AccessModeRead
}
p, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
...
if !p.CanAccess(accessMode, unitType) {
ctx.PlainText(http.StatusNotFound, "Repository not found")
return nil
}
...
}
与 SSH 侧完全对称的两件事:AGit 降级(仅主仓库非 wiki)、CanAccess 复核(失败返回 404 "Repository not found" ------又见信息隐藏,与 Web 中间件口径一致)。unitType 由仓库名后缀决定:xxx.wiki.git → TypeWiki,否则 TypeCode。
5.9.3 协作者:collaboration 表的两种用法
Collaboration 结构(models/repo/collaboration.go):
go
type Collaboration struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
Mode perm.AccessMode `xorm:"DEFAULT 2 NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
Mode DEFAULT 2 即默认 Write------加协作者不指定档位就是可写。协作者在权限体系里有两种存在方式:
- 物化进
access表 (refreshCollaboratorAccesses→refreshAccesses),主链的modeByOwnerAndAccess据此给档位。 - 内存标记
isCollaborator(检查点 3),用于两处豁免:owner 可见性防线(检查点 4)、组织仓库的单元铺底(主链 (c))。
第二种用法有个直接的 SQL 落点------HasAccessToRepoCodeUnit,"用户是否能碰这个仓库的代码单元"的快速判定,同时考虑团队与协作者:
go
func HasAccessToRepoCodeUnit(ctx context.Context, repo *Repository, userID int64) (bool, error) {
if repo.OwnerID == userID {
return true, nil
}
... // collaboration 表命中 或 团队 team_repo/team_unit 授权(SQL 层)
}
(摘录省略了 SQL 主体,完整逻辑是 OR 两分支。)它服务于"谁能被列为可写 Code 的人"这类批量/快速场景,绕开完整主链。
组织仓库还有一个专门的协作者管理权限(repo_permission.go:744):
go
func CanDoerManageOrgRepoCollaboratorTeam(ctx context.Context, repo *repo_model.Repository, perm *Permission) bool {
_ = repo.LoadOwner(ctx)
if repo.Owner == nil || !repo.Owner.IsOrganization() {
return false
}
return perm.IsOwner() || perm.IsAdmin() && repo.Owner.RepoAdminChangeTeamAccess
}
Go 的 && 优先于 ||,实际语义是 IsOwner() || (IsAdmin() && repo.Owner.RepoAdminChangeTeamAccess)------组织 owner 永远能管协作者与团队授权;组织仓库管理员要额外获得组织级开关 RepoAdminChangeTeamAccess 的许可。这个开关是组织设置里的"仓库管理员可更改团队授权"选项,给了组织对"Admin 是否可再分权"的控制。
5.9.4 分支保护:档位如何被规则二次过滤
分支保护不重新算权限,而是拿现成的 Permission 做规则过滤(models/git/protected_branch.go)。核心函数组:
go
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
if !protectBranch.CanPush {
return false
}
if !protectBranch.EnableWhitelist {
return permissionInRepo.CanWrite(unit.TypeCode)
}
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
return true
}
if len(protectBranch.WhitelistTeamIDs) == 0 {
return false
}
in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.WhitelistTeamIDs)
...
return in
}
结构是三层门 :规则级开关(CanPush 全局禁止直推)→ 未开白名单则回落 Permission.CanWrite(TypeCode) → 开了白名单则按用户/团队名单精确匹配(名单是白名单,不在名单上即使有 Write 权限也不行 ------注意 CanUserPush 的"未开白名单才看 Write"意味着白名单模式下权限档位被完全替换)。CanUserForcePush 在其上叠加 force-push 白名单(还要先过 CanUserPush)。IsUserMergeWhitelisted 同构,回落项是"能写 Code 即可合并"。
CanBypassBranchProtection 处理"强制规则是否可被绕过":
go
func CanBypassBranchProtection(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User, isRepoAdmin bool) bool {
if isRepoAdmin && !protectBranch.BlockAdminMergeOverride {
return true
}
...
}
仓库管理员默认可绕过保护分支 (强制合并、绕过审批),除非规则勾选了 BlockAdminMergeOverride("阻塞管理员覆盖")------这就是第 8 章讨论双人审核时"管理员能否强合"的开关来源。isRepoAdmin 由调用方传入(通常来自 IsUserRealRepoAdmin/Permission.IsAdmin())。
push 阶段的强制执行发生在 hook 链(routers/private/hook_pre_receive.go 的 HookPreReceive)------第 1 章讲过的 gitea hook pre-receive → private API 链路。其中 preReceiveContext 把权限判断做了请求内缓存:
go
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
ctx.canWriteCodeUnitCached = new(ctx.Repo.Permission.CanWrite(unit.TypeCode))
}
return *ctx.canWriteCodeUnitCached
}
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if ctx.canWriteCodeUnit() {
return true
}
// then check whether if the pusher is a maintainer who can write to the PR author's head repo branch
if !refFullName.IsBranch() {
return false
}
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, refFullName.BranchName(), ctx.Doer)
}
能写 Code 单元 → 放行;否则若是分支,再看"维护者可推 PR head 分支"的特例(CanMaintainerWriteToBranch)------fork PR 场景下基仓库维护者需要把修复推到贡献者分支。保护分支的最终 canPush 计算区分了 deploy key:
go
var canPush bool
if ctx.opts.UserID == user_model.DeployKeyUserID {
// This flag is only ever true if protectBranch.CanForcePush is true
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys)
} else {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
}
} else {
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission)
} else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer, ctx.Repo.Permission)
}
}
deploy key 不走用户白名单 ,而是看规则上的"允许 deploy keys"开关(WhitelistDeployKeys/ForcePushAllowlistDeployKeys)------因为 deploy key 不是人,塞进用户名单没有意义,规则作者需显式决定"这个白名单是否也信任部署密钥"。changedProtectedfiles(改动了受保护文件模式匹配的文件)是否定项:即便在白名单里,改了保护文件也拦。
保护规则还允许"只改未保护文件"的放行路径(GetUnprotectedFilePatterns 匹配的文件改动可跳过 canPush 检查),以及"从 UI/API 合并"(opts.PullRequestID != 0)走另一分支------说明 HookPreReceive 处理的不只是人类 push,还有合并动作回写的 ref。
5.9.5 双人审核:official review 的权限来源(第 8 章引用)
第 8 章讨论 PR 双人审核,这里给出权限侧的两个关键函数。谁的批准算数 由 GetGrantedApprovalsCount 决定(models/issues/pull.go:770):
go
// GetGrantedApprovalsCount returns the number of granted approvals for pr. A granted approval must be authored by a user in an approval whitelist.
func GetGrantedApprovalsCount(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) int64 {
sess := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
And("type = ?", ReviewTypeApprove).
And("official = ?", true).
And("dismissed = ?", false)
if protectBranch.IgnoreStaleApprovals {
sess = sess.And("stale = ?", false)
}
approvals, err := sess.Count(new(Review))
...
}
// HasEnoughApprovals returns true if pr has enough granted approvals.
func HasEnoughApprovals(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
if protectBranch.RequiredApprovals == 0 {
return true
}
return GetGrantedApprovalsCount(ctx, protectBranch, pr) >= protectBranch.RequiredApprovals
}
计数条件四个:type=ReviewTypeApprove、official = true 、dismissed = false、可选 stale = false(规则开了 IgnoreStaleApprovals 时,新提交后旧批准失效不计)。official 标志是权限体系的注入口------它在创建 review 时由 IsUserOfficialReviewer 判定:
go
func IsUserOfficialReviewer(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) (bool, error) {
repo, err := repo_model.GetRepositoryByID(ctx, protectBranch.RepoID)
...
if !protectBranch.EnableApprovalsWhitelist {
// Anyone with write access is considered official reviewer
writeAccess, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeCode, perm.AccessModeWrite)
...
return writeAccess, nil
}
if slices.Contains(protectBranch.ApprovalsWhitelistUserIDs, user.ID) {
return true, nil
}
inTeam, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.ApprovalsWhitelistTeamIDs)
...
}
默认口径(未开审批白名单):对 Code 单元有 Write 权限的人,其 approve 是 official。开了白名单则按名单匹配。于是"双人审核所需权限"的完整链条是:
ini
团队/协作者权限(第 5.4 节)→ Permission.CanWrite(TypeCode)
→ IsUserOfficialReviewer(默认口径)→ Review.official = true
→ GetGrantedApprovalsCount 计入 → HasEnoughApprovals → 合并放行
注意 HasAccessUnit 走的是 GetIndividualUserRepoPermission(不解析 Actions 用户)------机器人/系统账号的 approve 若要算数,得靠白名单名单而不是权限档位。
5.9.6 Actions 任务:ComputeTaskTokenPermissions 与跨仓访问
Actions 的权限面是三条链里最特殊的:任务没有"用户关系",只有令牌配置 。GetActionsUserRepoPermission(repo_permission.go:283)的骨架:
go
func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) {
if actionsUser.ID != user_model.ActionsUserID {
return perm, errors.New("api GetActionsUserRepoPermission can only be called by the actions user")
}
task, err := actions_model.GetTaskByID(ctx, taskID)
...
// Compute effective permissions for this task against the target repo
effectivePerms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, repo)
...
if task.RepoID != repo.ID {
// Cross-repo access must also respect the target repo's permission ceiling.
targetRepoActionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
if targetRepoActionsCfg.OverrideOwnerConfig {
effectivePerms = targetRepoActionsCfg.ClampPermissions(effectivePerms)
} else {
targetRepoOwnerActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, repo.OwnerID)
...
effectivePerms = targetRepoOwnerActionsCfg.ClampPermissions(effectivePerms)
}
}
...
var maxPerm Permission
// Set up per-unit access modes based on configured permissions
maxPerm.units = repo.Units
maxPerm.unitsMode = maps.Clone(effectivePerms.UnitAccessModes)
// Check permission like simple user but limit to read-only (PR #36095)
botPerm, err := GetIndividualUserRepoPermission(ctx, repo, user_model.NewActionsUser())
...
if botPerm.AccessMode >= perm_model.AccessModeRead {
// Public repo allows read access, increase permissions to at least read
for _, u := range repo.Units {
if botPerm.CanRead(u.Type) {
maxPerm.unitsMode[u.Type] = max(maxPerm.unitsMode[u.Type], perm_model.AccessModeRead)
}
}
}
...
}
四层叠加:
- 令牌有效权限 :
ComputeTaskTokenPermissions(task, repo)按 workflow 声明(permissions:块)与仓库/组织的 token 权限配置算出每单元档位,填充maxPerm.unitsMode。 - 跨仓库夹逼 :任务访问的不是自己的仓库时,目标仓库的
ActionsConfig(OverrideOwnerConfig开启则用仓库级配置,否则用组织/用户级配置)执行ClampPermissions------目标仓库可以调低但不能被调高到其允许上限之外。 - 读权保底 :用
NewActionsUser()(伪用户)走一遍普通个体权限,若"像匿名一样"能读(公开仓库),把可读单元抬到 Read------注释解释了动机:"Public repo allows read access, increase permissions to at least read / Otherwise you cannot access your own repository if your permissions are set to none but the repository is public"。 - 返回裁决 :同仓库 → 直接返回;跨仓库 → 依次尝试
checkSameOwnerCrossRepoAccess(同 owner 且目标仓库在AllowedCrossRepoIDs白名单)、botPerm 读权(公开仓库)、IsCollaborativeOwner(目标仓库把任务 owner 列为协作 owner,且任务仓库非 fork PR)------都不满足返回零权限。
go
func checkSameOwnerCrossRepoAccess(ctx context.Context, taskRepo, targetRepo *repo_model.Repository, isForkPR bool) bool {
if isForkPR {
// Fork PRs are never allowed cross-repo access to other private repositories of the owner.
return false
}
if taskRepo.OwnerID != targetRepo.OwnerID {
return false
}
ownerCfg, err := actions_model.GetOwnerActionsConfig(ctx, taskRepo.OwnerID)
...
return slices.Contains(ownerCfg.AllowedCrossRepoIDs, targetRepo.ID)
}
fork PR 永不享受同 owner 跨仓授权 ------防止外部贡献者的 fork PR 运行时读取 owner 的其他私有仓库(这是 Actions 安全模型里著名的攻击面,Gitea 用 isForkPR 判别符在两处一致地堵上)。
CanReadWorkflowCrossRepo 是它的兄弟函数(repo_permission.go:685),服务 uses: 跨仓引用 workflow 文件的场景,三条允许路径同构:同 owner 策略、私有任务仓库 + 目标 IsCollaborativeOwner、公开目标("Matches GitHub's behavior: public reusable workflows are universally readable")。
管理面与使用面的分离 :secrets/variables 的管理要求仓库管理员(5.8.4),运行时注入由令牌权限体系(ComputeTaskTokenPermissions,第 14 章展开)决定------管理员能写 secret 不代表 workflow 能读到它 ,读取还要过 Actions 的令牌权限配置(ActionsConfig 的 TokenPermissions 等字段)。这个双层设计是"配置权限"与"运行时权限"分离的典型。
5.9.7 权限矩阵:角色 × 操作
以默认配置(仓库启用全部默认单元、无分支保护白名单、无公共访问设置、无 ForcePrivate、组织为常规配置)为准的权限矩阵。行是身份,列是操作,格内是所需档位/结果:
| 身份 \ 操作 | 浏览代码/克隆公开仓 | 推送公开仓 | 浏览私有仓 | 推送私有仓 | 改仓库设置 | 建 issue | 批准 PR(official) | 管理协作者 | 管理 secrets | 删仓库(DangerZone) | 创建组织仓库 | 创建组织 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 匿名 | Read(owner public 且非私有,或公共访问 anon-read) | 拒(HTTP 要 401) | 404 | 404 | 404 | 404(issue 单元无权) | --- | --- | --- | --- | --- | --- |
| 登录非成员(受限用户) | Read(仅"完全公开") | 拒 | 404 | 404 | 404 | 依单元保底 | --- | --- | --- | --- | --- | 依 AllowCreateOrganization |
| 登录非成员(普通) | Read(非私有保底) | 个人非私有仓 Write(协作者/保底) | 404 | 404 | 404 | 非私有仓 Issues 单元 Read 起 | --- | --- | --- | --- | --- | 同上 |
| 个人仓库 owner | Read | Write | Read | Write | IsAdmin → 放行 |
Write | Write | 管自己的仓 | Admin | IsOwner → 放行 |
在自己名下 | 同上 |
| 协作者(Write) | Read/Write | Write | Read | Write | 拒(非 Admin) | Write | Write | 拒(组织仓需 RepoAdminChangeTeamAccess) |
拒 | 拒 | --- | --- |
| 协作者(Admin) | Write | Write | Write | Write | reqAdmin 放行 |
Write | Write | 组织仓需开关 | 放行 | 宽口径放行 | --- | --- |
| 组织成员(无关联团队,公开仓) | Read 保底 | 拒 | 404/保底 | 拒 | 拒 | Read 起(Issues 单元保底) | --- | --- | --- | --- | 拒 | --- |
团队成员(authorize=write) |
单元档位 | 单元 Write 起 | 团队关联才可见 | 单元 Write | 拒 | Write | Write(Code 单元) | 拒 | 拒 | 拒 | CanCreateOrgRepo 团队则可 |
--- |
团队成员(authorize=admin) |
Write | Write | Write | Write | reqAdmin 放行 |
Write | Write | 需组织开关 | 放行 | 团队 Admin → 宽口径放行 | 同上 | --- |
| Owners 团队成员 | Owner | Owner | Owner | Owner | 放行 | Owner | Owner | 放行 | 放行 | 放行 | 放行(隐含) | --- |
| 仓库站点管理员 | Owner(检查点 6) | Owner | Owner | Owner | 放行 | Owner | Owner | 放行 | 放行 | 放行 | 放行 | 放行(无条件) |
| deploy key(Read) | Code/Wiki Read | 拒(除非规则允许且 Mode=Write) | 按 Mode | 按 Mode | 无权(整体 None) | 无权 | 无权(非 official) | --- | --- | --- | --- | --- |
| Actions 任务(同仓) | 按 ComputeTaskTokenPermissions |
按令牌权限 | 按令牌 | 按令牌 | 无权(unitsMode 不含设置类判定位... 实为按令牌配置) | 按令牌 | 非 official(除非白名单) | --- | --- | --- | --- | --- |
| 公共访问"所有人可写"下的登录用户 | Read/Write(仅该单元) | ---(Code 不提供 everyone-write) | --- | --- | 拒 | 依单元 | 依 Code 单元是否 everyone-read... | --- | --- | --- | --- | --- |
表下的注解与例外:
- "404 而非 403" :Web 中间件与
repoAssignment层的拒绝统一 404;只有 API 的具体 checkers 用 403(5.8.3 的分工)。 - 批准 PR 一列依赖 Code 单元 Write(默认口径),开了审批白名单则以名单为准(5.9.5)。
- 管理 secrets 一列是"页面可进 + Actions 启用",运行时读取另算(5.9.6)。
- 受限用户 (
IsRestricted)的 Read 保底被收紧为"完全公开"才给(5.1.2)。 RequireSignInViewStrict开启时所有匿名 Read 保底消失,全部走公共访问/显式授权。- Actions 行的"按令牌权限"具体到每个单元由
UnitAccessModes决定,第 14 章展开。