From 4020e5dab30aed708523b7ba49890ff32a491f4f Mon Sep 17 00:00:00 2001 From: Elias Kohout Date: Tue, 31 Mar 2026 23:52:24 +0200 Subject: [PATCH] feat: add namespace-based access control with read/write permissions --- e2e_test.go | 153 +++++++++++++++++++++++++++- models/rel_type.go | 16 +-- output/output.go | 6 +- service/node_service_impl.go | 189 +++++++++++++++++++++++++++++++++-- 4 files changed, 343 insertions(+), 21 deletions(-) diff --git a/e2e_test.go b/e2e_test.go index 5a52efb..ae9df87 100644 --- a/e2e_test.go +++ b/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") + } + }) + }) } diff --git a/models/rel_type.go b/models/rel_type.go index 086dfe1..c6f7911 100644 --- a/models/rel_type.go +++ b/models/rel_type.go @@ -8,11 +8,13 @@ type Rel struct { } const ( - RelBlocks RelType = "blocks" - RelSubtask RelType = "subtask" - RelRelated RelType = "related" - RelCreated RelType = "created" - RelAssignee RelType = "assignee" - RelInNamespace RelType = "in_namespace" - RelMentions RelType = "mentions" + RelBlocks RelType = "blocks" + RelSubtask RelType = "subtask" + RelRelated RelType = "related" + RelCreated RelType = "created" + RelAssignee RelType = "assignee" + RelInNamespace RelType = "in_namespace" + RelMentions RelType = "mentions" + RelHasReadAccess RelType = "has_read_access" // user → namespace + RelHasWriteAccess RelType = "has_write_access" // user → namespace ) diff --git a/output/output.go b/output/output.go index c944afa..b252746 100644 --- a/output/output.go +++ b/output/output.go @@ -83,7 +83,8 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json for _, id := range ns_rel_node_ids { ns_rel_node, err := svc.GetByID(id) if err != nil { - fmt.Fprintf(w, "err: %v", err) + ns_rel_node_titles = append(ns_rel_node_titles, id) + continue } ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title) } @@ -133,7 +134,8 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo for _, id := range rel_node_ids { rel_node, err := svc.GetByID(id) if err != nil { - fmt.Fprintf(w, "err: %v", err) + fmt.Fprintf(w, " %s %s\n", relIcons[relType], cDim.Sprint(id)) + continue } fmt.Fprintf(w, " %s %s\n", relIcons[relType], rel_node.Title) } diff --git a/service/node_service_impl.go b/service/node_service_impl.go index 55ce6e6..bf337f0 100644 --- a/service/node_service_impl.go +++ b/service/node_service_impl.go @@ -28,6 +28,88 @@ func mentions(t string) []string { func (s *nodeServiceImpl) User() string { return s.userID } +// --- Access control --- + +// accessContext holds namespace IDs readable/writable by the current user. +// Nodes with no in_namespace are globally accessible (empty namespaceID always passes). +type accessContext struct { + readable map[string]bool + writable map[string]bool +} + +func (ac *accessContext) canRead(namespaceID string) bool { + if namespaceID == "" { + return true + } + return ac.readable[namespaceID] +} + +func (ac *accessContext) canWrite(namespaceID string) bool { + if namespaceID == "" { + return true + } + return ac.writable[namespaceID] +} + +// getAccessContext builds an accessContext by reading the current user's outgoing +// has_write_access and has_read_access edges. If the user node does not yet exist +// (first-time bootstrap) both maps are empty. +func (s *nodeServiceImpl) getAccessContext() (*accessContext, error) { + ctx := &accessContext{ + readable: make(map[string]bool), + writable: make(map[string]bool), + } + userNodeID, err := s.resolveIDByNameAndType(s.store, s.userID, "user") + if err != nil { + return nil, err + } + if userNodeID == "" { + return ctx, nil // not yet bootstrapped; no namespace permissions + } + userNode, err := s.store.GetNode(userNodeID) + if err != nil { + return nil, err + } + rels := userNode.Relations() + for _, nsID := range rels[string(models.RelHasWriteAccess)] { + ctx.writable[nsID] = true + ctx.readable[nsID] = true + } + for _, nsID := range rels[string(models.RelHasReadAccess)] { + ctx.readable[nsID] = true + } + return ctx, nil +} + +// nodeNamespaceID returns the first in_namespace target of n, or "" if none. +func (s *nodeServiceImpl) nodeNamespaceID(n *models.Node) string { + ids := n.Relations()[string(models.RelInNamespace)] + if len(ids) == 0 { + return "" + } + return ids[0] +} + +// checkRelTargetWrite verifies the current user has write access to the namespace +// of each relation target. Targets that do not yet exist are skipped (they will +// be created during the transaction and access granted there). +func (s *nodeServiceImpl) checkRelTargetWrite(ac *accessContext, addRels []RelInput) error { + for _, ri := range addRels { + targetID, found := s.lookupRelTarget(ri.Type, ri.Target) + if !found || targetID == "" { + continue + } + targetNode, err := s.store.GetNode(targetID) + if err != nil { + continue // let the transaction surface missing-node errors + } + if !ac.canWrite(s.nodeNamespaceID(targetNode)) { + return fmt.Errorf("permission denied: no write access to namespace of %s target %q", ri.Type, ri.Target) + } + } + return nil +} + // --- Validation --- var ( @@ -68,7 +150,18 @@ func tagValue(tags []string, prefix string) string { // --- Query --- func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { - return s.store.GetNode(id) + n, err := s.store.GetNode(id) + if err != nil { + return nil, err + } + ac, err := s.getAccessContext() + if err != nil { + return nil, err + } + if !ac.canRead(s.nodeNamespaceID(n)) { + return nil, fmt.Errorf("permission denied: no read access to node %s", id) + } + return n, nil } func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { @@ -80,7 +173,21 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { } relFilters = append(relFilters, &models.Rel{Type: ri.Type, Target: id}) } - return s.store.FindNodes(filter.Tags, relFilters) + nodes, err := s.store.FindNodes(filter.Tags, relFilters) + if err != nil { + return nil, err + } + ac, err := s.getAccessContext() + if err != nil { + return nil, err + } + var result []*models.Node + for _, n := range nodes { + if ac.canRead(s.nodeNamespaceID(n)) { + result = append(result, n) + } + } + return result, nil } // --- Lifecycle --- @@ -105,6 +212,37 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { return nil, err } + // --- Permission check --- + ac, err := s.getAccessContext() + if err != nil { + return nil, err + } + // Determine the target namespace name (explicit or default). + targetNSName := s.userID + for _, ri := range input.Rels { + if ri.Type == models.RelInNamespace { + targetNSName = ri.Target + break + } + } + // Check write access only when the namespace already exists; if it doesn't + // exist yet it will be created in the transaction and access granted there. + if nsID, found := s.lookupRelTarget(models.RelInNamespace, targetNSName); found { + if !ac.canWrite(nsID) { + return nil, fmt.Errorf("permission denied: no write access to namespace %q", targetNSName) + } + } + // Check write access for all other relation targets. + var nonNSRels []RelInput + for _, ri := range input.Rels { + if ri.Type != models.RelInNamespace { + nonNSRels = append(nonNSRels, ri) + } + } + if err := s.checkRelTargetWrite(ac, nonNSRels); err != nil { + return nil, err + } + // Build initial relation map from rels input. rels := make(map[models.RelType][]string) hasNamespace := false @@ -180,6 +318,27 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { } func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) { + // Validate tags before doing any I/O. + if err := validateTags(input.AddTags); err != nil { + return nil, err + } + + // --- Permission check --- + current, err := s.store.GetNode(id) + if err != nil { + return nil, err + } + ac, err := s.getAccessContext() + if err != nil { + return nil, err + } + if !ac.canWrite(s.nodeNamespaceID(current)) { + return nil, fmt.Errorf("permission denied: no write access to node %s", id) + } + if err := s.checkRelTargetWrite(ac, input.AddRels); err != nil { + return nil, err + } + // Enforce blocking constraint before allowing status=done. for _, t := range input.AddTags { if t == "_status::done" { @@ -190,12 +349,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er } } - // Validate tags being added. - if err := validateTags(input.AddTags); err != nil { - return nil, err - } - - err := s.store.Transaction(func(st store.Store) error { + err = s.store.Transaction(func(st store.Store) error { current, err := st.GetNode(id) if err != nil { return err @@ -293,6 +447,17 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er } func (s *nodeServiceImpl) Delete(id string) error { + n, err := s.store.GetNode(id) + if err != nil { + return err + } + ac, err := s.getAccessContext() + if err != nil { + return err + } + if !ac.canWrite(s.nodeNamespaceID(n)) { + return fmt.Errorf("permission denied: no write access to node %s", id) + } return s.store.DeleteNode(id) } @@ -369,7 +534,7 @@ func (s *nodeServiceImpl) resolveRelTarget(st store.Store, ri RelInput) (string, switch ri.Type { case models.RelAssignee, models.RelCreated, models.RelMentions: return s.resolveUserRef(st, ri.Target) - case models.RelInNamespace: + case models.RelInNamespace, models.RelHasReadAccess, models.RelHasWriteAccess: return s.resolveNamespaceRef(st, ri.Target) default: return ri.Target, nil // blocks/subtask/related expect raw node IDs @@ -386,7 +551,7 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) switch relType { case models.RelAssignee, models.RelCreated, models.RelMentions: nodeType = "user" - case models.RelInNamespace: + case models.RelInNamespace, models.RelHasReadAccess, models.RelHasWriteAccess: nodeType = "namespace" default: return target, true @@ -477,5 +642,9 @@ func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, if err := st.AddEdge(id, userID, models.RelCreated); err != nil { return "", err } + // Grant the creator write access to the new namespace. + if err := st.AddEdge(userID, id, models.RelHasWriteAccess); err != nil { + return "", err + } return id, nil }