初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Access))
|
||||
}
|
||||
|
||||
func accessLevel(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
|
||||
}
|
||||
|
||||
func maxAccessMode(modes ...perm.AccessMode) perm.AccessMode {
|
||||
maxMode := perm.AccessModeNone
|
||||
for _, mode := range modes {
|
||||
maxMode = max(maxMode, mode)
|
||||
}
|
||||
return maxMode
|
||||
}
|
||||
|
||||
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 = maxAccessMode(ua.Mode, mode)
|
||||
} else {
|
||||
accessMap[user.ID] = &userAccess{User: user, Mode: mode}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshAccesses updates the repository's access records in the database by comparing the provided accessMap
|
||||
// with existing records. It minimizes DB operations by performing selective inserts, updates, and deletes
|
||||
// instead of removing all existing records and re-adding them.
|
||||
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)
|
||||
}
|
||||
|
||||
// Execute deletions
|
||||
if len(toDelete) > 0 {
|
||||
if _, err = db.GetEngine(ctx).In("user_id", toDelete).And("repo_id = ?", repo.ID).Delete(&Access{}); err != nil {
|
||||
return fmt.Errorf("delete accesses: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute updates
|
||||
for _, u := range toUpdate {
|
||||
if _, err = db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", u.UserID, repo.ID).Cols("mode").Update(&Access{Mode: u.Mode}); err != nil {
|
||||
return fmt.Errorf("update access for user %d: %w", u.UserID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute insertions
|
||||
if len(toInsert) > 0 {
|
||||
if err = db.Insert(ctx, toInsert); err != nil {
|
||||
return fmt.Errorf("insert new accesses: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCollaboratorAccesses retrieves repository collaborations with their access modes.
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
accessMap := make(map[int64]*userAccess, 20)
|
||||
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
} else if !repo.Owner.IsOrganization() {
|
||||
return fmt.Errorf("owner is not an organization: %d", repo.OwnerID)
|
||||
}
|
||||
|
||||
if err = refreshCollaboratorAccesses(ctx, repo.ID, accessMap); err != nil {
|
||||
return fmt.Errorf("refreshCollaboratorAccesses: %w", err)
|
||||
}
|
||||
|
||||
teams, err := organization.FindOrgTeams(ctx, repo.Owner.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range teams {
|
||||
if t.ID == ignTeamID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Owner team gets owner access, and skip for teams that do not
|
||||
// have relations with repository.
|
||||
if t.IsOwnerTeam() {
|
||||
t.AccessMode = perm.AccessModeOwner
|
||||
} else if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = t.LoadMembers(ctx); err != nil {
|
||||
return fmt.Errorf("getMembers '%d': %w", t.ID, err)
|
||||
}
|
||||
for _, m := range t.Members {
|
||||
updateUserAccess(accessMap, m, t.AccessMode)
|
||||
}
|
||||
}
|
||||
|
||||
return refreshAccesses(ctx, repo, accessMap)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
minMode := perm.AccessModeRead
|
||||
if !repo.IsPrivate {
|
||||
minMode = perm.AccessModeWrite
|
||||
}
|
||||
|
||||
accessMode := perm.AccessModeNone
|
||||
e := db.GetEngine(ctx)
|
||||
collaborator, err := repo_model.GetCollaboration(ctx, repo.ID, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if collaborator != nil {
|
||||
accessMode = collaborator.Mode
|
||||
}
|
||||
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
} else if repo.Owner.IsOrganization() {
|
||||
var teams []organization.Team
|
||||
if err := e.Join("INNER", "team_repo", "team_repo.team_id = team.id").
|
||||
Join("INNER", "team_user", "team_user.team_id = team.id").
|
||||
Where("team.org_id = ?", repo.OwnerID).
|
||||
And("team_repo.repo_id=?", repo.ID).
|
||||
And("team_user.uid=?", uid).
|
||||
Find(&teams); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range teams {
|
||||
if t.IsOwnerTeam() {
|
||||
t.AccessMode = perm.AccessModeOwner
|
||||
}
|
||||
|
||||
accessMode = maxAccessMode(accessMode, t.AccessMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old user accesses and insert new one for repository.
|
||||
if _, err = e.Delete(&Access{RepoID: repo.ID, UserID: uid}); err != nil {
|
||||
return fmt.Errorf("delete old user accesses: %w", err)
|
||||
} else if accessMode >= minMode {
|
||||
if err = db.Insert(ctx, &Access{RepoID: repo.ID, UserID: uid, Mode: accessMode}); err != nil {
|
||||
return fmt.Errorf("insert new user accesses: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package access_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccess(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
t.Run("AccessLevel", testAccessLevel)
|
||||
t.Run("HasAccess", testHasAccess)
|
||||
t.Run("RecalculateAccesses", testRecalculateAccesses)
|
||||
t.Run("RecalculateAccesses2", testRecalculateAccesses2)
|
||||
t.Run("RecalculateAccessesUpdateMode", testRecalculateAccessesUpdateMode)
|
||||
t.Run("RecalculateAccessesRemoveAccess", testRecalculateAccessesRemoveAccess)
|
||||
}
|
||||
|
||||
func testAccessLevel(t *testing.T) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
|
||||
// A public repository owned by User 2
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.False(t, repo1.IsPrivate)
|
||||
// A private repository owned by Org 3
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.True(t, repo3.IsPrivate)
|
||||
|
||||
// Another public repository
|
||||
repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.False(t, repo4.IsPrivate)
|
||||
// org. owned private repo
|
||||
repo24 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 24})
|
||||
|
||||
level, err := access_model.AccessLevel(t.Context(), user2, repo1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeOwner, level)
|
||||
|
||||
level, err = access_model.AccessLevel(t.Context(), user2, repo3)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeOwner, level)
|
||||
|
||||
level, err = access_model.AccessLevel(t.Context(), user5, repo1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeRead, level)
|
||||
|
||||
level, err = access_model.AccessLevel(t.Context(), user5, repo3)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeNone, level)
|
||||
|
||||
// restricted user has default access to a public repo if no sign-in is required
|
||||
setting.Service.RequireSignInViewStrict = false
|
||||
level, err = access_model.AccessLevel(t.Context(), user29, repo1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeRead, level)
|
||||
|
||||
// restricted user has no access to a public repo if sign-in is required
|
||||
setting.Service.RequireSignInViewStrict = true
|
||||
level, err = access_model.AccessLevel(t.Context(), user29, repo1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeNone, level)
|
||||
|
||||
// ... unless he's a collaborator
|
||||
level, err = access_model.AccessLevel(t.Context(), user29, repo4)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeWrite, level)
|
||||
|
||||
// ... or a team member
|
||||
level, err = access_model.AccessLevel(t.Context(), user29, repo24)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeRead, level)
|
||||
}
|
||||
|
||||
func testHasAccess(t *testing.T) {
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
// A public repository owned by User 2
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.False(t, repo1.IsPrivate)
|
||||
// A private repository owned by Org 3
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.True(t, repo2.IsPrivate)
|
||||
|
||||
has, err := access_model.HasAnyUnitAccess(t.Context(), user1.ID, repo1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
|
||||
_, err = access_model.HasAnyUnitAccess(t.Context(), user1.ID, repo2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = access_model.HasAnyUnitAccess(t.Context(), user2.ID, repo1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = access_model.HasAnyUnitAccess(t.Context(), user2.ID, repo2)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func testRecalculateAccesses(t *testing.T) {
|
||||
// test with organization repo
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
assert.NoError(t, repo1.LoadOwner(t.Context()))
|
||||
|
||||
_, err := db.GetEngine(t.Context()).Delete(&repo_model.Collaboration{UserID: 2, RepoID: 3})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, access_model.RecalculateAccesses(t.Context(), repo1))
|
||||
|
||||
access := &access_model.Access{UserID: 2, RepoID: 3}
|
||||
has, err := db.GetEngine(t.Context()).Get(access)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
assert.Equal(t, perm_model.AccessModeOwner, access.Mode)
|
||||
}
|
||||
|
||||
func testRecalculateAccesses2(t *testing.T) {
|
||||
// test with non-organization repo
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo1.LoadOwner(t.Context()))
|
||||
|
||||
_, err := db.GetEngine(t.Context()).Delete(&repo_model.Collaboration{UserID: 4, RepoID: 4})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, access_model.RecalculateAccesses(t.Context(), repo1))
|
||||
|
||||
has, err := db.GetEngine(t.Context()).Get(&access_model.Access{UserID: 4, RepoID: 4})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, has)
|
||||
}
|
||||
|
||||
func testRecalculateAccessesUpdateMode(t *testing.T) {
|
||||
// Test the update path in refreshAccesses optimization
|
||||
// Scenario: User's access mode changes from Read to Write
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo.LoadOwner(t.Context()))
|
||||
|
||||
// Verify initial access mode
|
||||
_ = db.Insert(t.Context(), &repo_model.Collaboration{UserID: 4, RepoID: 4, Mode: perm_model.AccessModeWrite})
|
||||
_ = db.Insert(t.Context(), &access_model.Access{UserID: 4, RepoID: 4, Mode: perm_model.AccessModeWrite})
|
||||
initialAccess := &access_model.Access{UserID: 4, RepoID: 4}
|
||||
has, err := db.GetEngine(t.Context()).Get(initialAccess)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
initialMode := initialAccess.Mode
|
||||
|
||||
// Change collaboration mode to trigger update path
|
||||
newMode := perm_model.AccessModeAdmin
|
||||
assert.NotEqual(t, initialMode, newMode, "New mode should differ from initial mode")
|
||||
|
||||
_, err = db.GetEngine(t.Context()).
|
||||
Where("user_id = ? AND repo_id = ?", 4, 4).
|
||||
Cols("mode").
|
||||
Update(&repo_model.Collaboration{Mode: newMode})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Recalculate accesses - should UPDATE existing access, not delete+insert
|
||||
assert.NoError(t, access_model.RecalculateAccesses(t.Context(), repo))
|
||||
|
||||
// Verify access was updated, not deleted and re-inserted
|
||||
updatedAccess := &access_model.Access{UserID: 4, RepoID: 4}
|
||||
has, err = db.GetEngine(t.Context()).Get(updatedAccess)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, has, "Access should still exist")
|
||||
assert.Equal(t, newMode, updatedAccess.Mode, "Access mode should be updated to new collaboration mode")
|
||||
}
|
||||
|
||||
func testRecalculateAccessesRemoveAccess(t *testing.T) {
|
||||
// Test the delete path in refreshAccesses optimization
|
||||
// Scenario: Remove a user's collaboration, access should be deleted
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo.LoadOwner(t.Context()))
|
||||
|
||||
// Verify initial access exists
|
||||
initialAccess := &access_model.Access{UserID: 4, RepoID: 4}
|
||||
has, err := db.GetEngine(t.Context()).Get(initialAccess)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, has, "Access should exist initially")
|
||||
|
||||
// Remove the collaboration to trigger delete path
|
||||
_, err = db.GetEngine(t.Context()).
|
||||
Where("user_id = ? AND repo_id = ?", 4, 4).
|
||||
Delete(&repo_model.Collaboration{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Recalculate accesses - should DELETE the access record
|
||||
assert.NoError(t, access_model.RecalculateAccesses(t.Context(), repo))
|
||||
|
||||
// Verify access was deleted
|
||||
removedAccess := &access_model.Access{UserID: 4, RepoID: 4}
|
||||
has, err = db.GetEngine(t.Context()).Get(removedAccess)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, has, "Access should be deleted after removing collaboration")
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetActionsUserRepoPermission(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Use fixtures for repos and users
|
||||
repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // Public, Owner 5, has Actions unit
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) // Private, Owner 2, no Actions unit in fixtures
|
||||
repo15 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) // Private, Owner 2, no Actions unit in fixtures
|
||||
owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
actionsUser := user_model.NewActionsUser()
|
||||
|
||||
// Ensure repo2 and repo15 have Actions units for testing configuration
|
||||
for _, r := range []*repo_model.Repository{repo2, repo15} {
|
||||
require.NoError(t, db.Insert(ctx, &repo_model.RepoUnit{
|
||||
RepoID: r.ID,
|
||||
Type: unit.TypeActions,
|
||||
Config: &repo_model.ActionsConfig{},
|
||||
}))
|
||||
}
|
||||
|
||||
t.Run("SameRepo_Public", func(t *testing.T) {
|
||||
task47 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
|
||||
require.Equal(t, repo4.ID, task47.RepoID)
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo4, actionsUser, task47.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Public repo, bot should have Read access even if not collaborator
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
|
||||
assert.True(t, perm.CanRead(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("SameRepo_Private", func(t *testing.T) {
|
||||
// Use Task 53 which is already in Repo 2 (Private)
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
require.Equal(t, repo2.ID, task53.RepoID)
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Private repo, bot has no base access, but gets Write from effective tokens perms (Permissive by default)
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
|
||||
assert.True(t, perm.CanWrite(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("CrossRepo_Denied_None", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
|
||||
// Set owner policy to nil allowed repos (None)
|
||||
cfg := actions_model.OwnerActionsConfig{}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT have access to the private repo.
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("ForkPR_NoCrossRepo", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
task53.IsForkPullRequest = true
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
|
||||
// Policy contains repo15
|
||||
cfg := actions_model.OwnerActionsConfig{
|
||||
AllowedCrossRepoIDs: []int64{repo15.ID},
|
||||
}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fork PR never gets cross-repo access to other private repos
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("Inheritance_And_Clamping", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
task53.IsForkPullRequest = false
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
|
||||
// Owner policy: Restricted mode (Read-only Code)
|
||||
ownerCfg := actions_model.OwnerActionsConfig{
|
||||
TokenPermissionMode: repo_model.ActionsTokenPermissionModeRestricted,
|
||||
MaxTokenPermissions: &repo_model.ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeCode: perm_model.AccessModeRead,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg))
|
||||
|
||||
// Repo policy: OverrideOwnerConfig = false (should inherit owner's restricted mode)
|
||||
repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions)
|
||||
repo2ActionsCfg := repo2ActionsUnit.ActionsConfig()
|
||||
repo2ActionsCfg.OverrideOwnerConfig = false
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be clamped to Read-only
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode))
|
||||
assert.False(t, perm.CanWrite(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("RepoOverride_Clamping", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
|
||||
// Owner policy: Permissive (Write access)
|
||||
ownerCfg := actions_model.OwnerActionsConfig{
|
||||
TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive,
|
||||
}
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg))
|
||||
|
||||
// Repo policy: OverrideOwnerConfig = true, MaxTokenPermissions = Read
|
||||
repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions)
|
||||
repo2ActionsCfg := repo2ActionsUnit.ActionsConfig()
|
||||
repo2ActionsCfg.OverrideOwnerConfig = true
|
||||
repo2ActionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted
|
||||
repo2ActionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeCode: perm_model.AccessModeRead,
|
||||
},
|
||||
}
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit))
|
||||
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be clamped to Read-only
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package access_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "gitea.dev/models"
|
||||
_ "gitea.dev/models/actions"
|
||||
_ "gitea.dev/models/activities"
|
||||
_ "gitea.dev/models/repo"
|
||||
_ "gitea.dev/models/user"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// IsOwner returns true if current user is the owner of repository.
|
||||
func (p *Permission) IsOwner() bool {
|
||||
return p.AccessMode >= perm_model.AccessModeOwner
|
||||
}
|
||||
|
||||
// IsAdmin returns true if current user has admin or higher access of repository.
|
||||
func (p *Permission) IsAdmin() bool {
|
||||
return p.AccessMode >= perm_model.AccessModeAdmin
|
||||
}
|
||||
|
||||
// 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) HasAnyUnitPublicAccess() bool {
|
||||
for _, v := range p.anonymousAccessMode {
|
||||
if v >= perm_model.AccessModeRead {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, v := range p.everyoneAccessMode {
|
||||
if v >= perm_model.AccessModeRead {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Permission) HasAnyUnitAccessOrPublicAccess() bool {
|
||||
return p.HasAnyUnitPublicAccess() || p.HasAnyUnitAccess()
|
||||
}
|
||||
|
||||
// HasUnits returns true if the permission contains attached units
|
||||
func (p *Permission) HasUnits() bool {
|
||||
return len(p.units) > 0
|
||||
}
|
||||
|
||||
// GetFirstUnitRepoID returns the repo ID of the first unit, it is a fragile design and should NOT be used anymore
|
||||
// deprecated
|
||||
func (p *Permission) GetFirstUnitRepoID() int64 {
|
||||
if len(p.units) > 0 {
|
||||
return p.units[0].RepoID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (p *Permission) SetUnitsWithDefaultAccessMode(units []*repo_model.RepoUnit, mode perm_model.AccessMode) {
|
||||
p.units = units
|
||||
p.unitsMode = make(map[unit.Type]perm_model.AccessMode)
|
||||
for _, u := range p.units {
|
||||
p.unitsMode[u.Type] = mode
|
||||
}
|
||||
}
|
||||
|
||||
// CanAccess returns true if user has mode access to the unit of the repository
|
||||
func (p *Permission) CanAccess(mode perm_model.AccessMode, unitType unit.Type) bool {
|
||||
return p.UnitAccessMode(unitType) >= mode
|
||||
}
|
||||
|
||||
// CanAccessAny returns true if user has mode access to any of the units of the repository
|
||||
func (p *Permission) CanAccessAny(mode perm_model.AccessMode, unitTypes ...unit.Type) bool {
|
||||
for _, u := range unitTypes {
|
||||
if p.CanAccess(mode, u) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanRead returns true if user could read to this unit
|
||||
func (p *Permission) CanRead(unitType unit.Type) bool {
|
||||
return p.CanAccess(perm_model.AccessModeRead, unitType)
|
||||
}
|
||||
|
||||
// CanReadAny returns true if user has read access to any of the units of the repository
|
||||
func (p *Permission) CanReadAny(unitTypes ...unit.Type) bool {
|
||||
return p.CanAccessAny(perm_model.AccessModeRead, unitTypes...)
|
||||
}
|
||||
|
||||
// CanReadIssuesOrPulls returns true if isPull is true and user could read pull requests and
|
||||
// returns true if isPull is false and user could read to issues
|
||||
func (p *Permission) CanReadIssuesOrPulls(isPull bool) bool {
|
||||
if isPull {
|
||||
return p.CanRead(unit.TypePullRequests)
|
||||
}
|
||||
return p.CanRead(unit.TypeIssues)
|
||||
}
|
||||
|
||||
// CanWrite returns true if user could write to this unit
|
||||
func (p *Permission) CanWrite(unitType unit.Type) bool {
|
||||
return p.CanAccess(perm_model.AccessModeWrite, unitType)
|
||||
}
|
||||
|
||||
// CanWriteIssuesOrPulls returns true if isPull is true and user could write to pull requests and
|
||||
// returns true if isPull is false and user could write to issues
|
||||
func (p *Permission) CanWriteIssuesOrPulls(isPull bool) bool {
|
||||
if isPull {
|
||||
return p.CanWrite(unit.TypePullRequests)
|
||||
}
|
||||
return p.CanWrite(unit.TypeIssues)
|
||||
}
|
||||
|
||||
func (p *Permission) ReadableUnitTypes() []unit.Type {
|
||||
types := make([]unit.Type, 0, len(p.units))
|
||||
for _, u := range p.units {
|
||||
if p.CanRead(u.Type) {
|
||||
types = append(types, u.Type)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func (p *Permission) LogString() string {
|
||||
var format strings.Builder
|
||||
format.WriteString("<Permission AccessMode=%s, %d Units, %d UnitsMode(s): [")
|
||||
args := []any{p.AccessMode.ToString(), len(p.units), len(p.unitsMode)}
|
||||
|
||||
for i, u := range p.units {
|
||||
config := ""
|
||||
if u.Config != nil {
|
||||
configBytes, err := u.Config.ToDB()
|
||||
config = string(configBytes)
|
||||
if err != nil {
|
||||
config = err.Error()
|
||||
}
|
||||
}
|
||||
format.WriteString("\n\tunits[%d]: ID=%d RepoID=%d Type=%s Config=%s")
|
||||
args = append(args, i, u.ID, u.RepoID, u.Type.LogString(), config)
|
||||
}
|
||||
for key, value := range p.unitsMode {
|
||||
format.WriteString("\n\tunitsMode[%-v]: %-v")
|
||||
args = append(args, key.LogString(), value.LogString())
|
||||
}
|
||||
format.WriteString("\n\tanonymousAccessMode: %-v")
|
||||
args = append(args, p.anonymousAccessMode)
|
||||
format.WriteString("\n\teveryoneAccessMode: %-v")
|
||||
args = append(args, p.everyoneAccessMode)
|
||||
format.WriteString("\n\t]>")
|
||||
return fmt.Sprintf(format.String(), args...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, targetRepo.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("GetOwnerActionsConfig: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.Contains(ownerCfg.AllowedCrossRepoIDs, targetRepo.ID)
|
||||
}
|
||||
|
||||
// GetActionsUserRepoPermission returns the actions user permissions to the repository
|
||||
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)
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
return perm, err
|
||||
}
|
||||
|
||||
var taskRepo *repo_model.Repository
|
||||
if task.RepoID != repo.ID {
|
||||
if err := task.Job.LoadRepo(ctx); err != nil {
|
||||
return perm, err
|
||||
}
|
||||
taskRepo = task.Job.Repo
|
||||
} else {
|
||||
taskRepo = repo
|
||||
}
|
||||
|
||||
// Compute effective permissions for this task against the target repo
|
||||
effectivePerms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, repo)
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
effectivePerms = targetRepoOwnerActionsCfg.ClampPermissions(effectivePerms)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.LoadUnits(ctx); err != nil {
|
||||
return perm, err
|
||||
}
|
||||
|
||||
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)
|
||||
// Enhanced to also grant read-only access if isSameRepo is true and target repository is public
|
||||
botPerm, err := GetIndividualUserRepoPermission(ctx, repo, user_model.NewActionsUser())
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
if botPerm.AccessMode >= perm_model.AccessModeRead {
|
||||
// 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
|
||||
for _, u := range repo.Units {
|
||||
if botPerm.CanRead(u.Type) {
|
||||
maxPerm.unitsMode[u.Type] = max(maxPerm.unitsMode[u.Type], perm_model.AccessModeRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if task.RepoID == repo.ID {
|
||||
return maxPerm, nil
|
||||
}
|
||||
|
||||
if checkSameOwnerCrossRepoAccess(ctx, taskRepo, repo, task.IsForkPullRequest) {
|
||||
// Access allowed by owner policy (grants access to private repos).
|
||||
// Note: maxPerm has already been restricted to Read-Only in ComputeTaskTokenPermissions
|
||||
// because isSameRepo is false.
|
||||
return maxPerm, nil
|
||||
}
|
||||
|
||||
// Fall through to allow public repository read access via botPerm check below
|
||||
|
||||
// Check if the repo is public or the Bot has explicit access
|
||||
if botPerm.AccessMode >= perm_model.AccessModeRead {
|
||||
return maxPerm, nil
|
||||
}
|
||||
|
||||
// Check Collaborative Owner and explicit Bot permissions
|
||||
// We allow access if:
|
||||
// 1. It's a collaborative owner relationship
|
||||
// 2. The Actions Bot user has been explicitly granted access and repository is private
|
||||
// 3. The repository is public (handled by botPerm above)
|
||||
|
||||
if taskRepo.IsPrivate {
|
||||
actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
||||
if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) {
|
||||
return maxPerm, nil
|
||||
}
|
||||
}
|
||||
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
// GetDoerRepoPermission returns the repository permission for the current actor,
|
||||
// dispatching to GetActionsUserRepoPermission when the actor is an Actions token user.
|
||||
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)
|
||||
}
|
||||
return GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
}
|
||||
|
||||
// GetIndividualUserRepoPermission returns the permissions for an explicit user identity.
|
||||
// In most request paths, callers should use GetDoerRepoPermission instead.
|
||||
// Unlike GetDoerRepoPermission, this helper does not resolve Actions task users.
|
||||
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 = accessLevel(ctx, user, repo)
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
|
||||
if !repo.Owner.IsOrganization() {
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
teams, err := organization.GetUserRepoTeams(ctx, repo.OwnerID, user.ID, repo.ID)
|
||||
if err != nil {
|
||||
return perm, err
|
||||
}
|
||||
if len(teams) == 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 teams {
|
||||
if team.HasAdminAccess() {
|
||||
perm.AccessMode = perm_model.AccessModeOwner
|
||||
perm.unitsMode = nil
|
||||
return perm, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, u := range repo.Units {
|
||||
for _, team := range teams {
|
||||
teamMode, _ := team.UnitAccessModeEx(ctx, u.Type)
|
||||
unitAccessMode := max(perm.unitsMode[u.Type], minAccessMode, teamMode)
|
||||
perm.unitsMode[u.Type] = unitAccessMode
|
||||
}
|
||||
}
|
||||
|
||||
return perm, err
|
||||
}
|
||||
|
||||
// IsUserRealRepoAdmin check if this user is real repo admin
|
||||
func IsUserRealRepoAdmin(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||
if repo.OwnerID == user.ID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
accessMode, err := accessLevel(ctx, user, repo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return accessMode >= perm_model.AccessModeAdmin, nil
|
||||
}
|
||||
|
||||
// IsUserRepoAdmin return true if user has admin right of a repo
|
||||
func IsUserRepoAdmin(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||
if user == nil || repo == nil {
|
||||
return false, nil
|
||||
}
|
||||
if user.IsAdmin {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
mode, err := accessLevel(ctx, user, repo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if mode >= perm_model.AccessModeAdmin {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
teams, err := organization.GetUserRepoTeams(ctx, repo.OwnerID, user.ID, repo.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if team.HasAdminAccess() {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// AccessLevel returns the Access a user has to a repository. Will return NoneAccess if the
|
||||
// user does not have access.
|
||||
func AccessLevel(ctx context.Context, user *user_model.User, repo *repo_model.Repository) (perm_model.AccessMode, error) { //nolint:revive // export stutter
|
||||
return AccessLevelUnit(ctx, user, repo, unit.TypeCode)
|
||||
}
|
||||
|
||||
// AccessLevelUnit returns the Access a user has to a repository's. Will return NoneAccess if the
|
||||
// user does not have access.
|
||||
// This helper only supports explicit user identities and does not resolve Actions task users.
|
||||
func AccessLevelUnit(ctx context.Context, user *user_model.User, repo *repo_model.Repository, unitType unit.Type) (perm_model.AccessMode, error) { //nolint:revive // export stutter
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
return perm_model.AccessModeNone, err
|
||||
}
|
||||
return perm.UnitAccessMode(unitType), nil
|
||||
}
|
||||
|
||||
// HasAccessUnit returns true if user has testMode to the unit of the repository
|
||||
func HasAccessUnit(ctx context.Context, user *user_model.User, repo *repo_model.Repository, unitType unit.Type, testMode perm_model.AccessMode) (bool, error) {
|
||||
mode, err := AccessLevelUnit(ctx, user, repo, unitType)
|
||||
return testMode <= mode, err
|
||||
}
|
||||
|
||||
// CanBeAssigned return true if user can be assigned to issue or pull requests in repo
|
||||
// Currently any write access (code, issues or pr's) is assignable, to match assignee list in user interface.
|
||||
func CanBeAssigned(ctx context.Context, user *user_model.User, repo *repo_model.Repository, _ bool) (bool, error) {
|
||||
if user.IsOrganization() {
|
||||
return false, fmt.Errorf("organization can't be added as assignee [user_id: %d, repo_id: %d]", user.ID, repo.ID)
|
||||
}
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return perm.CanAccessAny(perm_model.AccessModeWrite, unit.AllRepoUnitTypes...) ||
|
||||
perm.CanAccessAny(perm_model.AccessModeRead, unit.TypePullRequests), nil
|
||||
}
|
||||
|
||||
// HasAnyUnitAccess see the comment of "perm.HasAnyUnitAccess"
|
||||
// This helper only supports explicit user identities and does not resolve Actions task users.
|
||||
func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Repository) (bool, error) {
|
||||
var user *user_model.User
|
||||
var err error
|
||||
if userID > 0 {
|
||||
user, err = user_model.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return perm.HasAnyUnitAccess(), nil
|
||||
}
|
||||
|
||||
func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (users []*user_model.User, err error) {
|
||||
userIDs, err := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
if err = db.GetEngine(ctx).In("id", userIDs.Values()).OrderBy("`name`").Find(&users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (container.Set[int64], error) {
|
||||
userIDs := container.Set[int64]{}
|
||||
e := db.GetEngine(ctx)
|
||||
accesses := make([]*Access, 0, 10)
|
||||
if err := e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range accesses {
|
||||
userIDs.Add(a.UserID)
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !repo.Owner.IsOrganization() {
|
||||
userIDs.Add(repo.Owner.ID)
|
||||
} else {
|
||||
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs.AddMultiple(teamUserIDs...)
|
||||
}
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
// CheckRepoUnitUser check whether user could visit the unit of this repository
|
||||
// This helper only supports explicit user identities and does not resolve Actions task users.
|
||||
func CheckRepoUnitUser(ctx context.Context, repo *repo_model.Repository, user *user_model.User, unitType unit.Type) bool {
|
||||
if user != nil && user.IsAdmin {
|
||||
return true
|
||||
}
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
log.Error("GetIndividualUserRepoPermission: %w", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return perm.CanRead(unitType)
|
||||
}
|
||||
|
||||
func PermissionNoAccess() Permission {
|
||||
return Permission{AccessMode: perm_model.AccessModeNone}
|
||||
}
|
||||
|
||||
// CanReadWorkflowCrossRepo checks whether the run can read workflow files from targetRepo.
|
||||
func CanReadWorkflowCrossRepo(ctx context.Context, targetRepo *repo_model.Repository, run *actions_model.ActionRun) (bool, error) {
|
||||
if err := run.LoadRepo(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// (1) Same owner: always allowed (fork-PR scrubbing handled inside).
|
||||
if checkSameOwnerCrossRepoAccess(ctx, run.Repo, targetRepo, run.IsForkPullRequest) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// (2) Cross-owner: respect the target repo's collaborative-owner allowlist on its Actions unit.
|
||||
// The caller (run.Repo) must itself be private. The collaborative-owner grant is owner-level, so without this
|
||||
// guard a public caller owned by a grantee could pull a private reusable workflow and expose its definition and
|
||||
// logs in a publicly visible run; requiring a private caller keeps private content flowing private -> private.
|
||||
// This is intentionally stricter than GitHub, which gates on the target repo's access setting (introduced in #32562):
|
||||
// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository
|
||||
if run.Repo.IsPrivate {
|
||||
if actionsUnit, err := targetRepo.GetUnit(ctx, unit.TypeActions); err == nil {
|
||||
if actionsUnit.ActionsConfig().IsCollaborativeOwner(run.Repo.OwnerID) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Public target: the Actions user's individual permission gives read on any public repo, so `uses:` to a public reusable-workflow library is permitted by default.
|
||||
// Matches GitHub's behavior: public reusable workflows are universally readable.
|
||||
botPerm, err := GetIndividualUserRepoPermission(ctx, targetRepo, user_model.NewActionsUser())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return botPerm.AccessMode >= perm_model.AccessModeRead, nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHasAnyUnitAccess(t *testing.T) {
|
||||
perm := Permission{}
|
||||
assert.False(t, perm.HasAnyUnitAccess())
|
||||
|
||||
perm = Permission{
|
||||
units: []*repo_model.RepoUnit{{Type: unit.TypeWiki}},
|
||||
}
|
||||
assert.False(t, perm.HasAnyUnitAccess())
|
||||
assert.False(t, perm.HasAnyUnitAccessOrPublicAccess())
|
||||
|
||||
perm = Permission{
|
||||
units: []*repo_model.RepoUnit{{Type: unit.TypeWiki}},
|
||||
everyoneAccessMode: map[unit.Type]perm_model.AccessMode{unit.TypeIssues: perm_model.AccessModeRead},
|
||||
}
|
||||
assert.False(t, perm.HasAnyUnitAccess())
|
||||
assert.True(t, perm.HasAnyUnitAccessOrPublicAccess())
|
||||
|
||||
perm = Permission{
|
||||
units: []*repo_model.RepoUnit{{Type: unit.TypeWiki}},
|
||||
anonymousAccessMode: map[unit.Type]perm_model.AccessMode{unit.TypeIssues: perm_model.AccessModeRead},
|
||||
}
|
||||
assert.False(t, perm.HasAnyUnitAccess())
|
||||
assert.True(t, perm.HasAnyUnitAccessOrPublicAccess())
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeRead,
|
||||
units: []*repo_model.RepoUnit{{Type: unit.TypeWiki}},
|
||||
}
|
||||
assert.True(t, perm.HasAnyUnitAccess())
|
||||
|
||||
perm = Permission{
|
||||
unitsMode: map[unit.Type]perm_model.AccessMode{unit.TypeWiki: perm_model.AccessModeRead},
|
||||
}
|
||||
assert.True(t, perm.HasAnyUnitAccess())
|
||||
}
|
||||
|
||||
func TestApplyPublicAccessRepoPermission(t *testing.T) {
|
||||
perm := Permission{
|
||||
AccessMode: perm_model.AccessModeNone,
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeWiki, EveryoneAccessMode: perm_model.AccessModeRead},
|
||||
},
|
||||
}
|
||||
finalProcessRepoUnitPermission(nil, &perm)
|
||||
assert.False(t, perm.CanRead(unit.TypeWiki))
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeNone,
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeWiki, AnonymousAccessMode: perm_model.AccessModeRead},
|
||||
},
|
||||
}
|
||||
finalProcessRepoUnitPermission(nil, &perm)
|
||||
assert.True(t, perm.CanRead(unit.TypeWiki))
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeNone,
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeWiki, EveryoneAccessMode: perm_model.AccessModeRead},
|
||||
},
|
||||
}
|
||||
finalProcessRepoUnitPermission(&user_model.User{ID: 0}, &perm)
|
||||
assert.False(t, perm.CanRead(unit.TypeWiki))
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeNone,
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeWiki, EveryoneAccessMode: perm_model.AccessModeRead},
|
||||
},
|
||||
}
|
||||
finalProcessRepoUnitPermission(&user_model.User{ID: 1}, &perm)
|
||||
assert.True(t, perm.CanRead(unit.TypeWiki))
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeWrite,
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeWiki, EveryoneAccessMode: perm_model.AccessModeRead},
|
||||
},
|
||||
}
|
||||
finalProcessRepoUnitPermission(&user_model.User{ID: 1}, &perm)
|
||||
// it should work the same as "EveryoneAccessMode: none" because the default AccessMode should be applied to units
|
||||
assert.True(t, perm.CanWrite(unit.TypeWiki))
|
||||
|
||||
perm = Permission{
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeCode}, // will be removed
|
||||
{Type: unit.TypeWiki, EveryoneAccessMode: perm_model.AccessModeRead},
|
||||
},
|
||||
unitsMode: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeWiki: perm_model.AccessModeWrite,
|
||||
},
|
||||
}
|
||||
finalProcessRepoUnitPermission(&user_model.User{ID: 1}, &perm)
|
||||
assert.True(t, perm.CanWrite(unit.TypeWiki))
|
||||
assert.Len(t, perm.units, 1)
|
||||
}
|
||||
|
||||
func TestUnitAccessMode(t *testing.T) {
|
||||
perm := Permission{
|
||||
AccessMode: perm_model.AccessModeNone,
|
||||
}
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.UnitAccessMode(unit.TypeWiki), "no unit, no map, use AccessMode")
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeRead,
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeWiki},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeWiki), "only unit, no map, use AccessMode")
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeAdmin,
|
||||
unitsMode: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeWiki: perm_model.AccessModeRead,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, perm_model.AccessModeAdmin, perm.UnitAccessMode(unit.TypeWiki), "no unit, only map, admin overrides map")
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeNone,
|
||||
unitsMode: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeWiki: perm_model.AccessModeRead,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeWiki), "no unit, only map, use map")
|
||||
|
||||
perm = Permission{
|
||||
AccessMode: perm_model.AccessModeNone,
|
||||
units: []*repo_model.RepoUnit{
|
||||
{Type: unit.TypeWiki},
|
||||
},
|
||||
unitsMode: map[unit.Type]perm_model.AccessMode{
|
||||
unit.TypeWiki: perm_model.AccessModeRead,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeWiki), "has unit, and map, use map")
|
||||
}
|
||||
|
||||
func TestGetRepoPermission(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
t.Run("GetIndividualUserRepoPermission", testGetIndividualUserRepoPermission)
|
||||
t.Run("GetDoerRepoPermission", testGetDoerRepoPermission)
|
||||
}
|
||||
|
||||
func testGetIndividualUserRepoPermission(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
repo32 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32}) // org public repo
|
||||
require.NoError(t, repo32.LoadOwner(ctx))
|
||||
require.True(t, repo32.Owner.IsOrganization())
|
||||
|
||||
require.NoError(t, db.TruncateBeans(ctx, &organization.Team{}, &organization.TeamUser{}, &organization.TeamRepo{}, &organization.TeamUnit{}))
|
||||
org := repo32.Owner
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
team := &organization.Team{OrgID: org.ID, LowerName: "test_team"}
|
||||
require.NoError(t, db.Insert(ctx, team))
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: org.ID, TeamID: team.ID, UID: user.ID}))
|
||||
|
||||
t.Run("DoerInTeamWithNoRepo", func(t *testing.T) {
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo32, user)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode)
|
||||
assert.Nil(t, perm.unitsMode) // doer in the team, but has no access to the repo
|
||||
})
|
||||
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: org.ID, TeamID: team.ID, RepoID: repo32.ID}))
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeCode, AccessMode: perm_model.AccessModeNone}))
|
||||
t.Run("DoerWithTeamUnitAccessNone", func(t *testing.T) {
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo32, user)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode)
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
|
||||
})
|
||||
|
||||
require.NoError(t, db.TruncateBeans(ctx, &organization.TeamUnit{}))
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeCode, AccessMode: perm_model.AccessModeWrite}))
|
||||
t.Run("DoerWithTeamUnitAccessWrite", func(t *testing.T) {
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo32, user)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode)
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
|
||||
})
|
||||
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // org private repo, same org as repo 32
|
||||
require.NoError(t, repo3.LoadOwner(ctx))
|
||||
require.True(t, repo3.Owner.IsOrganization())
|
||||
require.NoError(t, db.TruncateBeans(ctx, &organization.TeamUnit{}, &Access{})) // The user has access set of that repo, remove it, it is useless for our test
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: org.ID, TeamID: team.ID, RepoID: repo3.ID}))
|
||||
t.Run("DoerWithNoopTeamOnPrivateRepo", func(t *testing.T) {
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo3, user)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeIssues])
|
||||
})
|
||||
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeCode, AccessMode: perm_model.AccessModeNone}))
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: org.ID, TeamID: team.ID, Type: unit.TypeIssues, AccessMode: perm_model.AccessModeRead}))
|
||||
t.Run("DoerWithReadIssueTeamOnPrivateRepo", func(t *testing.T) {
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo3, user)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
|
||||
|
||||
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, user.ID, users[0].ID)
|
||||
|
||||
users, err = GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, users)
|
||||
})
|
||||
|
||||
require.NoError(t, db.Insert(ctx, repo_model.Collaboration{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
|
||||
require.NoError(t, db.Insert(ctx, Access{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
|
||||
t.Run("DoerWithReadIssueTeamAndWriteCollaboratorOnPrivateRepo", func(t *testing.T) {
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo3, user)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.AccessMode)
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues])
|
||||
|
||||
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, user.ID, users[0].ID)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetDoerRepoPermission(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
task47 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
|
||||
actionsDoer := user_model.NewActionsUserWithTaskID(task47.ID)
|
||||
regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
actionsPerm, err := GetDoerRepoPermission(ctx, repo4, actionsDoer)
|
||||
require.NoError(t, err)
|
||||
directPerm, err := GetActionsUserRepoPermission(ctx, repo4, actionsDoer, task47.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, directPerm, actionsPerm)
|
||||
|
||||
doerPerm, err := GetDoerRepoPermission(ctx, repo1, regularUser)
|
||||
require.NoError(t, err)
|
||||
individualPerm, err := GetIndividualUserRepoPermission(ctx, repo1, regularUser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, individualPerm, doerPerm)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package perm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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 AccessModeRead:
|
||||
return "read"
|
||||
case AccessModeWrite:
|
||||
return "write"
|
||||
case AccessModeAdmin:
|
||||
return "admin"
|
||||
case AccessModeOwner:
|
||||
return "owner"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
func (mode AccessMode) LogString() string {
|
||||
return fmt.Sprintf("<AccessMode:%d:%s>", mode, mode.ToString())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ErrInvalidAccessMode is returned when an invalid access mode is used
|
||||
var ErrInvalidAccessMode = util.NewInvalidArgumentErrorf("Invalid access mode")
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package perm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAccessMode(t *testing.T) {
|
||||
names := []string{"none", "read", "write", "admin"}
|
||||
for i, name := range names {
|
||||
m := ParseAccessMode(name)
|
||||
assert.Equal(t, AccessMode(i), m)
|
||||
}
|
||||
assert.Equal(t, AccessModeOwner, AccessMode(4))
|
||||
assert.Equal(t, "owner", AccessModeOwner.ToString())
|
||||
assert.Equal(t, AccessModeNone, ParseAccessMode("owner"))
|
||||
assert.Equal(t, AccessModeNone, ParseAccessMode("invalid"))
|
||||
}
|
||||
Reference in New Issue
Block a user