feat: replace namespace permissions with per-node graph permission model (can_read/can_create_rel/can_write/has_ownership)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
84
e2e_test.go
84
e2e_test.go
@@ -827,24 +827,19 @@ func TestE2E(t *testing.T) {
|
||||
|
||||
alice.mustAx("init")
|
||||
|
||||
// Alice creates a node in her namespace.
|
||||
// Alice creates a node (she gets has_ownership on it automatically).
|
||||
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
|
||||
// Resolve alice's user node ID.
|
||||
var aliceUserID 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")
|
||||
if aliceUserID == "" {
|
||||
t.Fatal("could not resolve alice's user node ID")
|
||||
}
|
||||
|
||||
t.Run("NoAccess_CannotShow", func(t *testing.T) {
|
||||
@@ -855,7 +850,7 @@ func TestE2E(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("NoAccess_NotInList", func(t *testing.T) {
|
||||
// Bob bootstraps his own namespace first.
|
||||
// Bob bootstraps by creating his own node first.
|
||||
bob.mustAx("add", "Bob's scratch", "--json")
|
||||
|
||||
nodes := bob.parseNodes(bob.mustAx("list", "--json"))
|
||||
@@ -871,7 +866,7 @@ func TestE2E(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Resolve bob's user node ID (visible to alice because user nodes have no namespace).
|
||||
// Resolve bob's user node ID. User nodes are globally readable.
|
||||
var bobUserID string
|
||||
for _, u := range alice.parseNodes(alice.mustAx("list", "--type", "user", "--json")) {
|
||||
if u.Title == "bob" {
|
||||
@@ -883,16 +878,18 @@ func TestE2E(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
// Bob attempts to grant himself can_write on alice's node.
|
||||
// Bob has no permissions on aliceNode, so this must fail.
|
||||
_, err := bob.ax("update", bobUserID, "--rel", "can_write:"+aliceNodeID)
|
||||
if err == nil {
|
||||
t.Error("bob should not be able to grant himself write access to alice's namespace")
|
||||
t.Error("bob should not be able to grant himself write access to alice's node")
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
// Alice grants bob can_read on her node.
|
||||
// Alice has has_ownership on aliceNode (level 4 >= can_read level 1) so this succeeds.
|
||||
alice.mustAx("update", bobUserID, "--rel", "can_read:"+aliceNodeID)
|
||||
|
||||
// Bob can now show alice's node.
|
||||
if _, err := bob.ax("show", aliceNodeID); err != nil {
|
||||
@@ -914,6 +911,7 @@ func TestE2E(t *testing.T) {
|
||||
|
||||
t.Run("ReadAccess_CannotAddRelationToNode", func(t *testing.T) {
|
||||
// Bob creates his own node and tries to link it to alice's node.
|
||||
// Requires can_create_rel on BOTH nodes; bob only has can_read on 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 {
|
||||
@@ -922,8 +920,9 @@ func TestE2E(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("WriteAccess_Grant", func(t *testing.T) {
|
||||
// Alice grants bob write access.
|
||||
alice.mustAx("update", bobUserID, "--rel", "has_write_access:"+aliceNSID)
|
||||
// Alice grants bob can_write on her node.
|
||||
// Alice has has_ownership (level 4 >= can_write level 3) so this succeeds.
|
||||
alice.mustAx("update", bobUserID, "--rel", "can_write:"+aliceNodeID)
|
||||
|
||||
out := bob.mustAx("update", aliceNodeID, "--title", "Bob modified this", "--json")
|
||||
n := bob.parseNode(out)
|
||||
@@ -933,7 +932,7 @@ func TestE2E(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("WriteAccess_CanAddRelationToNode", func(t *testing.T) {
|
||||
// Bob creates a node and links it to alice's node (now has write access).
|
||||
// Bob creates a node and links it to alice's node (now has can_write → can_create_rel).
|
||||
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")
|
||||
@@ -942,6 +941,47 @@ func TestE2E(t *testing.T) {
|
||||
t.Error("expected related relation after write access granted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Ownership_DefaultOnCreate", func(t *testing.T) {
|
||||
// Verify alice's user node has self-ownership.
|
||||
userOut := alice.mustAx("show", aliceUserID, "--json")
|
||||
userNode := alice.parseNode(userOut)
|
||||
if !userNode.HasRelation("has_ownership", aliceUserID) {
|
||||
t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations)
|
||||
}
|
||||
// Verify alice owns her issue node.
|
||||
if !userNode.HasRelation("has_ownership", aliceNodeID) {
|
||||
t.Errorf("expected alice to own her node, got relations: %v", userNode.Relations)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Ownership_CascadeDelete", func(t *testing.T) {
|
||||
// Alice creates a child node. Alice owns both.
|
||||
// Deleting alice's user node should cascade and delete the child.
|
||||
// (We use a throwaway user to avoid breaking other subtests.)
|
||||
throwaway := &testEnv{t: t, dir: permDir, user: "throwaway"}
|
||||
child := throwaway.parseNode(throwaway.mustAx("add", "Child node", "--json"))
|
||||
|
||||
// Resolve throwaway's user node.
|
||||
var throwawayUserID string
|
||||
for _, u := range throwaway.parseNodes(throwaway.mustAx("list", "--type", "user", "--json")) {
|
||||
if u.Title == "throwaway" {
|
||||
throwawayUserID = u.ID
|
||||
}
|
||||
}
|
||||
if throwawayUserID == "" {
|
||||
t.Fatal("could not find throwaway user node")
|
||||
}
|
||||
|
||||
// Delete throwaway's user node — should cascade to child.
|
||||
throwaway.mustAx("del", throwawayUserID, "--force")
|
||||
|
||||
// Child should be gone.
|
||||
_, err := throwaway.ax("show", child.ID)
|
||||
if err == nil {
|
||||
t.Error("child node should have been cascade-deleted when its owner was deleted")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Namespace_ExplicitCreate", func(t *testing.T) {
|
||||
@@ -976,8 +1016,8 @@ func TestE2E(t *testing.T) {
|
||||
if userNode == nil {
|
||||
t.Fatal("could not find testuser node")
|
||||
}
|
||||
if !userNode.HasRelation("has_write_access", nsNode.ID) {
|
||||
t.Errorf("expected creator to have has_write_access to new namespace, got relations: %v", userNode.Relations)
|
||||
if !userNode.HasRelation("has_ownership", nsNode.ID) {
|
||||
t.Errorf("expected creator to have has_ownership on new namespace, got relations: %v", userNode.Relations)
|
||||
}
|
||||
|
||||
// Nodes added to the new namespace should be accessible.
|
||||
|
||||
Reference in New Issue
Block a user