From 87a06d6cb919f78a72f915ce0899cea12f04c966 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 20 Apr 2026 18:55:25 +0200 Subject: [PATCH] feat(permissions): site admins bypass all Can* checks (license-gated) --- pkg/models/admin_bypass.go | 44 +++++++ pkg/models/admin_bypass_test.go | 145 ++++++++++++++++++++++++ pkg/models/project_permissions.go | 27 +++++ pkg/models/project_team_permissions.go | 4 + pkg/models/project_users_permissions.go | 4 + pkg/models/project_view_permissions.go | 12 ++ pkg/models/team_members_permissions.go | 4 + pkg/models/teams_permissions.go | 7 ++ 8 files changed, 247 insertions(+) create mode 100644 pkg/models/admin_bypass.go create mode 100644 pkg/models/admin_bypass_test.go diff --git a/pkg/models/admin_bypass.go b/pkg/models/admin_bypass.go new file mode 100644 index 000000000..ae7d6b1b5 --- /dev/null +++ b/pkg/models/admin_bypass.go @@ -0,0 +1,44 @@ +// 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/license" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/web" + + "xorm.io/xorm" +) + +// isInstanceAdmin gates cross-user access on both is_admin and the admin-panel +// license so flipping is_admin on a free instance cannot recover the paid bypass. +// is_admin is re-read from the DB because the auth's flag is claim-derived and +// stale until the JWT expires. +func isInstanceAdmin(s *xorm.Session, a web.Auth) bool { + if !license.IsFeatureEnabled(license.FeatureAdminPanel) { + return false + } + u, ok := a.(*user.User) + if !ok { + return false + } + fresh, err := user.GetUserByID(s, u.ID) + if err != nil { + return false + } + return fresh.IsAdmin +} diff --git a/pkg/models/admin_bypass_test.go b/pkg/models/admin_bypass_test.go new file mode 100644 index 000000000..c84d6bf8d --- /dev/null +++ b/pkg/models/admin_bypass_test.go @@ -0,0 +1,145 @@ +// 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/license" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdminBypass_Project(t *testing.T) { + db.LoadAndAssertFixtures(t) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + s := db.NewSession() + defer s.Close() + + _, err := s.ID(int64(2)).Cols("is_admin").Update(&user.User{IsAdmin: true}) + require.NoError(t, err) + + admin := &user.User{ID: 2, IsAdmin: true} + p := &Project{ID: 1} + + t.Run("CanRead", func(t *testing.T) { + can, _, err := p.CanRead(s, admin) + require.NoError(t, err) + assert.True(t, can, "admin must be able to read any project") + }) + + t.Run("CanWrite", func(t *testing.T) { + can, err := p.CanWrite(s, admin) + require.NoError(t, err) + assert.True(t, can) + }) + + t.Run("CanUpdate", func(t *testing.T) { + can, err := p.CanUpdate(s, admin) + require.NoError(t, err) + assert.True(t, can) + }) + + t.Run("CanDelete", func(t *testing.T) { + can, err := p.CanDelete(s, admin) + require.NoError(t, err) + assert.True(t, can) + }) +} + +// Without the admin-panel license, flipping is_admin must not recover the paid bypass. +func TestAdminBypass_Project_LicenseInactive(t *testing.T) { + db.LoadAndAssertFixtures(t) + license.ResetForTests() + s := db.NewSession() + defer s.Close() + + _, err := s.ID(int64(2)).Cols("is_admin").Update(&user.User{IsAdmin: true}) + require.NoError(t, err) + + admin := &user.User{ID: 2, IsAdmin: true} + p := &Project{ID: 1} + + t.Run("CanRead", func(t *testing.T) { + can, _, err := p.CanRead(s, admin) + require.NoError(t, err) + assert.False(t, can, "unlicensed admin must not read another user's project") + }) + + t.Run("CanWrite", func(t *testing.T) { + can, err := p.CanWrite(s, admin) + require.NoError(t, err) + assert.False(t, can) + }) + + t.Run("CanDelete", func(t *testing.T) { + can, err := p.CanDelete(s, admin) + require.NoError(t, err) + assert.False(t, can) + }) +} + +// A stale JWT admin claim must not grant the bypass after DB demotion. +func TestAdminBypass_StaleJWT_Demoted(t *testing.T) { + db.LoadAndAssertFixtures(t) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + s := db.NewSession() + defer s.Close() + + stale := &user.User{ID: 3, IsAdmin: true} + p := &Project{ID: 1} + + t.Run("CanRead", func(t *testing.T) { + can, _, err := p.CanRead(s, stale) + require.NoError(t, err) + assert.False(t, can, "stale admin claim must not grant project read without DB confirmation") + }) + + t.Run("CanWrite", func(t *testing.T) { + can, err := p.CanWrite(s, stale) + require.NoError(t, err) + assert.False(t, can) + }) + + t.Run("CanDelete", func(t *testing.T) { + can, err := p.CanDelete(s, stale) + require.NoError(t, err) + assert.False(t, can) + }) +} + +func TestAdminBypass_StaleJWT_DeletedUser(t *testing.T) { + db.LoadAndAssertFixtures(t) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + s := db.NewSession() + defer s.Close() + + stale := &user.User{ID: 99999, IsAdmin: true} + p := &Project{ID: 1} + + t.Run("CanRead", func(t *testing.T) { + can, _, err := p.CanRead(s, stale) + require.NoError(t, err) + assert.False(t, can, "deleted admin must not grant bypass") + }) +} diff --git a/pkg/models/project_permissions.go b/pkg/models/project_permissions.go index bf4351cda..4e9e8558c 100644 --- a/pkg/models/project_permissions.go +++ b/pkg/models/project_permissions.go @@ -34,6 +34,10 @@ func (p *Project) CanWrite(s *xorm.Session, a web.Auth) (bool, error) { return false, nil } + if isInstanceAdmin(s, a) { + return true, nil + } + // Get the project and check the permission originalProject, err := GetProjectSimpleByID(s, p.ID) if err != nil { @@ -74,6 +78,15 @@ func (p *Project) CanWrite(s *xorm.Session, a web.Auth) (bool, error) { // CanRead checks if a user has read access to a project func (p *Project) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + if isInstanceAdmin(s, a) { + originalProject, err := GetProjectSimpleByID(s, p.ID) + if err != nil { + return false, 0, err + } + *p = *originalProject + return true, int(PermissionAdmin), nil + } + // The favorite project needs a special treatment if p.ID == FavoritesPseudoProject.ID { owner, err := user.GetFromAuth(a) @@ -118,6 +131,10 @@ func (p *Project) CanUpdate(s *xorm.Session, a web.Auth) (canUpdate bool, err er return false, nil } + if isInstanceAdmin(s, a) { + return true, nil + } + fid := GetSavedFilterIDFromProjectID(p.ID) if fid > 0 { sf, err := GetSavedFilterSimpleByID(s, fid) @@ -165,11 +182,17 @@ func (p *Project) CanUpdate(s *xorm.Session, a web.Auth) (canUpdate bool, err er // CanDelete checks if the user can delete a project func (p *Project) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + if isInstanceAdmin(s, a) { + return true, nil + } return p.IsAdmin(s, a) } // CanCreate checks if the user can create a project func (p *Project) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + if isInstanceAdmin(s, a) { + return true, nil + } if p.ParentProjectID != 0 { parent := &Project{ID: p.ParentProjectID} return parent.CanWrite(s, a) @@ -189,6 +212,10 @@ func (p *Project) IsAdmin(s *xorm.Session, a web.Auth) (bool, error) { return false, nil } + if isInstanceAdmin(s, a) { + return true, nil + } + originalProject, err := GetProjectSimpleByID(s, p.ID) if err != nil { return false, err diff --git a/pkg/models/project_team_permissions.go b/pkg/models/project_team_permissions.go index 3796a62c5..51d62e8f5 100644 --- a/pkg/models/project_team_permissions.go +++ b/pkg/models/project_team_permissions.go @@ -42,6 +42,10 @@ func (tl *TeamProject) canDoTeamProject(s *xorm.Session, a web.Auth) (bool, erro return false, nil } + if isInstanceAdmin(s, a) { + return true, nil + } + l := Project{ID: tl.ProjectID} return l.IsAdmin(s, a) } diff --git a/pkg/models/project_users_permissions.go b/pkg/models/project_users_permissions.go index 92d04d66b..9f9e280dc 100644 --- a/pkg/models/project_users_permissions.go +++ b/pkg/models/project_users_permissions.go @@ -42,6 +42,10 @@ func (lu *ProjectUser) canDoProjectUser(s *xorm.Session, a web.Auth) (bool, erro return false, nil } + if isInstanceAdmin(s, a) { + return true, nil + } + // Get the project and check if the user has write access on it l := Project{ID: lu.ProjectID} return l.IsAdmin(s, a) diff --git a/pkg/models/project_view_permissions.go b/pkg/models/project_view_permissions.go index 3369ea3f7..0aa71fe5e 100644 --- a/pkg/models/project_view_permissions.go +++ b/pkg/models/project_view_permissions.go @@ -22,6 +22,9 @@ import ( ) func (pv *ProjectView) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + if isInstanceAdmin(s, a) { + return true, int(PermissionAdmin), nil + } filterID := GetSavedFilterIDFromProjectID(pv.ProjectID) if filterID > 0 { sf := &SavedFilter{ID: filterID} @@ -33,6 +36,9 @@ func (pv *ProjectView) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { } func (pv *ProjectView) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { + if isInstanceAdmin(s, a) { + return true, nil + } filterID := GetSavedFilterIDFromProjectID(pv.ProjectID) if filterID > 0 { sf := &SavedFilter{ID: filterID} @@ -44,6 +50,9 @@ func (pv *ProjectView) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { } func (pv *ProjectView) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { + if isInstanceAdmin(s, a) { + return true, nil + } filterID := GetSavedFilterIDFromProjectID(pv.ProjectID) if filterID > 0 { sf := &SavedFilter{ID: filterID} @@ -55,6 +64,9 @@ func (pv *ProjectView) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { } func (pv *ProjectView) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { + if isInstanceAdmin(s, a) { + return true, nil + } filterID := GetSavedFilterIDFromProjectID(pv.ProjectID) if filterID > 0 { sf := &SavedFilter{ID: filterID} diff --git a/pkg/models/team_members_permissions.go b/pkg/models/team_members_permissions.go index 671210a73..36ff8b786 100644 --- a/pkg/models/team_members_permissions.go +++ b/pkg/models/team_members_permissions.go @@ -51,6 +51,10 @@ func (tm *TeamMember) IsAdmin(s *xorm.Session, a web.Auth) (bool, error) { return false, nil } + if isInstanceAdmin(s, a) { + return true, nil + } + // A user can add a member to a team if he is admin of that team exists, err := s. Where("user_id = ? AND team_id = ? AND admin = ?", a.GetID(), tm.TeamID, true). diff --git a/pkg/models/teams_permissions.go b/pkg/models/teams_permissions.go index b6b8f61a3..d40c0dd21 100644 --- a/pkg/models/teams_permissions.go +++ b/pkg/models/teams_permissions.go @@ -54,6 +54,10 @@ func (t *Team) IsAdmin(s *xorm.Session, a web.Auth) (bool, error) { return false, err } + if isInstanceAdmin(s, a) { + return true, nil + } + return s.Where("team_id = ?", t.ID). And("user_id = ?", a.GetID()). And("admin = ?", true). @@ -62,6 +66,9 @@ func (t *Team) IsAdmin(s *xorm.Session, a web.Auth) (bool, error) { // CanRead returns true if the user has read access to the team func (t *Team) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { + if isInstanceAdmin(s, a) { + return true, int(PermissionAdmin), nil + } // Check if the user is in the team tm := &TeamMember{} can, err := s.