diff --git a/.deadcode-out b/.deadcode-out index 6d2c35e374..790e47fb4c 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -14,6 +14,7 @@ forgejo.org/models IsErrMergeDivergingFastForwardOnly forgejo.org/models/auth + GetRepositoriesAccessibleWithToken WebAuthnCredentials forgejo.org/models/db diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go index e3800bdb59..2881091589 100644 --- a/cmd/admin_user_create.go +++ b/cmd/admin_user_create.go @@ -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 } diff --git a/cmd/admin_user_generate_access_token.go b/cmd/admin_user_generate_access_token.go index 0a3a7fa89d..8b1d14f946 100644 --- a/cmd/admin_user_generate_access_token.go +++ b/cmd/admin_user_generate_access_token.go @@ -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 diff --git a/models/auth/TestGetRepositoriesAccessibleWithToken/access_token_resource_repo.yml b/models/auth/TestGetRepositoriesAccessibleWithToken/access_token_resource_repo.yml new file mode 100644 index 0000000000..e3266a8c91 --- /dev/null +++ b/models/auth/TestGetRepositoriesAccessibleWithToken/access_token_resource_repo.yml @@ -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 diff --git a/models/auth/access_token.go b/models/auth/access_token.go index ceade7dbad..85b89055e1 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -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. diff --git a/models/auth/access_token_resource.go b/models/auth/access_token_resource.go new file mode 100644 index 0000000000..1e29ac4765 --- /dev/null +++ b/models/auth/access_token_resource.go @@ -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 +} diff --git a/models/auth/access_token_resource_test.go b/models/auth/access_token_resource_test.go new file mode 100644 index 0000000000..722dcc070d --- /dev/null +++ b/models/auth/access_token_resource_test.go @@ -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)) + }) +} diff --git a/models/forgejo_migrations/v15b_add-access_token-owned-repos.go b/models/forgejo_migrations/v15b_add-access_token-owned-repos.go new file mode 100644 index 0000000000..dd96c55c05 --- /dev/null +++ b/models/forgejo_migrations/v15b_add-access_token-owned-repos.go @@ -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 +} diff --git a/models/forgejo_migrations/v15b_add-access_token_resource.go b/models/forgejo_migrations/v15b_add-access_token_resource.go new file mode 100644 index 0000000000..2e4afbcd43 --- /dev/null +++ b/models/forgejo_migrations/v15b_add-access_token_resource.go @@ -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 +} diff --git a/modules/activitypub/main_test.go b/modules/activitypub/main_test.go index a3f173f408..3f1a1b3562 100644 --- a/modules/activitypub/main_test.go +++ b/modules/activitypub/main_test.go @@ -7,6 +7,8 @@ import ( "testing" "forgejo.org/models/unittest" + + _ "forgejo.org/modules/testimport" ) func TestMain(m *testing.M) { diff --git a/modules/indexer/issues/internal/qstring_test.go b/modules/indexer/issues/internal/qstring_test.go index 2ad924076f..66c8a66ca3 100644 --- a/modules/indexer/issues/internal/qstring_test.go +++ b/modules/indexer/issues/internal/qstring_test.go @@ -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" ) diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index d65d082e6d..a75c389a34 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -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 diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index e73239b79b..359f0c1dce 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -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)