feat: services/authz package for evaluating fine-grained access token

This commit is contained in:
Mathieu Fenniak 2026-02-15 10:58:52 -07:00 committed by Mathieu Fenniak
parent a1eff6f0dc
commit 44c18465b5
12 changed files with 550 additions and 0 deletions

View file

@ -0,0 +1,31 @@
-
id: 5
uid: 2
name: Unrestricted Token
token_hash: a6d404048048812d9e911d93aefbe94fc768d4876fdf75e3bef0bdc67828e0af422846d3056f2f25ec35c51dc92075685ec5
token_salt: 99ArgXKlQQ
token_last_eight: 69d28c91
created_unix: 946687980
updated_unix: 946687980
resource_all_repos: true
-
id: 6
uid: 2
name: Public Resources Only Token
token_hash: b6d404048048812d9e911d93aefbe94fc768d4876fdf75e3bef0bdc67828e0af422846d3056f2f25ec35c51dc92075685ec5
token_salt: 99ArgXKlQQ
token_last_eight: 69d28c91
created_unix: 946687980
updated_unix: 946687980
scope: public-only,read:activitypub
resource_all_repos: true
-
id: 7
uid: 2
name: Specific Repos Only Token
token_hash: c6d404048048812d9e911d93aefbe94fc768d4876fdf75e3bef0bdc67828e0af422846d3056f2f25ec35c51dc92075685ec5
token_salt: 99ArgXKlQQ
token_last_eight: 69d28c91
created_unix: 946687980
updated_unix: 946687980
resource_all_repos: false

View file

@ -0,0 +1,5 @@
-
id: 1
token_id: 7
repo_id: 1
created_unix: 1772158384

View file

@ -0,0 +1,28 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"context"
"fmt"
auth_model "forgejo.org/models/auth"
)
func GetAuthorizationReducerForAccessToken(ctx context.Context, token *auth_model.AccessToken) (AuthorizationReducer, error) {
if token.ResourceAllRepos {
if publicOnly, err := token.Scope.PublicOnly(); err != nil {
return nil, fmt.Errorf("PublicOnly: %w", err)
} else if publicOnly {
return &PublicReposAuthorizationReducer{}, nil
}
return &AllAccessAuthorizationReducer{}, nil
}
repos, err := auth_model.GetRepositoriesAccessibleWithToken(ctx, token.ID)
if err != nil {
return nil, fmt.Errorf("GetRepositoriesAccessibleWithToken: %w", err)
}
return &SpecificReposAuthorizationReducer{resourceRepos: repos}, nil
}

View file

@ -0,0 +1,46 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"testing"
"forgejo.org/models/auth"
"forgejo.org/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetAuthorizationReducerForAccessToken(t *testing.T) {
defer unittest.OverrideFixtures("services/authz/TestGetAuthorizationReducerForAccessToken")()
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("all access", func(t *testing.T) {
token := unittest.AssertExistsAndLoadBean(t, &auth.AccessToken{ID: 5})
reducer, err := GetAuthorizationReducerForAccessToken(t.Context(), token)
require.NoError(t, err)
assert.IsType(t, &AllAccessAuthorizationReducer{}, reducer)
})
t.Run("public resources only", func(t *testing.T) {
token := unittest.AssertExistsAndLoadBean(t, &auth.AccessToken{ID: 6})
reducer, err := GetAuthorizationReducerForAccessToken(t.Context(), token)
require.NoError(t, err)
assert.IsType(t, &PublicReposAuthorizationReducer{}, reducer)
})
t.Run("specific repos only", func(t *testing.T) {
token := unittest.AssertExistsAndLoadBean(t, &auth.AccessToken{ID: 7})
reducer, err := GetAuthorizationReducerForAccessToken(t.Context(), token)
require.NoError(t, err)
specific, ok := reducer.(*SpecificReposAuthorizationReducer)
require.True(t, ok)
require.NotNil(t, specific)
require.Len(t, specific.resourceRepos, 1)
assert.EqualValues(t, 1, specific.resourceRepos[0].RepoID)
})
}

View file

@ -0,0 +1,29 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"context"
"forgejo.org/models/perm"
repo_model "forgejo.org/models/repo"
"xorm.io/builder"
)
// Implementation of [AuthorizationReducer] that does no authorization reduction, allowing normal access to all
// resources.
type AllAccessAuthorizationReducer struct{}
func (*AllAccessAuthorizationReducer) ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error) {
return accessMode, nil
}
func (*AllAccessAuthorizationReducer) RepoFilter(accessMode perm.AccessMode) builder.Cond {
return builder.NewCond() // invalid cond should be excluded and cause no filtering
}
func (*AllAccessAuthorizationReducer) AllowAdminOverride() bool {
return true
}

View file

@ -0,0 +1,49 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"testing"
"forgejo.org/models/db"
"forgejo.org/models/perm"
"forgejo.org/models/repo"
"forgejo.org/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAllAccessAuthorizationReducer(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
reducer := &AllAccessAuthorizationReducer{}
t.Run("ReduceRepoAccess no changes", func(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
p1, err := reducer.ReduceRepoAccess(t.Context(), repo1, am)
require.NoError(t, err)
assert.Equal(t, am, p1)
}
})
t.Run("RepoFilter no restrictions", func(t *testing.T) {
numRepos, err := db.GetEngine(t.Context()).Table(&repo.Repository{}).Count()
require.NoError(t, err)
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
cond := reducer.RepoFilter(am)
var rows []*repo.Repository
err := db.GetEngine(t.Context()).Table(&repo.Repository{}).Where(cond).OrderBy("id").Cols("id", "owner_id", "is_private").Find(&rows)
require.NoError(t, err)
assert.Len(t, rows, int(numRepos))
}
})
t.Run("AllowAdminOverride is true", func(t *testing.T) {
assert.True(t, reducer.AllowAdminOverride())
})
}

View file

@ -0,0 +1,39 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"context"
"forgejo.org/models/perm"
repo_model "forgejo.org/models/repo"
"xorm.io/builder"
)
// Defines an API for reducing available permissions to specific resources. Typically associated with a fine-grained
// access tokens and provides methods to reduce authorization that the access token provides down to specific resources.
type AuthorizationReducer interface {
// Given a repository and an accessMode, ReduceRepoAccess will return a new, possibly reduced, AccessMode that
// reflects the actual access that is currently permitted. For example, when using a fine-grained access token that
// only grants write access to one target repository, `ReduceRepoAccess(target, AccessModeWrite)` would return
// `AccessModeWrite`, and `ReduceRepoAccess(other-repo, AccessModeWrite)` would return a lesser access mode,
// restricting access to other repositories.
ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error)
// If querying the repository table, apply this condition to return only repositories that the restriction will
// allow the target access mode (or higher). For example, when using a fine-grained access token that only grants
// write access to one target repository, `RepoFilter(AccessModeWrite)` would return a filter that only returns that
// single repository, while `RepoFilter(AccessModeRead)` would return a filter that includes all public repositories
// and the target repository.
RepoFilter(accessMode perm.AccessMode) builder.Cond
// Controls whether the presence of an authorization reducer will prevent administrators from overriding permission
// checks. Typically site administrators and repo administrators are exempted from permission checks, but if an
// authorization reducer is present then it may be intended for its restrictions to apply even to administrators.
//
// `true` allows the typical case where administrators *can* override permissions. `false` disables administrator
// overrides of permission checks.
AllowAdminOverride() bool
}

View file

@ -0,0 +1,14 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"testing"
"forgejo.org/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

View file

@ -0,0 +1,46 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"context"
"fmt"
"forgejo.org/models/perm"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/structs"
"xorm.io/builder"
)
// Grants access only to public repositories. Does not change the level of access for any public repos.
type PublicReposAuthorizationReducer struct{}
func (*PublicReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error) {
if err := repo.LoadOwner(ctx); err != nil {
return 0, fmt.Errorf("failed to LoadOwner during ReduceRepoAccess: %w", err)
}
// Fine-grained access tokens remove access to any private repositories, or repository owned by non-public users,
// that aren't listed in their resource list.
if !repo.Owner.Visibility.IsPublic() || repo.IsPrivate {
return perm.AccessModeNone, nil
}
return accessMode, nil
}
func (*PublicReposAuthorizationReducer) RepoFilter(accessMode perm.AccessMode) builder.Cond {
// Regardless of access mode, allow access only to non-private repositories, that aren't in a private or limited
// organization.
return builder.And(
builder.Eq{"is_private": false},
builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(
builder.Or(builder.Eq{"visibility": structs.VisibleTypeLimited}, builder.Eq{"visibility": structs.VisibleTypePrivate}),
)))
}
func (*PublicReposAuthorizationReducer) AllowAdminOverride() bool {
return false
}

View file

@ -0,0 +1,73 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"testing"
"forgejo.org/models/db"
"forgejo.org/models/perm"
"forgejo.org/models/repo"
"forgejo.org/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublicReposAuthorizationReducer(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
reducer := &PublicReposAuthorizationReducer{}
t.Run("ReduceRepoAccess unrestricted on public repos", func(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
repo4 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 4})
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
p1, err := reducer.ReduceRepoAccess(t.Context(), repo1, am)
require.NoError(t, err)
assert.Equal(t, am, p1)
p4, err := reducer.ReduceRepoAccess(t.Context(), repo4, am)
require.NoError(t, err)
assert.Equal(t, am, p4)
}
})
t.Run("ReduceRepoAccess restricted to None on private repos", func(t *testing.T) {
// private repo
repo3 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 3})
// public repo on a limited-visibility org
repo38 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 38})
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
p3, err := reducer.ReduceRepoAccess(t.Context(), repo3, am)
require.NoError(t, err)
assert.Equal(t, perm.AccessModeNone, p3)
p38, err := reducer.ReduceRepoAccess(t.Context(), repo38, am)
require.NoError(t, err)
assert.Equal(t, perm.AccessModeNone, p38)
}
})
t.Run("RepoFilter unrestricted access only permitted to public repos", func(t *testing.T) {
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
cond := reducer.RepoFilter(am)
var rows []*repo.Repository
err := db.GetEngine(t.Context()).Table(&repo.Repository{}).Where(cond).OrderBy("id").Cols("id", "owner_id", "is_private").Find(&rows)
require.NoError(t, err)
assert.NotEmpty(t, rows)
for _, repo := range rows {
assert.False(t, repo.IsPrivate)
require.NoError(t, repo.LoadOwner(t.Context()))
assert.True(t, repo.Owner.Visibility.IsPublic())
}
}
})
t.Run("AllowAdminOverride is false", func(t *testing.T) {
assert.False(t, reducer.AllowAdminOverride())
})
}

View file

@ -0,0 +1,73 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"context"
"fmt"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/perm"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/structs"
"xorm.io/builder"
)
// For specific repositories listed in [AccessTokenResourceRepo] models, all access is permitted. For public
// repositories that aren't listed among the specific repos, read-only access is permitted. For all other repos, no
// access is permitted.
type SpecificReposAuthorizationReducer struct {
resourceRepos []*auth_model.AccessTokenResourceRepo
}
func (r *SpecificReposAuthorizationReducer) ReduceRepoAccess(ctx context.Context, repo *repo_model.Repository, accessMode perm.AccessMode) (perm.AccessMode, error) {
for _, tokenRepo := range r.resourceRepos {
if tokenRepo.RepoID == repo.ID {
// No restrictions as this repo is within the scope of the access token.
return accessMode, nil
}
}
if err := repo.LoadOwner(ctx); err != nil {
return 0, fmt.Errorf("failed to LoadOwner during ReduceRepoAccess: %w", err)
}
// Fine-grained access tokens remove access to any private repositories, or repository owned by non-public users,
// that aren't listed in their resource list.
if !repo.Owner.Visibility.IsPublic() || repo.IsPrivate {
return perm.AccessModeNone, nil
}
// Public repos will be reduced to read access.
return min(accessMode, perm.AccessModeRead), nil
}
func (r *SpecificReposAuthorizationReducer) RepoFilter(accessMode perm.AccessMode) builder.Cond {
repoIDs := make([]int64, len(r.resourceRepos))
for i, tokenRepo := range r.resourceRepos {
repoIDs[i] = tokenRepo.RepoID
}
targetRepos := builder.In("repository.id", repoIDs)
// If requesting anything higher than read access, it will only be available for repos within the scope of the
// access token.
if accessMode > perm.AccessModeRead {
return targetRepos
}
// For read access, we should be able to see all non-private repositories that aren't in a private or limited
// organisation.
return builder.Or(
targetRepos,
builder.And(
builder.Eq{"repository.is_private": false},
builder.NotIn("repository.owner_id", builder.Select("id").From("`user`").Where(
builder.Or(builder.Eq{"visibility": structs.VisibleTypeLimited}, builder.Eq{"visibility": structs.VisibleTypePrivate}),
))))
}
func (*SpecificReposAuthorizationReducer) AllowAdminOverride() bool {
return false
}

View file

@ -0,0 +1,117 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package authz
import (
"testing"
"forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/perm"
"forgejo.org/models/repo"
"forgejo.org/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSpecificReposAuthorizationReducer(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
reducer := &SpecificReposAuthorizationReducer{
resourceRepos: []*auth.AccessTokenResourceRepo{
{
RepoID: 1,
},
{
RepoID: 2,
},
},
}
t.Run("ReduceRepoAccess unrestricted on targeted repos", func(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
repo2 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 2})
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
p1, err := reducer.ReduceRepoAccess(t.Context(), repo1, am)
require.NoError(t, err)
assert.Equal(t, am, p1)
p2, err := reducer.ReduceRepoAccess(t.Context(), repo2, am)
require.NoError(t, err)
assert.Equal(t, am, p2)
}
})
t.Run("ReduceRepoAccess restricted to None on private repos", func(t *testing.T) {
// private repo
repo3 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 3})
// public repo on a limited-visibility org
repo38 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 38})
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
p3, err := reducer.ReduceRepoAccess(t.Context(), repo3, am)
require.NoError(t, err)
assert.Equal(t, perm.AccessModeNone, p3)
p38, err := reducer.ReduceRepoAccess(t.Context(), repo38, am)
require.NoError(t, err)
assert.Equal(t, perm.AccessModeNone, p38)
}
})
t.Run("ReduceRepoAccess restricted to Read on public repos", func(t *testing.T) {
// public repo
repo4 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 4})
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite, perm.AccessModeRead} {
p3, err := reducer.ReduceRepoAccess(t.Context(), repo4, am)
require.NoError(t, err)
assert.Equal(t, perm.AccessModeRead, p3)
}
// don't elevate AccessModeNone to AccessModeRead:
p3, err := reducer.ReduceRepoAccess(t.Context(), repo4, perm.AccessModeNone)
require.NoError(t, err)
assert.Equal(t, perm.AccessModeNone, p3)
})
t.Run("RepoFilter >write access only permitted to targeted repos", func(t *testing.T) {
for _, am := range []perm.AccessMode{perm.AccessModeOwner, perm.AccessModeAdmin, perm.AccessModeWrite} {
cond := reducer.RepoFilter(am)
var repoIDs []int64
err := db.GetEngine(t.Context()).Table(&repo.Repository{}).Where(cond).OrderBy("id").Cols("id").Find(&repoIDs)
require.NoError(t, err)
assert.Len(t, repoIDs, 2)
assert.EqualValues(t, 1, repoIDs[0])
assert.EqualValues(t, 2, repoIDs[1])
}
})
t.Run("RepoFilter read access only permitted to target repos & public repos", func(t *testing.T) {
cond := reducer.RepoFilter(perm.AccessModeRead)
var rows []*repo.Repository
err := db.GetEngine(t.Context()).Table(&repo.Repository{}).Where(cond).OrderBy("id").Cols("id", "owner_id", "is_private").Find(&rows)
require.NoError(t, err)
// Both target repos should be returned:
assert.EqualValues(t, 1, rows[0].ID)
assert.EqualValues(t, 2, rows[1].ID)
// And there should be more return values, all of which appear as public repos:
assert.Greater(t, len(rows), 2)
for _, repo := range rows[2:] {
assert.False(t, repo.IsPrivate)
require.NoError(t, repo.LoadOwner(t.Context()))
assert.True(t, repo.Owner.Visibility.IsPublic())
}
})
t.Run("AllowAdminOverride is false", func(t *testing.T) {
assert.False(t, reducer.AllowAdminOverride())
})
}