Files
vikunja/pkg/models/api_routes.go
kolaente 9e880e98a5 fix(api): export api-token permission groups in snake_case
The api-token permission group key is derived from the route slug. Every
group is snake_case except "time-entries", whose URL slug carries a hyphen.
The frontend snake_cases request payloads, rewriting that group key to
"time_entries", which the backend then rejected — so a token granted the
Time Entries scope could not be saved.

Canonicalise group and path-segment names to snake_case where they are
derived, and normalise the group key on token validation and authorisation
so any token stored under the old hyphenated key keeps resolving. No data
migration is needed: the v2 time-entries resource has never shipped in a
release.
2026-06-27 15:01:54 +00:00

480 lines
15 KiB
Go

// 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 <https://www.gnu.org/licenses/>.
package models
import (
"net/http"
"strings"
"code.vikunja.io/api/pkg/log"
"github.com/labstack/echo/v5"
)
var apiTokenRoutes = map[string]APITokenRoute{}
// apiTokenRoutesV2 holds /api/v2 routes under the same (group, permission)
// keys as v1, so a token granted e.g. labels.read_one authorises both
// versions. CanDoAPIRoute consults both tables; GetAPITokenRoutes (the /routes
// exposure the frontend reads) merges v2-only groups so they're discoverable.
var apiTokenRoutesV2 = map[string]APITokenRoute{}
func init() {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
apiTokenRoutes["caldav"] = APITokenRoute{
"access": &RouteDetail{
Path: "/dav/*",
Method: "ANY",
},
}
apiTokenRoutes["feeds"] = APITokenRoute{
"access": &RouteDetail{
Path: "/feeds/*",
Method: "GET",
},
}
}
type APITokenRoute map[string]*RouteDetail
type RouteDetail struct {
Path string `json:"path"`
Method string `json:"method"`
}
// isV2Path reports whether the given route path lives under /api/v2.
func isV2Path(path string) bool {
return strings.HasPrefix(path, "/api/v2/") || path == "/api/v2"
}
// stripAPIVersion removes the /api/v1/ or /api/v2/ prefix so both
// versions normalise to the same token-permission group name.
func stripAPIVersion(path string) string {
if stripped := strings.TrimPrefix(path, "/api/v1/"); stripped != path {
return stripped
}
if stripped := strings.TrimPrefix(path, "/api/v2/"); stripped != path {
return stripped
}
return path
}
// canonicalAPITokenGroup snake_cases a permission group name. The frontend
// snake_cases request payloads, so a hyphenated group slug (e.g. from
// /api/v2/time-entries) can't round-trip and fails validation on save.
func canonicalAPITokenGroup(group string) string {
return strings.ReplaceAll(group, "-", "_")
}
func getRouteGroupName(path string) (finalName string, filteredParts []string) {
parts := strings.Split(stripAPIVersion(path), "/")
filteredParts = []string{}
for _, part := range parts {
if strings.HasPrefix(part, ":") {
continue
}
filteredParts = append(filteredParts, canonicalAPITokenGroup(part))
}
finalName = strings.Join(filteredParts, "_")
switch finalName {
case "projects_tasks":
fallthrough
case "tasks_all":
return "tasks", []string{"tasks"}
default:
return finalName, filteredParts
}
}
// getRouteDetail determines the API permission type from the route's HTTP method and path.
// In Echo v5, route.Name is auto-generated as METHOD:PATH, so we derive permissions from
// the HTTP method and path structure instead of the handler function name.
//
// v1 and v2 have inverted create/update verbs: v1 uses PUT for create and POST
// for update, v2 follows REST conventions (POST create, PUT/PATCH update).
func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
detail = &RouteDetail{
Path: route.Path,
Method: route.Method,
}
// Check if path ends with a parameter (e.g., /:id, /:task, /:project)
pathParts := strings.Split(route.Path, "/")
lastPart := ""
if len(pathParts) > 0 {
lastPart = pathParts[len(pathParts)-1]
}
endsWithParam := strings.HasPrefix(lastPart, ":")
v2 := isV2Path(route.Path)
switch route.Method {
case http.MethodGet:
if endsWithParam {
return "read_one", detail
}
return "read_all", detail
case http.MethodPut:
if v2 {
// v2: PUT replaces an existing resource → update.
return "update", detail
}
// v1: PUT is used for creating resources.
return "create", detail
case http.MethodPost:
if v2 {
// v2: POST creates a new resource on the collection.
return "create", detail
}
// v1: POST is used for updating resources.
return "update", detail
case http.MethodPatch:
// Both versions use PATCH for partial updates.
return "update", detail
case http.MethodDelete:
return "delete", detail
}
return "", detail
}
func ensureAPITokenRoutesGroup(target map[string]APITokenRoute, group string) {
if _, has := target[group]; !has {
target[group] = make(APITokenRoute)
}
}
// isStandardCRUDRoute checks if a route follows the standard CRUD pattern.
// In Echo v5, route.Name is auto-generated as METHOD:PATH, so we can no longer
// check for "(*WebHandler)" in the name. Instead, we identify CRUD routes by:
// 1. Path structure: simple /resource or /resource/:param patterns
// 2. HTTP method: GET, PUT, POST, DELETE matching CRUD semantics
//
// Standard CRUD routes have paths like:
// - /projects, /tasks, /teams, /labels, /notifications, /webhooks, /filters, etc.
// - /projects/:project, /tasks/:task, /teams/:team, etc.
//
// Non-CRUD routes have paths with additional segments or special paths like:
// - /user/settings/email, /projects/:project/background, /backgrounds/unsplash/search
func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) bool {
// Standard CRUD resource groups that follow the WebHandler pattern
crudResources := map[string]bool{
"projects": true,
"tasks": true,
"teams": true,
"labels": true,
"filters": true,
"notifications": true,
"webhooks": true,
"reactions": true,
"shares": true,
"buckets": true,
"views": true,
"assignees": true,
"comments": true,
"relations": true,
"attachments": true,
"time_entries": true,
"projects_views": true,
"projects_teams": true,
"projects_users": true,
"projects_shares": true,
"projects_webhooks": true,
"projects_buckets": true,
"tasks_attachments": true,
"tasks_assignees": true,
"tasks_labels": true,
"tasks_comments": true,
"tasks_relations": true,
"teams_members": true,
"projects_views_tasks": true,
}
// Check if this is a standard CRUD resource
if crudResources[routeGroupName] {
return true
}
// Check if this is a bulk variant of a known CRUD resource
if strings.HasSuffix(routeGroupName, "_bulk") {
parent := strings.TrimSuffix(routeGroupName, "_bulk")
if crudResources[parent] {
return true
}
}
// Also check the base resource for nested paths
if len(routeParts) > 0 && crudResources[routeParts[0]] {
// For single-segment paths, it's CRUD if it's a known resource
if len(routeParts) == 1 {
return true
}
}
return false
}
// CollectRoutesForAPITokenUsage records a route for token authorisation.
// v1 and v2 share group/permission keys derived from the prefix-stripped
// path; v2 entries land in apiTokenRoutesV2 so the v1-only frontend UI is
// unchanged while CanDoAPIRoute consults both tables.
func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
if route.Method == "echo_route_not_found" {
return
}
if !requiresJWT {
return
}
routeGroupName, routeParts := getRouteGroupName(route.Path)
if routeGroupName == "token_test" ||
routeGroupName == "subscriptions" ||
routeGroupName == "tokens" ||
routeGroupName == "*" ||
strings.HasPrefix(routeGroupName, "user_") {
return
}
target := apiTokenRoutes
if isV2Path(route.Path) {
target = apiTokenRoutesV2
// AutoPatch's synthesised PATCH and the original PUT both derive the
// "update" permission and would clobber each other on the map. Store
// only PUT; CanDoAPIRoute accepts PATCH as its alias on the same path.
if route.Method == http.MethodPatch {
return
}
}
// Check if this is a standard CRUD route using path-based heuristics
// In Echo v5, we can no longer rely on route.Name containing "(*WebHandler)"
isCRUD := isStandardCRUDRoute(routeGroupName, routeParts, route.Method)
// Special case for task attachments which use custom handlers
isAttachmentRoute := routeGroupName == "tasks_attachments"
if !isCRUD && !isAttachmentRoute {
routeDetail := &RouteDetail{
Path: route.Path,
Method: route.Method,
}
// We're trying to add routes to the routes of a matching "parent" - for
// example, projects_background should show up under "projects".
// To do this, we check if the route is a sub route of some other route
// and if that's the case, add it to its parent instead.
// Otherwise, we add it to the "other" key.
if len(routeParts) == 1 {
if routeGroupName == "notifications" && route.Method == http.MethodPost {
ensureAPITokenRoutesGroup(target, "notifications")
target["notifications"]["mark_all_as_read"] = routeDetail
return
}
ensureAPITokenRoutesGroup(target, "other")
_, exists := target["other"][routeGroupName]
if exists {
routeGroupName += "_" + strings.ToLower(route.Method)
}
target["other"][routeGroupName] = routeDetail
return
}
subkey := strings.Join(routeParts[1:], "_")
if _, has := target[routeParts[0]]; !has {
target[routeParts[0]] = make(APITokenRoute)
}
if _, has := target[routeParts[0]][subkey]; has {
subkey += "_" + strings.ToLower(route.Method)
}
target[routeParts[0]][subkey] = routeDetail
return
}
if strings.HasSuffix(routeGroupName, "_bulk") {
parent := strings.TrimSuffix(routeGroupName, "_bulk")
ensureAPITokenRoutesGroup(target, parent)
method, routeDetail := getRouteDetail(route)
target[parent][method+"_bulk"] = routeDetail
return
}
_, has := target[routeGroupName]
if !has {
target[routeGroupName] = make(APITokenRoute)
}
method, routeDetail := getRouteDetail(route)
if method != "" {
target[routeGroupName][method] = routeDetail
}
// Handle task attachments specially - they use custom handlers not WebHandler
if routeGroupName == "tasks_attachments" {
// PUT is upload (create), GET with :attachment param is download (read_one)
if route.Method == http.MethodPut {
target[routeGroupName]["create"] = &RouteDetail{
Path: route.Path,
Method: route.Method,
}
}
if route.Method == http.MethodGet && strings.HasSuffix(route.Path, ":attachment") {
target[routeGroupName]["read_one"] = &RouteDetail{
Path: route.Path,
Method: route.Method,
}
}
}
}
// GetAPITokenRoutes exposes the registered scoped-token routes for the /routes
// handler and tests. v1 is the base; v2-only groups and permissions (a v2-only
// resource like time-entries has no v1 counterpart) are merged in so tokens can
// discover and grant them. Shared (group, permission) keys keep their v1 entry —
// CanDoAPIRoute authorises both versions off the same key regardless.
func GetAPITokenRoutes() map[string]APITokenRoute {
merged := make(map[string]APITokenRoute, len(apiTokenRoutes))
for group, perms := range apiTokenRoutes {
merged[group] = make(APITokenRoute, len(perms))
for perm, rd := range perms {
merged[group][perm] = rd
}
}
for group, perms := range apiTokenRoutesV2 {
if merged[group] == nil {
merged[group] = make(APITokenRoute)
}
for perm, rd := range perms {
if merged[group][perm] == nil {
merged[group][perm] = rd
}
}
}
return merged
}
// GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage.
// @Summary Get a list of all token api routes
// @Description Returns a list of all API routes which are available to use with an api token, not a user login.
// @tags api
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {array} models.APITokenRoute "The list of all routes."
// @Router /routes [get]
func GetAvailableAPIRoutesForToken(c *echo.Context) error {
return c.JSON(http.StatusOK, GetAPITokenRoutes())
}
// CanDoAPIRoute checks if a token is allowed to use the current api route.
//
// Each permission is authoritative: the request is allowed only if the
// stored (Path, Method) for that permission matches exactly. This closes
// GHSA-v479-vf79-mg83 and the wider method/sub-resource confusion it
// enabled. The one exception is the tasks.read_all quirk handled below.
// One (group, permission) pair can legitimately match both v1 and v2
// routes; we walk apiTokenRoutes and apiTokenRoutesV2 in turn. On v2,
// PATCH is accepted as an alias for the stored PUT on the same path
// (AutoPatch collapses both onto the "update" permission).
func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
path := c.Path()
if path == "" {
// c.Path() is empty during testing, but returns the path which
// the route used during registration which is what we need.
path = c.Request().URL.Path
}
method := c.Request().Method
for rawGroup, perms := range token.APIPermissions {
group := canonicalAPITokenGroup(rawGroup)
tables := []APITokenRoute{apiTokenRoutes[group], apiTokenRoutesV2[group]}
for _, routes := range tables {
if routes == nil {
continue
}
for _, p := range perms {
rd := routes[p]
if rd == nil {
continue
}
if rd.Method == method && rd.Path == path {
return true
}
// v2: AutoPatch mirrors every PUT as a PATCH on the same
// path. PATCH isn't stored (it would clobber PUT under
// the same "update" key), so accept it as an alias here.
if isV2Path(rd.Path) && rd.Method == http.MethodPut &&
method == http.MethodPatch && rd.Path == path {
return true
}
// Two list endpoints share tasks.read_all but only one
// survives collection, so allow either explicitly.
if group == "tasks" && p == "read_all" && method == http.MethodGet &&
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks" ||
path == "/api/v2/tasks" || path == "/api/v2/projects/:project/tasks") {
return true
}
}
}
}
log.Debugf("[auth] Token %d tried to use route %s %s which is not covered by its permissions %v",
token.ID, method, path, token.APIPermissions)
return false
}
func PermissionsAreValid(permissions APIPermissions) (err error) {
for key, methods := range permissions {
// A permission is valid if the group exists in either table. v2-only
// resources (no v1 counterpart) live solely in apiTokenRoutesV2, so
// validating against the union lets tokens grant them. CanDoAPIRoute
// already consults both tables when authorising.
group := canonicalAPITokenGroup(key)
v1Routes := apiTokenRoutes[group]
v2Routes := apiTokenRoutesV2[group]
if v1Routes == nil && v2Routes == nil {
return &ErrInvalidAPITokenPermission{
Group: key,
}
}
for _, method := range methods {
if v1Routes[method] == nil && v2Routes[method] == nil {
return &ErrInvalidAPITokenPermission{
Group: key,
Permission: method,
}
}
}
}
return nil
}