feat: add agent user nodes with access token authentication
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m50s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 51s
Build and Publish Docker Image / build-apk (amd64, x86_64) (push) Successful in 53s
Build and Publish Docker Image / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Docker Image / build-and-push-docker (push) Successful in 12m50s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m50s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 51s
Build and Publish Docker Image / build-apk (amd64, x86_64) (push) Successful in 53s
Build and Publish Docker Image / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Docker Image / build-and-push-docker (push) Successful in 12m50s
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,11 @@ func (c *apiClient) do(method, path string, body any) (*http.Response, error) {
|
||||
// setAuth attaches either a Bearer token (when a session exists) or the
|
||||
// X-Ax-User header (no session / non-OIDC servers).
|
||||
func (c *apiClient) setAuth(req *http.Request) error {
|
||||
// Agent token takes priority (stateless, no login needed).
|
||||
if token := os.Getenv("AX_TOKEN"); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return nil
|
||||
}
|
||||
sess, err := store.LoadSession()
|
||||
if err != nil || sess == nil || sess.Token == "" {
|
||||
req.Header.Set("X-Ax-User", c.user)
|
||||
|
||||
@@ -47,12 +47,13 @@ type AddInput struct {
|
||||
// Adding an assignee rel replaces the previous single target.
|
||||
// Setting Namespace transfers ownership from the current namespace to the new one.
|
||||
type UpdateInput struct {
|
||||
Title *string
|
||||
Content *string
|
||||
DueDate *string // nil = no change; pointer to "" = clear due date
|
||||
Namespace *string // nil = no change; namespace name or ID to move node into
|
||||
AddRels []RelInput
|
||||
RemoveRels []RelInput
|
||||
Title *string
|
||||
Content *string
|
||||
DueDate *string // nil = no change; pointer to "" = clear due date
|
||||
Namespace *string // nil = no change; namespace name or ID to move node into
|
||||
AddRels []RelInput
|
||||
RemoveRels []RelInput
|
||||
RegenerateAccessToken bool // when true, regenerates the access token for agent nodes
|
||||
}
|
||||
|
||||
// ListFilter specifies which nodes to return. Empty slices are ignored.
|
||||
|
||||
@@ -3,6 +3,10 @@ package service
|
||||
import (
|
||||
"axolotl/models"
|
||||
"axolotl/store"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
@@ -11,6 +15,39 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// agentContent is the JSON structure stored in agent node content.
|
||||
type agentContent struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
// generateAccessToken returns a cryptographically random base64url token.
|
||||
func generateAccessToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// LookupAgentToken finds the agent node whose content contains the given
|
||||
// access token. Returns the agent node ID, or "" if not found.
|
||||
func LookupAgentToken(st store.GraphStore, token string) string {
|
||||
agents, err := st.FindNodes([]*models.Rel{{Type: "_type::agent", Target: ""}})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, agent := range agents {
|
||||
var c agentContent
|
||||
if err := json.Unmarshal([]byte(agent.Content), &c); err != nil {
|
||||
continue
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(c.AccessToken), []byte(token)) == 1 {
|
||||
return agent.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type nodeServiceImpl struct {
|
||||
store store.GraphStore
|
||||
userID string
|
||||
@@ -105,9 +142,16 @@ func (pc *permContext) hasOwnership(nodeID string) bool { return pc.levels[nodeI
|
||||
// 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
|
||||
// If userID is already a node ID (e.g. for agents), use it directly.
|
||||
var userNodeID string
|
||||
if exists, _ := s.store.NodeExists(s.userID); exists {
|
||||
userNodeID = s.userID
|
||||
} else {
|
||||
var err 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 == "" {
|
||||
@@ -158,7 +202,7 @@ func (s *nodeServiceImpl) getPermContext() (*permContext, error) {
|
||||
// --- Validation ---
|
||||
|
||||
var (
|
||||
validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true}
|
||||
validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true, "agent": true}
|
||||
validStatuses = map[string]bool{"open": true, "done": true}
|
||||
validPrios = map[string]bool{"high": true, "medium": true, "low": true}
|
||||
)
|
||||
@@ -349,6 +393,16 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Agent nodes get an auto-generated access token stored as JSON content.
|
||||
if tmp.GetProperty("type") == "agent" {
|
||||
token, err := generateAccessToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
data, _ := json.Marshal(agentContent{AccessToken: token})
|
||||
input.Content = string(data)
|
||||
}
|
||||
|
||||
dueDate, err := parseDueDate(input.DueDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -419,8 +473,15 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Agent nodes get self-ownership (like users).
|
||||
if tmp.GetProperty("type") == "agent" {
|
||||
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Grant ownership of the new node.
|
||||
// Namespace nodes are owned by their creator (user node).
|
||||
// Namespace and agent nodes are owned by their creator (user node).
|
||||
// All other nodes are owned by the namespace they belong to — the user
|
||||
// retains transitive ownership through the namespace's own ownership chain
|
||||
// (e.g. user→has_ownership→default-ns→has_ownership→node).
|
||||
@@ -429,7 +490,8 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
||||
return err
|
||||
}
|
||||
ownerID := creatorID
|
||||
if tmp.GetProperty("type") != "namespace" {
|
||||
nodeType := tmp.GetProperty("type")
|
||||
if nodeType != "namespace" && nodeType != "agent" {
|
||||
nsRef := input.Namespace
|
||||
if nsRef == "" {
|
||||
nsRef = s.userID
|
||||
@@ -465,7 +527,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
|
||||
}
|
||||
|
||||
// Field/tag changes, rel removals, and namespace change require can_write on the node.
|
||||
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil
|
||||
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil || input.RegenerateAccessToken
|
||||
for _, ri := range input.AddRels {
|
||||
if ri.Target == "" {
|
||||
needsWrite = true
|
||||
@@ -548,8 +610,22 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
|
||||
title = *input.Title
|
||||
}
|
||||
if input.Content != nil {
|
||||
if current.GetProperty("type") == "agent" {
|
||||
return fmt.Errorf("cannot set content on agent nodes; use --regenerate-access-token to rotate the token")
|
||||
}
|
||||
content = *input.Content
|
||||
}
|
||||
if input.RegenerateAccessToken {
|
||||
if current.GetProperty("type") != "agent" {
|
||||
return fmt.Errorf("cannot regenerate access token: node is not an agent")
|
||||
}
|
||||
token, err := generateAccessToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
data, _ := json.Marshal(agentContent{AccessToken: token})
|
||||
content = string(data)
|
||||
}
|
||||
if input.DueDate != nil {
|
||||
parsed, err := parseDueDate(*input.DueDate)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user