diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c9c3c8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Axolotl (`ax`) is a CLI-native issue tracker built in Go, using a local SQLite file (`.ax.db`) as its database. It's designed for use by individuals and AI agents, with JSON output support for machine integration. + +## Commands + +```bash +go build -o ax . # Build the binary +go test ./... # Run all tests (e2e_test.go covers most functionality) +go test -run TestName . # Run a single test by name +``` + +## Architecture + +The codebase has four distinct layers: + +### 1. `cmd/` — CLI layer (Cobra) +Parses flags into typed input structs and calls the service layer. `root.go` handles alias expansion (including `$me`, `$@`, `$1`-`$N` variable substitution) and wires up the `NodeService`. + +### 2. `service/` — Business logic +`NodeService` is the central interface (`service/node_service.go`). The implementation (`node_service_impl.go`) enforces: +- Permission model via `getPermContext()` — BFS from the user's own node following permission rels +- Blocker validation (can't close an issue with open blockers) +- `@mention` extraction → automatic edge creation +- Single-value relation enforcement (`assignee`, `in_namespace`) +- Auto-creation of referenced user/namespace nodes + +### 3. `store/` — Persistence +`Store` interface wraps SQLite with graph primitives: nodes, tags, and typed directed edges. Schema is 3 tables (`nodes`, `tags`, `rels`). All multi-step ops use `store.Transaction()`. + +### 4. `output/` — Presentation +Handles both colored terminal output and JSON serialization. Applies sort order: open → due → done, high → medium → low priority. + +## Core Data Model + +**Node**: a graph node with a 5-char ID, title, content, `Tags []string`, and `Relations map[string][]string`. + +**Property tags** use the `_key::value` pattern: `_type::issue|note|user|namespace`, `_status::open|done`, `_prio::high|medium|low`. + +**Relation types** (`models/rel_type.go`): `blocks`, `subtask`, `related`, `assignee` (single-value), `in_namespace` (single-value), `created`, `mentions`, `can_read`, `can_create_rel`, `can_write`, `has_ownership`. + +**Permission model**: Four inclusive levels (1–4). Transitive via BFS from user's self-owned node. `can_read`=1, `can_create_rel`=2, `can_write`=3, `has_ownership`=4. Creator auto-gets `has_ownership` on every new node. Users self-own. Deleting a node cascades to all nodes it owns. User/namespace nodes are globally readable. + +## Config + +The CLI searches upward from CWD for `.axconfig` (like git), falling back to `~/.config/ax/config.json`. The `AX_USER` env var overrides the configured username. The database file `.ax.db` is similarly discovered by walking upward. diff --git a/e2e_test.go b/e2e_test.go index a25516e..cae2d67 100644 --- a/e2e_test.go +++ b/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. diff --git a/models/rel_type.go b/models/rel_type.go index c6f7911..3e3a85f 100644 --- a/models/rel_type.go +++ b/models/rel_type.go @@ -8,13 +8,17 @@ 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" - RelHasReadAccess RelType = "has_read_access" // user → namespace - RelHasWriteAccess RelType = "has_write_access" // user → namespace + RelBlocks RelType = "blocks" + RelSubtask RelType = "subtask" + RelRelated RelType = "related" + RelCreated RelType = "created" + RelAssignee RelType = "assignee" + RelInNamespace RelType = "in_namespace" + RelMentions RelType = "mentions" + + // Permission rels (subject → object). Levels are inclusive and transitive. + RelCanRead RelType = "can_read" // level 1: visible in list/show + RelCanCreateRel RelType = "can_create_rel" // level 2: can create relations between nodes + RelCanWrite RelType = "can_write" // level 3: can update/delete + RelHasOwnership RelType = "has_ownership" // level 4: sole owner; deletion cascades to owned nodes ) diff --git a/service/node_service_impl.go b/service/node_service_impl.go index dd27153..e3ebd8b 100644 --- a/service/node_service_impl.go +++ b/service/node_service_impl.go @@ -28,89 +28,119 @@ func mentions(t string) []string { func (s *nodeServiceImpl) User() string { return s.userID } -// --- Access control --- +// --- Permission model --- +// +// Four levels (inclusive: higher includes lower): +// 1 can_read – visible in list/show +// 2 can_create_rel – can create non-permission relations between nodes +// 3 can_write – can update/delete a node +// 4 has_ownership – sole owner; deletion cascades to owned nodes +// +// Permissions are transitive: if A has level L on B, and B has level M on C, +// then A has level min(L, M) on C. Computed by BFS from the user's own node. +// Users have self-ownership (has_ownership → self), so BFS starts at level 4. +// +// Rules for adding edge rels in Add/Update: +// Non-perm rel A → B : need can_create_rel on A, can_read on B +// Perm rel A --perm_P→ B : need perm_P on B (resource owner grants to any subject) +// Ownership A --has_ownership→ B : need has_ownership on B + can_create_rel on A +// → also removes existing ownership rels pointing to B +// +// Field/tag changes and rel removals require can_write on the node. -// 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 -} +const ( + permRead = 1 + permCreateRel = 2 + permWrite = 3 + permOwnership = 4 +) -func (ac *accessContext) canRead(namespaceID string) bool { - if namespaceID == "" { +// isReferenceRel returns true for rels that point to "identity" nodes (users, namespaces). +// For these rels, the target only needs can_read (not can_create_rel), because users and +// namespaces are globally readable and any node can reference them. +func isReferenceRel(t models.RelType) bool { + switch t { + case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace: return true } - return ac.readable[namespaceID] + return false } -func (ac *accessContext) canWrite(namespaceID string) bool { - if namespaceID == "" { - return true - } - return ac.writable[namespaceID] +// permRelLevels maps permission rel types to their numeric level. +var permRelLevels = map[models.RelType]int{ + models.RelCanRead: permRead, + models.RelCanCreateRel: permCreateRel, + models.RelCanWrite: permWrite, + models.RelHasOwnership: permOwnership, } -// getAccessContext builds an accessContext by reading the current user's outgoing -// has_write_access and has_read_access rels. 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), - } +type permContext struct { + levels map[string]int +} + +func (pc *permContext) level(nodeID string) int { return pc.levels[nodeID] } +func (pc *permContext) canRead(nodeID string) bool { return pc.levels[nodeID] >= permRead } +func (pc *permContext) canCreateRel(nodeID string) bool { return pc.levels[nodeID] >= permCreateRel } +func (pc *permContext) canWrite(nodeID string) bool { return pc.levels[nodeID] >= permWrite } +func (pc *permContext) hasOwnership(nodeID string) bool { return pc.levels[nodeID] >= permOwnership } + +// getPermContext builds a permContext by BFS from the current user's node, +// following permission rels and taking the minimum level along each path. +// User and namespace nodes are made globally readable after the BFS. +// If the user node doesn't exist yet, returns an empty permContext (no access); +// Add operations still work because unresolved targets skip the permission check. +func (s *nodeServiceImpl) getPermContext() (*permContext, error) { userNodeID, err := s.resolveIDByNameAndType(s.store, s.userID, "user") if err != nil { return nil, err } + pc := &permContext{levels: make(map[string]int)} if userNodeID == "" { - return ctx, nil // not yet bootstrapped; no namespace permissions + return pc, nil // user not bootstrapped yet; Add will auto-create user node } - 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 "" + type entry struct { + nodeID string + level int } - return ids[0] -} - -// checkRelTargetWrite verifies the current user has write access to the namespace -// of each edge rel target. Tag rels (empty Target) and targets that do not yet -// exist are skipped. -func (s *nodeServiceImpl) checkRelTargetWrite(ac *accessContext, addRels []RelInput) error { - for _, ri := range addRels { - if ri.Target == "" { - continue // tag rel — no target node to check + // Start at the user's own node at ownership level (users have self-ownership). + queue := []entry{{userNodeID, permOwnership}} + for len(queue) > 0 { + curr := queue[0] + queue = queue[1:] + if pc.levels[curr.nodeID] >= curr.level { + continue // already reached at a higher or equal level } - targetID, found := s.lookupRelTarget(ri.Type, ri.Target) - if !found || targetID == "" { - continue - } - targetNode, err := s.store.GetNode(targetID) + pc.levels[curr.nodeID] = curr.level + node, err := s.store.GetNode(curr.nodeID) if err != nil { - continue // let the transaction surface missing-node errors + continue // node may have been deleted; skip } - if !ac.canWrite(s.nodeNamespaceID(targetNode)) { - return fmt.Errorf("permission denied: no write access to namespace of %s target %q", ri.Type, ri.Target) + for relType, pLevel := range permRelLevels { + for _, tgt := range node.Relations[string(relType)] { + eff := curr.level + if pLevel < eff { + eff = pLevel + } + if eff > pc.levels[tgt] { + queue = append(queue, entry{tgt, eff}) + } + } } } - return nil + + // User and namespace nodes are globally readable (they represent identities, + // and anyone can reference or assign to them). + for _, nodeType := range []string{"user", "namespace"} { + nodes, _ := s.store.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}}) + for _, n := range nodes { + if pc.levels[n.ID] < permRead { + pc.levels[n.ID] = permRead + } + } + } + + return pc, nil } // --- Validation --- @@ -155,11 +185,11 @@ func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { if err != nil { return nil, err } - ac, err := s.getAccessContext() + pc, err := s.getPermContext() if err != nil { return nil, err } - if !ac.canRead(s.nodeNamespaceID(n)) { + if !pc.canRead(id) { return nil, fmt.Errorf("permission denied: no read access to node %s", id) } return n, nil @@ -169,13 +199,11 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { var storeFilters []*models.Rel for _, ri := range filter.Rels { if ri.Target == "" { - // Tag filter: pass through with empty target. storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: ""}) } else { - // Edge filter: resolve target name to node ID. id, ok := s.lookupRelTarget(ri.Type, ri.Target) if !ok { - return nil, nil // named target doesn't exist; no nodes can match + return nil, nil } storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: id}) } @@ -184,13 +212,13 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { if err != nil { return nil, err } - ac, err := s.getAccessContext() + pc, err := s.getPermContext() if err != nil { return nil, err } var result []*models.Node for _, n := range nodes { - if ac.canRead(s.nodeNamespaceID(n)) { + if pc.canRead(n.ID) { result = append(result, n) } } @@ -225,32 +253,43 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { return nil, err } - // --- Permission check --- - ac, err := s.getAccessContext() + // Permission checks for edge rels. + pc, err := s.getPermContext() if err != nil { return nil, err } - targetNSName := s.userID for _, ri := range input.Rels { - if ri.Type == models.RelInNamespace && ri.Target != "" { - targetNSName = ri.Target - break + if ri.Target == "" { + continue // tag rel, no target to check } - } - 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) + targetID, found := s.lookupRelTarget(ri.Type, ri.Target) + if !found { + continue // will be auto-created; skip check } - } - var nonNSEdgeRels []RelInput - for _, ri := range input.Rels { - if ri.Target != "" && ri.Type != models.RelInNamespace { - nonNSEdgeRels = append(nonNSEdgeRels, ri) + permLevel, isPerm := permRelLevels[ri.Type] + switch { + case ri.Type == models.RelHasOwnership: + if !pc.hasOwnership(targetID) { + return nil, fmt.Errorf("permission denied: no ownership of %q to transfer", ri.Target) + } + case isPerm: + if pc.level(targetID) < permLevel { + return nil, fmt.Errorf("permission denied: cannot grant %s on %q", ri.Type, ri.Target) + } + default: + // Non-perm rel: source is the new node (creator gets ownership = can_create_rel). + // Target: reference rels (assignee/mentions/in_namespace) need can_read; others need can_create_rel. + if isReferenceRel(ri.Type) { + if !pc.canRead(targetID) { + return nil, fmt.Errorf("permission denied: no read access to %q", ri.Target) + } + } else { + if !pc.canCreateRel(targetID) { + return nil, fmt.Errorf("permission denied: no create_rel access to %q", ri.Target) + } + } } } - if err := s.checkRelTargetWrite(ac, nonNSEdgeRels); err != nil { - return nil, err - } hasNamespace := false for _, ri := range input.Rels { @@ -301,6 +340,13 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { if err != nil { return err } + if ri.Type == models.RelHasOwnership { + // Ownership transfer: remove existing owner of the target. + existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}}) + for _, owner := range existingOwners { + st.RemoveRel(owner.ID, string(models.RelHasOwnership), resolved) //nolint:errcheck + } + } if err := st.AddRel(id, string(ri.Type), resolved); err != nil { return err } @@ -328,8 +374,17 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { } } + // Grant creator ownership of the new node. + creatorID, err := s.resolveUserRef(st, s.userID) + if err != nil { + return err + } + if err := st.AddRel(creatorID, string(models.RelHasOwnership), id); err != nil { + return err + } + // Namespace bootstrap: when creating a namespace node directly, apply the - // same setup as ensureNamespace — self in_namespace and creator write access. + // same setup as ensureNamespace — self in_namespace and creator ownership. if tmp.GetProperty("type") == "namespace" { if !hasNamespace { // Replace the default namespace rel (user's ns) with self-reference. @@ -343,13 +398,7 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { return err } } - creatorID, err := s.resolveUserRef(st, s.userID) - if err != nil { - return err - } - if err := st.AddRel(creatorID, string(models.RelHasWriteAccess), id); err != nil { - return err - } + // Creator already gets ownership via the block above; nothing more to do. } return nil @@ -366,20 +415,68 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er return nil, err } - // --- Permission check --- - current, err := s.store.GetNode(id) + // --- Permission checks --- + pc, err := s.getPermContext() if err != nil { return nil, err } - ac, err := s.getAccessContext() - if err != nil { - return nil, err + + // Field/tag changes and rel removals require can_write on the node. + needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil + for _, ri := range input.AddRels { + if ri.Target == "" { + needsWrite = true + break + } } - if !ac.canWrite(s.nodeNamespaceID(current)) { + if len(input.RemoveRels) > 0 { + needsWrite = true + } + if needsWrite && !pc.canWrite(id) { 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 + + // Check each edge rel being added. + for _, ri := range input.AddRels { + if ri.Target == "" { + continue // tag — handled above + } + permLevel, isPerm := permRelLevels[ri.Type] + targetID, found := s.lookupRelTarget(ri.Type, ri.Target) + switch { + case ri.Type == models.RelHasOwnership: + if !found { + return nil, fmt.Errorf("ownership target %q not found", ri.Target) + } + if !pc.hasOwnership(targetID) { + return nil, fmt.Errorf("permission denied: no ownership of %q to transfer", ri.Target) + } + if !pc.canCreateRel(id) { + return nil, fmt.Errorf("permission denied: no create_rel access to node %s", id) + } + case isPerm: + // Perm rel: need perm_P on target; no check on source. + if found && pc.level(targetID) < permLevel { + return nil, fmt.Errorf("permission denied: insufficient permission on %q to grant %s", ri.Target, ri.Type) + } + default: + // Non-perm rel: need can_create_rel on source. + // Target: reference rels (assignee/mentions/in_namespace) need can_read; others need can_create_rel. + if !pc.canCreateRel(id) { + return nil, fmt.Errorf("permission denied: no create_rel access to node %s", id) + } + if found { + if isReferenceRel(ri.Type) { + if !pc.canRead(targetID) { + return nil, fmt.Errorf("permission denied: no read access to %s target %q", ri.Type, ri.Target) + } + } else { + if !pc.canCreateRel(targetID) { + return nil, fmt.Errorf("permission denied: no create_rel access to %s target %q", ri.Type, ri.Target) + } + } + } + } } // Enforce blocking constraint before allowing status=done. @@ -471,6 +568,13 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er } } } + // Ownership transfer: enforce single-owner constraint. + if ri.Type == models.RelHasOwnership { + existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}}) + for _, owner := range existingOwners { + st.RemoveRel(owner.ID, string(models.RelHasOwnership), resolved) //nolint:errcheck + } + } if err := st.AddRel(id, string(ri.Type), resolved); err != nil { return err } @@ -498,18 +602,42 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er } func (s *nodeServiceImpl) Delete(id string) error { - n, err := s.store.GetNode(id) + pc, err := s.getPermContext() if err != nil { return err } - ac, err := s.getAccessContext() - if err != nil { - return err - } - if !ac.canWrite(s.nodeNamespaceID(n)) { + if !pc.canWrite(id) { return fmt.Errorf("permission denied: no write access to node %s", id) } - return s.store.DeleteNode(id) + return s.store.Transaction(func(st store.Store) error { + return s.cascadeDelete(st, id, make(map[string]bool)) + }) +} + +// cascadeDelete deletes id and all nodes it owns (recursively). +// visited prevents infinite loops from ownership cycles. +func (s *nodeServiceImpl) cascadeDelete(st store.Store, id string, visited map[string]bool) error { + if visited[id] { + return nil + } + visited[id] = true + node, err := st.GetNode(id) + if err != nil { + return err + } + // Capture owned node IDs before deleting (DeleteNode cascades the rels). + ownedIDs := make([]string, len(node.Relations[string(models.RelHasOwnership)])) + copy(ownedIDs, node.Relations[string(models.RelHasOwnership)]) + if err := st.DeleteNode(id); err != nil { + return err + } + for _, ownedID := range ownedIDs { + if ownedID == id { + continue // skip self-ownership + } + s.cascadeDelete(st, ownedID, visited) //nolint:errcheck — node may already be gone + } + return nil } // --- User management --- @@ -584,10 +712,11 @@ 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, models.RelHasReadAccess, models.RelHasWriteAccess: + case models.RelInNamespace: return s.resolveNamespaceRef(st, ri.Target) default: - return ri.Target, nil // blocks/subtask/related/custom expect raw node IDs + // Permission rels and all other edge rels expect raw node IDs. + return ri.Target, nil } } @@ -601,10 +730,11 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) switch relType { case models.RelAssignee, models.RelCreated, models.RelMentions: nodeType = "user" - case models.RelInNamespace, models.RelHasReadAccess, models.RelHasWriteAccess: + case models.RelInNamespace: nodeType = "namespace" default: - return target, true + // Permission rels and other edge rels use raw node IDs. + return "", false } id, err := s.resolveIDByNameAndType(s.store, target, nodeType) if err != nil || id == "" { @@ -653,6 +783,10 @@ func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, e if err := st.AddRel(id, "_type::user", ""); err != nil { return "", err } + // Users have self-ownership by default. + if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil { + return "", err + } return id, nil } @@ -692,8 +826,8 @@ func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, if err := st.AddRel(id, string(models.RelCreated), userID); err != nil { return "", err } - // Grant the creator write access to the new namespace. - if err := st.AddRel(userID, string(models.RelHasWriteAccess), id); err != nil { + // Creator owns the namespace. + if err := st.AddRel(userID, string(models.RelHasOwnership), id); err != nil { return "", err } return id, nil