From 99a67e09b14f40ffddf7761da69275349a196541 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 3 Jun 2024 21:35:09 +0200 Subject: [PATCH] feat(api): all usable routes behind authentication now have permissions Previously, only routes which were coming from crudable entities could be used with an api token because there was no way to assign permissions to them. This change implements a more flexible structure for api permissions under the hood, allowing to add permissions for these routes and making them usable with an api token. Resolves https://github.com/go-vikunja/vikunja/issues/266 --- .../src/views/user/settings/ApiTokens.vue | 12 +- pkg/models/api_routes.go | 154 +++++++++++------- pkg/routes/routes.go | 4 +- 3 files changed, 106 insertions(+), 64 deletions(-) diff --git a/frontend/src/views/user/settings/ApiTokens.vue b/frontend/src/views/user/settings/ApiTokens.vue index a379317f9..963f8bf16 100644 --- a/frontend/src/views/user/settings/ApiTokens.vue +++ b/frontend/src/views/user/settings/ApiTokens.vue @@ -47,7 +47,17 @@ const flatPickerConfig = computed(() => ({ onMounted(async () => { tokens.value = await service.getAll() - availableRoutes.value = await service.getAvailableRoutes() + const allRoutes = await service.getAvailableRoutes() + + const routesAvailable = {} + const keys = Object.keys(allRoutes) + keys.sort((a, b) => (a === 'other' ? 1 : b === 'other' ? -1 : 0)) + keys.forEach(key => { + routesAvailable[key] = allRoutes[key] + }) + + availableRoutes.value = routesAvailable + resetPermissions() }) diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index 211c81251..6917d18ff 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -18,6 +18,8 @@ package models import ( "net/http" + "reflect" + "runtime" "strings" "code.vikunja.io/api/pkg/log" @@ -25,28 +27,22 @@ import ( "github.com/labstack/echo/v4" ) -var apiTokenRoutes = map[string]*APITokenRoute{} +var apiTokenRoutes = map[string]APITokenRoute{} func init() { - apiTokenRoutes = make(map[string]*APITokenRoute) + apiTokenRoutes = make(map[string]APITokenRoute) } -type APITokenRoute struct { - Create *RouteDetail `json:"create,omitempty"` - ReadOne *RouteDetail `json:"read_one,omitempty"` - ReadAll *RouteDetail `json:"read_all,omitempty"` - Update *RouteDetail `json:"update,omitempty"` - Delete *RouteDetail `json:"delete,omitempty"` -} +type APITokenRoute map[string]*RouteDetail type RouteDetail struct { Path string `json:"path"` Method string `json:"method"` } -func getRouteGroupName(path string) string { +func getRouteGroupName(path string) (finalName string, filteredParts []string) { parts := strings.Split(strings.TrimPrefix(path, "/api/v1/"), "/") - filteredParts := []string{} + filteredParts = []string{} for _, part := range parts { if strings.HasPrefix(part, ":") { continue @@ -55,63 +51,116 @@ func getRouteGroupName(path string) string { filteredParts = append(filteredParts, part) } - finalName := strings.Join(filteredParts, "_") + finalName = strings.Join(filteredParts, "_") switch finalName { case "projects_tasks": fallthrough case "tasks_all": - return "tasks" + return "tasks", []string{"tasks"} default: - return finalName + return finalName, filteredParts } } // CollectRoutesForAPITokenUsage gets called for every added APITokenRoute and builds a list of all routes we can use for the api tokens. -func CollectRoutesForAPITokenUsage(route echo.Route) { +func CollectRoutesForAPITokenUsage(route echo.Route, middlewares []echo.MiddlewareFunc) { - if !strings.Contains(route.Name, "(*WebHandler)") && !strings.Contains(route.Name, "Attachment") { + if route.Method == "echo_route_not_found" { return } - routeGroupName := getRouteGroupName(route.Path) + seenJWT := false + for _, middleware := range middlewares { + if strings.Contains(runtime.FuncForPC(reflect.ValueOf(middleware).Pointer()).Name(), "github.com/labstack/echo-jwt/") { + seenJWT = true + } + } - if routeGroupName == "subscriptions" || + if !seenJWT { + return + } + + routeGroupName, routeParts := getRouteGroupName(route.Path) + + if routeGroupName == "user" || + routeGroupName == "tokenTest" || + routeGroupName == "subscriptions" || routeGroupName == "tokens" || + routeGroupName == "*" || + strings.HasPrefix(routeGroupName, "user_") || strings.HasSuffix(routeGroupName, "_bulk") { return } + if !strings.Contains(route.Name, "(*WebHandler)") && !strings.Contains(route.Name, "Attachment") { + 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 _, has := apiTokenRoutes["other"]; !has { + apiTokenRoutes["other"] = make(APITokenRoute) + } + + _, exists := apiTokenRoutes["other"][routeGroupName] + if exists { + routeGroupName += "_" + strings.ToLower(route.Method) + } + apiTokenRoutes["other"][routeGroupName] = routeDetail + return + } + + subkey := strings.Join(routeParts[1:], "_") + + if _, has := apiTokenRoutes[routeParts[0]]; !has { + apiTokenRoutes[routeParts[0]] = make(APITokenRoute) + } + + if _, has := apiTokenRoutes[routeParts[0]][subkey]; has { + subkey += "_" + strings.ToLower(route.Method) + } + + apiTokenRoutes[routeParts[0]][subkey] = routeDetail + + return + } + _, has := apiTokenRoutes[routeGroupName] if !has { - apiTokenRoutes[routeGroupName] = &APITokenRoute{} + apiTokenRoutes[routeGroupName] = make(APITokenRoute) } if strings.Contains(route.Name, "CreateWeb") { - apiTokenRoutes[routeGroupName].Create = &RouteDetail{ + apiTokenRoutes[routeGroupName]["create"] = &RouteDetail{ Path: route.Path, Method: route.Method, } } if strings.Contains(route.Name, "ReadOneWeb") { - apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{ + apiTokenRoutes[routeGroupName]["read_one"] = &RouteDetail{ Path: route.Path, Method: route.Method, } } if strings.Contains(route.Name, "ReadAllWeb") { - apiTokenRoutes[routeGroupName].ReadAll = &RouteDetail{ + apiTokenRoutes[routeGroupName]["read_all"] = &RouteDetail{ Path: route.Path, Method: route.Method, } } if strings.Contains(route.Name, "UpdateWeb") { - apiTokenRoutes[routeGroupName].Update = &RouteDetail{ + apiTokenRoutes[routeGroupName]["update"] = &RouteDetail{ Path: route.Path, Method: route.Method, } } if strings.Contains(route.Name, "DeleteWeb") { - apiTokenRoutes[routeGroupName].Delete = &RouteDetail{ + apiTokenRoutes[routeGroupName]["delete"] = &RouteDetail{ Path: route.Path, Method: route.Method, } @@ -119,13 +168,13 @@ func CollectRoutesForAPITokenUsage(route echo.Route) { if routeGroupName == "tasks_attachments" { if strings.Contains(route.Name, "UploadTaskAttachment") { - apiTokenRoutes[routeGroupName].Create = &RouteDetail{ + apiTokenRoutes[routeGroupName]["create"] = &RouteDetail{ Path: route.Path, Method: route.Method, } } if strings.Contains(route.Name, "GetTaskAttachment") { - apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{ + apiTokenRoutes[routeGroupName]["read_one"] = &RouteDetail{ Path: route.Path, Method: route.Method, } @@ -149,37 +198,44 @@ func GetAvailableAPIRoutesForToken(c echo.Context) error { 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. + // 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 } - routeGroupName := getRouteGroupName(path) + routeGroupName, routeParts := getRouteGroupName(path) group, hasGroup := token.Permissions[routeGroupName] if !hasGroup { - return false + group, hasGroup = token.Permissions[routeParts[0]] + if !hasGroup { + return false + } } var route string routes, has := apiTokenRoutes[routeGroupName] if !has { - return false + routes, has = apiTokenRoutes[routeParts[0]] + if !has { + return false + } + route = strings.Join(routeParts[1:], "_") } - if routes.Create != nil && routes.Create.Path == path && routes.Create.Method == c.Request().Method { + if routes["create"] != nil && routes["create"].Path == path && routes["create"].Method == c.Request().Method { route = "create" } - if routes.ReadOne != nil && routes.ReadOne.Path == path && routes.ReadOne.Method == c.Request().Method { + if routes["read_one"] != nil && routes["read_one"].Path == path && routes["read_one"].Method == c.Request().Method { route = "read_one" } - if routes.ReadAll != nil && routes.ReadAll.Path == path && routes.ReadAll.Method == c.Request().Method { + if routes["read_all"] != nil && routes["read_all"].Path == path && routes["read_all"].Method == c.Request().Method { route = "read_all" } - if routes.Update != nil && routes.Update.Path == path && routes.Update.Method == c.Request().Method { + if routes["update"] != nil && routes["update"].Path == path && routes["update"].Method == c.Request().Method { route = "update" } - if routes.Delete != nil && routes.Delete.Path == path && routes.Delete.Method == c.Request().Method { + if routes["delete"] != nil && routes["delete"].Path == path && routes["delete"].Method == c.Request().Method { route = "delete" } @@ -210,31 +266,7 @@ func PermissionsAreValid(permissions APIPermissions) (err error) { } for _, method := range methods { - if method == "create" && routes.Create == nil { - return &ErrInvalidAPITokenPermission{ - Group: key, - Permission: method, - } - } - if method == "read_one" && routes.ReadOne == nil { - return &ErrInvalidAPITokenPermission{ - Group: key, - Permission: method, - } - } - if method == "read_all" && routes.ReadAll == nil { - return &ErrInvalidAPITokenPermission{ - Group: key, - Permission: method, - } - } - if method == "update" && routes.Update == nil { - return &ErrInvalidAPITokenPermission{ - Group: key, - Permission: method, - } - } - if method == "delete" && routes.Delete == nil { + if routes[method] == nil { return &ErrInvalidAPITokenPermission{ Group: key, Permission: method, diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 429a3f3f0..09e5a7955 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -211,8 +211,8 @@ func RegisterRoutes(e *echo.Echo) { // API Routes a := e.Group("/api/v1") - e.OnAddRouteHandler = func(_ string, route echo.Route, _ echo.HandlerFunc, _ []echo.MiddlewareFunc) { - models.CollectRoutesForAPITokenUsage(route) + e.OnAddRouteHandler = func(_ string, route echo.Route, _ echo.HandlerFunc, middlewares []echo.MiddlewareFunc) { + models.CollectRoutesForAPITokenUsage(route, middlewares) } registerAPIRoutes(a) }