diff --git a/pkg/models/bot_users.go b/pkg/models/bot_users.go new file mode 100644 index 000000000..efc544987 --- /dev/null +++ b/pkg/models/bot_users.go @@ -0,0 +1,133 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "strings" + + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" + + "xorm.io/xorm" +) + +// BotUser is a thin wrapper around user.User that implements CRUDable + Permissions +// for bot-management endpoints. Ownership lives on users.bot_owner_id, so there is +// no separate table. +type BotUser struct { + // Status shadows user.User.Status so it is included in JSON responses + // (the original has json:"-"). + Status user.Status `xorm:"-" json:"status"` + + user.User `xorm:"extends"` + + web.CRUDable `xorm:"-" json:"-"` + web.Permissions `xorm:"-" json:"-"` +} + +// Create creates a new bot user. +func (b *BotUser) Create(s *xorm.Session, a web.Auth) error { + owner, ok := a.(*user.User) + if !ok { + return ErrGenericForbidden{} + } + b.ID = 0 + created, err := user.CreateBotUser(s, &b.User, owner) + if err != nil { + return err + } + b.User = *created + b.Status = created.Status + return nil +} + +// ReadAll returns all bots owned by the calling user. +func (b *BotUser) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result any, resultCount int, numberOfTotalItems int64, err error) { + limit, start := getLimitFromPageIndex(page, perPage) + var bots []*BotUser + q := s.Where("bot_owner_id = ?", a.GetID()) + if search != "" { + q = q.And("(username LIKE ? OR name LIKE ?)", "%"+search+"%", "%"+search+"%") + } + if limit > 0 { + q = q.Limit(limit, start) + } + total, err := q.FindAndCount(&bots) + if err != nil { + return nil, 0, 0, err + } + for _, bot := range bots { + bot.Status = bot.User.Status + } + return bots, len(bots), total, nil +} + +// ReadOne returns a single bot user. +// Ownership is verified in CanRead. +func (b *BotUser) ReadOne(s *xorm.Session, _ web.Auth) error { + u, err := user.GetUserByID(s, b.ID) + if err != nil { + return err + } + b.User = *u + b.Status = u.Status + return nil +} + +// Update allows a narrow set of fields to be changed on an owned bot. +// Ownership is verified in CanUpdate. +func (b *BotUser) Update(s *xorm.Session, _ web.Auth) error { + existing, err := user.GetUserByID(s, b.ID) + if err != nil { + return err + } + + cols := []string{"name"} + existing.Name = b.Name + + if b.Status == user.StatusDisabled { + existing.Status = b.Status + cols = append(cols, "status") + } else if b.Status == user.StatusActive && existing.Status != user.StatusActive { + existing.Status = b.Status + cols = append(cols, "status") + } + if b.Username != "" && b.Username != existing.Username { + if !strings.HasPrefix(b.Username, "bot-") { + return &user.ErrBotUsernameMustHavePrefix{Username: b.Username} + } + existing.Username = b.Username + cols = append(cols, "username") + } + + if len(cols) > 0 { + _, err = s.ID(existing.ID).Cols(cols...).Update(existing) + } + b.User = *existing + b.Status = existing.Status + return err +} + +// Delete completely removes the bot user and all associated data. +// Ownership is verified in CanDelete. +func (b *BotUser) Delete(s *xorm.Session, _ web.Auth) error { + existing, err := user.GetUserByID(s, b.ID) + if err != nil { + return err + } + return DeleteUser(s, existing) +} diff --git a/pkg/models/bot_users_permissions.go b/pkg/models/bot_users_permissions.go new file mode 100644 index 000000000..8fb4db854 --- /dev/null +++ b/pkg/models/bot_users_permissions.go @@ -0,0 +1,56 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" + + "xorm.io/xorm" +) + +// CanCreate checks if a user can create a bot user. +func (b *BotUser) CanCreate(_ *xorm.Session, a web.Auth) (bool, error) { + u, ok := a.(*user.User) + if !ok || u.IsBot() { + return false, nil + } + return true, nil +} + +// CanRead checks if a user can read a bot user. +func (b *BotUser) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + ok, err := b.isOwner(s, a) + return ok, 0, err +} + +// CanUpdate checks if a user can update a bot user. +func (b *BotUser) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { return b.isOwner(s, a) } + +// CanDelete checks if a user can delete a bot user. +func (b *BotUser) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { return b.isOwner(s, a) } + +func (b *BotUser) isOwner(s *xorm.Session, a web.Auth) (bool, error) { + u, err := user.GetUserByID(s, b.ID) + if err != nil { + if user.IsErrUserDoesNotExist(err) { + return false, nil + } + return false, err + } + return u.BotOwnerID == a.GetID(), nil +} diff --git a/pkg/models/bot_users_test.go b/pkg/models/bot_users_test.go new file mode 100644 index 000000000..bfb808aed --- /dev/null +++ b/pkg/models/bot_users_test.go @@ -0,0 +1,130 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBotUser_Create(t *testing.T) { + t.Run("success", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + owner, err := user.GetUserByID(s, 1) + require.NoError(t, err) + + bot := &BotUser{User: user.User{Username: "bot-model-success"}} + require.NoError(t, bot.Create(s, owner)) + assert.True(t, bot.IsBot()) + assert.Equal(t, owner.ID, bot.BotOwnerID) + }) + t.Run("bot cannot create bot", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + botOwner := &user.User{ID: 555, BotOwnerID: 1} + bot := &BotUser{User: user.User{Username: "bot-child"}} + err := bot.Create(s, botOwner) + require.Error(t, err) + assert.True(t, user.IsErrBotNotOwned(err)) + }) +} + +func TestBotUser_ReadAll(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + owner, err := user.GetUserByID(s, 1) + require.NoError(t, err) + + bot := &BotUser{User: user.User{Username: "bot-readall"}} + require.NoError(t, bot.Create(s, owner)) + + list := &BotUser{} + result, _, _, err := list.ReadAll(s, owner, "", 1, 50) + require.NoError(t, err) + bots, ok := result.([]*BotUser) + require.True(t, ok) + found := false + for _, u := range bots { + if u.Username == "bot-readall" { + found = true + } + } + assert.True(t, found) +} + +func TestBotUser_CanRead_NotOwned(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + owner, err := user.GetUserByID(s, 1) + require.NoError(t, err) + other, err := user.GetUserByID(s, 2) + require.NoError(t, err) + + bot := &BotUser{User: user.User{Username: "bot-notowned"}} + require.NoError(t, bot.Create(s, owner)) + + view := &BotUser{User: user.User{ID: bot.ID}} + canRead, _, err := view.CanRead(s, other) + require.NoError(t, err) + assert.False(t, canRead) +} + +func TestBotUser_Update_Status(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + owner, err := user.GetUserByID(s, 1) + require.NoError(t, err) + + bot := &BotUser{User: user.User{Username: "bot-update"}} + require.NoError(t, bot.Create(s, owner)) + + upd := &BotUser{Status: user.StatusDisabled, User: user.User{ID: bot.ID, Name: "Renamed"}} + require.NoError(t, upd.Update(s, owner)) + assert.Equal(t, user.StatusDisabled, upd.Status) + assert.Equal(t, "Renamed", upd.Name) +} + +func TestBotUser_Delete(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + owner, err := user.GetUserByID(s, 1) + require.NoError(t, err) + + bot := &BotUser{User: user.User{Username: "bot-delete"}} + require.NoError(t, bot.Create(s, owner)) + + del := &BotUser{User: user.User{ID: bot.ID}} + require.NoError(t, del.Delete(s, owner)) +} diff --git a/pkg/models/user_delete.go b/pkg/models/user_delete.go index 8cdba1e0d..59bd33107 100644 --- a/pkg/models/user_delete.go +++ b/pkg/models/user_delete.go @@ -128,6 +128,17 @@ func getProjectsToDelete(s *xorm.Session, u *user.User) (projectsToDelete []*Pro // This action is irrevocable. // Public to allow deletion from the CLI. func DeleteUser(s *xorm.Session, u *user.User) (err error) { + // Delete any bot users owned by this user first (cascades their data too). + var ownedBots []*user.User + if err = s.Where("bot_owner_id = ?", u.ID).Find(&ownedBots); err != nil { + return err + } + for _, bot := range ownedBots { + if err = DeleteUser(s, bot); err != nil { + return err + } + } + projectsToDelete, err := getProjectsToDelete(s, u) if err != nil { return err diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 0f8ded367..6e7c9a6ba 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -55,6 +55,7 @@ type vikunjaInfos struct { DemoModeEnabled bool `json:"demo_mode_enabled"` WebhooksEnabled bool `json:"webhooks_enabled"` PublicTeamsEnabled bool `json:"public_teams_enabled"` + BotUsersEnabled bool `json:"bot_users_enabled"` EnabledProFeatures []license.Feature `json:"enabled_pro_features"` } @@ -107,6 +108,7 @@ func Info(c *echo.Context) error { DemoModeEnabled: config.ServiceDemoMode.GetBool(), WebhooksEnabled: config.WebhooksEnabled.GetBool(), PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), + BotUsersEnabled: config.ServiceEnableBotUsers.GetBool(), EnabledProFeatures: license.EnabledProFeatures(), AvailableMigrators: []string{ (&vikunja_file.FileMigrator{}).Name(), diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 6c52a1af5..a305c3dc6 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -481,6 +481,24 @@ func registerAPIRoutes(a *echo.Group) { u.POST("/deletion/cancel", apiv1.UserCancelDeletion) } + // Bot users + if config.ServiceEnableBotUsers.GetBool() { + botHandler := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.BotUser{} + }, + } + u.PUT("/bots", botHandler.CreateWeb) + u.GET("/bots", botHandler.ReadAllWeb) + u.GET("/bots/:bot", botHandler.ReadOneWeb) + u.POST("/bots/:bot", botHandler.UpdateWeb) + u.DELETE("/bots/:bot", botHandler.DeleteWeb) + + u.PUT("/bots/:bot/tokens", apiv1.CreateBotToken) + u.GET("/bots/:bot/tokens", apiv1.ListBotTokens) + u.DELETE("/bots/:bot/tokens/:token", apiv1.DeleteBotToken) + } + projectHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.Project{} diff --git a/pkg/user/user.go b/pkg/user/user.go index ae2305628..eaf8b18b4 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -84,7 +84,7 @@ const ( // User holds information about an user type User struct { // The unique, numeric id of this user. - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"bot"` // The full name of the user. Name string `xorm:"text null" json:"name"` // The username of the user. Is always unique.