Files
ax/service/node_service_impl.go

835 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}