package service import ( "axolotl/models" "axolotl/store" "fmt" "maps" "regexp" "slices" "strings" "time" ) type nodeServiceImpl struct { store store.Store userID string } var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) func mentions(t string) []string { seen := make(map[string]bool) for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { seen[m[1]] = true } return slices.Collect(maps.Keys(seen)) } func (s *nodeServiceImpl) User() string { return s.userID } // --- 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. const ( permRead = 1 permCreateRel = 2 permWrite = 3 permOwnership = 4 ) // 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 false } // 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, } 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 pc, nil // user not bootstrapped yet; Add will auto-create user node } type entry struct { nodeID string level int } // 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 } pc.levels[curr.nodeID] = curr.level node, err := s.store.GetNode(curr.nodeID) if err != nil { continue // node may have been deleted; skip } 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}) } } } } // 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 --- var ( validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true} validStatuses = map[string]bool{"open": true, "done": true} validPrios = map[string]bool{"high": true, "medium": true, "low": true} ) // validateRels checks that any _ -prefixed rel names are known system properties // and that their values are valid. Users may not define custom _ -prefixed rels. func validateRels(rels []RelInput) error { for _, r := range rels { name := string(r.Type) if !strings.HasPrefix(name, "_") { continue } if v, ok := strings.CutPrefix(name, "_type::"); ok { if !validTypes[v] { return fmt.Errorf("invalid type %q: must be one of issue, note, user, namespace", v) } } else if v, ok := strings.CutPrefix(name, "_status::"); ok { if !validStatuses[v] { return fmt.Errorf("invalid status %q: must be one of open, done", v) } } else if v, ok := strings.CutPrefix(name, "_prio::"); ok { if !validPrios[v] { return fmt.Errorf("invalid priority %q: must be one of high, medium, low", v) } } else { return fmt.Errorf("invalid relation %q: custom _ prefix not allowed", name) } } return nil } // --- Query --- func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { n, err := s.store.GetNode(id) if err != nil { return nil, err } pc, err := s.getPermContext() if err != nil { return nil, err } if !pc.canRead(id) { 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) { var storeFilters []*models.Rel for _, ri := range filter.Rels { if ri.Target == "" { storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: ""}) } else { id, ok := s.lookupRelTarget(ri.Type, ri.Target) if !ok { return nil, nil } storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: id}) } } nodes, err := s.store.FindNodes(storeFilters) if err != nil { return nil, err } pc, err := s.getPermContext() if err != nil { return nil, err } var result []*models.Node for _, n := range nodes { if pc.canRead(n.ID) { result = append(result, n) } } return result, nil } // --- Lifecycle --- func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { // Build tag set from tag rels (Target == ""), applying property-replacement semantics. tmp := models.NewNode() for _, r := range input.Rels { if r.Target == "" { tmp.AddTag(string(r.Type)) } } // Apply defaults. if tmp.GetProperty("type") == "" { tmp.AddTag("_type::issue") } if tmp.GetProperty("type") == "issue" && tmp.GetProperty("status") == "" { tmp.AddTag("_status::open") } // Validate all rels (including the resolved default tags). tagRels := make([]RelInput, len(tmp.Tags)) for i, t := range tmp.Tags { tagRels[i] = RelInput{Type: models.RelType(t)} } if err := validateRels(append(tagRels, input.Rels...)); err != nil { return nil, err } // Permission checks for edge rels. pc, err := s.getPermContext() if err != nil { return nil, err } for _, ri := range input.Rels { if ri.Target == "" { continue // tag rel, no target to check } targetID, found := s.lookupRelTarget(ri.Type, ri.Target) if !found { continue // will be auto-created; skip check } 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) } } } } hasNamespace := false for _, ri := range input.Rels { if ri.Type == models.RelInNamespace && ri.Target != "" { hasNamespace = true } } id, err := s.store.GenerateID() if err != nil { return nil, err } err = s.store.Transaction(func(st store.Store) error { now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, input.Title, input.Content, input.DueDate, now, now); err != nil { return err } // Store tag rels. for _, t := range tmp.Tags { if err := st.AddRel(id, t, ""); err != nil { return err } } // Mentions. for _, m := range mentions(input.Title + " " + input.Content) { userID, err := s.resolveUserRef(st, m) if err != nil { return err } if err := st.AddRel(id, string(models.RelMentions), userID); err != nil { return err } } // Edge rels. hasCreated := false for _, ri := range input.Rels { if ri.Target == "" { continue // already stored as tag } if ri.Type == models.RelCreated { hasCreated = true } resolved, err := s.resolveRelTarget(st, ri) 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 } } // Default namespace. if !hasNamespace { nsID, err := s.resolveNamespaceRef(st, s.userID) if err != nil { return err } if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil { return err } } // Default created. if !hasCreated { userID, err := s.resolveUserRef(st, s.userID) if err != nil { return err } if err := st.AddRel(id, string(models.RelCreated), userID); err != nil { return err } } // 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 ownership. if tmp.GetProperty("type") == "namespace" { if !hasNamespace { // Replace the default namespace rel (user's ns) with self-reference. userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace") if userNsID != "" { if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil { return err } } if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil { return err } } // Creator already gets ownership via the block above; nothing more to do. } return nil }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) { // Validate rels before doing any I/O. if err := validateRels(input.AddRels); err != nil { return nil, err } // --- Permission checks --- pc, err := s.getPermContext() 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 len(input.RemoveRels) > 0 { needsWrite = true } if needsWrite && !pc.canWrite(id) { return nil, fmt.Errorf("permission denied: no write access to node %s", id) } // 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. for _, r := range input.AddRels { if r.Target == "" && string(r.Type) == "_status::done" { if err := s.checkBlockers(id); err != nil { return nil, err } break } } err = s.store.Transaction(func(st store.Store) error { current, err := st.GetNode(id) if err != nil { return err } title, content, dueDate := current.Title, current.Content, current.DueDate if input.Title != nil { title = *input.Title } if input.Content != nil { content = *input.Content } if input.DueDate != nil { dueDate = *input.DueDate } now := time.Now().UTC().Format(time.RFC3339) if err := st.UpdateNode(id, title, content, dueDate, now); err != nil { return err } // Compute new tag set using the model's AddTag/RemoveTag to preserve // property-prefix replacement semantics. tmp := models.NewNode() for _, t := range current.Tags { tmp.AddTag(t) } for _, r := range input.AddRels { if r.Target == "" { tmp.AddTag(string(r.Type)) } } for _, r := range input.RemoveRels { if r.Target == "" { tmp.RemoveTag(string(r.Type)) } } currentTags, newTags := current.Tags, tmp.Tags for _, t := range currentTags { if !slices.Contains(newTags, t) { if err := st.RemoveRel(id, t, ""); err != nil { return err } } } for _, t := range newTags { if !slices.Contains(currentTags, t) { if err := st.AddRel(id, t, ""); err != nil { return err } } } // Sync mention edges when title or content changed. if input.Title != nil || input.Content != nil { if err := s.syncMentions(st, id, current, title, content); err != nil { return err } } currentRels := current.Relations for _, ri := range input.AddRels { if ri.Target == "" { continue // already handled as tag } resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } // Single-value relations replace the previous target. if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace { for _, oldTgt := range currentRels[string(ri.Type)] { if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil { return err } } } // 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 } } for _, ri := range input.RemoveRels { if ri.Target == "" { continue // already handled as tag } resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } if err := st.RemoveRel(id, string(ri.Type), resolved); err != nil { return err } } return nil }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) Delete(id string) error { pc, err := s.getPermContext() if err != nil { return err } if !pc.canWrite(id) { return fmt.Errorf("permission denied: no write access to node %s", 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 --- func (s *nodeServiceImpl) AddUser(name string) (*models.Node, error) { var id string err := s.store.Transaction(func(st store.Store) error { var err error id, err = s.ensureUser(st, name) return err }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) ListUsers() ([]*models.Node, error) { return s.store.FindNodes([]*models.Rel{{Type: "_type::user", Target: ""}}) } // --- Internal helpers --- func (s *nodeServiceImpl) checkBlockers(id string) error { blockers, err := s.store.FindNodes([]*models.Rel{{Type: models.RelBlocks, Target: id}}) if err != nil { return err } var blocking []string for _, b := range blockers { if b.GetProperty("status") == "open" { blocking = append(blocking, b.ID) } } if len(blocking) > 0 { return fmt.Errorf("cannot close: blocked by %v", blocking) } return nil } func (s *nodeServiceImpl) syncMentions(st store.Store, id string, current *models.Node, newTitle, newContent string) error { existingMentionIDs := make(map[string]bool) for _, uid := range current.Relations[string(models.RelMentions)] { existingMentionIDs[uid] = true } mentionedUserIDs := make(map[string]bool) for _, m := range mentions(newTitle + " " + newContent) { userID, err := s.resolveUserRef(st, m) if err != nil { return err } mentionedUserIDs[userID] = true if !existingMentionIDs[userID] { if err := st.AddRel(id, string(models.RelMentions), userID); err != nil { return err } } } for uid := range existingMentionIDs { if !mentionedUserIDs[uid] { if err := st.RemoveRel(id, string(models.RelMentions), uid); err != nil { return err } } } return nil } // resolveRelTarget resolves a RelInput target to a node ID, auto-creating users // and namespaces as needed. Use only inside a transaction. func (s *nodeServiceImpl) resolveRelTarget(st store.Store, ri RelInput) (string, error) { switch ri.Type { case models.RelAssignee, models.RelCreated, models.RelMentions: return s.resolveUserRef(st, ri.Target) case models.RelInNamespace: return s.resolveNamespaceRef(st, ri.Target) default: // Permission rels and all other edge rels expect raw node IDs. return ri.Target, nil } } // lookupRelTarget resolves a filter target to a node ID without creating anything. // Returns ("", false) when the target doesn't exist. func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) (string, bool) { if exists, _ := s.store.NodeExists(target); exists { return target, true } var nodeType string switch relType { case models.RelAssignee, models.RelCreated, models.RelMentions: nodeType = "user" case models.RelInNamespace: nodeType = "namespace" default: // Permission rels and other edge rels use raw node IDs. return "", false } id, err := s.resolveIDByNameAndType(s.store, target, nodeType) if err != nil || id == "" { return "", false } return id, true } // resolveIDByNameAndType finds a node by title and _type property without creating it. func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) { nodes, err := st.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}}) if err != nil { return "", err } for _, n := range nodes { if n.Title == title { return n.ID, nil } } return "", nil } func (s *nodeServiceImpl) resolveUserRef(st store.Store, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureUser(st, ref) } func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, error) { userID, err := s.resolveIDByNameAndType(st, username, "user") if err != nil { return "", err } if userID != "" { return userID, nil } id, err := st.GenerateID() if err != nil { return "", err } now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, username, "", "", now, now); err != nil { return "", err } 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 } func (s *nodeServiceImpl) resolveNamespaceRef(st store.Store, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureNamespace(st, ref) } func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, error) { nsID, err := s.resolveIDByNameAndType(st, name, "namespace") if err != nil { return "", err } if nsID != "" { return nsID, nil } id, err := st.GenerateID() if err != nil { return "", err } now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, name, "", "", now, now); err != nil { return "", err } if err := st.AddRel(id, "_type::namespace", ""); err != nil { return "", err } if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil { return "", err } userID, err := s.resolveUserRef(st, s.userID) if err != nil { return "", err } if err := st.AddRel(id, string(models.RelCreated), userID); err != nil { return "", err } // Creator owns the namespace. if err := st.AddRel(userID, string(models.RelHasOwnership), id); err != nil { return "", err } return id, nil }