Gitea源码深度剖析-第5章 权限体系

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)。

单元还有三层"启用"状态,容易混淆:

  1. 全局禁用 :setting.Repository.DisabledRepoUnits 在启动时经 LoadUnitConfig 存入 disabledRepoUnitsAtomic,Type.UnitGlobalDisabled() 查询;getUnitsByRepoID 加载仓库单元时直接过滤掉全局禁用的类型------数据行还在,只是不加载。
  2. 仓库未启用 :仓库的 repo_unit 表里没有该类型的行,repo.GetUnit 返回 ErrUnitTypeNotExist,页面表现为"该功能已禁用"。
  3. 团队未授权 :仓库启用了单元,但团队的 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)
}

三行关键信息:

  1. "owner" 不可解析 ------它是纯代码内部档位,永远不会出现在表单或 URL 参数里(防止用户提交 permission=owner 提权)。
  2. allowed 变参是白名单收紧 :ParseAccessMode("admin", AccessModeNone, AccessModeWrite, AccessModeAdmin) 拿到 Admin,但 ParseAccessMode("read", ...Write, Admin) 返回 None(Read 不在白名单里)。
  3. 任何解析失败一律归零为 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)
}

逐行拆解成四条规则:

  1. 有单元级记录时 :返回单元档位 m,但若整体 AccessMode >= Admin(即仓库管理员/owner/站点管理员),用整体档位覆盖------管理员不会被团队的单元级限制降权。
  2. 无单元级记录时 :以整体档位为基底,max 上匿名/所有人的公共访问保底。
  3. 单元不存在时 (仓库没启用该单元,或被全局禁用过滤掉了):一律 AccessModeNone------这是"仓库未启用单元 ⇒ 无权访问"的实现点。
  4. 两个 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)
		}
	}
}

三个行为要点:

  1. 匿名用户只拿 anonymousAccessMode,不拿 everyoneAccessMode ------user == nil || user.ID <= 0 时提前返回。"所有人可读"对匿名者不生效,这是语义区分("everyone" 指所有登录用户)而非疏漏。
  2. unitsMode == nil 时不做裁剪------整体档位模式下 units 保持完整,模板按 ReadableUnitTypes() 渲染。
  3. 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
}

三条来源,按序覆盖:

  1. 可见性保底 :非私有仓库给 Read(受限用户要"完全公开"才给)。匿名(userID == 0)在此返回------所以匿名者的基线就是这条保底。
  2. 个人 owner :userID == repo.OwnerID → Owner(虽然主链在检查点 6 已短路过一次,IsUserRealRepoAdmin 等其他调用者依赖这里的兜底)。
  3. 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. 检查点 1:LoadUnits 拿到 Code/Issues/PRs/Releases/Wiki/Projects/Packages/Actions 行。
  2. 检查点 2:alice 非匿名,跳过。
  3. 检查点 3:alice 非协作者,isCollaborator=false。
  4. 检查点 4:ownerVisibilitySatisfiesDoer(acme, alice)------alice 非管理员、非 owner 本人、RequireSignInViewStrict=false 但 Visibility != public(limited),走最后一条 Visibility != private && !restricted → true。防线通过。
  5. 检查点 5:alice 非匿名,跳过。
  6. 检查点 6:alice 非管理员、非 owner(owner 是组织),跳过。
  7. 检查点 7:modeByOwnerAndAccess------非私有 → 基线 Read;access 表若无 alice 记录则返回 Read。
  8. 检查点 8:owner 是组织,继续。
  9. minAccessMode = Read(非私有且 alice 非受限),perm.AccessMode = max(Read, Read) = Read。
  10. GetUserRepoTeams 返回 [acme/dev]。
  11. 非协作者,跳过铺底(unitsMode 是刚 make 的空 map)。
  12. acme/dev 非 Owners 团队,不短路。
  13. 逐单元:对 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(团队整体档位兜底)。
  14. 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 表有两类消费:

  1. 单点判定 :modeByOwnerAndAccess 的 db.Get[Access]------主链的核心一步。
  2. 批量筛选 :仓库列表的 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 下设置页显示禁用提示 :PublicAccess handler 把 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
}

三步:

  1. 找 team_unit 行,命中则以行内档位为起点(找不到则从 0 开始)。
  2. max(行档位, 团队整体档位) ------团队整体档位是所有单元的下限 :authorize=write 的团队对每个单元至少 Write,即使没有 team_unit 行。(UI 在整体档位非 none 时不显示单元选择,与此一致。)
  3. 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
		}
	}
	...
}

三个要点:

  1. UI 只有三档 :ParseAccessMode(form.Permission, None, Write, Admin)------白名单明确排除 read(解析 read 会归 None,模板 radio 的 value 也只有 none/write/admin)。团队要么整体 none(走单元级配置),要么整体 write/admin。
  2. 整体档位非 none 时不解析单元表单 ------if t.AccessMode == AccessModeNone 才调 paresFormTeamUnits,这正是 UnitAccessModeEx 步骤 2(整体档位是下限)在 UI 侧的对应物。
  3. 单元级档位被夹到 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
	...
}

四个关键行为:

  1. 2FA 强制降权 :DoerNeedTwoFactorAuth() 为真时不计算权限,直接给 PermissionNoAccess() ------站点强制 2FA 而用户未启用时,他登录了但对任何仓库都"零权限",所有后续 CanRead/CanWrite 为 false、HasAnyUnitAccessOrPublicAccess 为 false → 404。这是一种"半封禁"状态:账号能登录、能收到提示,但仓库内容一律不可见。
  2. 写缓存 :算完立刻 cache.Put,同请求后续的权限解析(如模板渲染、子资源加载)命中请求级缓存,避免重复走主链的多次 DB 查询。缓存键格式由 5.2.4 的 RepoUserPermissionCacheKey 统一。
  3. 可见性闸门 :!HasAnyUnitAccessOrPublicAccess() && !canWriteAsMaintainer(ctx) → NotFound。注意 canWriteAsMaintainer(PR 维护者可推分支的特例,第 5.9 节)可以豁免------一个对 Code 单元无权但拥有 PR 分支维护权的人,仍能进入仓库页面。go-get=1 的例外是给 git 客户端 go-get 元数据的(仓库不存在也要回 meta 标签,否则 go 工具链报错而非 404 提示)。
  4. 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 时序图:一次页面请求的权限判定全链

sequenceDiagram participant B as 浏览器 participant W as routers/web/web.go participant RA as services/context/repo.go<br/>RepoAssignment participant P as models/perm/access<br/>GetDoerRepoPermission participant DB as 数据库 participant M as 中间件<br/>RequireUnitReader participant H as handler (routers/web/repo/*) B->>W: GET /{owner}/{repo}/issues W->>RA: reqSignIn → RepoAssignment RA->>RA: repoAssignmentPrepareOwner/Repo<br/>定位 owner 与 repo(含 redirect) RA->>P: GetDoerRepoPermission(repo, doer) alt doer 是 Actions 任务用户 P->>DB: GetTaskByID + ComputeTaskTokenPermissions else doer 是 deploy key P->>DB: GetDeployKeyByID else 普通身份 P->>DB: LoadUnits(过滤全局禁用) P->>DB: IsCollaborator P->>DB: HasOrgOrUserVisible(owner 可见性) P->>DB: modeByOwnerAndAccess(access 表) opt 组织仓库 P->>DB: GetUserRepoTeams(team_user JOIN team_repo) P->>P: 逐 unit 取 max(铺底, 保底, teamUnitAccessMode) end P->>P: finalProcessRepoUnitPermission<br/>(注入公共访问 + 裁剪 units) end P-->>RA: Permission RA->>RA: cache.Put(RepoUserPermission, key) alt HasAnyUnitAccessOrPublicAccess 为假 且非维护者 RA-->>B: 404 NotFound else RA->>RA: ctx.Data[&#34;Permission&#34;] = &perm W->>M: reqUnitIssuesReader(组级中间件) M->>M: Permission.CanRead(TypeIssues) alt 无权 M-->>B: 404 NotFound else有权 M->>H: next → issues handler H->>H: 模板 {{if .Permission.CanWrite ...}}<br/>渲染评论框/关闭按钮 H-->>B: 200 HTML end end

图里最容易被忽略的两点:一是权限计算发生在 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
	}
}

两个精妙处:

  1. 短路优化 :只在"要求写 / 私有仓库 / owner 私有可见 / 受限用户 / 强制登录"时才做权限计算------公开仓库的匿名 fetch 直接跳过整个权限查询(省掉一次主链计算)。
  2. 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------加协作者不指定档位就是可写。协作者在权限体系里有两种存在方式:

  1. 物化进 access 表 (refreshCollaboratorAccesses → refreshAccesses),主链的 modeByOwnerAndAccess 据此给档位。
  2. 内存标记 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)
			}
		}
	}
	...
}

四层叠加:

  1. 令牌有效权限 :ComputeTaskTokenPermissions(task, repo) 按 workflow 声明(permissions: 块)与仓库/组织的 token 权限配置算出每单元档位,填充 maxPerm.unitsMode。
  2. 跨仓库夹逼 :任务访问的不是自己的仓库时,目标仓库的 ActionsConfig(OverrideOwnerConfig 开启则用仓库级配置,否则用组织/用户级配置)执行 ClampPermissions------目标仓库可以调低但不能被调高到其允许上限之外。
  3. 读权保底 :用 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"。
  4. 返回裁决 :同仓库 → 直接返回;跨仓库 → 依次尝试 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 章展开。
相关推荐
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第4章 数据库层与 ORM 体系(xorm)
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第13章 前端工程与模板体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第9章 REST API v1 体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第8章 Pull Request 全生命周期:Gitea 最复杂的子系统
git·开源
TTATTC1 小时前
告别开源 Agent 的本地运维折磨:拆解阿里 QoderWork 的沙盒隔离架构与三级渐进式 Skills 编排实践
docker·开源
除不掉的灰色1 小时前
git回退提交前
git
武雄(小星Ai)1 小时前
Hermes Agent 周报:v0.21.6 发布,多账号连接器上线,6 项安全修复
开源·llm·周报·ai agent·hermes
认真恰饭2 小时前
Git push时警告邮箱暴露,推送失败
git
程序员老赵2 小时前
Docker 部署 Redmine:轻松搭建私有化项目管理平台
docker·容器·开源