feat(veans): discover /routes for permission-group negotiation

This commit is contained in:
Tink bot 2026-05-26 22:39:18 +02:00 committed by kolaente
parent 1f5abaa6fb
commit d2c3f3244d
2 changed files with 111 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}
}