From 2f19237a143e7015f5037ac55cab87e48ca4e24c Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Sun, 15 Feb 2026 13:35:00 -0700 Subject: [PATCH] feat: add GetUserRepoPermissionWithReducer --- .deadcode-out | 1 + assets/go-licenses.json | 5 + go.mod | 1 + models/perm/access/repo_permission.go | 24 ++++- models/perm/access/repo_permission_test.go | 74 +++++++++++++++ services/authz/authorization_reducer_mock.go | 99 ++++++++++++++++++++ 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 services/authz/authorization_reducer_mock.go diff --git a/.deadcode-out b/.deadcode-out index 6d2c35e374..b347989b6b 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -49,6 +49,7 @@ forgejo.org/models/organization SearchMembersOptions.ToConds forgejo.org/models/perm/access + GetUserRepoPermissionWithReducer GetRepoWriters forgejo.org/models/user diff --git a/assets/go-licenses.json b/assets/go-licenses.json index fcf45b8da5..cca9ba355f 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -1124,6 +1124,11 @@ "path": "github.com/ssor/bom/LICENSE", "licenseText": "MIT License\n\nCopyright (c) 2017 Asher\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, + { + "name": "github.com/stretchr/objx", + "path": "github.com/stretchr/objx/LICENSE", + "licenseText": "The MIT License\n\nCopyright (c) 2014 Stretchr, Inc.\nCopyright (c) 2017-2018 objx contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + }, { "name": "github.com/stretchr/testify", "path": "github.com/stretchr/testify/LICENSE", diff --git a/go.mod b/go.mod index b1df258019..b33cec2635 100644 --- a/go.mod +++ b/go.mod @@ -245,6 +245,7 @@ require ( github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/assert v1.3.0 // indirect diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index f7daf38e5c..22639d1e42 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -15,6 +15,7 @@ import ( "forgejo.org/models/unit" user_model "forgejo.org/models/user" "forgejo.org/modules/log" + "forgejo.org/services/authz" ) // Permission contains all the permissions related variables to a repository for a user @@ -164,7 +165,28 @@ func GetActionRepoPermission(ctx context.Context, repo *repo_model.Repository, t return GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) } -// GetUserRepoPermission returns the user permissions to the repository +// GetUserRepoPermission returns the user permissions to the repository, where the user's permissions may be +// artificially restricted by a an authorization reducer. +func GetUserRepoPermissionWithReducer(ctx context.Context, repo *repo_model.Repository, user *user_model.User, reducer authz.AuthorizationReducer) (Permission, error) { + perm, err := GetUserRepoPermission(ctx, repo, user) + if err != nil { + return perm, err + } + perm.AccessMode, err = reducer.ReduceRepoAccess(ctx, repo, perm.AccessMode) + if err != nil { + return perm, fmt.Errorf("failure in ReduceRepoAccess: %w", err) + } + for unit, currentAccessMode := range perm.UnitsMode { + reduced, err := reducer.ReduceRepoAccess(ctx, repo, currentAccessMode) + if err != nil { + return perm, fmt.Errorf("failure in ReduceRepoAccess: %w", err) + } + perm.UnitsMode[unit] = reduced + } + return perm, nil +} + +// GetUserRepoPermission returns the user permissions to the repository. func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (Permission, error) { var perm Permission if log.IsTrace() { diff --git a/models/perm/access/repo_permission_test.go b/models/perm/access/repo_permission_test.go index 55bc975421..303605047f 100644 --- a/models/perm/access/repo_permission_test.go +++ b/models/perm/access/repo_permission_test.go @@ -8,9 +8,13 @@ import ( perm_model "forgejo.org/models/perm" "forgejo.org/models/perm/access" repo_model "forgejo.org/models/repo" + "forgejo.org/models/unit" "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/services/authz" "github.com/stretchr/testify/assert" + mock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -76,3 +80,73 @@ func TestActionTaskNoAccessPrivateRepo(t *testing.T) { require.NoError(t, err) assertAccess(t, perm_model.AccessModeNone, &perm) } + +func TestGetUserRepoPermissionWithReducer(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("no unit-level overrides", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // Baseline check that without a reducer, we get AccessModeOwner... + permWithoutReducer, err := access.GetUserRepoPermission(t.Context(), repo, user) + require.NoError(t, err) + require.NotNil(t, permWithoutReducer) + assert.True(t, permWithoutReducer.IsOwner()) + assert.True(t, permWithoutReducer.IsAdmin()) + assert.True(t, permWithoutReducer.HasAccess()) + assert.True(t, permWithoutReducer.CanWrite(unit.TypeIssues)) + + reducer := authz.NewMockAuthorizationReducer(t) + reducer.On( + "ReduceRepoAccess", + mock.Anything, // context + mock.MatchedBy(func(repo *repo_model.Repository) bool { // repo + return repo.ID == 1 + }), + perm_model.AccessModeOwner, // incoming access mode + ).Return(perm_model.AccessModeNone, nil) + + permWithReducer, err := access.GetUserRepoPermissionWithReducer(t.Context(), repo, user, reducer) + require.NoError(t, err) + require.NotNil(t, permWithReducer) + assert.False(t, permWithReducer.IsOwner()) + assert.False(t, permWithReducer.IsAdmin()) + assert.False(t, permWithReducer.HasAccess()) + assert.False(t, permWithReducer.CanWrite(unit.TypeIssues)) + }) + + t.Run("team unit-level overrides", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32}) + + // Baseline check that without a reducer, we get mixed access for different units... + permWithoutReducer, err := access.GetUserRepoPermission(t.Context(), repo, user) + require.NoError(t, err) + require.NotNil(t, permWithoutReducer) + require.NotEmpty(t, permWithoutReducer.UnitsMode) // unit-specific access modes loaded + assert.True(t, permWithoutReducer.CanRead(unit.TypeCode)) + assert.False(t, permWithoutReducer.CanWrite(unit.TypeCode)) + assert.True(t, permWithoutReducer.CanRead(unit.TypeIssues)) + assert.True(t, permWithoutReducer.CanWrite(unit.TypeIssues)) + + reducer := authz.NewMockAuthorizationReducer(t) + reducer.On( + "ReduceRepoAccess", + mock.Anything, // context + mock.MatchedBy(func(repo *repo_model.Repository) bool { // repo + return repo.ID == 32 + }), + mock.Anything, // incoming access mode - will vary for each unit + ).Return(perm_model.AccessModeRead, nil) + + permWithReducer, err := access.GetUserRepoPermissionWithReducer(t.Context(), repo, user, reducer) + require.NoError(t, err) + require.NotNil(t, permWithReducer) + require.NotEmpty(t, permWithReducer.UnitsMode) // unit-specific access modes loaded + assert.True(t, permWithReducer.CanRead(unit.TypeCode)) + assert.False(t, permWithReducer.CanWrite(unit.TypeCode)) + assert.True(t, permWithReducer.CanRead(unit.TypeIssues)) + assert.False(t, permWithReducer.CanWrite(unit.TypeIssues)) + }) +} diff --git a/services/authz/authorization_reducer_mock.go b/services/authz/authorization_reducer_mock.go new file mode 100644 index 0000000000..27e90b24a4 --- /dev/null +++ b/services/authz/authorization_reducer_mock.go @@ -0,0 +1,99 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package authz + +import ( + context "context" + + "forgejo.org/models/perm" + repo_model "forgejo.org/models/repo" + + mock "github.com/stretchr/testify/mock" + "xorm.io/builder" +) + +// MockAuthorizationReducer is an autogenerated mock type for the AuthorizationReducer type +type MockAuthorizationReducer struct { + mock.Mock +} + +// AllowAdminOverride provides a mock function with no fields +func (_m *MockAuthorizationReducer) AllowAdminOverride() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for AllowAdminOverride") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// ReduceRepoAccess provides a mock function with given fields: ctx, repo, accessMode +func (_m *MockAuthorizationReducer) ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error) { + ret := _m.Called(ctx, repo, accessMode) + + if len(ret) == 0 { + panic("no return value specified for ReduceRepoAccess") + } + + var r0 perm.AccessMode + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *repo_model.Repository, perm.AccessMode) (perm.AccessMode, error)); ok { + return rf(ctx, repo, accessMode) + } + if rf, ok := ret.Get(0).(func(context.Context, *repo_model.Repository, perm.AccessMode) perm.AccessMode); ok { + r0 = rf(ctx, repo, accessMode) + } else { + r0 = ret.Get(0).(perm.AccessMode) + } + + if rf, ok := ret.Get(1).(func(context.Context, *repo_model.Repository, perm.AccessMode) error); ok { + r1 = rf(ctx, repo, accessMode) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RepoFilter provides a mock function with given fields: accessMode +func (_m *MockAuthorizationReducer) RepoFilter(accessMode perm.AccessMode) builder.Cond { + ret := _m.Called(accessMode) + + if len(ret) == 0 { + panic("no return value specified for RepoFilter") + } + + var r0 builder.Cond + if rf, ok := ret.Get(0).(func(perm.AccessMode) builder.Cond); ok { + r0 = rf(accessMode) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(builder.Cond) + } + } + + return r0 +} + +// NewMockAuthorizationReducer creates a new instance of MockAuthorizationReducer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAuthorizationReducer(t interface { + mock.TestingT + Cleanup(func()) +}, +) *MockAuthorizationReducer { + mock := &MockAuthorizationReducer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}