1
0

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
This commit is contained in:
kolaente 2024-06-03 21:35:09 +02:00
parent 5ef140fba2
commit 99a67e09b1
No known key found for this signature in database
GPG Key ID: F40E70337AB24C9B
3 changed files with 106 additions and 64 deletions

View File

@ -47,7 +47,17 @@ const flatPickerConfig = computed(() => ({
onMounted(async () => { onMounted(async () => {
tokens.value = await service.getAll() 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() resetPermissions()
}) })

View File

@ -18,6 +18,8 @@ package models
import ( import (
"net/http" "net/http"
"reflect"
"runtime"
"strings" "strings"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
@ -25,28 +27,22 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
var apiTokenRoutes = map[string]*APITokenRoute{} var apiTokenRoutes = map[string]APITokenRoute{}
func init() { func init() {
apiTokenRoutes = make(map[string]*APITokenRoute) apiTokenRoutes = make(map[string]APITokenRoute)
} }
type APITokenRoute struct { type APITokenRoute map[string]*RouteDetail
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 RouteDetail struct { type RouteDetail struct {
Path string `json:"path"` Path string `json:"path"`
Method string `json:"method"` 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/"), "/") parts := strings.Split(strings.TrimPrefix(path, "/api/v1/"), "/")
filteredParts := []string{} filteredParts = []string{}
for _, part := range parts { for _, part := range parts {
if strings.HasPrefix(part, ":") { if strings.HasPrefix(part, ":") {
continue continue
@ -55,63 +51,116 @@ func getRouteGroupName(path string) string {
filteredParts = append(filteredParts, part) filteredParts = append(filteredParts, part)
} }
finalName := strings.Join(filteredParts, "_") finalName = strings.Join(filteredParts, "_")
switch finalName { switch finalName {
case "projects_tasks": case "projects_tasks":
fallthrough fallthrough
case "tasks_all": case "tasks_all":
return "tasks" return "tasks", []string{"tasks"}
default: 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. // 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 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 == "tokens" ||
routeGroupName == "*" ||
strings.HasPrefix(routeGroupName, "user_") ||
strings.HasSuffix(routeGroupName, "_bulk") { strings.HasSuffix(routeGroupName, "_bulk") {
return 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] _, has := apiTokenRoutes[routeGroupName]
if !has { if !has {
apiTokenRoutes[routeGroupName] = &APITokenRoute{} apiTokenRoutes[routeGroupName] = make(APITokenRoute)
} }
if strings.Contains(route.Name, "CreateWeb") { if strings.Contains(route.Name, "CreateWeb") {
apiTokenRoutes[routeGroupName].Create = &RouteDetail{ apiTokenRoutes[routeGroupName]["create"] = &RouteDetail{
Path: route.Path, Path: route.Path,
Method: route.Method, Method: route.Method,
} }
} }
if strings.Contains(route.Name, "ReadOneWeb") { if strings.Contains(route.Name, "ReadOneWeb") {
apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{ apiTokenRoutes[routeGroupName]["read_one"] = &RouteDetail{
Path: route.Path, Path: route.Path,
Method: route.Method, Method: route.Method,
} }
} }
if strings.Contains(route.Name, "ReadAllWeb") { if strings.Contains(route.Name, "ReadAllWeb") {
apiTokenRoutes[routeGroupName].ReadAll = &RouteDetail{ apiTokenRoutes[routeGroupName]["read_all"] = &RouteDetail{
Path: route.Path, Path: route.Path,
Method: route.Method, Method: route.Method,
} }
} }
if strings.Contains(route.Name, "UpdateWeb") { if strings.Contains(route.Name, "UpdateWeb") {
apiTokenRoutes[routeGroupName].Update = &RouteDetail{ apiTokenRoutes[routeGroupName]["update"] = &RouteDetail{
Path: route.Path, Path: route.Path,
Method: route.Method, Method: route.Method,
} }
} }
if strings.Contains(route.Name, "DeleteWeb") { if strings.Contains(route.Name, "DeleteWeb") {
apiTokenRoutes[routeGroupName].Delete = &RouteDetail{ apiTokenRoutes[routeGroupName]["delete"] = &RouteDetail{
Path: route.Path, Path: route.Path,
Method: route.Method, Method: route.Method,
} }
@ -119,13 +168,13 @@ func CollectRoutesForAPITokenUsage(route echo.Route) {
if routeGroupName == "tasks_attachments" { if routeGroupName == "tasks_attachments" {
if strings.Contains(route.Name, "UploadTaskAttachment") { if strings.Contains(route.Name, "UploadTaskAttachment") {
apiTokenRoutes[routeGroupName].Create = &RouteDetail{ apiTokenRoutes[routeGroupName]["create"] = &RouteDetail{
Path: route.Path, Path: route.Path,
Method: route.Method, Method: route.Method,
} }
} }
if strings.Contains(route.Name, "GetTaskAttachment") { if strings.Contains(route.Name, "GetTaskAttachment") {
apiTokenRoutes[routeGroupName].ReadOne = &RouteDetail{ apiTokenRoutes[routeGroupName]["read_one"] = &RouteDetail{
Path: route.Path, Path: route.Path,
Method: route.Method, Method: route.Method,
} }
@ -149,37 +198,44 @@ func GetAvailableAPIRoutesForToken(c echo.Context) error {
func CanDoAPIRoute(c echo.Context, token *APIToken) (can bool) { func CanDoAPIRoute(c echo.Context, token *APIToken) (can bool) {
path := c.Path() path := c.Path()
if path == "" { if path == "" {
// c.Path() is empty during testing, but returns the path which the route used during registration // c.Path() is empty during testing, but returns the path which
// which is what we need. // the route used during registration which is what we need.
path = c.Request().URL.Path path = c.Request().URL.Path
} }
routeGroupName := getRouteGroupName(path) routeGroupName, routeParts := getRouteGroupName(path)
group, hasGroup := token.Permissions[routeGroupName] group, hasGroup := token.Permissions[routeGroupName]
if !hasGroup { if !hasGroup {
return false group, hasGroup = token.Permissions[routeParts[0]]
if !hasGroup {
return false
}
} }
var route string var route string
routes, has := apiTokenRoutes[routeGroupName] routes, has := apiTokenRoutes[routeGroupName]
if !has { 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" 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" 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" 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" 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" route = "delete"
} }
@ -210,31 +266,7 @@ func PermissionsAreValid(permissions APIPermissions) (err error) {
} }
for _, method := range methods { for _, method := range methods {
if method == "create" && routes.Create == nil { if routes[method] == 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 {
return &ErrInvalidAPITokenPermission{ return &ErrInvalidAPITokenPermission{
Group: key, Group: key,
Permission: method, Permission: method,

View File

@ -211,8 +211,8 @@ func RegisterRoutes(e *echo.Echo) {
// API Routes // API Routes
a := e.Group("/api/v1") a := e.Group("/api/v1")
e.OnAddRouteHandler = func(_ string, route echo.Route, _ echo.HandlerFunc, _ []echo.MiddlewareFunc) { e.OnAddRouteHandler = func(_ string, route echo.Route, _ echo.HandlerFunc, middlewares []echo.MiddlewareFunc) {
models.CollectRoutesForAPITokenUsage(route) models.CollectRoutesForAPITokenUsage(route, middlewares)
} }
registerAPIRoutes(a) registerAPIRoutes(a)
} }