diff --git a/veans/internal/client/routes.go b/veans/internal/client/routes.go new file mode 100644 index 000000000..a30c7f960 --- /dev/null +++ b/veans/internal/client/routes.go @@ -0,0 +1,64 @@ +package client + +import "context" + +// RouteGroup mirrors models.APITokenRoute on the wire — the per-action +// detail object is opaque to us. +type RouteGroup map[string]struct { + Path string `json:"path"` + Method string `json:"method"` +} + +// Routes returns the API token route map. Used during bootstrap to +// negotiate exactly which permission groups+actions exist on this Vikunja +// instance, so the bot's API token only requests scopes the server knows +// about — avoiding hard-coding a permission list that could drift. +func (c *Client) Routes(ctx context.Context) (map[string]RouteGroup, error) { + out := map[string]RouteGroup{} + if err := c.Do(ctx, "GET", "/routes", nil, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// PermissionsForBot picks a curated subset of route groups the veans bot +// needs and projects the available actions of each. Groups not present on +// the server are silently dropped, so the resulting permission map is +// always valid for PUT /tokens regardless of Vikunja version. +// +// The set is intentionally tasks-centric — the bot doesn't need to manage +// users, teams, or webhooks. We grant `read_one`/`read_all` on projects so +// the bot can resolve PROJ-NN and #NN identifiers, but no project mutation. +func PermissionsForBot(routes map[string]RouteGroup) map[string][]string { + wanted := map[string][]string{ + "tasks": {"read_one", "read_all", "create", "update", "delete"}, + "projects": {"read_one", "read_all"}, + "projects_views": {"read_one", "read_all"}, + "buckets": {"read_one", "read_all", "create", "update", "delete"}, + "labels": {"read_one", "read_all", "create", "update", "delete"}, + "comments": {"read_one", "read_all", "create", "update", "delete"}, + "tasks_comments": {"read_one", "read_all", "create", "update", "delete"}, + "relations": {"create", "delete"}, + "tasks_relations": {"create", "delete"}, + "assignees": {"read_all", "create", "delete"}, + "tasks_assignees": {"read_all", "create", "delete"}, + "tasks_labels": {"create", "delete", "read_all"}, + } + out := map[string][]string{} + for group, actions := range wanted { + avail, ok := routes[group] + if !ok { + continue + } + var picked []string + for _, a := range actions { + if _, has := avail[a]; has { + picked = append(picked, a) + } + } + if len(picked) > 0 { + out[group] = picked + } + } + return out +} diff --git a/veans/internal/client/routes_test.go b/veans/internal/client/routes_test.go new file mode 100644 index 000000000..168a25018 --- /dev/null +++ b/veans/internal/client/routes_test.go @@ -0,0 +1,47 @@ +package client + +import "testing" + +func TestPermissionsForBot_DropsUnknownGroups(t *testing.T) { + // Server only exposes a subset of what we ask for. + server := map[string]RouteGroup{ + "tasks": { + "read_one": {}, + "read_all": {}, + "create": {}, + "update": {}, + // "delete" intentionally absent + }, + "projects": { + "read_one": {}, + "read_all": {}, + }, + // no "labels", no "comments", etc. + } + got := PermissionsForBot(server) + + if _, ok := got["tasks"]; !ok { + t.Fatalf("expected tasks group in result") + } + for _, a := range got["tasks"] { + if a == "delete" { + t.Errorf("delete should have been dropped") + } + } + if _, ok := got["projects"]; !ok { + t.Fatalf("expected projects group") + } + if _, ok := got["labels"]; ok { + t.Errorf("labels was not on server, should not appear in result") + } + if _, ok := got["nonexistent_group"]; ok { + t.Errorf("phantom group leaked into result") + } +} + +func TestPermissionsForBot_EmptyWhenServerIsEmpty(t *testing.T) { + got := PermissionsForBot(map[string]RouteGroup{}) + if len(got) != 0 { + t.Fatalf("expected empty map, got %v", got) + } +}