From 55a69ae4c63ee8551eadb161cb901ba0a2a2e194 Mon Sep 17 00:00:00 2001 From: Kemal Zebari <60799661+kemzeb@users.noreply.github.com> Date: Wed, 2 Apr 2025 07:00:54 -0700 Subject: [PATCH] Add new CLI flags to set name and scopes when creating a user with access token (#34080) Resolves #33474. --------- Co-authored-by: wxiaoguang --- cmd/admin_user_create.go | 46 ++++++++++++++---- cmd/admin_user_create_test.go | 62 ++++++++++++++++++++++-- cmd/admin_user_generate_access_token.go | 9 ++-- models/auth/access_token_scope.go | 4 ++ routers/web/user/setting/applications.go | 2 +- 5 files changed, 106 insertions(+), 17 deletions(-) diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go index 5e03d6ca3f..ebe0266d1f 100644 --- a/cmd/admin_user_create.go +++ b/cmd/admin_user_create.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strings" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -66,6 +67,16 @@ var microcmdUserCreate = &cli.Command{ Name: "access-token", Usage: "Generate access token for the user", }, + &cli.StringFlag{ + Name: "access-token-name", + Usage: `Name of the generated access token`, + Value: "gitea-admin", + }, + &cli.StringFlag{ + Name: "access-token-scopes", + Usage: `Scopes of the generated access token, comma separated. Examples: "all", "public-only,read:issue", "write:repository,write:user"`, + Value: "all", + }, &cli.BoolFlag{ Name: "restricted", Usage: "Make a restricted user account", @@ -187,23 +198,40 @@ func runCreateUser(c *cli.Context) error { IsRestricted: restricted, } + var accessTokenName string + var accessTokenScope auth_model.AccessTokenScope + if c.IsSet("access-token") { + accessTokenName = strings.TrimSpace(c.String("access-token-name")) + if accessTokenName == "" { + return errors.New("access-token-name cannot be empty") + } + var err error + accessTokenScope, err = auth_model.AccessTokenScope(c.String("access-token-scopes")).Normalize() + if err != nil { + return fmt.Errorf("invalid access token scope provided: %w", err) + } + if !accessTokenScope.HasPermissionScope() { + return errors.New("access token does not have any permission") + } + } else if c.IsSet("access-token-name") || c.IsSet("access-token-scopes") { + return errors.New("access-token-name and access-token-scopes flags are only valid when access-token flag is set") + } + + // arguments should be prepared before creating the user & access token, in case there is anything wrong + + // create the user if err := user_model.CreateUser(ctx, u, &user_model.Meta{}, overwriteDefault); err != nil { return fmt.Errorf("CreateUser: %w", err) } + fmt.Printf("New user '%s' has been successfully created!\n", username) - if c.Bool("access-token") { - t := &auth_model.AccessToken{ - Name: "gitea-admin", - UID: u.ID, - } - + // create the access token + if accessTokenScope != "" { + t := &auth_model.AccessToken{Name: accessTokenName, UID: u.ID, Scope: accessTokenScope} if err := auth_model.NewAccessToken(ctx, t); err != nil { return err } - fmt.Printf("Access token was successfully created... %s\n", t.Token) } - - fmt.Printf("New user '%s' has been successfully created!\n", username) return nil } diff --git a/cmd/admin_user_create_test.go b/cmd/admin_user_create_test.go index 0783cb570f..6fd6f8c226 100644 --- a/cmd/admin_user_create_test.go +++ b/cmd/admin_user_create_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -22,6 +23,7 @@ func TestAdminUserCreate(t *testing.T) { reset := func() { require.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.User{})) require.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.EmailAddress{})) + require.NoError(t, db.TruncateBeans(db.DefaultContext, &auth_model.AccessToken{})) } t.Run("MustChangePassword", func(t *testing.T) { @@ -48,11 +50,11 @@ func TestAdminUserCreate(t *testing.T) { assert.Equal(t, check{IsAdmin: false, MustChangePassword: false}, createCheck("u5", "--must-change-password=false")) }) - t.Run("UserType", func(t *testing.T) { - createUser := func(name, args string) error { - return app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s", name, name, args))) - } + createUser := func(name, args string) error { + return app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s", name, name, args))) + } + t.Run("UserType", func(t *testing.T) { reset() assert.ErrorContains(t, createUser("u", "--user-type invalid"), "invalid user type") assert.ErrorContains(t, createUser("u", "--user-type bot --password 123"), "can only be set for individual users") @@ -63,4 +65,56 @@ func TestAdminUserCreate(t *testing.T) { assert.Equal(t, user_model.UserTypeBot, u.Type) assert.Empty(t, u.Passwd) }) + + t.Run("AccessToken", func(t *testing.T) { + // no generated access token + reset() + assert.NoError(t, createUser("u", "--random-password")) + assert.Equal(t, 1, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + + // using "--access-token" only means "all" access + reset() + assert.NoError(t, createUser("u", "--random-password --access-token")) + assert.Equal(t, 1, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 1, unittest.GetCount(t, &auth_model.AccessToken{})) + accessToken := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{Name: "gitea-admin"}) + hasScopes, err := accessToken.Scope.HasScope(auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository) + assert.NoError(t, err) + assert.True(t, hasScopes) + + // using "--access-token" with name & scopes + reset() + assert.NoError(t, createUser("u", "--random-password --access-token --access-token-name new-token-name --access-token-scopes read:issue,read:user")) + assert.Equal(t, 1, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 1, unittest.GetCount(t, &auth_model.AccessToken{})) + accessToken = unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{Name: "new-token-name"}) + hasScopes, err = accessToken.Scope.HasScope(auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadUser) + assert.NoError(t, err) + assert.True(t, hasScopes) + hasScopes, err = accessToken.Scope.HasScope(auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository) + assert.NoError(t, err) + assert.False(t, hasScopes) + + // using "--access-token-name" without "--access-token" + reset() + err = createUser("u", "--random-password --access-token-name new-token-name") + assert.Equal(t, 0, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + assert.ErrorContains(t, err, "access-token-name and access-token-scopes flags are only valid when access-token flag is set") + + // using "--access-token-scopes" without "--access-token" + reset() + err = createUser("u", "--random-password --access-token-scopes read:issue") + assert.Equal(t, 0, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + assert.ErrorContains(t, err, "access-token-name and access-token-scopes flags are only valid when access-token flag is set") + + // empty permission + reset() + err = createUser("u", "--random-password --access-token --access-token-scopes public-only") + assert.Equal(t, 0, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + assert.ErrorContains(t, err, "access token does not have any permission") + }) } diff --git a/cmd/admin_user_generate_access_token.go b/cmd/admin_user_generate_access_token.go index 6c2c10494e..f6db7a74bd 100644 --- a/cmd/admin_user_generate_access_token.go +++ b/cmd/admin_user_generate_access_token.go @@ -34,8 +34,8 @@ var microcmdUserGenerateAccessToken = &cli.Command{ }, &cli.StringFlag{ Name: "scopes", - Value: "", - Usage: "Comma separated list of scopes to apply to access token", + Value: "all", + Usage: `Comma separated list of scopes to apply to access token, examples: "all", "public-only,read:issue", "write:repository,write:user"`, }, }, Action: runGenerateAccessToken, @@ -43,7 +43,7 @@ var microcmdUserGenerateAccessToken = &cli.Command{ func runGenerateAccessToken(c *cli.Context) error { if !c.IsSet("username") { - return errors.New("You must provide a username to generate a token for") + return errors.New("you must provide a username to generate a token for") } ctx, cancel := installSignals() @@ -77,6 +77,9 @@ func runGenerateAccessToken(c *cli.Context) error { if err != nil { return fmt.Errorf("invalid access token scope provided: %w", err) } + if !accessTokenScope.HasPermissionScope() { + return errors.New("access token does not have any permission") + } t.Scope = accessTokenScope // create the token diff --git a/models/auth/access_token_scope.go b/models/auth/access_token_scope.go index 0e5b2e96e6..2293fd89a0 100644 --- a/models/auth/access_token_scope.go +++ b/models/auth/access_token_scope.go @@ -295,6 +295,10 @@ func (s AccessTokenScope) Normalize() (AccessTokenScope, error) { return bitmap.toScope(), nil } +func (s AccessTokenScope) HasPermissionScope() bool { + return s != "" && s != AccessTokenScopePublicOnly +} + // PublicOnly checks if this token scope is limited to public resources func (s AccessTokenScope) PublicOnly() (bool, error) { bitmap, err := s.parse() diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 1f6c97a5cc..c3d8b93adb 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -54,7 +54,7 @@ func ApplicationsPost(ctx *context.Context) { ctx.ServerError("GetScope", err) return } - if scope == "" || scope == auth_model.AccessTokenScopePublicOnly { + if !scope.HasPermissionScope() { ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true) }