Bläddra i källkod

require auth for all operations, use db_bootstrap.sql script, remove redundant tests

david 6 dagar sedan
förälder
incheckning
7b1d31feba

+ 1 - 1
README.md

@@ -1,4 +1,4 @@
-# ARP - Agent Resource Planner
+# ARP - Agent Resource Platform
 
 A GraphQL-based coordination system for users and agents to collaborate on services, tasks, and communications.
 

+ 108 - 11
graph/integration_test.go

@@ -9,6 +9,7 @@ import (
 	"github.com/99designs/gqlgen/client"
 	"github.com/99designs/gqlgen/graphql/handler"
 	"github.com/bradleyjkemp/cupaloy/v2"
+	"gogs.dmsc.dev/arp/auth"
 	"gogs.dmsc.dev/arp/graph/testutil"
 	"gorm.io/gorm"
 )
@@ -18,6 +19,7 @@ var snapshotter = cupaloy.New(cupaloy.SnapshotSubdirectory("testdata/snapshots")
 type TestClient struct {
 	client *client.Client
 	db     *gorm.DB
+	token  string
 }
 
 type IDTracker struct {
@@ -47,14 +49,85 @@ func NewIDTracker() *IDTracker {
 }
 
 func setupTestClient(t *testing.T) (*TestClient, *IDTracker) {
-	db, err := testutil.SetupTestDB()
+	// Setup and bootstrap the database with initial data from init_tests.sql
+	db, err := testutil.SetupAndBootstrapTestDB()
 	if err != nil {
 		t.Fatalf("Failed to setup test database: %v", err)
 	}
 	resolver := &Resolver{DB: db}
 	schema := NewExecutableSchema(Config{Resolvers: resolver})
 	srv := handler.NewDefaultServer(schema)
-	return &TestClient{client: client.New(srv), db: db}, NewIDTracker()
+
+	// Wrap with auth middleware
+	authSrv := auth.AuthMiddleware(srv)
+
+	gqlClient := client.New(authSrv)
+
+	// Login as admin to get a token
+	var loginResponse struct {
+		Login struct {
+			Token string `json:"token"`
+			User  struct {
+				ID    string `json:"id"`
+				Email string `json:"email"`
+			} `json:"user"`
+		} `json:"login"`
+	}
+	loginQuery := `mutation { login(email: "admin@example.com", password: "secret123") { token user { id email } } }`
+	err = gqlClient.Post(loginQuery, &loginResponse)
+	if err != nil {
+		t.Fatalf("Failed to login as admin: %v", err)
+	}
+	if loginResponse.Login.Token == "" {
+		t.Fatal("Login returned empty token")
+	}
+
+	// Create authenticated client
+	authClient := client.New(authSrv, client.AddHeader("Authorization", "Bearer "+loginResponse.Login.Token))
+
+	// Initialize tracker with bootstrapped data
+	tracker := NewIDTracker()
+
+	// Track bootstrapped admin user
+	tracker.Users["admin@example.com"] = loginResponse.Login.User.ID
+
+	// Fetch and track bootstrapped permissions
+	var permsResponse struct {
+		Permissions []struct {
+			ID   string `json:"id"`
+			Code string `json:"code"`
+		} `json:"permissions"`
+	}
+	authClient.Post(`query { permissions { id code } }`, &permsResponse)
+	for _, perm := range permsResponse.Permissions {
+		tracker.Permissions[perm.Code] = perm.ID
+	}
+
+	// Fetch and track bootstrapped roles
+	var rolesResponse struct {
+		Roles []struct {
+			ID   string `json:"id"`
+			Name string `json:"name"`
+		} `json:"roles"`
+	}
+	authClient.Post(`query { roles { id name } }`, &rolesResponse)
+	for _, role := range rolesResponse.Roles {
+		tracker.Roles[role.Name] = role.ID
+	}
+
+	// Fetch and track bootstrapped task statuses
+	var statusesResponse struct {
+		TaskStatuses []struct {
+			ID   string `json:"id"`
+			Code string `json:"code"`
+		} `json:"taskStatuses"`
+	}
+	authClient.Post(`query { taskStatuses { id code } }`, &statusesResponse)
+	for _, status := range statusesResponse.TaskStatuses {
+		tracker.TaskStatuses[status.Code] = status.ID
+	}
+
+	return &TestClient{client: authClient, db: db, token: loginResponse.Login.Token}, tracker
 }
 
 func normalizeJSON(jsonStr string) string {
@@ -101,9 +174,13 @@ func TestIntegration_Bootstrap(t *testing.T) {
 	tc, tracker := setupTestClient(t)
 	seed := testutil.GetSeedData()
 
-	// Phase 1: Create Permissions
+	// Phase 1: Create Permissions (skip if already bootstrapped)
 	t.Run("CreatePermissions", func(t *testing.T) {
 		for _, perm := range seed.Permissions {
+			// Skip if already exists from bootstrap
+			if _, exists := tracker.Permissions[perm.Code]; exists {
+				continue
+			}
 			var response struct {
 				CreatePermission struct {
 					ID          string `json:"id"`
@@ -126,9 +203,13 @@ func TestIntegration_Bootstrap(t *testing.T) {
 		snapshotResult(t, "permissions", string(jsonBytes))
 	})
 
-	// Phase 2: Create Roles
+	// Phase 2: Create Roles (skip if already bootstrapped)
 	t.Run("CreateRoles", func(t *testing.T) {
 		for _, role := range seed.Roles {
+			// Skip if already exists from bootstrap
+			if _, exists := tracker.Roles[role.Name]; exists {
+				continue
+			}
 			permIDs := make([]string, len(role.PermissionCodes))
 			for i, code := range role.PermissionCodes {
 				permIDs[i] = tracker.Permissions[code]
@@ -155,7 +236,7 @@ func TestIntegration_Bootstrap(t *testing.T) {
 		snapshotResult(t, "roles", string(jsonBytes))
 	})
 
-	// Phase 3: Create Users
+	// Phase 3: Create Users (additional to bootstrapped admin)
 	t.Run("CreateUsers", func(t *testing.T) {
 		for _, user := range seed.Users {
 			roleIDs := make([]string, len(user.RoleNames))
@@ -183,9 +264,13 @@ func TestIntegration_Bootstrap(t *testing.T) {
 		snapshotResult(t, "users", string(jsonBytes))
 	})
 
-	// Phase 4: Create Task Statuses
+	// Phase 4: Create Task Statuses (skip if already bootstrapped)
 	t.Run("CreateTaskStatuses", func(t *testing.T) {
 		for _, status := range seed.TaskStatuses {
+			// Skip if already exists from bootstrap
+			if _, exists := tracker.TaskStatuses[status.Code]; exists {
+				continue
+			}
 			var response struct {
 				CreateTaskStatus struct {
 					ID    string `json:"id"`
@@ -448,10 +533,13 @@ func TestIntegration_Delete(t *testing.T) {
 	})
 }
 
-// bootstrapData creates all entities for testing
+// bootstrapData creates all entities for testing (skips existing items)
 func bootstrapData(t *testing.T, tc *TestClient, tracker *IDTracker, seed testutil.SeedData) {
-	// Create Permissions
+	// Create Permissions (skip if already exists)
 	for _, perm := range seed.Permissions {
+		if _, exists := tracker.Permissions[perm.Code]; exists {
+			continue
+		}
 		var response struct {
 			CreatePermission struct {
 				ID string `json:"id"`
@@ -465,8 +553,11 @@ func bootstrapData(t *testing.T, tc *TestClient, tracker *IDTracker, seed testut
 		tracker.Permissions[perm.Code] = response.CreatePermission.ID
 	}
 
-	// Create Roles
+	// Create Roles (skip if already exists)
 	for _, role := range seed.Roles {
+		if _, exists := tracker.Roles[role.Name]; exists {
+			continue
+		}
 		permIDs := make([]string, len(role.PermissionCodes))
 		for i, code := range role.PermissionCodes {
 			permIDs[i] = tracker.Permissions[code]
@@ -484,8 +575,11 @@ func bootstrapData(t *testing.T, tc *TestClient, tracker *IDTracker, seed testut
 		tracker.Roles[role.Name] = response.CreateRole.ID
 	}
 
-	// Create Users
+	// Create Users (skip if already exists)
 	for _, user := range seed.Users {
+		if _, exists := tracker.Users[user.Email]; exists {
+			continue
+		}
 		roleIDs := make([]string, len(user.RoleNames))
 		for i, name := range user.RoleNames {
 			roleIDs[i] = tracker.Roles[name]
@@ -503,8 +597,11 @@ func bootstrapData(t *testing.T, tc *TestClient, tracker *IDTracker, seed testut
 		tracker.Users[user.Email] = response.CreateUser.ID
 	}
 
-	// Create Task Statuses
+	// Create Task Statuses (skip if already exists)
 	for _, status := range seed.TaskStatuses {
+		if _, exists := tracker.TaskStatuses[status.Code]; exists {
+			continue
+		}
 		var response struct {
 			CreateTaskStatus struct {
 				ID string `json:"id"`

+ 0 - 125
graph/resolver_test.go

@@ -1,125 +0,0 @@
-package graph
-
-import (
-	"context"
-	"strconv"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"gogs.dmsc.dev/arp/models"
-	"gorm.io/driver/sqlite"
-	"gorm.io/gorm"
-)
-
-func setupTestDB() (*gorm.DB, error) {
-	// Use in-memory SQLite database for testing
-	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
-	if err != nil {
-		return nil, err
-	}
-
-	// Run auto-migration for all models
-	err = db.AutoMigrate(&models.User{}, &models.Role{}, &models.Permission{}, &models.Service{}, &models.Task{}, &models.TaskStatus{}, &models.Channel{}, &models.Message{}, &models.Note{})
-	if err != nil {
-		return nil, err
-	}
-
-	return db, nil
-}
-
-func TestResolver_UserQueries(t *testing.T) {
-	// Setup test database
-	db, err := setupTestDB()
-	assert.NoError(t, err)
-
-	// Create resolver with test DB
-	resolver := &Resolver{DB: db}
-	queryResolver := &queryResolver{resolver}
-
-	// Test creating a user
-	ctx := context.Background()
-
-	// Create a user directly without complex relationships for now
-	user := models.User{
-		Email:    "test@example.com",
-		Password: "hashed-password",
-	}
-	err = db.Create(&user).Error
-	assert.NoError(t, err)
-
-	// Test querying users
-	users, err := queryResolver.Users(ctx)
-	assert.NoError(t, err)
-	assert.NotNil(t, users)
-	assert.Len(t, users, 1)
-
-	// Test querying a single user by ID
-	userResult, err := queryResolver.User(ctx, strconv.FormatUint(uint64(user.ID), 10))
-	assert.NoError(t, err)
-	assert.NotNil(t, userResult)
-	assert.Equal(t, user.Email, userResult.Email)
-}
-
-func TestResolver_RoleQueries(t *testing.T) {
-	// Setup test database
-	db, err := setupTestDB()
-	assert.NoError(t, err)
-
-	// Create resolver with test DB
-	resolver := &Resolver{DB: db}
-	queryResolver := &queryResolver{resolver}
-
-	// Test creating a role
-	ctx := context.Background()
-
-	role := models.Role{
-		Name:        "admin",
-		Description: "Administrator role",
-	}
-	err = db.Create(&role).Error
-	assert.NoError(t, err)
-
-	// Test querying roles
-	roles, err := queryResolver.Roles(ctx)
-	assert.NoError(t, err)
-	assert.NotNil(t, roles)
-	assert.Len(t, roles, 1)
-
-	// Test querying a single role by ID
-	roleResult, err := queryResolver.Role(ctx, strconv.FormatUint(uint64(role.ID), 10))
-	assert.NoError(t, err)
-	assert.NotNil(t, roleResult)
-	assert.Equal(t, role.Name, roleResult.Name)
-}
-
-func TestResolver_ServiceQueries(t *testing.T) {
-	// Setup test database
-	db, err := setupTestDB()
-	assert.NoError(t, err)
-
-	// Create resolver with test DB
-	resolver := &Resolver{DB: db}
-	queryResolver := &queryResolver{resolver}
-
-	// Test creating a service
-	ctx := context.Background()
-
-	service := models.Service{
-		Name:        "Test Service",
-		Description: "A test service",
-	}
-	err = db.Create(&service).Error
-	assert.NoError(t, err)
-
-	// Test querying services
-	services, err := queryResolver.Services(ctx)
-	assert.NoError(t, err)
-	assert.NotNil(t, services)
-	assert.Len(t, services, 1)
-
-	// Test querying a single service by ID
-	serviceResult, err := queryResolver.Service(ctx, strconv.FormatUint(uint64(service.ID), 10))
-	assert.NoError(t, err)
-	assert.NotNil(t, serviceResult)
-	assert.Equal(t, service.Name, serviceResult.Name)
-}

+ 236 - 1
graph/schema.resolvers.go

@@ -13,6 +13,7 @@ import (
 
 	"gogs.dmsc.dev/arp/auth"
 	"gogs.dmsc.dev/arp/graph/model"
+	"gogs.dmsc.dev/arp/logging"
 	"gogs.dmsc.dev/arp/models"
 )
 
@@ -41,6 +42,11 @@ func (r *mutationResolver) Login(ctx context.Context, email string, password str
 
 // CreateUser is the resolver for the createUser field.
 func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	roles := make([]models.Role, len(input.Roles))
 	for i, roleIDStr := range input.Roles {
 		roleID, err := toID(roleIDStr)
@@ -70,12 +76,16 @@ func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser)
 		return nil, fmt.Errorf("failed to create user: %w", err)
 	}
 
+	logging.LogMutation(ctx, "CREATE", "USER", user.Email)
 	return convertUser(user), nil
 }
 
 // UpdateUser is the resolver for the updateUser field.
 func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input model.UpdateUserInput) (*model.User, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "user:update") {
 		return nil, errors.New("unauthorized: missing user:update permission")
 	}
@@ -121,12 +131,16 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input mode
 		return nil, fmt.Errorf("failed to update user: %w", err)
 	}
 
+	logging.LogMutation(ctx, "UPDATE", "USER", existing.Email)
 	return convertUser(existing), nil
 }
 
 // DeleteUser is the resolver for the deleteUser field.
 func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "user:delete") {
 		return false, errors.New("unauthorized: missing user:delete permission")
 	}
@@ -141,11 +155,17 @@ func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, err
 		return false, fmt.Errorf("failed to delete user: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "USER", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreateNote is the resolver for the createNote field.
 func (r *mutationResolver) CreateNote(ctx context.Context, input model.NewNote) (*model.Note, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	userID, err := toID(input.UserID)
 	if err != nil {
 		return nil, fmt.Errorf("invalid user ID: %w", err)
@@ -167,12 +187,16 @@ func (r *mutationResolver) CreateNote(ctx context.Context, input model.NewNote)
 		return nil, fmt.Errorf("failed to create note: %w", err)
 	}
 
+	logging.LogMutation(ctx, "CREATE", "NOTE", note.Title)
 	return convertNote(note), nil
 }
 
 // UpdateNote is the resolver for the updateNote field.
 func (r *mutationResolver) UpdateNote(ctx context.Context, id string, input model.UpdateNoteInput) (*model.Note, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "note:update") {
 		return nil, errors.New("unauthorized: missing note:update permission")
 	}
@@ -212,12 +236,16 @@ func (r *mutationResolver) UpdateNote(ctx context.Context, id string, input mode
 		return nil, fmt.Errorf("failed to update note: %w", err)
 	}
 
+	logging.LogMutation(ctx, "UPDATE", "NOTE", existing.Title)
 	return convertNote(existing), nil
 }
 
 // DeleteNote is the resolver for the deleteNote field.
 func (r *mutationResolver) DeleteNote(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "note:delete") {
 		return false, errors.New("unauthorized: missing note:delete permission")
 	}
@@ -232,11 +260,17 @@ func (r *mutationResolver) DeleteNote(ctx context.Context, id string) (bool, err
 		return false, fmt.Errorf("failed to delete note: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "NOTE", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreateRole is the resolver for the createRole field.
 func (r *mutationResolver) CreateRole(ctx context.Context, input model.NewRole) (*model.Role, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	permissions := make([]models.Permission, len(input.Permissions))
 	for i, permIDStr := range input.Permissions {
 		permID, err := toID(permIDStr)
@@ -260,12 +294,16 @@ func (r *mutationResolver) CreateRole(ctx context.Context, input model.NewRole)
 		return nil, fmt.Errorf("failed to create role: %w", err)
 	}
 
+	logging.LogMutation(ctx, "CREATE", "ROLE", role.Name)
 	return convertRole(role), nil
 }
 
 // UpdateRole is the resolver for the updateRole field.
 func (r *mutationResolver) UpdateRole(ctx context.Context, id string, input model.UpdateRoleInput) (*model.Role, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "role:update") {
 		return nil, errors.New("unauthorized: missing role:update permission")
 	}
@@ -306,12 +344,16 @@ func (r *mutationResolver) UpdateRole(ctx context.Context, id string, input mode
 		return nil, fmt.Errorf("failed to update role: %w", err)
 	}
 
+	logging.LogMutation(ctx, "UPDATE", "ROLE", existing.Name)
 	return convertRole(existing), nil
 }
 
 // DeleteRole is the resolver for the deleteRole field.
 func (r *mutationResolver) DeleteRole(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "role:delete") {
 		return false, errors.New("unauthorized: missing role:delete permission")
 	}
@@ -326,11 +368,17 @@ func (r *mutationResolver) DeleteRole(ctx context.Context, id string) (bool, err
 		return false, fmt.Errorf("failed to delete role: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "ROLE", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreatePermission is the resolver for the createPermission field.
 func (r *mutationResolver) CreatePermission(ctx context.Context, input model.NewPermission) (*model.Permission, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	permission := models.Permission{
 		Code:        input.Code,
 		Description: input.Description,
@@ -340,12 +388,16 @@ func (r *mutationResolver) CreatePermission(ctx context.Context, input model.New
 		return nil, fmt.Errorf("failed to create permission: %w", err)
 	}
 
+	logging.LogMutation(ctx, "CREATE", "PERMISSION", permission.Code)
 	return convertPermission(permission), nil
 }
 
 // UpdatePermission is the resolver for the updatePermission field.
 func (r *mutationResolver) UpdatePermission(ctx context.Context, id string, input model.UpdatePermissionInput) (*model.Permission, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "permission:update") {
 		return nil, errors.New("unauthorized: missing permission:update permission")
 	}
@@ -371,12 +423,16 @@ func (r *mutationResolver) UpdatePermission(ctx context.Context, id string, inpu
 		return nil, fmt.Errorf("failed to update permission: %w", err)
 	}
 
+	logging.LogMutation(ctx, "UPDATE", "PERMISSION", existing.Code)
 	return convertPermission(existing), nil
 }
 
 // DeletePermission is the resolver for the deletePermission field.
 func (r *mutationResolver) DeletePermission(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "permission:delete") {
 		return false, errors.New("unauthorized: missing permission:delete permission")
 	}
@@ -391,11 +447,17 @@ func (r *mutationResolver) DeletePermission(ctx context.Context, id string) (boo
 		return false, fmt.Errorf("failed to delete permission: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "PERMISSION", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreateService is the resolver for the createService field.
 func (r *mutationResolver) CreateService(ctx context.Context, input model.NewService) (*model.Service, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	createdByID, err := toID(input.CreatedByID)
 	if err != nil {
 		return nil, fmt.Errorf("invalid created by ID: %w", err)
@@ -429,12 +491,16 @@ func (r *mutationResolver) CreateService(ctx context.Context, input model.NewSer
 	// Reload with associations
 	r.DB.Preload("Participants").Preload("Tasks").First(&service, service.ID)
 
+	logging.LogMutation(ctx, "CREATE", "SERVICE", service.Name)
 	return convertService(service), nil
 }
 
 // UpdateService is the resolver for the updateService field.
 func (r *mutationResolver) UpdateService(ctx context.Context, id string, input model.UpdateServiceInput) (*model.Service, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "service:update") {
 		return nil, errors.New("unauthorized: missing service:update permission")
 	}
@@ -478,12 +544,16 @@ func (r *mutationResolver) UpdateService(ctx context.Context, id string, input m
 	// Reload with associations for response
 	r.DB.Preload("Participants").Preload("Tasks").First(&existing, existing.ID)
 
+	logging.LogMutation(ctx, "UPDATE", "SERVICE", existing.Name)
 	return convertService(existing), nil
 }
 
 // DeleteService is the resolver for the deleteService field.
 func (r *mutationResolver) DeleteService(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "service:delete") {
 		return false, errors.New("unauthorized: missing service:delete permission")
 	}
@@ -498,11 +568,17 @@ func (r *mutationResolver) DeleteService(ctx context.Context, id string) (bool,
 		return false, fmt.Errorf("failed to delete service: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "SERVICE", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreateTask is the resolver for the createTask field.
 func (r *mutationResolver) CreateTask(ctx context.Context, input model.NewTask) (*model.Task, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	createdByID, err := toID(input.CreatedByID)
 	if err != nil {
 		return nil, fmt.Errorf("invalid created by ID: %w", err)
@@ -543,12 +619,16 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input model.NewTask)
 	// Reload with associations
 	r.DB.Preload("CreatedBy").Preload("Assignee").Preload("Status").First(&task, task.ID)
 
+	logging.LogMutation(ctx, "CREATE", "TASK", task.Title)
 	return convertTask(task), nil
 }
 
 // UpdateTask is the resolver for the updateTask field.
 func (r *mutationResolver) UpdateTask(ctx context.Context, id string, input model.UpdateTaskInput) (*model.Task, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "task:update") {
 		return nil, errors.New("unauthorized: missing task:update permission")
 	}
@@ -613,12 +693,16 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, id string, input mode
 	// Reload with associations for response
 	r.DB.Preload("CreatedBy").Preload("Assignee").Preload("Status").First(&existing, existing.ID)
 
+	logging.LogMutation(ctx, "UPDATE", "TASK", existing.Title)
 	return convertTask(existing), nil
 }
 
 // DeleteTask is the resolver for the deleteTask field.
 func (r *mutationResolver) DeleteTask(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "task:delete") {
 		return false, errors.New("unauthorized: missing task:delete permission")
 	}
@@ -633,11 +717,17 @@ func (r *mutationResolver) DeleteTask(ctx context.Context, id string) (bool, err
 		return false, fmt.Errorf("failed to delete task: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "TASK", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreateTaskStatus is the resolver for the createTaskStatus field.
 func (r *mutationResolver) CreateTaskStatus(ctx context.Context, input model.NewTaskStatus) (*model.TaskStatus, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	taskStatus := models.TaskStatus{
 		Code:  input.Code,
 		Label: input.Label,
@@ -647,12 +737,16 @@ func (r *mutationResolver) CreateTaskStatus(ctx context.Context, input model.New
 		return nil, fmt.Errorf("failed to create task status: %w", err)
 	}
 
+	logging.LogMutation(ctx, "CREATE", "TASKSTATUS", taskStatus.Code)
 	return convertTaskStatus(taskStatus), nil
 }
 
 // UpdateTaskStatus is the resolver for the updateTaskStatus field.
 func (r *mutationResolver) UpdateTaskStatus(ctx context.Context, id string, input model.UpdateTaskStatusInput) (*model.TaskStatus, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "taskstatus:update") {
 		return nil, errors.New("unauthorized: missing taskstatus:update permission")
 	}
@@ -681,12 +775,16 @@ func (r *mutationResolver) UpdateTaskStatus(ctx context.Context, id string, inpu
 	// Reload with tasks for response
 	r.DB.Preload("Tasks").First(&existing, existing.ID)
 
+	logging.LogMutation(ctx, "UPDATE", "TASKSTATUS", existing.Code)
 	return convertTaskStatus(existing), nil
 }
 
 // DeleteTaskStatus is the resolver for the deleteTaskStatus field.
 func (r *mutationResolver) DeleteTaskStatus(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "taskstatus:delete") {
 		return false, errors.New("unauthorized: missing taskstatus:delete permission")
 	}
@@ -701,11 +799,17 @@ func (r *mutationResolver) DeleteTaskStatus(ctx context.Context, id string) (boo
 		return false, fmt.Errorf("failed to delete task status: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "TASKSTATUS", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreateChannel is the resolver for the createChannel field.
 func (r *mutationResolver) CreateChannel(ctx context.Context, input model.NewChannel) (*model.Channel, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	channel := models.Channel{}
 
 	for _, participantIDStr := range input.Participants {
@@ -727,12 +831,16 @@ func (r *mutationResolver) CreateChannel(ctx context.Context, input model.NewCha
 	// Reload with participants
 	r.DB.Preload("Participants").First(&channel, channel.ID)
 
+	logging.LogMutation(ctx, "CREATE", "CHANNEL", fmt.Sprintf("id=%d", channel.ID))
 	return convertChannel(channel), nil
 }
 
 // UpdateChannel is the resolver for the updateChannel field.
 func (r *mutationResolver) UpdateChannel(ctx context.Context, id string, input model.UpdateChannelInput) (*model.Channel, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "channel:update") {
 		return nil, errors.New("unauthorized: missing channel:update permission")
 	}
@@ -765,12 +873,16 @@ func (r *mutationResolver) UpdateChannel(ctx context.Context, id string, input m
 		return nil, fmt.Errorf("failed to update channel: %w", err)
 	}
 
+	logging.LogMutation(ctx, "UPDATE", "CHANNEL", id)
 	return convertChannel(existing), nil
 }
 
 // DeleteChannel is the resolver for the deleteChannel field.
 func (r *mutationResolver) DeleteChannel(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "channel:delete") {
 		return false, errors.New("unauthorized: missing channel:delete permission")
 	}
@@ -785,11 +897,17 @@ func (r *mutationResolver) DeleteChannel(ctx context.Context, id string) (bool,
 		return false, fmt.Errorf("failed to delete channel: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "CHANNEL", id)
 	return result.RowsAffected > 0, nil
 }
 
 // CreateMessage is the resolver for the createMessage field.
 func (r *mutationResolver) CreateMessage(ctx context.Context, input model.NewMessage) (*model.Message, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	conversationID, err := toID(input.ConversationID)
 	if err != nil {
 		return nil, fmt.Errorf("invalid conversation ID: %w", err)
@@ -812,12 +930,16 @@ func (r *mutationResolver) CreateMessage(ctx context.Context, input model.NewMes
 	// Reload with associations
 	r.DB.Preload("Sender").First(&message, message.ID)
 
+	logging.LogMutation(ctx, "CREATE", "MESSAGE", fmt.Sprintf("id=%d", message.ID))
 	return convertMessage(message), nil
 }
 
 // UpdateMessage is the resolver for the updateMessage field.
 func (r *mutationResolver) UpdateMessage(ctx context.Context, id string, input model.UpdateMessageInput) (*model.Message, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "message:update") {
 		return nil, errors.New("unauthorized: missing message:update permission")
 	}
@@ -854,12 +976,16 @@ func (r *mutationResolver) UpdateMessage(ctx context.Context, id string, input m
 		return nil, fmt.Errorf("failed to update message: %w", err)
 	}
 
+	logging.LogMutation(ctx, "UPDATE", "MESSAGE", id)
 	return convertMessage(existing), nil
 }
 
 // DeleteMessage is the resolver for the deleteMessage field.
 func (r *mutationResolver) DeleteMessage(ctx context.Context, id string) (bool, error) {
 	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return false, errors.New("unauthorized: authentication required")
+	}
 	if !auth.HasPermission(ctx, "message:delete") {
 		return false, errors.New("unauthorized: missing message:delete permission")
 	}
@@ -874,20 +1000,32 @@ func (r *mutationResolver) DeleteMessage(ctx context.Context, id string) (bool,
 		return false, fmt.Errorf("failed to delete message: %w", result.Error)
 	}
 
+	logging.LogMutation(ctx, "DELETE", "MESSAGE", id)
 	return result.RowsAffected > 0, nil
 }
 
 // Users is the resolver for the users field.
 func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var users []models.User
 	if err := r.DB.Find(&users).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch users: %w", err)
 	}
+	logging.LogQuery(ctx, "USERS", "all")
 	return convertUsers(users), nil
 }
 
 // User is the resolver for the user field.
 func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	userID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid user ID: %w", err)
@@ -898,20 +1036,32 @@ func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error
 		return nil, fmt.Errorf("user not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "USER", id)
 	return convertUser(user), nil
 }
 
 // Notes is the resolver for the notes field.
 func (r *queryResolver) Notes(ctx context.Context) ([]*model.Note, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var notes []models.Note
 	if err := r.DB.Preload("User").Preload("Service").Find(&notes).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch notes: %w", err)
 	}
+	logging.LogQuery(ctx, "NOTES", "all")
 	return convertNotes(notes), nil
 }
 
 // Note is the resolver for the note field.
 func (r *queryResolver) Note(ctx context.Context, id string) (*model.Note, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	noteID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid note ID: %w", err)
@@ -922,20 +1072,32 @@ func (r *queryResolver) Note(ctx context.Context, id string) (*model.Note, error
 		return nil, fmt.Errorf("note not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "NOTE", id)
 	return convertNote(note), nil
 }
 
 // Roles is the resolver for the roles field.
 func (r *queryResolver) Roles(ctx context.Context) ([]*model.Role, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var roles []models.Role
 	if err := r.DB.Preload("Permissions").Find(&roles).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch roles: %w", err)
 	}
+	logging.LogQuery(ctx, "ROLES", "all")
 	return convertRoles(roles), nil
 }
 
 // Role is the resolver for the role field.
 func (r *queryResolver) Role(ctx context.Context, id string) (*model.Role, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	roleID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid role ID: %w", err)
@@ -946,20 +1108,32 @@ func (r *queryResolver) Role(ctx context.Context, id string) (*model.Role, error
 		return nil, fmt.Errorf("role not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "ROLE", id)
 	return convertRole(role), nil
 }
 
 // Permissions is the resolver for the permissions field.
 func (r *queryResolver) Permissions(ctx context.Context) ([]*model.Permission, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var perms []models.Permission
 	if err := r.DB.Find(&perms).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch permissions: %w", err)
 	}
+	logging.LogQuery(ctx, "PERMISSIONS", "all")
 	return convertPermissions(perms), nil
 }
 
 // Permission is the resolver for the permission field.
 func (r *queryResolver) Permission(ctx context.Context, id string) (*model.Permission, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	permID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid permission ID: %w", err)
@@ -970,20 +1144,32 @@ func (r *queryResolver) Permission(ctx context.Context, id string) (*model.Permi
 		return nil, fmt.Errorf("permission not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "PERMISSION", id)
 	return convertPermission(perm), nil
 }
 
 // Services is the resolver for the services field.
 func (r *queryResolver) Services(ctx context.Context) ([]*model.Service, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var services []models.Service
 	if err := r.DB.Preload("CreatedBy").Preload("Participants").Preload("Tasks").Find(&services).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch services: %w", err)
 	}
+	logging.LogQuery(ctx, "SERVICES", "all")
 	return convertServices(services), nil
 }
 
 // Service is the resolver for the service field.
 func (r *queryResolver) Service(ctx context.Context, id string) (*model.Service, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	serviceID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid service ID: %w", err)
@@ -994,20 +1180,32 @@ func (r *queryResolver) Service(ctx context.Context, id string) (*model.Service,
 		return nil, fmt.Errorf("service not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "SERVICE", id)
 	return convertService(service), nil
 }
 
 // Tasks is the resolver for the tasks field.
 func (r *queryResolver) Tasks(ctx context.Context) ([]*model.Task, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var tasks []models.Task
 	if err := r.DB.Preload("CreatedBy").Preload("Assignee").Preload("Status").Find(&tasks).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch tasks: %w", err)
 	}
+	logging.LogQuery(ctx, "TASKS", "all")
 	return convertTasks(tasks), nil
 }
 
 // Task is the resolver for the task field.
 func (r *queryResolver) Task(ctx context.Context, id string) (*model.Task, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	taskID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid task ID: %w", err)
@@ -1018,20 +1216,32 @@ func (r *queryResolver) Task(ctx context.Context, id string) (*model.Task, error
 		return nil, fmt.Errorf("task not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "TASK", id)
 	return convertTask(task), nil
 }
 
 // TaskStatuses is the resolver for the taskStatuses field.
 func (r *queryResolver) TaskStatuses(ctx context.Context) ([]*model.TaskStatus, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var statuses []models.TaskStatus
 	if err := r.DB.Preload("Tasks").Find(&statuses).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch task statuses: %w", err)
 	}
+	logging.LogQuery(ctx, "TASKSTATUSES", "all")
 	return convertTaskStatuses(statuses), nil
 }
 
-// TaskStatus is the resolver for the taskStatus field.
+// TaskStatus
 func (r *queryResolver) TaskStatus(ctx context.Context, id string) (*model.TaskStatus, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	statusID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid task status ID: %w", err)
@@ -1042,20 +1252,32 @@ func (r *queryResolver) TaskStatus(ctx context.Context, id string) (*model.TaskS
 		return nil, fmt.Errorf("task status not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "TASKSTATUS", id)
 	return convertTaskStatus(status), nil
 }
 
 // Channels is the resolver for the channels field.
 func (r *queryResolver) Channels(ctx context.Context) ([]*model.Channel, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var channels []models.Channel
 	if err := r.DB.Preload("Participants").Find(&channels).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch channels: %w", err)
 	}
+	logging.LogQuery(ctx, "CHANNELS", "all")
 	return convertChannels(channels), nil
 }
 
 // Channel is the resolver for the channel field.
 func (r *queryResolver) Channel(ctx context.Context, id string) (*model.Channel, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	channelID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid channel ID: %w", err)
@@ -1066,20 +1288,32 @@ func (r *queryResolver) Channel(ctx context.Context, id string) (*model.Channel,
 		return nil, fmt.Errorf("channel not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "CHANNEL", id)
 	return convertChannel(channel), nil
 }
 
 // Messages is the resolver for the messages field.
 func (r *queryResolver) Messages(ctx context.Context) ([]*model.Message, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	var messages []models.Message
 	if err := r.DB.Preload("Sender").Find(&messages).Error; err != nil {
 		return nil, fmt.Errorf("failed to fetch messages: %w", err)
 	}
+	logging.LogQuery(ctx, "MESSAGES", "all")
 	return convertMessages(messages), nil
 }
 
 // Message is the resolver for the message field.
 func (r *queryResolver) Message(ctx context.Context, id string) (*model.Message, error) {
+	// Auth check
+	if !auth.IsAuthenticated(ctx) {
+		return nil, errors.New("unauthorized: authentication required")
+	}
+
 	messageID, err := toID(id)
 	if err != nil {
 		return nil, fmt.Errorf("invalid message ID: %w", err)
@@ -1090,6 +1324,7 @@ func (r *queryResolver) Message(ctx context.Context, id string) (*model.Message,
 		return nil, fmt.Errorf("message not found: %w", err)
 	}
 
+	logging.LogQuery(ctx, "MESSAGE", id)
 	return convertMessage(message), nil
 }
 

+ 138 - 10
graph/testdata/snapshots/TestIntegration_Bootstrap-CreatePermissions

@@ -1,34 +1,162 @@
 permissions
 {
   "permissions": [
+    {
+      "code": "user:create",
+      "description": "Create users"
+    },
     {
       "code": "user:read",
-      "description": "Read user information"
+      "description": "Read users"
     },
     {
-      "code": "user:write",
-      "description": "Create and update users"
+      "code": "user:update",
+      "description": "Update users"
     },
     {
-      "code": "task:read",
-      "description": "Read task information"
+      "code": "user:delete",
+      "description": "Delete users"
     },
     {
-      "code": "task:write",
-      "description": "Create and update tasks"
+      "code": "role:create",
+      "description": "Create roles"
+    },
+    {
+      "code": "role:read",
+      "description": "Read roles"
+    },
+    {
+      "code": "role:update",
+      "description": "Update roles"
+    },
+    {
+      "code": "role:delete",
+      "description": "Delete roles"
+    },
+    {
+      "code": "permission:create",
+      "description": "Create permissions"
+    },
+    {
+      "code": "permission:read",
+      "description": "Read permissions"
+    },
+    {
+      "code": "permission:update",
+      "description": "Update permissions"
+    },
+    {
+      "code": "permission:delete",
+      "description": "Delete permissions"
+    },
+    {
+      "code": "service:create",
+      "description": "Create services"
     },
     {
       "code": "service:read",
-      "description": "Read service information"
+      "description": "Read services"
     },
     {
-      "code": "service:write",
-      "description": "Create and update services"
+      "code": "service:update",
+      "description": "Update services"
+    },
+    {
+      "code": "service:delete",
+      "description": "Delete services"
+    },
+    {
+      "code": "task:create",
+      "description": "Create tasks"
+    },
+    {
+      "code": "task:read",
+      "description": "Read tasks"
+    },
+    {
+      "code": "task:update",
+      "description": "Update tasks"
+    },
+    {
+      "code": "task:delete",
+      "description": "Delete tasks"
+    },
+    {
+      "code": "note:create",
+      "description": "Create notes"
     },
     {
       "code": "note:read",
       "description": "Read notes"
     },
+    {
+      "code": "note:update",
+      "description": "Update notes"
+    },
+    {
+      "code": "note:delete",
+      "description": "Delete notes"
+    },
+    {
+      "code": "channel:create",
+      "description": "Create channels"
+    },
+    {
+      "code": "channel:read",
+      "description": "Read channels"
+    },
+    {
+      "code": "channel:update",
+      "description": "Update channels"
+    },
+    {
+      "code": "channel:delete",
+      "description": "Delete channels"
+    },
+    {
+      "code": "message:create",
+      "description": "Create messages"
+    },
+    {
+      "code": "message:read",
+      "description": "Read messages"
+    },
+    {
+      "code": "message:update",
+      "description": "Update messages"
+    },
+    {
+      "code": "message:delete",
+      "description": "Delete messages"
+    },
+    {
+      "code": "taskstatus:create",
+      "description": "Create task statuses"
+    },
+    {
+      "code": "taskstatus:read",
+      "description": "Read task statuses"
+    },
+    {
+      "code": "taskstatus:update",
+      "description": "Update task statuses"
+    },
+    {
+      "code": "taskstatus:delete",
+      "description": "Delete task statuses"
+    },
+    {
+      "code": "user:write",
+      "description": "Create and update users"
+    },
+    {
+      "code": "task:write",
+      "description": "Create and update tasks"
+    },
+    {
+      "code": "service:write",
+      "description": "Create and update services"
+    },
     {
       "code": "note:write",
       "description": "Create and update notes"

+ 210 - 9
graph/testdata/snapshots/TestIntegration_Bootstrap-CreateRoles

@@ -5,51 +5,252 @@ roles
       "description": "Administrator with full access",
       "name": "admin",
       "permissions": [
+        {
+          "code": "user:create"
+        },
         {
           "code": "user:read"
         },
         {
-          "code": "user:write"
+          "code": "user:update"
+        },
+        {
+          "code": "user:delete"
+        },
+        {
+          "code": "role:create"
+        },
+        {
+          "code": "role:read"
+        },
+        {
+          "code": "role:update"
+        },
+        {
+          "code": "role:delete"
+        },
+        {
+          "code": "permission:create"
+        },
+        {
+          "code": "permission:read"
+        },
+        {
+          "code": "permission:update"
+        },
+        {
+          "code": "permission:delete"
+        },
+        {
+          "code": "service:create"
+        },
+        {
+          "code": "service:read"
+        },
+        {
+          "code": "service:update"
+        },
+        {
+          "code": "service:delete"
+        },
+        {
+          "code": "task:create"
         },
         {
           "code": "task:read"
         },
         {
-          "code": "task:write"
+          "code": "task:update"
+        },
+        {
+          "code": "task:delete"
+        },
+        {
+          "code": "note:create"
+        },
+        {
+          "code": "note:read"
+        },
+        {
+          "code": "note:update"
+        },
+        {
+          "code": "note:delete"
+        },
+        {
+          "code": "channel:create"
+        },
+        {
+          "code": "channel:read"
+        },
+        {
+          "code": "channel:update"
+        },
+        {
+          "code": "channel:delete"
+        },
+        {
+          "code": "message:create"
+        },
+        {
+          "code": "message:read"
+        },
+        {
+          "code": "message:update"
+        },
+        {
+          "code": "message:delete"
+        },
+        {
+          "code": "taskstatus:create"
+        },
+        {
+          "code": "taskstatus:read"
+        },
+        {
+          "code": "taskstatus:update"
+        },
+        {
+          "code": "taskstatus:delete"
+        }
+      ]
+    },
+    {
+      "description": "Service manager with task management",
+      "name": "manager",
+      "permissions": [
+        {
+          "code": "service:create"
         },
         {
           "code": "service:read"
         },
         {
-          "code": "service:write"
+          "code": "service:update"
+        },
+        {
+          "code": "service:delete"
+        },
+        {
+          "code": "task:create"
+        },
+        {
+          "code": "task:read"
+        },
+        {
+          "code": "task:update"
+        },
+        {
+          "code": "task:delete"
+        },
+        {
+          "code": "note:create"
         },
         {
           "code": "note:read"
         },
         {
-          "code": "note:write"
+          "code": "note:update"
+        },
+        {
+          "code": "note:delete"
+        },
+        {
+          "code": "channel:create"
+        },
+        {
+          "code": "channel:read"
+        },
+        {
+          "code": "channel:update"
+        },
+        {
+          "code": "channel:delete"
+        },
+        {
+          "code": "message:create"
+        },
+        {
+          "code": "message:read"
+        },
+        {
+          "code": "message:update"
+        },
+        {
+          "code": "message:delete"
+        },
+        {
+          "code": "taskstatus:create"
+        },
+        {
+          "code": "taskstatus:read"
+        },
+        {
+          "code": "taskstatus:update"
+        },
+        {
+          "code": "taskstatus:delete"
         }
       ]
     },
     {
-      "description": "Team member with read access and limited write access",
-      "name": "member",
+      "description": "Regular user with limited access",
+      "name": "user",
       "permissions": [
         {
           "code": "user:read"
         },
+        {
+          "code": "role:read"
+        },
+        {
+          "code": "permission:read"
+        },
+        {
+          "code": "service:read"
+        },
         {
           "code": "task:read"
         },
         {
-          "code": "task:write"
+          "code": "note:create"
+        },
+        {
+          "code": "note:read"
+        },
+        {
+          "code": "channel:read"
+        },
+        {
+          "code": "message:create"
+        },
+        {
+          "code": "message:read"
+        },
+        {
+          "code": "taskstatus:read"
+        }
+      ]
+    },
+    {
+      "description": "Team member with read access and limited write access",
+      "name": "member",
+      "permissions": [
+        {
+          "code": "user:read"
         },
         {
           "code": "service:read"
         },
+        {
+          "code": "task:read"
+        },
         {
           "code": "note:read"
         },
+        {
+          "code": "task:write"
+        },
         {
           "code": "note:write"
         }
@@ -63,10 +264,10 @@ roles
           "code": "user:read"
         },
         {
-          "code": "task:read"
+          "code": "service:read"
         },
         {
-          "code": "service:read"
+          "code": "task:read"
         },
         {
           "code": "note:read"

+ 10 - 2
graph/testdata/snapshots/TestIntegration_Bootstrap-CreateTaskStatuses

@@ -9,13 +9,21 @@ taskStatuses
       "code": "in_progress",
       "label": "In Progress"
     },
+    {
+      "code": "blocked",
+      "label": "Blocked"
+    },
     {
       "code": "review",
-      "label": "Under Review"
+      "label": "In Review"
     },
     {
       "code": "done",
-      "label": "Completed"
+      "label": "Done"
+    },
+    {
+      "code": "cancelled",
+      "label": "Cancelled"
     }
   ]
 }

+ 32 - 5
graph/testutil/fixtures.go

@@ -1,11 +1,16 @@
 package testutil
 
 import (
+	"embed"
+
 	"gogs.dmsc.dev/arp/models"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 )
 
+//go:embed init_tests.sql
+var initSQLFS embed.FS
+
 // SetupTestDB creates an in-memory SQLite database for testing
 func SetupTestDB() (*gorm.DB, error) {
 	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -32,6 +37,32 @@ func SetupTestDB() (*gorm.DB, error) {
 	return db, nil
 }
 
+// BootstrapTestDB initializes the database with initial data from init_tests.sql
+func BootstrapTestDB(db *gorm.DB) error {
+	// Read the init SQL file
+	sqlContent, err := initSQLFS.ReadFile("init_tests.sql")
+	if err != nil {
+		return err
+	}
+
+	// Execute the SQL
+	return db.Exec(string(sqlContent)).Error
+}
+
+// SetupAndBootstrapTestDB creates an in-memory database and bootstraps it with initial data
+func SetupAndBootstrapTestDB() (*gorm.DB, error) {
+	db, err := SetupTestDB()
+	if err != nil {
+		return nil, err
+	}
+
+	if err := BootstrapTestDB(db); err != nil {
+		return nil, err
+	}
+
+	return db, nil
+}
+
 // SeedData contains all hardcoded test fixtures
 type SeedData struct {
 	Permissions  []PermissionFixture
@@ -140,11 +171,7 @@ func GetSeedData() SeedData {
 			},
 		},
 		Users: []UserFixture{
-			{
-				Email:     "admin@example.com",
-				Password:  "admin-hashed-password",
-				RoleNames: []string{"admin"},
-			},
+			// Note: admin@example.com is bootstrapped via init_tests.sql
 			{
 				Email:     "member1@example.com",
 				Password:  "member1-hashed-password",

+ 84 - 0
graph/testutil/init_tests.sql

@@ -0,0 +1,84 @@
+-- ARP Initial Data Bootstrap Script
+-- Run this script to set up initial permissions, roles, and an admin user
+--
+-- Note: The password hash below is for "secret123" using bcrypt.
+
+-- Permissions (no created_at/updated_at in model)
+INSERT INTO permissions (id, code, description) VALUES 
+  (1, 'user:create', 'Create users'),
+  (2, 'user:read', 'Read users'),
+  (3, 'user:update', 'Update users'),
+  (4, 'user:delete', 'Delete users'),
+  (5, 'role:create', 'Create roles'),
+  (6, 'role:read', 'Read roles'),
+  (7, 'role:update', 'Update roles'),
+  (8, 'role:delete', 'Delete roles'),
+  (9, 'permission:create', 'Create permissions'),
+  (10, 'permission:read', 'Read permissions'),
+  (11, 'permission:update', 'Update permissions'),
+  (12, 'permission:delete', 'Delete permissions'),
+  (13, 'service:create', 'Create services'),
+  (14, 'service:read', 'Read services'),
+  (15, 'service:update', 'Update services'),
+  (16, 'service:delete', 'Delete services'),
+  (17, 'task:create', 'Create tasks'),
+  (18, 'task:read', 'Read tasks'),
+  (19, 'task:update', 'Update tasks'),
+  (20, 'task:delete', 'Delete tasks'),
+  (21, 'note:create', 'Create notes'),
+  (22, 'note:read', 'Read notes'),
+  (23, 'note:update', 'Update notes'),
+  (24, 'note:delete', 'Delete notes'),
+  (25, 'channel:create', 'Create channels'),
+  (26, 'channel:read', 'Read channels'),
+  (27, 'channel:update', 'Update channels'),
+  (28, 'channel:delete', 'Delete channels'),
+  (29, 'message:create', 'Create messages'),
+  (30, 'message:read', 'Read messages'),
+  (31, 'message:update', 'Update messages'),
+  (32, 'message:delete', 'Delete messages'),
+  (33, 'taskstatus:create', 'Create task statuses'),
+  (34, 'taskstatus:read', 'Read task statuses'),
+  (35, 'taskstatus:update', 'Update task statuses'),
+  (36, 'taskstatus:delete', 'Delete task statuses');
+
+-- Roles (no created_at/updated_at in model)
+INSERT INTO roles (id, name, description) VALUES 
+  (1, 'admin', 'Administrator with full access'),
+  (2, 'manager', 'Service manager with task management'),
+  (3, 'user', 'Regular user with limited access');
+
+-- Role-Permission associations (admin gets all permissions)
+INSERT INTO role_permissions (role_id, permission_id) 
+SELECT 1, id FROM permissions;
+
+-- Manager role permissions (service, task, note operations)
+INSERT INTO role_permissions (role_id, permission_id) VALUES
+  (2, 13), (2, 14), (2, 15), (2, 16), -- service:*
+  (2, 17), (2, 18), (2, 19), (2, 20), -- task:*
+  (2, 21), (2, 22), (2, 23), (2, 24), -- note:*
+  (2, 25), (2, 26), (2, 27), (2, 28), -- channel:*
+  (2, 29), (2, 30), (2, 31), (2, 32), -- message:*
+  (2, 33), (2, 34), (2, 35), (2, 36); -- taskstatus:*
+
+-- User role permissions (read-only + create notes/messages)
+INSERT INTO role_permissions (role_id, permission_id) VALUES
+  (3, 2), (3, 6), (3, 10), (3, 14), (3, 18), (3, 22), (3, 26), (3, 30), (3, 34), -- read permissions
+  (3, 21), (3, 29); -- create notes and messages
+
+-- Admin user (password: secret123)
+-- bcrypt hash generated with cost 10
+INSERT INTO users (id, email, password, created_at, updated_at) VALUES 
+  (1, 'admin@example.com', '$2a$10$9CNePaChncemsl8ZgMFDfeFm.Rl1K1l8rurgZxVx7C6sbv5tojUDC', datetime('now'), datetime('now'));
+
+-- Associate admin user with admin role
+INSERT INTO user_roles (user_id, role_id) VALUES (1, 1);
+
+-- Task Statuses (common workflow states)
+INSERT INTO task_statuses (id, code, label, created_at, updated_at) VALUES 
+  (1, 'open', 'Open', datetime('now'), datetime('now')),
+  (2, 'in_progress', 'In Progress', datetime('now'), datetime('now')),
+  (3, 'blocked', 'Blocked', datetime('now'), datetime('now')),
+  (4, 'review', 'In Review', datetime('now'), datetime('now')),
+  (5, 'done', 'Done', datetime('now'), datetime('now')),
+  (6, 'cancelled', 'Cancelled', datetime('now'), datetime('now'));

+ 0 - 0
init.sql → init_tests.sql


+ 36 - 0
logging/logging.go

@@ -0,0 +1,36 @@
+package logging
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"gogs.dmsc.dev/arp/auth"
+)
+
+// LogOperation logs a CRUD operation to the console
+// Format: <datetime> <user> <operation> <entity_info>
+func LogOperation(ctx context.Context, operation string, entityInfo string) {
+	// Get user email from context if available
+	userEmail := "anonymous"
+
+	user, err := auth.CurrentUser(ctx)
+	if err == nil && user != nil {
+		userEmail = user.Email
+	}
+
+	timestamp := time.Now().Format("2006-01-02 15:04:05")
+	fmt.Printf("%s %s %s %s\n", timestamp, userEmail, operation, entityInfo)
+}
+
+// LogMutation logs a mutation operation (create/update/delete)
+func LogMutation(ctx context.Context, action string, entityType string, identifier string) {
+	operation := fmt.Sprintf("%s_%s", action, entityType)
+	LogOperation(ctx, operation, identifier)
+}
+
+// LogQuery logs a query operation
+func LogQuery(ctx context.Context, queryType string, details string) {
+	operation := fmt.Sprintf("QUERY_%s", queryType)
+	LogOperation(ctx, operation, details)
+}

+ 5 - 1
models/models.go

@@ -80,6 +80,9 @@ type Task struct {
 	CreatedByID uint
 	CreatedBy   User `gorm:"foreignKey:CreatedByID"`
 
+	// Who updated the task
+	UpdatedBy User `gorm:"foreignKey:CreatedByID"`
+
 	// Assignment – can be nil (unassigned) or point to a user/agent
 	AssigneeID *uint
 	Assignee   *User `gorm:"foreignKey:AssigneeID"`
@@ -109,7 +112,8 @@ type TaskStatus struct {
 
 // Simple chat / messaging between users/agents
 type Channel struct {
-	ID uint `gorm:"primaryKey"`
+	ID    uint   `gorm:"primaryKey"`
+	Title string `gorm:"size:200;not null"`
 
 	// Participants – many‑to‑many (GORM will create the join table automatically)
 	Participants []User `gorm:"many2many:conversation_participants;"`