feat: add namespace-based access control with read/write permissions
This commit is contained in:
153
e2e_test.go
153
e2e_test.go
@@ -40,13 +40,28 @@ func (n NodeResponse) HasRelation(relType, targetID string) bool {
|
||||
// ── Test environment ──────────────────────────────────────────────────────────
|
||||
|
||||
type testEnv struct {
|
||||
t *testing.T
|
||||
dir string
|
||||
t *testing.T
|
||||
dir string
|
||||
user string // if non-empty, overrides AX_USER for every command
|
||||
}
|
||||
|
||||
// envWithUser returns the current process environment with AX_USER replaced.
|
||||
func envWithUser(user string) []string {
|
||||
var filtered []string
|
||||
for _, e := range os.Environ() {
|
||||
if !strings.HasPrefix(e, "AX_USER=") {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return append(filtered, "AX_USER="+user)
|
||||
}
|
||||
|
||||
func (e *testEnv) ax(args ...string) (string, error) {
|
||||
cmd := exec.Command("./ax", args...)
|
||||
cmd.Dir = e.dir
|
||||
if e.user != "" {
|
||||
cmd.Env = envWithUser(e.user)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
@@ -794,4 +809,138 @@ func TestE2E(t *testing.T) {
|
||||
out := env.mustAx("mine", "--json")
|
||||
env.parseNodes(out) // must parse without error
|
||||
})
|
||||
|
||||
// ── Permissions ───────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("Permissions", func(t *testing.T) {
|
||||
permDir, err := os.MkdirTemp("", "ax-perm-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(permDir)
|
||||
if err := exec.Command("cp", "./ax", filepath.Join(permDir, "ax")).Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
alice := &testEnv{t: t, dir: permDir, user: "alice"}
|
||||
bob := &testEnv{t: t, dir: permDir, user: "bob"}
|
||||
|
||||
alice.mustAx("init")
|
||||
|
||||
// Alice creates a node in her namespace.
|
||||
aliceNode := alice.parseNode(alice.mustAx("add", "Alice's secret", "--json"))
|
||||
aliceNodeID := aliceNode.ID
|
||||
|
||||
// Resolve alice's user and namespace node IDs.
|
||||
var aliceUserID, aliceNSID string
|
||||
for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) {
|
||||
if u.Title == "alice" {
|
||||
aliceUserID = u.ID
|
||||
}
|
||||
}
|
||||
for _, ns := range alice.parseNodes(alice.mustAx("list", "--type", "namespace", "--json")) {
|
||||
if ns.Title == "alice" {
|
||||
aliceNSID = ns.ID
|
||||
}
|
||||
}
|
||||
if aliceUserID == "" || aliceNSID == "" {
|
||||
t.Fatal("could not resolve alice's user/namespace node IDs")
|
||||
}
|
||||
|
||||
t.Run("NoAccess_CannotShow", func(t *testing.T) {
|
||||
_, err := bob.ax("show", aliceNodeID)
|
||||
if err == nil {
|
||||
t.Error("bob should not be able to show alice's node without access")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoAccess_NotInList", func(t *testing.T) {
|
||||
// Bob bootstraps his own namespace first.
|
||||
bob.mustAx("add", "Bob's scratch", "--json")
|
||||
|
||||
nodes := bob.parseNodes(bob.mustAx("list", "--json"))
|
||||
if _, ok := bob.findInList(nodes, aliceNodeID); ok {
|
||||
t.Error("alice's node should not appear in bob's list without access")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoAccess_CannotUpdate", func(t *testing.T) {
|
||||
_, err := bob.ax("update", aliceNodeID, "--title", "hacked")
|
||||
if err == nil {
|
||||
t.Error("bob should not be able to update alice's node without access")
|
||||
}
|
||||
})
|
||||
|
||||
// Resolve bob's user node ID (visible to alice because user nodes have no namespace).
|
||||
var bobUserID string
|
||||
for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) {
|
||||
if u.Title == "bob" {
|
||||
bobUserID = u.ID
|
||||
}
|
||||
}
|
||||
if bobUserID == "" {
|
||||
t.Fatal("could not resolve bob's user node ID")
|
||||
}
|
||||
|
||||
t.Run("SelfEscalation_Denied", func(t *testing.T) {
|
||||
// Bob attempts to grant himself write access to alice's namespace.
|
||||
_, err := bob.ax("update", bobUserID, "--rel", "has_write_access:"+aliceNSID)
|
||||
if err == nil {
|
||||
t.Error("bob should not be able to grant himself write access to alice's namespace")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReadAccess_Grant", func(t *testing.T) {
|
||||
// Alice grants bob read access to her namespace.
|
||||
alice.mustAx("update", bobUserID, "--rel", "has_read_access:"+aliceNSID)
|
||||
|
||||
// Bob can now show alice's node.
|
||||
if _, err := bob.ax("show", aliceNodeID); err != nil {
|
||||
t.Error("bob should be able to show alice's node after read access granted")
|
||||
}
|
||||
// And it appears in his list.
|
||||
nodes := bob.parseNodes(bob.mustAx("list", "--json"))
|
||||
if _, ok := bob.findInList(nodes, aliceNodeID); !ok {
|
||||
t.Error("alice's node should appear in bob's list after read access granted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReadAccess_CannotUpdate", func(t *testing.T) {
|
||||
_, err := bob.ax("update", aliceNodeID, "--title", "hacked with read access")
|
||||
if err == nil {
|
||||
t.Error("bob should not be able to update alice's node with only read access")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReadAccess_CannotAddRelationToNode", func(t *testing.T) {
|
||||
// Bob creates his own node and tries to link it to alice's node.
|
||||
bobLinkedID := bob.parseNode(bob.mustAx("add", "Bob's linked node", "--json")).ID
|
||||
_, err := bob.ax("update", bobLinkedID, "--rel", "related:"+aliceNodeID)
|
||||
if err == nil {
|
||||
t.Error("bob should not be able to create a relation to alice's node with only read access")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WriteAccess_Grant", func(t *testing.T) {
|
||||
// Alice grants bob write access.
|
||||
alice.mustAx("update", bobUserID, "--rel", "has_write_access:"+aliceNSID)
|
||||
|
||||
out := bob.mustAx("update", aliceNodeID, "--title", "Bob modified this", "--json")
|
||||
n := bob.parseNode(out)
|
||||
if n.Title != "Bob modified this" {
|
||||
t.Errorf("expected 'Bob modified this', got %q", n.Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WriteAccess_CanAddRelationToNode", func(t *testing.T) {
|
||||
// Bob creates a node and links it to alice's node (now has write access).
|
||||
bobNode2 := bob.parseNode(bob.mustAx("add", "Bob's related node", "--json"))
|
||||
bob.mustAx("update", bobNode2.ID, "--rel", "related:"+aliceNodeID)
|
||||
out := bob.mustAx("show", bobNode2.ID, "--json")
|
||||
n := bob.parseNode(out)
|
||||
if !n.HasRelation("related", aliceNodeID) {
|
||||
t.Error("expected related relation after write access granted")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user