diff --git a/pkg/models/project.go b/pkg/models/project.go index c860ed0621..022f673a5f 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -38,52 +38,52 @@ import ( // Project represents a project of tasks type Project struct { // The unique, numeric id of this project. - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project" readOnly:"true" doc:"The unique, numeric id of this project."` // The title of the project. You'll see this in the overview. - Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"` + Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of the project. You'll see this in the overview."` // The description of the project. - Description string `xorm:"longtext null" json:"description"` + Description string `xorm:"longtext null" json:"description" doc:"The description of the project."` // The unique project short identifier. Used to build task identifiers. - Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10"` + Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10" doc:"The unique project short identifier. Used to build task identifiers (e.g. PROJ-123)."` // The hex color of this project - HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"` + HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The hex color of this project, without the leading #."` OwnerID int64 `xorm:"bigint INDEX not null" json:"-"` - ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"` + ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id" doc:"The id of the parent project. 0 if this is a top-level project."` ParentProject *Project `xorm:"-" json:"-"` // The user who created this project. - Owner *user.User `xorm:"-" json:"owner" valid:"-"` + Owner *user.User `xorm:"-" json:"owner" valid:"-" readOnly:"true" doc:"The user who owns this project. Set by the server; ignored on write."` // Whether a project is archived. - IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"` + IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived" doc:"Whether the project is archived. Archived projects are read-only."` // The id of the file this project has set as background BackgroundFileID int64 `xorm:"null" json:"-"` // Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /projects/{projectID}/background - BackgroundInformation interface{} `xorm:"-" json:"background_information"` + BackgroundInformation interface{} `xorm:"-" json:"background_information" readOnly:"true" doc:"Extra information about the background (e.g. attribution). When not null, the background is available at /projects/{projectID}/background."` // Contains a very small version of the project background to use as a blurry preview until the actual background is loaded. Check out https://blurha.sh/ to learn how it works. - BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash"` + BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash" readOnly:"true" doc:"A small BlurHash preview of the project background, shown until the real background loads. See https://blurha.sh/."` // True if a project is a favorite. Favorite projects show up in a separate parent project. This value depends on the user making the call to the api. - IsFavorite bool `xorm:"-" json:"is_favorite"` + IsFavorite bool `xorm:"-" json:"is_favorite" doc:"Whether the project is a favorite of the requesting user. This value is per-user and depends on who makes the call."` // The subscription status for the user reading this project. You can only read this property, use the subscription endpoints to modify it. // Will only returned when retreiving one project. - Subscription *Subscription `xorm:"-" json:"subscription,omitempty"` + Subscription *Subscription `xorm:"-" json:"subscription,omitempty" readOnly:"true" doc:"The requesting user's subscription status for this project. Read-only here; use the subscription endpoints to change it. Only returned when retrieving a single project."` // The position this project has when querying all projects. See the tasks.position property on how to use this. - Position float64 `xorm:"double null" json:"position"` + Position float64 `xorm:"double null" json:"position" doc:"The position of this project when listing all projects. See the tasks.position property for how positions work."` - Views []*ProjectView `xorm:"-" json:"views"` + Views []*ProjectView `xorm:"-" json:"views" readOnly:"true" doc:"The views configured for this project. Managed through the project view endpoints."` Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"` - MaxPermission Permission `xorm:"-" json:"max_permission"` + MaxPermission Permission `xorm:"-" json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this project (0 = read, 1 = read/write, 2 = admin)."` // A timestamp when this project was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this project was created. You cannot change this value."` // A timestamp when this project was last updated. You cannot change this value. - Updated time.Time `xorm:"updated not null" json:"updated"` + Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this project was last updated. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go new file mode 100644 index 0000000000..8e7b51d3d2 --- /dev/null +++ b/pkg/routes/api/v2/admin_projects.go @@ -0,0 +1,62 @@ +// 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 apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +type adminProjectListBody struct { + Body Paginated[*models.Project] +} + +// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler. +func RegisterAdminProjectRoutes(api huma.API) { + tags := []string{"admin"} + + Register(api, huma.Operation{ + OperationID: "admin-projects-list", + Summary: "List all projects (admin)", + Description: "Returns every project on the instance, including archived ones and projects the caller does not own. Restricted to instance admins on a licensed instance; unlicensed or non-admin callers get a 404, making the endpoint indistinguishable from one that is not registered.", + Method: http.MethodGet, + Path: "/admin/projects", + Tags: tags, + }, adminProjectsList) +} + +func adminProjectsList(ctx context.Context, in *ListParams) (*adminProjectListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.AdminProjectList{}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + items, ok := result.([]*models.Project) + if !ok { + return nil, fmt.Errorf("AdminProjectList.ReadAll returned unexpected type %T (expected []*models.Project)", result) + } + return &adminProjectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 5b00b13620..d95f7e7eff 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -376,6 +376,25 @@ func noStoreCacheControl() echo.MiddlewareFunc { } } +const v2AdminPathPrefix = "/api/v2/admin" + +// gateV2AdminRoutes reuses v1's RequireFeature/RequireInstanceAdmin gate (both +// 404-on-failure) as path-scoped middleware: splitting v2 into a gated Echo +// sub-group would split the Huma API and drop admin ops from the OpenAPI spec. +func gateV2AdminRoutes() echo.MiddlewareFunc { + feature := RequireFeature(license.FeatureAdminPanel) + admin := RequireInstanceAdmin() + return func(next echo.HandlerFunc) echo.HandlerFunc { + gated := feature(admin(next)) + return func(c *echo.Context) error { + if strings.HasPrefix(c.Request().URL.Path, v2AdminPathPrefix) { + return gated(c) + } + return next(c) + } + } +} + // registerAPIRoutesV2 wires the /api/v2 Echo group. Token middleware is // attached before any route so Huma's spec and Scalar docs share the // resource handlers' stack; unauthenticatedAPIPaths keeps them public. @@ -386,6 +405,9 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { // apply to v2 resource endpoints too. setupRateLimit(a, config.RateLimitKind.GetString()) setupMetricsMiddleware(a) + // Must come after rate limiting: the gate does a per-request admin DB read, + // so an unauthenticated flood to /api/v2/admin/* would otherwise be unbounded. + a.Use(gateV2AdminRoutes()) api := apiv2.NewAPI(e, a) @@ -396,6 +418,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { // Resource registrations. apiv2.RegisterLabelRoutes(api) apiv2.RegisterTaskDuplicateRoutes(api) + apiv2.RegisterAdminProjectRoutes(api) // AutoPatch must run AFTER all GET/PUT pairs are registered so it can // synthesize their PATCH counterparts. diff --git a/pkg/webtests/huma_admin_test.go b/pkg/webtests/huma_admin_test.go new file mode 100644 index 0000000000..36f25de154 --- /dev/null +++ b/pkg/webtests/huma_admin_test.go @@ -0,0 +1,103 @@ +// 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 webtests + +import ( + "encoding/json" + "net/http" + "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" +) + +// The error body shape is covered by TestHuma_ErrorShapeIsRFC9457; this test +// only asserts gate status codes (404 on failure, matching v1). +func TestHumaAdminProjects(t *testing.T) { + t.Run("non-admin user gets 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, 1) + require.NoError(t, err) + require.False(t, u.IsAdmin, "fixture precondition: user1 is not an admin") + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", u, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("admin without the feature gets 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // Empty feature set = licensed instance without the admin feature. + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", admin, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("admin with the feature sees every project", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + admin := promoteToAdmin(t, 1) + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", admin, "") + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + + var envelope struct { + Items []struct { + ID int64 `json:"id"` + } `json:"items"` + Total int64 `json:"total"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &envelope)) + + ids := make(map[int64]bool, len(envelope.Items)) + for _, item := range envelope.Items { + ids[item.ID] = true + } + // Project 6 (owned by user6, not shared with user1) proves the list ignores ownership. + assert.True(t, ids[6], "expected project 6 in the admin list, got items %v", ids) + // Project 22 is archived, proving the list includes archived projects. + assert.True(t, ids[22], "expected archived project 22 in the admin list, got items %v", ids) + }) + + t.Run("unauthenticated caller gets 401", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + license.SetForTests([]license.Feature{license.FeatureAdminPanel}) + defer license.ResetForTests() + + // The token middleware rejects with 401 before the gate runs, matching v1. + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", nil, "") + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) +}