feat: backend DB model for fine-grained repo access tokens

This commit is contained in:
Mathieu Fenniak 2026-02-15 10:57:25 -07:00 committed by Mathieu Fenniak
parent 2d4a3e5658
commit a1eff6f0dc
13 changed files with 166 additions and 1 deletions

View file

@ -14,6 +14,7 @@ forgejo.org/models
IsErrMergeDivergingFastForwardOnly
forgejo.org/models/auth
GetRepositoriesAccessibleWithToken
WebAuthnCredentials
forgejo.org/models/db

View file

@ -205,7 +205,15 @@ func runCreateUser(ctx context.Context, c *cli.Command) error {
// create the access token
if accessTokenScope != "" {
t := &auth_model.AccessToken{Name: accessTokenName, UID: u.ID, Scope: accessTokenScope}
t := &auth_model.AccessToken{
Name: accessTokenName,
UID: u.ID,
Scope: accessTokenScope,
// maintain legacy behaviour until new CLI options are added -- token has access to all resources, is not
// fine-grained
ResourceAllRepos: true,
}
if err := auth_model.NewAccessToken(ctx, t); err != nil {
return err
}

View file

@ -86,6 +86,10 @@ func runGenerateAccessToken(ctx context.Context, c *cli.Command) error {
}
t.Scope = accessTokenScope
// maintain legacy behaviour until new CLI options are added -- token has access to all resources, is not
// fine-grained
t.ResourceAllRepos = true
// create the token
if err := auth_model.NewAccessToken(ctx, t); err != nil {
return err

View file

@ -0,0 +1,9 @@
- token_id: 3
repo_id: 1
created_unix: 1772158384
- token_id: 3
repo_id: 2
created_unix: 1772158384
- token_id: 3
repo_id: 3
created_unix: 1772158384

View file

@ -73,6 +73,8 @@ type AccessToken struct {
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
HasRecentActivity bool `xorm:"-"`
HasUsed bool `xorm:"-"`
ResourceAllRepos bool `xorm:"NOT NULL DEFAULT TRUE"` // flag for whether AccessTokenResourceRepo instances will limit the resources this access token can access (false) or won't limit them (true).
}
// AfterLoad is invoked from XORM after setting the values of all fields of this object.

View file

@ -0,0 +1,36 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package auth
import (
"context"
"forgejo.org/models/db"
"forgejo.org/modules/timeutil"
)
// Represents a many-to-many join table which indicates specific repositories (RepoID) that can be accessed by an access
// token (TokenID). An access token's ResourceAllRepos field must be false for records in this table to become active.
type AccessTokenResourceRepo struct {
ID int64 `xorm:"pk autoincr"`
TokenID int64 `xorm:"NOT NULL REFERENCES(access_token, id)"` // needs to be shortened from "AccessTokenID" for the index to fit MySQL table identifier length restrictions
RepoID int64 `xorm:"NOT NULL REFERENCES(repository, id)"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
}
func init() {
db.RegisterModel(new(AccessTokenResourceRepo))
}
func GetRepositoriesAccessibleWithToken(ctx context.Context, accessTokenID int64) ([]*AccessTokenResourceRepo, error) {
var resources []*AccessTokenResourceRepo
err := db.GetEngine(ctx).
Where("token_id = ?", accessTokenID).
Find(&resources)
if err != nil {
return nil, err
}
return resources, nil
}

View file

@ -0,0 +1,41 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package auth_test
import (
"testing"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetRepositoriesAccessibleWithToken(t *testing.T) {
defer unittest.OverrideFixtures("models/auth/TestGetRepositoriesAccessibleWithToken")()
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("No Resources", func(t *testing.T) {
resources, err := auth_model.GetRepositoriesAccessibleWithToken(db.DefaultContext, 999)
require.NoError(t, err)
assert.Empty(t, resources)
})
t.Run("Has Resources", func(t *testing.T) {
resources, err := auth_model.GetRepositoriesAccessibleWithToken(db.DefaultContext, 3)
require.NoError(t, err)
require.Len(t, resources, 3)
// Verify all expected repo IDs are present
repoIDs := make([]int64, len(resources))
for i, res := range resources {
repoIDs[i] = res.RepoID
}
assert.Contains(t, repoIDs, int64(1))
assert.Contains(t, repoIDs, int64(2))
assert.Contains(t, repoIDs, int64(3))
})
}

View file

@ -0,0 +1,23 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"xorm.io/xorm"
)
func init() {
registerMigration(&Migration{
Description: "add resource_all_owned_repositories to table access_token",
Upgrade: addAllOwnedRepositoriesToAccessToken,
})
}
func addAllOwnedRepositoriesToAccessToken(x *xorm.Engine) error {
type AccessToken struct {
ResourceAllRepos bool `xorm:"NOT NULL DEFAULT TRUE"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(AccessToken))
return err
}

View file

@ -0,0 +1,29 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"forgejo.org/modules/timeutil"
"xorm.io/xorm"
)
func init() {
registerMigration(&Migration{
Description: "add access_token_resource table",
Upgrade: addAccessTokenResource,
})
}
func addAccessTokenResource(x *xorm.Engine) error {
type AccessTokenResourceRepo struct {
ID int64 `xorm:"pk autoincr"`
TokenID int64 `xorm:"NOT NULL REFERENCES(access_token, id)"` // needs to be shortened from "AccessTokenID" for the index to fit MySQL table identifier length restrictions
RepoID int64 `xorm:"NOT NULL REFERENCES(repository, id)"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(AccessTokenResourceRepo))
return err
}

View file

@ -7,6 +7,8 @@ import (
"testing"
"forgejo.org/models/unittest"
_ "forgejo.org/modules/testimport"
)
func TestMain(m *testing.M) {

View file

@ -11,6 +11,8 @@ import (
"forgejo.org/models/user"
"forgejo.org/modules/optional"
_ "forgejo.org/modules/testimport"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View file

@ -128,6 +128,10 @@ func CreateAccessToken(ctx *context.APIContext) {
}
t.Scope = scope
// maintain legacy behaviour until new API options are added -- token has access to all resources, is not
// fine-grained
t.ResourceAllRepos = true
if err := auth_model.NewAccessToken(ctx, t); err != nil {
ctx.Error(http.StatusInternalServerError, "NewAccessToken", err)
return

View file

@ -56,6 +56,10 @@ func ApplicationsPost(ctx *context.Context) {
UID: ctx.Doer.ID,
Name: form.Name,
Scope: scope,
// maintain legacy behaviour until new UI options are added -- token has access to all resources, is not
// fine-grained
ResourceAllRepos: true,
}
exist, err := auth_model.AccessTokenByNameExists(ctx, t)