Merge pull request 'Capability model: homelab.docker.restart instead of flat tool-to-enabled' (#146) from task/452-capability-model-homelab-docker-restart into master
This commit was merged in pull request #146.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
)
|
||||
|
||||
// Capability ids (Vikunja #452).
|
||||
//
|
||||
// A tool row is flat: one name, one scope, one enabled bit. Permission is
|
||||
// therefore per name, and nothing groups. Hexis has spoken dotted capability
|
||||
// ids since it existed, so the local surface was the odd one out — and the
|
||||
// flat shape gets expensive around fifteen rows, when "what can she do to the
|
||||
// house" stops being a question anyone can answer by reading a list.
|
||||
//
|
||||
// A capability id is scope.domain.action: homelab.docker.restart,
|
||||
// house.lock.unlock, mcp_vikunja.vikunja.delete_task.
|
||||
//
|
||||
// DERIVED, not stored, for the same reason the risk tier is (risk.go): a
|
||||
// derivation is one place to argue with, a column is whatever the last person
|
||||
// to enable the row happened to type. The name stays the primary key and
|
||||
// nothing about lookup or execution changes — this is a way to READ the
|
||||
// allowlist, not a second allowlist.
|
||||
type Capability struct {
|
||||
Scope string
|
||||
Domain string
|
||||
Action string
|
||||
}
|
||||
|
||||
// String renders the dotted id. An empty segment becomes "unknown" rather than
|
||||
// collapsing, so an id always has three parts and a prefix match cannot
|
||||
// accidentally widen.
|
||||
func (c Capability) String() string {
|
||||
return capSegment(c.Scope) + "." + capSegment(c.Domain) + "." + capSegment(c.Action)
|
||||
}
|
||||
|
||||
func capSegment(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, ".", "_")
|
||||
s = strings.ReplaceAll(s, " ", "_")
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CapabilityOf derives the id of a tool row.
|
||||
//
|
||||
// The domain is the thing acted on and the action is what is done to it, read
|
||||
// off whichever dispatch shape the row uses:
|
||||
//
|
||||
// - a house row: the Home Assistant entity domain and the service, so
|
||||
// light.kitchen + turn_off becomes house.light.turn_off. Its scope is
|
||||
// "house" whatever the row says, because the entity id is what decides
|
||||
// what it touches.
|
||||
// - an MCP row: the server handle and the remote tool name.
|
||||
// - a process row: the program (path stripped) and its first subcommand, or
|
||||
// the tool name when the argv carries no second word.
|
||||
func CapabilityOf(t ipc.Tool) Capability {
|
||||
if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
|
||||
domain := entityID
|
||||
if i := strings.Index(entityID, "."); i > 0 {
|
||||
domain = entityID[:i]
|
||||
}
|
||||
return Capability{Scope: "house", Domain: domain, Action: service}
|
||||
}
|
||||
if server, remote, ok := mcp.ParseCmd(t.Cmd); ok {
|
||||
return Capability{Scope: "mcp_" + server, Domain: server, Action: remote}
|
||||
}
|
||||
scope := t.Scope
|
||||
if scope == "" {
|
||||
scope = "homelab"
|
||||
}
|
||||
if len(t.Cmd) == 0 {
|
||||
// A proposal has no argv yet. It still gets an id, because "what did
|
||||
// she ask for" is exactly the question the proposed list answers.
|
||||
return Capability{Scope: scope, Domain: "unknown", Action: t.Name}
|
||||
}
|
||||
program := t.Cmd[0]
|
||||
if i := strings.LastIndex(program, "/"); i >= 0 {
|
||||
program = program[i+1:]
|
||||
}
|
||||
action := t.Name
|
||||
if len(t.Cmd) > 1 && !strings.HasPrefix(t.Cmd[1], "-") {
|
||||
action = t.Cmd[1]
|
||||
}
|
||||
return Capability{Scope: scope, Domain: program, Action: action}
|
||||
}
|
||||
|
||||
// MatchCapability reports whether an id matches a pattern. A pattern is a
|
||||
// dotted id whose segments may be "*", and a pattern with fewer segments than
|
||||
// the id matches every id under it: "house" and "house.*" both cover
|
||||
// house.lock.unlock.
|
||||
//
|
||||
// Prefix widening is deliberate and one-directional. "house.lock" covers every
|
||||
// action on the locks; nothing lets a narrower id claim a wider pattern.
|
||||
func MatchCapability(pattern string, c Capability) bool {
|
||||
want := strings.Split(strings.ToLower(strings.TrimSpace(pattern)), ".")
|
||||
got := strings.Split(c.String(), ".")
|
||||
if len(want) > len(got) {
|
||||
return false
|
||||
}
|
||||
for i, w := range want {
|
||||
if w == "*" || w == "" {
|
||||
continue
|
||||
}
|
||||
if w != got[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GroupByDomain buckets rows by "scope.domain" and returns the buckets in a
|
||||
// stable order, which is what makes the allowlist readable past the point
|
||||
// where a flat list stops being.
|
||||
func GroupByDomain(tools []ipc.Tool) []CapabilityGroup {
|
||||
byKey := map[string][]ipc.Tool{}
|
||||
for _, t := range tools {
|
||||
c := CapabilityOf(t)
|
||||
byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)] = append(byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)], t)
|
||||
}
|
||||
out := make([]CapabilityGroup, 0, len(byKey))
|
||||
for k, v := range byKey {
|
||||
sort.Slice(v, func(i, j int) bool { return v[i].Name < v[j].Name })
|
||||
out = append(out, CapabilityGroup{Prefix: k, Tools: v})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Prefix < out[j].Prefix })
|
||||
return out
|
||||
}
|
||||
|
||||
// CapabilityGroup — one scope.domain and the rows under it.
|
||||
type CapabilityGroup struct {
|
||||
Prefix string
|
||||
Tools []ipc.Tool
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestCapabilityOfDescribesTheRow(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tool ipc.Tool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"a process with a subcommand",
|
||||
ipc.Tool{Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}},
|
||||
"homelab.docker.restart",
|
||||
},
|
||||
{
|
||||
"a program with a path and a flag",
|
||||
ipc.Tool{Name: "backup", Scope: "homelab", Cmd: []string{"/usr/local/bin/borg", "-v"}},
|
||||
"homelab.borg.backup",
|
||||
},
|
||||
{
|
||||
"the house",
|
||||
ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}},
|
||||
"house.lock.unlock",
|
||||
},
|
||||
{
|
||||
"an mcp tool",
|
||||
ipc.Tool{Name: "vikunja_delete_task", Cmd: []string{"mcp", "vikunja", "delete_task"}},
|
||||
"mcp_vikunja.vikunja.delete_task",
|
||||
},
|
||||
{
|
||||
"a proposal with no command yet",
|
||||
ipc.Tool{Name: "перезапусти", Scope: "homelab"},
|
||||
"homelab.unknown.перезапусти",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := CapabilityOf(c.tool).String(); got != c.want {
|
||||
t.Errorf("%s: %q; want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A dotted id always has three segments, so a prefix pattern cannot widen by
|
||||
// accident onto a row whose scope happens to be empty.
|
||||
func TestCapabilityStringAlwaysHasThreeSegments(t *testing.T) {
|
||||
if got := (Capability{}).String(); got != "unknown.unknown.unknown" {
|
||||
t.Errorf("empty capability = %q", got)
|
||||
}
|
||||
if got := (Capability{Scope: "home lab", Domain: "a.b", Action: "X"}).String(); got != "home_lab.a_b.x" {
|
||||
t.Errorf("segments not folded: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCapabilityWidensOneWay(t *testing.T) {
|
||||
c := CapabilityOf(ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}})
|
||||
for _, p := range []string{"house", "house.lock", "house.lock.unlock", "house.*.unlock", "*.lock"} {
|
||||
if !MatchCapability(p, c) {
|
||||
t.Errorf("%q did not match %s", p, c)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"homelab", "house.light", "house.lock.lock", "house.lock.unlock.now"} {
|
||||
if MatchCapability(p, c) {
|
||||
t.Errorf("%q matched %s", p, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupByDomainIsStable(t *testing.T) {
|
||||
tools := []ipc.Tool{
|
||||
{Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}},
|
||||
{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}},
|
||||
{Name: "logs", Scope: "homelab", Cmd: []string{"docker", "logs"}},
|
||||
}
|
||||
groups := GroupByDomain(tools)
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("%d groups; want 2", len(groups))
|
||||
}
|
||||
if groups[0].Prefix != "homelab.docker" || len(groups[0].Tools) != 2 {
|
||||
t.Errorf("first group %+v; want homelab.docker with 2 rows", groups[0])
|
||||
}
|
||||
if groups[0].Tools[0].Name != "logs" {
|
||||
t.Errorf("rows not sorted: %+v", groups[0].Tools)
|
||||
}
|
||||
if groups[1].Prefix != "house.lock" {
|
||||
t.Errorf("second group %q; want house.lock", groups[1].Prefix)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user