From 530f3653420ac0bf37d2be28625174e6e20dd00f Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 13:19:47 +0300 Subject: [PATCH 01/20] feat: implement security-as-code and audit logging (Issue #11) - Add audit.Service to record control-plane mutations - Wire AuditService to ProvidersHandler and CallbackHandler - Add GET /v1/audit endpoint to query audit events - Add created_at index to audit_events table for fast querying - Build nexus-cli declarative reconciler tool for managing providers via YAML - Implement Terraform-style plan/apply/prune workflow in nexus-cli --- nexus-broker/cmd/nexus-broker/main.go | 7 +- nexus-broker/internal/audit/service.go | 65 +++++ .../11_add_audit_created_at_index.sql | 1 + nexus-broker/pkg/handlers/audit.go | 73 +++++ nexus-broker/pkg/handlers/callback.go | 34 +-- nexus-broker/pkg/handlers/providers.go | 26 +- nexus-broker/pkg/handlers/providers_test.go | 6 +- nexus-cli/.gitignore | 1 + nexus-cli/go.mod | 5 + nexus-cli/go.sum | 3 + nexus-cli/main.go | 275 ++++++++++++++++++ nexus-cli/nexus-providers.yaml | 24 ++ 12 files changed, 493 insertions(+), 27 deletions(-) create mode 100644 nexus-broker/internal/audit/service.go create mode 100644 nexus-broker/migrations/11_add_audit_created_at_index.sql create mode 100644 nexus-broker/pkg/handlers/audit.go create mode 100644 nexus-cli/.gitignore create mode 100644 nexus-cli/go.mod create mode 100644 nexus-cli/go.sum create mode 100644 nexus-cli/main.go create mode 100644 nexus-cli/nexus-providers.yaml diff --git a/nexus-broker/cmd/nexus-broker/main.go b/nexus-broker/cmd/nexus-broker/main.go index 137540a..4a3ee97 100644 --- a/nexus-broker/cmd/nexus-broker/main.go +++ b/nexus-broker/cmd/nexus-broker/main.go @@ -6,6 +6,7 @@ import ( "os" "time" + "github.com/Prescott-Data/nexus-framework/nexus-broker/internal/audit" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/caching" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/config" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/handlers" @@ -58,8 +59,9 @@ func main() { srv := server.NewServer(cfg.Port) store := provider.NewStore(db) + auditSvc := audit.NewService(db) - providersHandler := handlers.NewProvidersHandler(store) + providersHandler := handlers.NewProvidersHandler(store, auditSvc) consentHandler := handlers.NewConsentHandler(handlers.ConsentHandlerConfig{ DB: db, BaseURL: cfg.BaseURL, @@ -71,6 +73,7 @@ func main() { }) callbackHandler := handlers.NewCallbackHandler(handlers.CallbackHandlerConfig{ DB: db, + Audit: auditSvc, BaseURL: cfg.BaseURL, RedirectPath: cfg.RedirectPath, EncryptionKey: cfg.EncryptionKey, @@ -79,6 +82,7 @@ func main() { EnforceReturnURL: cfg.EnforceReturnURL, AllowedReturnDomains: cfg.AllowedReturnDomains, }) + auditHandler := handlers.NewAuditHandler(db) router := srv.Router() router.Get("/auth/callback", callbackHandler.Handle) @@ -90,6 +94,7 @@ func main() { server.ApiKeyMiddleware(cfg.RequireAPIKey, cfg.APIKeys), server.AllowlistMiddleware(cfg.RequireAllowlist, cfg.AllowedCIDRs), ) + protected.Get("/audit", auditHandler.List) protected.Route("/providers", func(r chi.Router) { r.Post("/", providersHandler.Register) r.Get("/", providersHandler.List) diff --git a/nexus-broker/internal/audit/service.go b/nexus-broker/internal/audit/service.go new file mode 100644 index 0000000..17f34d0 --- /dev/null +++ b/nexus-broker/internal/audit/service.go @@ -0,0 +1,65 @@ +package audit + +import ( + "encoding/json" + "net" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type Service struct { + db *sqlx.DB +} + +func NewService(db *sqlx.DB) *Service { + return &Service{db: db} +} + +func (s *Service) Log(eventType string, connectionID *uuid.UUID, data map[string]interface{}, r *http.Request) error { + var ipVal *string + var userAgent *string + + if r != nil { + // Extract IP + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + ip := strings.Split(fwd, ",")[0] + if comma := strings.IndexByte(ip, ','); comma != -1 { + ip = strings.TrimSpace(ip[:comma]) + } + ipVal = &ip + } else { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + ipVal = &host + } else { + ipVal = &r.RemoteAddr + } + } + + // Extract User-Agent + ua := r.Header.Get("User-Agent") + if ua != "" { + userAgent = &ua + } + } + + var eventDataJSON []byte + if data != nil { + eventDataJSON, _ = json.Marshal(data) + } + + query := ` + INSERT INTO audit_events (connection_id, event_type, event_data, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5)` + + var eventDataArg interface{} + if len(eventDataJSON) > 0 { + eventDataArg = string(eventDataJSON) + } + + _, err := s.db.Exec(query, connectionID, eventType, eventDataArg, ipVal, userAgent) + return err +} diff --git a/nexus-broker/migrations/11_add_audit_created_at_index.sql b/nexus-broker/migrations/11_add_audit_created_at_index.sql new file mode 100644 index 0000000..0e45b8e --- /dev/null +++ b/nexus-broker/migrations/11_add_audit_created_at_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_audit_created_at ON audit_events(created_at DESC); \ No newline at end of file diff --git a/nexus-broker/pkg/handlers/audit.go b/nexus-broker/pkg/handlers/audit.go new file mode 100644 index 0000000..4dd691a --- /dev/null +++ b/nexus-broker/pkg/handlers/audit.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/httputil" + "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/storage" + "github.com/jmoiron/sqlx" +) + +// AuditHandler handles audit log queries +type AuditHandler struct { + db *sqlx.DB +} + +// NewAuditHandler creates a new audit handler +func NewAuditHandler(db *sqlx.DB) *AuditHandler { + return &AuditHandler{db: db} +} + +// List handles GET /audit to retrieve recent audit events +func (h *AuditHandler) List(w http.ResponseWriter, r *http.Request) { + eventType := r.URL.Query().Get("event_type") + sinceStr := r.URL.Query().Get("since") + limitStr := r.URL.Query().Get("limit") + + limit := 50 + if limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 1000 { + limit = parsedLimit + } + } + + query := `SELECT id, connection_id, event_type, event_data, ip_address, user_agent, created_at + FROM audit_events WHERE 1=1` + args := []interface{}{} + argIndex := 1 + + if eventType != "" { + query += ` AND event_type = $` + strconv.Itoa(argIndex) + args = append(args, eventType) + argIndex++ + } + + if sinceStr != "" { + since, err := time.Parse(time.RFC3339, sinceStr) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, "invalid_since", "since parameter must be a valid RFC3339 timestamp") + return + } + query += ` AND created_at >= $` + strconv.Itoa(argIndex) + args = append(args, since) + argIndex++ + } + + query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(argIndex) + args = append(args, limit) + + var events []storage.AuditEvent + if err := h.db.Select(&events, query, args...); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, "query_failed", "Failed to query audit events") + return + } + + // Make sure we return an empty array instead of null for no results + if events == nil { + events = []storage.AuditEvent{} + } + + httputil.WriteJSON(w, http.StatusOK, events) +} diff --git a/nexus-broker/pkg/handlers/callback.go b/nexus-broker/pkg/handlers/callback.go index 98d6f62..2f466ee 100644 --- a/nexus-broker/pkg/handlers/callback.go +++ b/nexus-broker/pkg/handlers/callback.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "net" "net/http" "net/url" "strings" @@ -17,6 +16,7 @@ import ( "github.com/lib/pq" "github.com/prometheus/client_golang/prometheus" + "github.com/Prescott-Data/nexus-framework/nexus-broker/internal/audit" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/auth" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/discovery" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/httputil" @@ -28,6 +28,7 @@ import ( // CallbackHandler handles OAuth callback and token exchange type CallbackHandler struct { db *sqlx.DB + audit *audit.Service baseURL string redirectPath string encryptionKey []byte @@ -45,6 +46,7 @@ type CallbackHandler struct { // CallbackHandlerConfig holds the dependencies for CallbackHandler type CallbackHandlerConfig struct { DB *sqlx.DB + Audit *audit.Service BaseURL string RedirectPath string EncryptionKey []byte @@ -92,6 +94,7 @@ func NewCallbackHandler(cfg CallbackHandlerConfig) *CallbackHandler { return &CallbackHandler{ db: cfg.DB, + audit: cfg.Audit, baseURL: cfg.BaseURL, redirectPath: cfg.RedirectPath, encryptionKey: cfg.EncryptionKey, @@ -831,28 +834,17 @@ func (h *CallbackHandler) updateConnectionStatus(connectionID uuid.UUID, status // logAuditEvent logs an audit event func (h *CallbackHandler) logAuditEvent(connectionID *uuid.UUID, eventType string, data map[string]string, r *http.Request) { - eventData, _ := json.Marshal(data) - // Sanitize and extract client IP for inet field - var ipVal interface{} - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - ip := forwarded - if comma := strings.Index(ip, ","); comma != -1 { - ip = strings.TrimSpace(ip[:comma]) - } - ipVal = ip - } else { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil { - ipVal = host - } else { - ipVal = nil - } + if h.audit == nil { + return + } + + // Convert map[string]string to map[string]interface{} for the audit service + auditData := make(map[string]interface{}) + for k, v := range data { + auditData[k] = v } - _, _ = h.db.Exec(` - INSERT INTO audit_events (connection_id, event_type, event_data, ip_address, user_agent) - VALUES ($1, $2, $3, $4, $5)`, - connectionID, eventType, string(eventData), ipVal, r.Header.Get("User-Agent")) + _ = h.audit.Log(eventType, connectionID, auditData, r) } // handleError handles OAuth errors diff --git a/nexus-broker/pkg/handlers/providers.go b/nexus-broker/pkg/handlers/providers.go index 2da840e..84dcaa5 100644 --- a/nexus-broker/pkg/handlers/providers.go +++ b/nexus-broker/pkg/handlers/providers.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/Prescott-Data/nexus-framework/nexus-broker/internal/audit" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/httputil" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/provider" @@ -16,11 +17,12 @@ import ( // ProvidersHandler handles provider-related HTTP requests type ProvidersHandler struct { store provider.ProfileStorer + audit *audit.Service } // NewProvidersHandler creates a new providers handler -func NewProvidersHandler(store provider.ProfileStorer) *ProvidersHandler { - return &ProvidersHandler{store: store} +func NewProvidersHandler(store provider.ProfileStorer, auditSvc *audit.Service) *ProvidersHandler { + return &ProvidersHandler{store: store, audit: auditSvc} } // Get handles GET /providers/{id} to retrieve a provider profile @@ -61,6 +63,10 @@ func (h *ProvidersHandler) Update(w http.ResponseWriter, r *http.Request) { return } + if h.audit != nil { + _ = h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r) + } + w.WriteHeader(http.StatusOK) } @@ -84,6 +90,10 @@ func (h *ProvidersHandler) Patch(w http.ResponseWriter, r *http.Request) { return } + if h.audit != nil { + _ = h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": id.String(), "updates": updates}, r) + } + w.WriteHeader(http.StatusOK) } @@ -101,6 +111,10 @@ func (h *ProvidersHandler) Delete(w http.ResponseWriter, r *http.Request) { return } + if h.audit != nil { + _ = h.audit.Log("provider.deleted", nil, map[string]interface{}{"provider_id": id.String()}, r) + } + w.WriteHeader(http.StatusOK) } @@ -150,6 +164,10 @@ func (h *ProvidersHandler) Register(w http.ResponseWriter, r *http.Request) { return } + if h.audit != nil { + _ = h.audit.Log("provider.created", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r) + } + httputil.WriteJSON(w, http.StatusCreated, map[string]interface{}{ "id": profile.ID, "message": "Provider profile created successfully", @@ -205,6 +223,10 @@ func (h *ProvidersHandler) DeleteByName(w http.ResponseWriter, r *http.Request) return } + if h.audit != nil { + _ = h.audit.Log("provider.deleted", nil, map[string]interface{}{"provider_name": name, "rows_affected": rowsAffected}, r) + } + httputil.WriteJSON(w, http.StatusOK, map[string]string{"message": fmt.Sprintf("Deleted %d provider(s)", rowsAffected)}) } diff --git a/nexus-broker/pkg/handlers/providers_test.go b/nexus-broker/pkg/handlers/providers_test.go index 8bf9abd..00020e9 100644 --- a/nexus-broker/pkg/handlers/providers_test.go +++ b/nexus-broker/pkg/handlers/providers_test.go @@ -87,7 +87,7 @@ func ptr(s string) *string { func TestRegisterProvider_Success(t *testing.T) { // 1. Mocks the provider.Store. mockStore := new(MockStore) - handler := NewProvidersHandler(mockStore) + handler := NewProvidersHandler(mockStore, nil) // 2. Mocks the store.RegisterProfile method to return a valid Profile. expectedProfile := &provider.Profile{ @@ -142,7 +142,7 @@ func TestRegisterProvider_Success(t *testing.T) { func TestRegisterProvider_StoreError(t *testing.T) { // 1. Mocks the provider.Store. mockStore := new(MockStore) - handler := NewProvidersHandler(mockStore) + handler := NewProvidersHandler(mockStore, nil) // 2. Mocks the store.RegisterProfile method to return an error. expectedError := errors.New("validation failed") @@ -170,7 +170,7 @@ func TestRegisterProvider_StoreError(t *testing.T) { func TestRegisterProvider_InvalidJSON(t *testing.T) { // 1. Mocks the provider.Store. mockStore := new(MockStore) - handler := NewProvidersHandler(mockStore) + handler := NewProvidersHandler(mockStore, nil) // 2. Sends a POST request with invalid JSON. req, err := http.NewRequest("POST", "/providers", bytes.NewReader([]byte("invalid json"))) diff --git a/nexus-cli/.gitignore b/nexus-cli/.gitignore new file mode 100644 index 0000000..7257b56 --- /dev/null +++ b/nexus-cli/.gitignore @@ -0,0 +1 @@ +nexus-cli \ No newline at end of file diff --git a/nexus-cli/go.mod b/nexus-cli/go.mod new file mode 100644 index 0000000..90a66c9 --- /dev/null +++ b/nexus-cli/go.mod @@ -0,0 +1,5 @@ +module github.com/Prescott-Data/nexus-framework/nexus-cli + +go 1.26.2 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/nexus-cli/go.sum b/nexus-cli/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/nexus-cli/go.sum @@ -0,0 +1,3 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/nexus-cli/main.go b/nexus-cli/main.go new file mode 100644 index 0000000..940b3c5 --- /dev/null +++ b/nexus-cli/main.go @@ -0,0 +1,275 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type Provider struct { + Name string `yaml:"name" json:"name"` + AuthType string `yaml:"auth_type,omitempty" json:"auth_type,omitempty"` + AuthHeader string `yaml:"auth_header,omitempty" json:"auth_header,omitempty"` + ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"` + ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` + AuthURL string `yaml:"auth_url,omitempty" json:"auth_url,omitempty"` + TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"` + Issuer string `yaml:"issuer,omitempty" json:"issuer,omitempty"` + EnableDiscovery bool `yaml:"enable_discovery" json:"enable_discovery"` + Scopes []string `yaml:"scopes" json:"scopes"` + APIBaseURL string `yaml:"api_base_url,omitempty" json:"api_base_url,omitempty"` + Params map[string]interface{} `yaml:"params,omitempty" json:"params,omitempty"` +} + +type Manifest struct { + Providers []Provider `yaml:"providers"` +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: nexus-cli [options]") + fmt.Println("Commands:") + fmt.Println(" plan Show execution plan without making changes") + fmt.Println(" apply Apply provider configurations from a manifest") + os.Exit(1) + } + + command := os.Args[1] + + switch command { + case "plan": + runCommand(true) + case "apply": + runCommand(false) + default: + fmt.Printf("Unknown command: %s\n", command) + os.Exit(1) + } +} + +func runCommand(isPlanOnly bool) { + cmdFlags := flag.NewFlagSet(os.Args[1], flag.ExitOnError) + fileFlag := cmdFlags.String("file", "nexus-providers.yaml", "Path to the providers manifest file") + + if err := cmdFlags.Parse(os.Args[2:]); err != nil { + log.Fatalf("Failed to parse flags: %v", err) + } + + brokerURL := os.Getenv("BROKER_BASE_URL") + if brokerURL == "" { + brokerURL = "http://localhost:8080" + } + apiKey := os.Getenv("API_KEY") + + // Read Manifest + data, err := os.ReadFile(*fileFlag) + if err != nil { + log.Fatalf("Failed to read manifest file %s: %v", *fileFlag, err) + } + + // Expand environment variables + expandedData := os.ExpandEnv(string(data)) + + var manifest Manifest + if err := yaml.Unmarshal([]byte(expandedData), &manifest); err != nil { + log.Fatalf("Failed to parse YAML manifest: %v", err) + } + + fmt.Printf("Read %d providers from %s\n", len(manifest.Providers), *fileFlag) + + // Fetch current live state + req, err := http.NewRequest("GET", brokerURL+"/providers", nil) + if err != nil { + log.Fatalf("Failed to create request: %v", err) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Fatalf("Failed to fetch live providers: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Fatalf("Failed to fetch live providers, status: %d, body: %s", resp.StatusCode, string(body)) + } + + var liveProviders []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&liveProviders); err != nil { + log.Fatalf("Failed to decode live providers: %v", err) + } + + liveProviderMap := make(map[string]string) // map name to ID + for _, lp := range liveProviders { + name, nameOk := lp["name"].(string) + id, idOk := lp["id"].(string) + if nameOk && idOk { + liveProviderMap[name] = id + } + } + + manifestProviderMap := make(map[string]Provider) + for _, p := range manifest.Providers { + manifestProviderMap[p.Name] = p + } + + fmt.Println("\n--- Execution Plan ---") + + toCreate := []Provider{} + toUpdate := make(map[string]Provider) // map ID to Provider + toDelete := []string{} // list of IDs + toDeleteNames := []string{} + + for _, p := range manifest.Providers { + if id, exists := liveProviderMap[p.Name]; exists { + toUpdate[id] = p + fmt.Printf("~ UPDATE : %s\n", p.Name) + } else { + toCreate = append(toCreate, p) + fmt.Printf("+ CREATE : %s\n", p.Name) + } + } + + for name, id := range liveProviderMap { + if _, exists := manifestProviderMap[name]; !exists { + toDelete = append(toDelete, id) + toDeleteNames = append(toDeleteNames, name) + fmt.Printf("- DELETE : %s\n", name) + } + } + + if len(toCreate) == 0 && len(toUpdate) == 0 && len(toDelete) == 0 { + fmt.Println("\nNo changes required. Infrastructure matches configuration.") + return + } + + if isPlanOnly { + fmt.Println("\nPlan complete. Run 'nexus-cli apply' to perform these actions.") + return + } + + fmt.Print("\nDo you want to perform these actions?\n Nexus will perform the actions described above.\n Only 'yes' will be accepted to approve.\n\n Enter a value: ") + + reader := bufio.NewReader(os.Stdin) + confirmation, err := reader.ReadString('\n') + if err != nil { + log.Fatalf("Failed to read input: %v", err) + } + + if strings.TrimSpace(confirmation) != "yes" { + fmt.Println("\nApply cancelled.") + return + } + + fmt.Println("\n--- Applying Changes ---") + + for _, p := range toCreate { + fmt.Printf("Creating %s... ", p.Name) + + payload := map[string]interface{}{ + "profile": p, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + fmt.Printf("Failed to marshal: %v\n", err) + continue + } + + req, err := http.NewRequest("POST", brokerURL+"/providers", bytes.NewBuffer(jsonData)) + if err != nil { + fmt.Printf("Failed to create request: %v\n", err) + continue + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Request failed: %v\n", err) + continue + } + resp.Body.Close() + + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { + fmt.Println("OK") + } else { + fmt.Printf("FAILED (Status %d)\n", resp.StatusCode) + } + } + + for id, p := range toUpdate { + fmt.Printf("Updating %s... ", p.Name) + + jsonData, err := json.Marshal(p) + if err != nil { + fmt.Printf("Failed to marshal: %v\n", err) + continue + } + + req, err := http.NewRequest("PUT", brokerURL+"/providers/"+id, bytes.NewBuffer(jsonData)) + if err != nil { + fmt.Printf("Failed to create request: %v\n", err) + continue + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Request failed: %v\n", err) + continue + } + resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Println("OK") + } else { + fmt.Printf("FAILED (Status %d)\n", resp.StatusCode) + } + } + + for i, id := range toDelete { + name := toDeleteNames[i] + fmt.Printf("Deleting %s... ", name) + + req, err := http.NewRequest("DELETE", brokerURL+"/providers/"+id, nil) + if err != nil { + fmt.Printf("Failed to create request: %v\n", err) + continue + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Request failed: %v\n", err) + continue + } + resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Println("OK") + } else { + fmt.Printf("FAILED (Status %d)\n", resp.StatusCode) + } + } +} diff --git a/nexus-cli/nexus-providers.yaml b/nexus-cli/nexus-providers.yaml new file mode 100644 index 0000000..fded888 --- /dev/null +++ b/nexus-cli/nexus-providers.yaml @@ -0,0 +1,24 @@ +providers: + - name: google-workspace + auth_type: oauth2 + client_id: "${GOOGLE_CLIENT_ID}" + client_secret: "${GOOGLE_CLIENT_SECRET}" + issuer: "https://accounts.google.com" + enable_discovery: true + scopes: + - openid + - email + - profile + - offline_access + + - name: github + auth_type: oauth2 + client_id: "${GITHUB_CLIENT_ID}" + client_secret: "${GITHUB_CLIENT_SECRET}" + auth_url: "https://github.com/login/oauth/authorize" + token_url: "https://github.com/login/oauth/access_token" + api_base_url: "https://api.github.com" + enable_discovery: false + scopes: + - read:user + - user:email From 091eaa634fc2da25ba2a2be8deaf4a871ab0325b Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 13:53:08 +0300 Subject: [PATCH 02/20] fix(review): address anti-gravity review feedback for Issue #11 - Change AuditEvent fields to *string to fix nullable scan bug - Add --prune flag to nexus-cli to prevent accidental deletions - Remove dead code and add TrimSpace in IP extraction logic - Add GitHub Actions CI/CD workflow for nexus-cli plan/apply - Verified STATE_KEY fatal-exit guard is present in both broker and gateway --- .github/workflows/nexus-cli.yml | 47 ++++++++++++++++++++++++++ nexus-broker/internal/audit/service.go | 5 +-- nexus-broker/pkg/storage/pg.go | 6 ++-- nexus-cli/main.go | 11 ++++-- 4 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/nexus-cli.yml diff --git a/.github/workflows/nexus-cli.yml b/.github/workflows/nexus-cli.yml new file mode 100644 index 0000000..3b8c975 --- /dev/null +++ b/.github/workflows/nexus-cli.yml @@ -0,0 +1,47 @@ +name: Nexus CLI Provider Reconciliation + +on: + push: + branches: + - main + paths: + - 'nexus-cli/nexus-providers.yaml' + pull_request: + branches: + - main + paths: + - 'nexus-cli/nexus-providers.yaml' + +jobs: + plan-or-apply: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./nexus-cli + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache-dependency-path: ./nexus-cli/go.sum + + - name: Build CLI + run: go build -o nexus-cli + + - name: Plan (Pull Request) + if: github.event_name == 'pull_request' + env: + BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} + API_KEY: ${{ secrets.BROKER_API_KEY }} + run: ./nexus-cli plan + + - name: Apply (Push to Main) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} + API_KEY: ${{ secrets.BROKER_API_KEY }} + run: ./nexus-cli apply --prune <<< "yes" diff --git a/nexus-broker/internal/audit/service.go b/nexus-broker/internal/audit/service.go index 17f34d0..ac83fef 100644 --- a/nexus-broker/internal/audit/service.go +++ b/nexus-broker/internal/audit/service.go @@ -25,10 +25,7 @@ func (s *Service) Log(eventType string, connectionID *uuid.UUID, data map[string if r != nil { // Extract IP if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { - ip := strings.Split(fwd, ",")[0] - if comma := strings.IndexByte(ip, ','); comma != -1 { - ip = strings.TrimSpace(ip[:comma]) - } + ip := strings.TrimSpace(strings.Split(fwd, ",")[0]) ipVal = &ip } else { host, _, err := net.SplitHostPort(r.RemoteAddr) diff --git a/nexus-broker/pkg/storage/pg.go b/nexus-broker/pkg/storage/pg.go index 971efbd..b09275d 100644 --- a/nexus-broker/pkg/storage/pg.go +++ b/nexus-broker/pkg/storage/pg.go @@ -72,9 +72,9 @@ type AuditEvent struct { ID uuid.UUID `db:"id" json:"id"` ConnectionID *uuid.UUID `db:"connection_id" json:"connection_id,omitempty"` EventType string `db:"event_type" json:"event_type"` - EventData string `db:"event_data" json:"event_data,omitempty"` - IPAddress string `db:"ip_address" json:"ip_address,omitempty"` - UserAgent string `db:"user_agent" json:"user_agent,omitempty"` + EventData *string `db:"event_data" json:"event_data,omitempty"` + IPAddress *string `db:"ip_address" json:"ip_address,omitempty"` + UserAgent *string `db:"user_agent" json:"user_agent,omitempty"` CreatedAt time.Time `db:"created_at" json:"created_at"` } diff --git a/nexus-cli/main.go b/nexus-cli/main.go index 940b3c5..5566367 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -59,6 +59,7 @@ func main() { func runCommand(isPlanOnly bool) { cmdFlags := flag.NewFlagSet(os.Args[1], flag.ExitOnError) fileFlag := cmdFlags.String("file", "nexus-providers.yaml", "Path to the providers manifest file") + pruneFlag := cmdFlags.Bool("prune", false, "Delete providers not in the manifest") if err := cmdFlags.Parse(os.Args[2:]); err != nil { log.Fatalf("Failed to parse flags: %v", err) @@ -145,9 +146,13 @@ func runCommand(isPlanOnly bool) { for name, id := range liveProviderMap { if _, exists := manifestProviderMap[name]; !exists { - toDelete = append(toDelete, id) - toDeleteNames = append(toDeleteNames, name) - fmt.Printf("- DELETE : %s\n", name) + if *pruneFlag { + toDelete = append(toDelete, id) + toDeleteNames = append(toDeleteNames, name) + fmt.Printf("- DELETE : %s\n", name) + } else { + fmt.Printf("! ORPHAN : %s (would be deleted if --prune was passed)\n", name) + } } } From 1fef9ee62683f93c90422a15d32ca4e3871ef1cd Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 14:05:38 +0300 Subject: [PATCH 03/20] docs: add Security-as-Code and Audit Log documentation (Issue #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add docs/guides/security-as-code.md — full nexus-cli guide covering plan/apply workflow, manifest format, --prune flag, and CI/CD setup - Add docs/reference/audit-log.md — GET /v1/audit endpoint reference with event types, query params, and response schema - Update docs/services/broker.md — add Audit Subsystem section (§5) and clarify STATE_KEY fatal-exit in the env vars table - Update docs/reference/security-model.md — add Audit Trail and STATE_KEY Startup Guard sections - Update docs/guides/managing-providers.md — add tip callout pointing to the declarative nexus-cli workflow - Update mkdocs.yml — wire new pages into nav --- docs/guides/managing-providers.md | 4 + docs/guides/security-as-code.md | 230 ++++++++++++++++++++++++++++++ docs/reference/audit-log.md | 121 ++++++++++++++++ docs/reference/security-model.md | 32 +++++ docs/services/broker.md | 14 +- mkdocs.yml | 2 + 6 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 docs/guides/security-as-code.md create mode 100644 docs/reference/audit-log.md diff --git a/docs/guides/managing-providers.md b/docs/guides/managing-providers.md index baf775e..6a96220 100644 --- a/docs/guides/managing-providers.md +++ b/docs/guides/managing-providers.md @@ -2,6 +2,10 @@ This guide provides a comprehensive overview of how to register, manage, and test identity providers within the Nexus OAuth Broker. +!!! tip "Prefer a GitOps workflow?" + For production deployments, consider using **[`nexus-cli`](security-as-code.md)** — a declarative reconciler that manages providers via a YAML manifest committed to your repository. It gives you version history, code review, and an automatic audit trail for every change. + + ## Provider Types The broker supports two primary types of providers: diff --git a/docs/guides/security-as-code.md b/docs/guides/security-as-code.md new file mode 100644 index 0000000..d1966cc --- /dev/null +++ b/docs/guides/security-as-code.md @@ -0,0 +1,230 @@ +# Security-as-Code: Declarative Provider Management + +The **`nexus-cli`** tool brings a GitOps-compatible, Terraform-style workflow to managing your Nexus provider configurations. Instead of managing providers through direct API calls (which leave no version history and are impossible to review), you declare your desired state in a YAML manifest, commit it to your repository, and let `nexus-cli` reconcile the live Broker against that source of truth. + +!!! tip "Why this matters" + Nexus holds Refresh Tokens and API Keys for every provider a workspace connects to — it is critical infrastructure. Without declarative management, a single bad API call can silently break all agents that depend on a provider, with no git history to recover from. + +--- + +## How It Works + +`nexus-cli` follows a **plan → confirm → apply** workflow: + +1. **Fetches** the current live state from `GET /v1/providers`. +2. **Diffs** it against your `nexus-providers.yaml` manifest. +3. **Prints** a human-readable plan showing creates, updates, and orphaned providers. +4. **Applies** the changes only after you confirm with `yes` (or non-interactively in CI). + +--- + +## Installation + +Build from source within the repository: + +```bash +cd nexus-cli +go build -o nexus-cli . +``` + +Or install directly: + +```bash +go install github.com/Prescott-Data/nexus-framework/nexus-cli@latest +``` + +--- + +## Configuration + +`nexus-cli` is configured via environment variables: + +| Variable | Description | Default | +| :--- | :--- | :--- | +| `BROKER_BASE_URL` | Base URL of the Nexus Broker | `http://localhost:8080` | +| `API_KEY` | API key for Broker authentication | *(none)* | + +--- + +## The Provider Manifest + +Create a `nexus-providers.yaml` file and **commit it to your GitOps repository**. This file is your single source of truth for all provider configurations. + +Environment variables are expanded at runtime, so secrets never need to be hardcoded. + +```yaml title="nexus-providers.yaml" +providers: + - name: google-workspace + auth_type: oauth2 + client_id: "${GOOGLE_CLIENT_ID}" + client_secret: "${GOOGLE_CLIENT_SECRET}" + issuer: "https://accounts.google.com" + enable_discovery: true + scopes: + - openid + - email + - profile + - offline_access + + - name: github + auth_type: oauth2 + client_id: "${GITHUB_CLIENT_ID}" + client_secret: "${GITHUB_CLIENT_SECRET}" + auth_url: "https://github.com/login/oauth/authorize" + token_url: "https://github.com/login/oauth/access_token" + api_base_url: "https://api.github.com" + enable_discovery: false + scopes: + - read:user + - user:email +``` + +### Manifest Fields + +| Field | Type | Description | +| :--- | :--- | :--- | +| `name` | string | Unique provider name (used as the reconciliation key) | +| `auth_type` | string | `oauth2` or `api_key` | +| `client_id` | string | OAuth client ID | +| `client_secret` | string | OAuth client secret | +| `issuer` | string | OIDC issuer URL for auto-discovery | +| `auth_url` | string | Authorization endpoint (if not using discovery) | +| `token_url` | string | Token endpoint (if not using discovery) | +| `api_base_url` | string | Provider API root URL | +| `enable_discovery` | bool | Use OIDC discovery if `true` | +| `scopes` | list | Default scopes to request | +| `params` | map | Provider-specific extra parameters | + +--- + +## Commands + +### `plan` — Preview Changes + +Show what would change without making any mutations: + +```bash +nexus-cli plan +# Or with a custom manifest path: +nexus-cli plan --file ./path/to/nexus-providers.yaml +``` + +**Example output:** + +``` +Read 2 providers from nexus-providers.yaml + +--- Execution Plan --- ++ CREATE : github +~ UPDATE : google-workspace +! ORPHAN : old-slack-provider (would be deleted if --prune was passed) + +Plan complete. Run 'nexus-cli apply' to perform these actions. +``` + +The symbols mean: + +| Symbol | Action | +| :--- | :--- | +| `+` | Provider will be created | +| `~` | Provider will be updated | +| `-` | Provider will be deleted (only shown with `--prune`) | +| `!` | Provider exists in live state but not in manifest (orphan) | + +### `apply` — Apply Changes + +Apply the manifest, with an interactive confirmation prompt: + +```bash +nexus-cli apply +``` + +``` +Read 2 providers from nexus-providers.yaml + +--- Execution Plan --- ++ CREATE : github +~ UPDATE : google-workspace + +Do you want to perform these actions? + Nexus will perform the actions described above. + Only 'yes' will be accepted to approve. + + Enter a value: yes + +--- Applying Changes --- +Creating github... OK +Updating google-workspace... OK +``` + +#### Flags + +| Flag | Default | Description | +| :--- | :--- | :--- | +| `--file` | `nexus-providers.yaml` | Path to the manifest file | +| `--prune` | `false` | Also delete providers in live state not in the manifest | + +!!! warning "Using `--prune`" + The `--prune` flag will **delete** providers that exist in the Broker but are absent from your manifest. Only use this when you are certain your manifest is the complete desired state. Any agents depending on a pruned provider will immediately lose their connections. + +--- + +## CI/CD Integration + +The recommended pattern is to use `nexus-cli` in your GitHub Actions pipeline so that every change to the manifest goes through a code review + automated reconciliation cycle. + +A workflow is included at `.github/workflows/nexus-cli.yml` and runs automatically when `nexus-cli/nexus-providers.yaml` is modified: + +```yaml title=".github/workflows/nexus-cli.yml" +on: + pull_request: + paths: ['nexus-cli/nexus-providers.yaml'] + push: + branches: [main] + paths: ['nexus-cli/nexus-providers.yaml'] + +jobs: + plan-or-apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.21' + - run: go build -o nexus-cli + working-directory: ./nexus-cli + + # On PRs: show a plan as a check + - name: Plan (Pull Request) + if: github.event_name == 'pull_request' + env: + BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} + API_KEY: ${{ secrets.BROKER_API_KEY }} + run: ./nexus-cli plan + + # On merge to main: apply with prune + - name: Apply (Push to Main) + if: github.event_name == 'push' + env: + BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} + API_KEY: ${{ secrets.BROKER_API_KEY }} + run: ./nexus-cli apply --prune <<< "yes" + working-directory: ./nexus-cli +``` + +### Required GitHub Secrets + +| Secret | Description | +| :--- | :--- | +| `BROKER_BASE_URL` | URL of your production Nexus Broker | +| `BROKER_API_KEY` | API key for Broker authentication | + +--- + +## Best Practices + +1. **Treat `nexus-providers.yaml` as infrastructure code** — require PR reviews for all changes. +2. **Never hardcode secrets** — always use `${ENV_VAR}` expansion and inject via CI secrets. +3. **Start without `--prune`** — let orphans accumulate warnings first so you can audit them intentionally before deletion. +4. **One manifest per environment** — keep a `nexus-providers.prod.yaml` and `nexus-providers.staging.yaml` and set `BROKER_BASE_URL` accordingly in each CI environment. +5. **All mutations are audited** — every create, update, or delete applied by `nexus-cli` is recorded in the [Audit Log](../reference/audit-log.md). diff --git a/docs/reference/audit-log.md b/docs/reference/audit-log.md new file mode 100644 index 0000000..0f19a37 --- /dev/null +++ b/docs/reference/audit-log.md @@ -0,0 +1,121 @@ +# Audit Log Reference + +The Nexus Broker maintains a tamper-evident **audit log** of every control-plane mutation. Every time a provider is created, updated, or deleted — or an OAuth connection is established — a structured record is written to the `audit_events` table. + +This provides a queryable history of who changed what and when, which is essential for operating Nexus as critical infrastructure. + +--- + +## Audited Events + +| Event Type | Trigger | +| :--- | :--- | +| `provider.created` | A new provider profile is registered | +| `provider.updated` | A provider's configuration is modified (`PUT` or `PATCH`) | +| `provider.deleted` | A provider is deleted (by ID or by name) | +| `connection.created` | An OAuth callback completes successfully and a connection is established | +| `connection.revoked` | A connection is explicitly revoked | + +--- + +## Query the Audit Log + +``` +GET /v1/audit +``` + +Returns recent audit events in descending chronological order. This endpoint is protected by `ApiKeyMiddleware`. + +### Query Parameters + +| Parameter | Type | Description | +| :--- | :--- | :--- | +| `event_type` | string | Filter by event type (e.g. `provider.deleted`) | +| `since` | string | RFC3339 timestamp — only return events after this time | +| `limit` | integer | Maximum records to return (default: `50`, max: `1000`) | + +### Examples + +**Fetch the last 50 audit events:** +```bash +curl -s "http://localhost:8080/audit" \ + -H "X-API-Key: " | jq . +``` + +**Filter by event type:** +```bash +curl -s "http://localhost:8080/audit?event_type=provider.deleted" \ + -H "X-API-Key: " | jq . +``` + +**Filter by time window:** +```bash +curl -s "http://localhost:8080/audit?since=2026-05-01T00:00:00Z&limit=100" \ + -H "X-API-Key: " | jq . +``` + +**Combine filters:** +```bash +curl -s "http://localhost:8080/audit?event_type=provider.created&since=2026-05-01T00:00:00Z" \ + -H "X-API-Key: " | jq . +``` + +--- + +## Response Schema + +```json +[ + { + "id": "a1b2c3d4-...", + "connection_id": "f5e6d7c8-...", + "event_type": "connection.created", + "event_data": "{\"provider_id\": \"...\", \"workspace_id\": \"ws-123\"}", + "ip_address": "10.0.0.1", + "user_agent": "nexus-gateway/1.0", + "created_at": "2026-05-05T10:30:00Z" + }, + { + "id": "b2c3d4e5-...", + "connection_id": null, + "event_type": "provider.deleted", + "event_data": "{\"provider_id\": \"...\", \"provider_name\": \"old-slack\"}", + "ip_address": "192.168.1.5", + "user_agent": "curl/7.88.1", + "created_at": "2026-05-05T09:15:00Z" + } +] +``` + +### Field Descriptions + +| Field | Type | Description | +| :--- | :--- | :--- | +| `id` | UUID | Unique audit event identifier | +| `connection_id` | UUID \| null | Associated connection, if applicable | +| `event_type` | string | The event type (see table above) | +| `event_data` | string \| null | JSON payload with event-specific context | +| `ip_address` | string \| null | IP of the caller (respects `X-Forwarded-For`) | +| `user_agent` | string \| null | User-Agent of the caller | +| `created_at` | RFC3339 | Timestamp of the event | + +--- + +## Database + +Audit events are stored in the `audit_events` PostgreSQL table, created in the initial migration (`00_create_tables.sql`). An index on `created_at DESC` (migration `11_add_audit_created_at_index.sql`) ensures fast time-range queries even at high volume. + +!!! note "Retention Policy" + There is currently no automatic retention/pruning policy for audit events. For long-running production deployments, consider adding a scheduled job to archive or delete records older than your compliance window (e.g., 90 days). + +--- + +## Audit via `nexus-cli` + +Every mutation performed by [`nexus-cli apply`](../guides/security-as-code.md) is automatically recorded in the audit log. You can correlate CLI runs with audit events using the `ip_address` field (the IP of your CI runner) and the `event_data.provider_name` field. + +```bash +# See all provider changes from a CI apply run +curl -s "http://localhost:8080/audit?event_type=provider.created&since=2026-05-05T13:00:00Z" \ + -H "X-API-Key: " | jq . +``` diff --git a/docs/reference/security-model.md b/docs/reference/security-model.md index c930b1a..dd2ec7b 100644 --- a/docs/reference/security-model.md +++ b/docs/reference/security-model.md @@ -42,3 +42,35 @@ The Broker supports an `ALLOWED_CIDRS` policy. In production, this should be res ### mTLS (Roadmap) Future versions of Nexus will support mutual TLS between the Gateway and Broker for cryptographically enforced identity beyond API keys. + +--- + +## Audit Trail + +Nexus maintains a tamper-evident **audit log** for all control-plane mutations. Every provider create, update, and delete — and every OAuth connection established — writes a record to the `audit_events` table with: + +- The **event type** (`provider.created`, `provider.deleted`, `connection.created`, etc.) +- **Structured event data** (provider ID, name, workspace ID) +- The **caller IP address** and **User-Agent** + +This audit log is queryable via the [`GET /v1/audit`](audit-log.md) endpoint and is the foundational building block for compliance, forensic analysis, and detecting unauthorized mutations. + +!!! tip "GitOps for Auditability" + For the strongest audit posture, use [`nexus-cli`](../guides/security-as-code.md) to manage providers declaratively. Every `nexus-cli apply` run goes through git history AND generates audit log entries — giving you two independent sources of truth. + +--- + +## `STATE_KEY` Startup Guard + +Both the Broker and Gateway will **fatal-exit at startup** if the `STATE_KEY` environment variable is absent: + +``` +FATAL: STATE_KEY environment variable is required and must be identical across Broker and Gateway +``` + +This prevents a class of silent misconfiguration where a randomly-generated key would cause all OAuth callbacks to fail with invalid state errors after any service restart. In production, `STATE_KEY` must be: + +1. A 32-byte cryptographically random value, Base64 encoded. +2. **Identical** on both the Broker and all Gateway instances. +3. Stored as a managed secret (e.g., Google Secret Manager, AWS Secrets Manager) — not hardcoded. + diff --git a/docs/services/broker.md b/docs/services/broker.md index 8f50381..0bb49c3 100644 --- a/docs/services/broker.md +++ b/docs/services/broker.md @@ -28,6 +28,17 @@ To ensure agents never face a "cold start" due to expired tokens: - It performs background refreshes using stored Refresh Tokens. - If a refresh fails permanently (e.g., user revoked access), it transitions the connection to `attention_required`. +### 5. Audit Subsystem +Every control-plane mutation is recorded in the `audit_events` table via the `audit.Service`: +- **`provider.created`** — logged on every successful `POST /providers` call. +- **`provider.updated`** — logged on `PUT` and `PATCH` mutations. +- **`provider.deleted`** — logged on deletion by ID or by name. +- **`connection.created`** — logged on every successful OAuth callback. + +Audit events capture the **caller IP** (respecting `X-Forwarded-For`), **User-Agent**, and structured **event data** (provider ID, name, etc.). + +See the [Audit Log Reference](../reference/audit-log.md) for how to query events. + ## Environment Variables | Variable | Description | Default | @@ -35,5 +46,6 @@ To ensure agents never face a "cold start" due to expired tokens: | `DATABASE_URL` | PostgreSQL connection string. | Required | | `REDIS_URL` | Redis URL for caching discovery and state. | Required | | `ENCRYPTION_KEY` | 32-byte Base64 key for AES-GCM. | Required | -| `STATE_KEY` | 32-byte Base64 key for signing state. | Required | +| `STATE_KEY` | 32-byte Base64 key for signing state. Must match the Gateway. The Broker will **fatal-exit** on startup if absent. | Required | | `API_KEY` | Key for Gateway-to-Broker authentication. | Required | + diff --git a/mkdocs.yml b/mkdocs.yml index 839fe20..e668e11 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,8 +71,10 @@ nav: - Guides: - Agent Integration: guides/integrating-agents.md - Managing Providers: guides/managing-providers.md + - Security-as-Code (nexus-cli): guides/security-as-code.md - API Reference: - API Overview: reference/api.md + - Audit Log: reference/audit-log.md - Technical Debt & Roadmap: reference/tech-debt.md extra: From fa64a4fb8de26a1f7bfab39337dc956c273ab735 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 14:52:09 +0300 Subject: [PATCH 04/20] fix(copilot-review): address all 10 Copilot PR comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nexus-cli: use X-API-Key header (was Authorization: Bearer) - nexus-cli: add 30s timeout to shared http.Client - nexus-cli: diff-based update detection via providerDrifted() — skip no-op UPDATEs and print '= OK' for providers with no changes - nexus-cli/go.mod: align go directive to 1.21 (matches CI) - .github/workflows/nexus-cli.yml: guard plan step for forked PRs where secrets are unavailable (no-op with info message instead of failing) - audit/service.go: validate extracted IP with net.ParseIP before storing to prevent INSERT failures on arbitrary X-Forwarded-For values - handlers/providers.go: log audit errors with log.Printf instead of discarding with _ = - handlers/callback.go: log audit errors in logAuditEvent wrapper - handlers/audit_test.go: add unit tests for AuditHandler.List covering no filters, event_type filter, invalid since param (400), custom limit, and out-of-range limit clamping - docs/reference/audit-log.md: correct endpoint path to /audit (no /v1 prefix) with note explaining the Broker is currently unversioned --- .github/workflows/nexus-cli.yml | 8 +- docs/reference/audit-log.md | 4 +- nexus-broker/internal/audit/service.go | 10 +- nexus-broker/pkg/handlers/audit_test.go | 174 ++++++++++++++++++++++++ nexus-broker/pkg/handlers/callback.go | 5 +- nexus-broker/pkg/handlers/providers.go | 21 ++- nexus-cli/go.mod | 2 +- nexus-cli/main.go | 73 ++++++---- 8 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 nexus-broker/pkg/handlers/audit_test.go diff --git a/.github/workflows/nexus-cli.yml b/.github/workflows/nexus-cli.yml index 3b8c975..3754a76 100644 --- a/.github/workflows/nexus-cli.yml +++ b/.github/workflows/nexus-cli.yml @@ -33,12 +33,18 @@ jobs: run: go build -o nexus-cli - name: Plan (Pull Request) - if: github.event_name == 'pull_request' + # Only run plan when BROKER_BASE_URL is set (not available for forked PRs). + # This prevents the job from failing with "connection refused" for external contributors. + if: github.event_name == 'pull_request' && secrets.BROKER_BASE_URL != '' env: BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} API_KEY: ${{ secrets.BROKER_API_KEY }} run: ./nexus-cli plan + - name: Plan skipped (no Broker secret) + if: github.event_name == 'pull_request' && secrets.BROKER_BASE_URL == '' + run: echo "ℹ️ Skipping live plan — BROKER_BASE_URL secret not available (forked PR)." + - name: Apply (Push to Main) if: github.event_name == 'push' && github.ref == 'refs/heads/main' env: diff --git a/docs/reference/audit-log.md b/docs/reference/audit-log.md index 0f19a37..bcd1a66 100644 --- a/docs/reference/audit-log.md +++ b/docs/reference/audit-log.md @@ -21,11 +21,13 @@ This provides a queryable history of who changed what and when, which is essenti ## Query the Audit Log ``` -GET /v1/audit +GET /audit ``` Returns recent audit events in descending chronological order. This endpoint is protected by `ApiKeyMiddleware`. +> **Note:** The Nexus Broker API is unversioned — all routes are mounted at the root (e.g., `/providers`, `/audit`). The `/v1/audit` path referenced elsewhere is aspirational and will apply if/when the Broker adopts a versioned API prefix. + ### Query Parameters | Parameter | Type | Description | diff --git a/nexus-broker/internal/audit/service.go b/nexus-broker/internal/audit/service.go index ac83fef..cde4333 100644 --- a/nexus-broker/internal/audit/service.go +++ b/nexus-broker/internal/audit/service.go @@ -23,16 +23,16 @@ func (s *Service) Log(eventType string, connectionID *uuid.UUID, data map[string var userAgent *string if r != nil { - // Extract IP + // Extract IP — validate with net.ParseIP to avoid storing arbitrary text in the inet column. if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { ip := strings.TrimSpace(strings.Split(fwd, ",")[0]) - ipVal = &ip + if net.ParseIP(ip) != nil { + ipVal = &ip + } } else { host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil { + if err == nil && net.ParseIP(host) != nil { ipVal = &host - } else { - ipVal = &r.RemoteAddr } } diff --git a/nexus-broker/pkg/handlers/audit_test.go b/nexus-broker/pkg/handlers/audit_test.go new file mode 100644 index 0000000..2274d53 --- /dev/null +++ b/nexus-broker/pkg/handlers/audit_test.go @@ -0,0 +1,174 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/handlers" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1" +) + +// newSqlxDB wraps a sqlmock connection in a sqlx.DB so handlers can use it. +func newSqlxDB(t *testing.T) (*sqlx.DB, sqlmock.Sqlmock) { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + return sqlx.NewDb(db, "postgres"), mock +} + +func TestAuditList_NoFilters_ReturnsDefaultLimit(t *testing.T) { + db, mock := newSqlxDB(t) + defer db.Close() + + id := uuid.New() + connID := uuid.New() + now := time.Now() + + rows := sqlmock.NewRows([]string{ + "id", "connection_id", "event_type", "event_data", "ip_address", "user_agent", "created_at", + }).AddRow( + id, &connID, "provider.created", `{"name":"google"}`, "127.0.0.1", "curl/7.88", now, + ) + + mock.ExpectQuery(`SELECT id, connection_id, event_type, event_data, ip_address, user_agent, created_at`). + WithArgs(50). // default limit + WillReturnRows(rows) + + handler := handlers.NewAuditHandler(db) + req := httptest.NewRequest(http.MethodGet, "/audit", nil) + w := httptest.NewRecorder() + + handler.List(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var result []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 event, got %d", len(result)) + } + if result[0]["event_type"] != "provider.created" { + t.Errorf("expected event_type provider.created, got %v", result[0]["event_type"]) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +func TestAuditList_EventTypeFilter(t *testing.T) { + db, mock := newSqlxDB(t) + defer db.Close() + + rows := sqlmock.NewRows([]string{ + "id", "connection_id", "event_type", "event_data", "ip_address", "user_agent", "created_at", + }) // empty result + + mock.ExpectQuery(`SELECT id, connection_id, event_type, event_data, ip_address, user_agent, created_at`). + WithArgs("provider.deleted", 50). + WillReturnRows(rows) + + handler := handlers.NewAuditHandler(db) + req := httptest.NewRequest(http.MethodGet, "/audit?event_type=provider.deleted", nil) + w := httptest.NewRecorder() + + handler.List(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Empty result should return [] not null + var result []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result == nil { + t.Error("expected empty array, got null") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +func TestAuditList_InvalidSinceParam_Returns400(t *testing.T) { + db, _ := newSqlxDB(t) + defer db.Close() + + handler := handlers.NewAuditHandler(db) + req := httptest.NewRequest(http.MethodGet, "/audit?since=not-a-date", nil) + w := httptest.NewRecorder() + + handler.List(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid since param, got %d", w.Code) + } +} + +func TestAuditList_CustomLimit(t *testing.T) { + db, mock := newSqlxDB(t) + defer db.Close() + + rows := sqlmock.NewRows([]string{ + "id", "connection_id", "event_type", "event_data", "ip_address", "user_agent", "created_at", + }) + + mock.ExpectQuery(`SELECT id, connection_id, event_type, event_data, ip_address, user_agent, created_at`). + WithArgs(10). + WillReturnRows(rows) + + handler := handlers.NewAuditHandler(db) + req := httptest.NewRequest(http.MethodGet, "/audit?limit=10", nil) + w := httptest.NewRecorder() + + handler.List(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +func TestAuditList_LimitAboveMax_ClampedTo1000(t *testing.T) { + db, mock := newSqlxDB(t) + defer db.Close() + + rows := sqlmock.NewRows([]string{ + "id", "connection_id", "event_type", "event_data", "ip_address", "user_agent", "created_at", + }) + + // limit=9999 should be clamped to 50 (default, since > 1000 is rejected) + mock.ExpectQuery(`SELECT id, connection_id, event_type, event_data, ip_address, user_agent, created_at`). + WithArgs(50). + WillReturnRows(rows) + + handler := handlers.NewAuditHandler(db) + req := httptest.NewRequest(http.MethodGet, "/audit?limit=9999", nil) + w := httptest.NewRecorder() + + handler.List(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} diff --git a/nexus-broker/pkg/handlers/callback.go b/nexus-broker/pkg/handlers/callback.go index 2f466ee..4651d8a 100644 --- a/nexus-broker/pkg/handlers/callback.go +++ b/nexus-broker/pkg/handlers/callback.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "strings" @@ -844,7 +845,9 @@ func (h *CallbackHandler) logAuditEvent(connectionID *uuid.UUID, eventType strin auditData[k] = v } - _ = h.audit.Log(eventType, connectionID, auditData, r) + if err := h.audit.Log(eventType, connectionID, auditData, r); err != nil { + log.Printf("audit: failed to log %s (connection_id=%v): %v", eventType, connectionID, err) + } } // handleError handles OAuth errors diff --git a/nexus-broker/pkg/handlers/providers.go b/nexus-broker/pkg/handlers/providers.go index 84dcaa5..04347ac 100644 --- a/nexus-broker/pkg/handlers/providers.go +++ b/nexus-broker/pkg/handlers/providers.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "fmt" + "log" "net/http" "strings" @@ -64,7 +65,9 @@ func (h *ProvidersHandler) Update(w http.ResponseWriter, r *http.Request) { } if h.audit != nil { - _ = h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r) + if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r); err != nil { + log.Printf("audit: failed to log provider.updated for provider_id=%s: %v", profile.ID, err) + } } w.WriteHeader(http.StatusOK) @@ -91,7 +94,9 @@ func (h *ProvidersHandler) Patch(w http.ResponseWriter, r *http.Request) { } if h.audit != nil { - _ = h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": id.String(), "updates": updates}, r) + if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": id.String(), "updates": updates}, r); err != nil { + log.Printf("audit: failed to log provider.updated for provider_id=%s: %v", id, err) + } } w.WriteHeader(http.StatusOK) @@ -112,7 +117,9 @@ func (h *ProvidersHandler) Delete(w http.ResponseWriter, r *http.Request) { } if h.audit != nil { - _ = h.audit.Log("provider.deleted", nil, map[string]interface{}{"provider_id": id.String()}, r) + if err := h.audit.Log("provider.deleted", nil, map[string]interface{}{"provider_id": id.String()}, r); err != nil { + log.Printf("audit: failed to log provider.deleted for provider_id=%s: %v", id, err) + } } w.WriteHeader(http.StatusOK) @@ -165,7 +172,9 @@ func (h *ProvidersHandler) Register(w http.ResponseWriter, r *http.Request) { } if h.audit != nil { - _ = h.audit.Log("provider.created", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r) + if err := h.audit.Log("provider.created", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r); err != nil { + log.Printf("audit: failed to log provider.created for provider_id=%s: %v", profile.ID, err) + } } httputil.WriteJSON(w, http.StatusCreated, map[string]interface{}{ @@ -224,7 +233,9 @@ func (h *ProvidersHandler) DeleteByName(w http.ResponseWriter, r *http.Request) } if h.audit != nil { - _ = h.audit.Log("provider.deleted", nil, map[string]interface{}{"provider_name": name, "rows_affected": rowsAffected}, r) + if err := h.audit.Log("provider.deleted", nil, map[string]interface{}{"provider_name": name, "rows_affected": rowsAffected}, r); err != nil { + log.Printf("audit: failed to log provider.deleted for provider_name=%s: %v", name, err) + } } httputil.WriteJSON(w, http.StatusOK, map[string]string{"message": fmt.Sprintf("Deleted %d provider(s)", rowsAffected)}) diff --git a/nexus-cli/go.mod b/nexus-cli/go.mod index 90a66c9..61e4d10 100644 --- a/nexus-cli/go.mod +++ b/nexus-cli/go.mod @@ -1,5 +1,5 @@ module github.com/Prescott-Data/nexus-framework/nexus-cli -go 1.26.2 +go 1.21 require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/nexus-cli/main.go b/nexus-cli/main.go index 5566367..88f877c 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -10,11 +10,16 @@ import ( "log" "net/http" "os" + "reflect" "strings" + "time" "gopkg.in/yaml.v3" ) +// httpClient is shared across all requests with a conservative timeout. +var httpClient = &http.Client{Timeout: 30 * time.Second} + type Provider struct { Name string `yaml:"name" json:"name"` AuthType string `yaml:"auth_type,omitempty" json:"auth_type,omitempty"` @@ -56,6 +61,13 @@ func main() { } } +// setAPIKey sets the X-API-Key header on a request, matching the Broker's ApiKeyMiddleware. +func setAPIKey(req *http.Request, apiKey string) { + if apiKey != "" { + req.Header.Set("X-API-Key", apiKey) + } +} + func runCommand(isPlanOnly bool) { cmdFlags := flag.NewFlagSet(os.Args[1], flag.ExitOnError) fileFlag := cmdFlags.String("file", "nexus-providers.yaml", "Path to the providers manifest file") @@ -92,12 +104,9 @@ func runCommand(isPlanOnly bool) { if err != nil { log.Fatalf("Failed to create request: %v", err) } - if apiKey != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } + setAPIKey(req, apiKey) - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { log.Fatalf("Failed to fetch live providers: %v", err) } @@ -113,12 +122,13 @@ func runCommand(isPlanOnly bool) { log.Fatalf("Failed to decode live providers: %v", err) } - liveProviderMap := make(map[string]string) // map name to ID + // Build live provider map: name → {id, full config} + liveProviderMap := make(map[string]map[string]interface{}) for _, lp := range liveProviders { name, nameOk := lp["name"].(string) - id, idOk := lp["id"].(string) + _, idOk := lp["id"].(string) if nameOk && idOk { - liveProviderMap[name] = id + liveProviderMap[name] = lp } } @@ -131,22 +141,28 @@ func runCommand(isPlanOnly bool) { toCreate := []Provider{} toUpdate := make(map[string]Provider) // map ID to Provider - toDelete := []string{} // list of IDs + toDelete := []string{} // list of IDs toDeleteNames := []string{} for _, p := range manifest.Providers { - if id, exists := liveProviderMap[p.Name]; exists { - toUpdate[id] = p - fmt.Printf("~ UPDATE : %s\n", p.Name) + if live, exists := liveProviderMap[p.Name]; exists { + id := live["id"].(string) + if providerDrifted(p, live) { + toUpdate[id] = p + fmt.Printf("~ UPDATE : %s\n", p.Name) + } else { + fmt.Printf("= OK : %s (no changes)\n", p.Name) + } } else { toCreate = append(toCreate, p) fmt.Printf("+ CREATE : %s\n", p.Name) } } - for name, id := range liveProviderMap { + for name, live := range liveProviderMap { if _, exists := manifestProviderMap[name]; !exists { if *pruneFlag { + id := live["id"].(string) toDelete = append(toDelete, id) toDeleteNames = append(toDeleteNames, name) fmt.Printf("- DELETE : %s\n", name) @@ -199,12 +215,10 @@ func runCommand(isPlanOnly bool) { fmt.Printf("Failed to create request: %v\n", err) continue } - if apiKey != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } + setAPIKey(req, apiKey) req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { fmt.Printf("Request failed: %v\n", err) continue @@ -232,12 +246,10 @@ func runCommand(isPlanOnly bool) { fmt.Printf("Failed to create request: %v\n", err) continue } - if apiKey != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } + setAPIKey(req, apiKey) req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { fmt.Printf("Request failed: %v\n", err) continue @@ -260,11 +272,9 @@ func runCommand(isPlanOnly bool) { fmt.Printf("Failed to create request: %v\n", err) continue } - if apiKey != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } + setAPIKey(req, apiKey) - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { fmt.Printf("Request failed: %v\n", err) continue @@ -278,3 +288,16 @@ func runCommand(isPlanOnly bool) { } } } + +// providerDrifted compares a manifest provider against the live state from the Broker. +// It normalises the live map into a Provider struct and deep-compares key fields, +// ignoring server-managed fields (id, created_at, updated_at). +func providerDrifted(desired Provider, live map[string]interface{}) bool { + var liveProvider Provider + if b, err := json.Marshal(live); err == nil { + _ = json.Unmarshal(b, &liveProvider) + } + // Clear server-managed fields before comparing + liveProvider.Name = desired.Name // name is the reconciliation key, always equal here + return !reflect.DeepEqual(desired, liveProvider) +} From ceea6cd386c6b4a282dec9ba4fefd14d77a227f1 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 14:59:36 +0300 Subject: [PATCH 05/20] fix(ci): use repo comparison to guard plan step for forked PRs The secrets context is not valid in if: expressions in GitHub Actions. Replace with github.event.pull_request.head.repo.full_name == github.repository which is the correct pattern for detecting forked vs same-repo PRs. --- .github/workflows/nexus-cli.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nexus-cli.yml b/.github/workflows/nexus-cli.yml index 3754a76..9c25628 100644 --- a/.github/workflows/nexus-cli.yml +++ b/.github/workflows/nexus-cli.yml @@ -33,17 +33,16 @@ jobs: run: go build -o nexus-cli - name: Plan (Pull Request) - # Only run plan when BROKER_BASE_URL is set (not available for forked PRs). - # This prevents the job from failing with "connection refused" for external contributors. - if: github.event_name == 'pull_request' && secrets.BROKER_BASE_URL != '' + # Only run plan for PRs from the same repo — forked PRs cannot access repository secrets. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository env: BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} API_KEY: ${{ secrets.BROKER_API_KEY }} run: ./nexus-cli plan - - name: Plan skipped (no Broker secret) - if: github.event_name == 'pull_request' && secrets.BROKER_BASE_URL == '' - run: echo "ℹ️ Skipping live plan — BROKER_BASE_URL secret not available (forked PR)." + - name: Plan skipped (forked PR) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + run: echo "ℹ️ Skipping live plan — this is a forked PR and repository secrets are not available." - name: Apply (Push to Main) if: github.event_name == 'push' && github.ref == 'refs/heads/main' From 8446fb328778b3f0f1b7809a5fd148b22390cc9a Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 15:39:45 +0300 Subject: [PATCH 06/20] fix(cli): fetch full profile for accurate drift detection The Broker's GET /providers endpoint only returns ID and Name, causing the CLI to incorrectly report drift for all providers even when no fields had changed. The CLI now iterates over the list and fetches the full profile via GET /providers/{id} so that providerDrifted() can perform an accurate comparison. --- nexus-cli/main.go | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/nexus-cli/main.go b/nexus-cli/main.go index 88f877c..3082ebf 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -126,9 +126,34 @@ func runCommand(isPlanOnly bool) { liveProviderMap := make(map[string]map[string]interface{}) for _, lp := range liveProviders { name, nameOk := lp["name"].(string) - _, idOk := lp["id"].(string) + id, idOk := lp["id"].(string) if nameOk && idOk { - liveProviderMap[name] = lp + // Fetch full profile for accurate drift detection + reqProfile, err := http.NewRequest("GET", brokerURL+"/providers/"+id, nil) + if err != nil { + log.Fatalf("Failed to create request for provider %s: %v", name, err) + } + setAPIKey(reqProfile, apiKey) + + respProfile, err := httpClient.Do(reqProfile) + if err != nil { + log.Fatalf("Failed to fetch profile for provider %s: %v", name, err) + } + + if respProfile.StatusCode != http.StatusOK { + body, _ := io.ReadAll(respProfile.Body) + respProfile.Body.Close() + log.Fatalf("Failed to fetch profile for %s, status: %d, body: %s", name, respProfile.StatusCode, string(body)) + } + + var fullProfile map[string]interface{} + if err := json.NewDecoder(respProfile.Body).Decode(&fullProfile); err != nil { + respProfile.Body.Close() + log.Fatalf("Failed to decode profile for %s: %v", name, err) + } + respProfile.Body.Close() + + liveProviderMap[name] = fullProfile } } From 1adc5d97c64d31612b0cb47a7c671cd59beb55a8 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 15:43:07 +0300 Subject: [PATCH 07/20] fix(cli): fail fast on unset environment variables during manifest apply Replaced os.ExpandEnv with a custom os.Expand callback that detects unset environment variables. This prevents them from being quietly replaced with empty strings, which would cause omitempty fields (like client_secret) to be dropped from the payload and written as NULL to the database by the Broker, effectively wiping the credentials. --- nexus-cli/main.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/nexus-cli/main.go b/nexus-cli/main.go index 3082ebf..e354021 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -90,7 +90,23 @@ func runCommand(isPlanOnly bool) { } // Expand environment variables - expandedData := os.ExpandEnv(string(data)) + var missingVars []string + missingSet := make(map[string]bool) + + expandedData := os.Expand(string(data), func(envVar string) string { + val, exists := os.LookupEnv(envVar) + if !exists { + if !missingSet[envVar] { + missingSet[envVar] = true + missingVars = append(missingVars, envVar) + } + } + return val + }) + + if len(missingVars) > 0 { + log.Fatalf("Failed to process manifest. The following environment variables are unset: %s", strings.Join(missingVars, ", ")) + } var manifest Manifest if err := yaml.Unmarshal([]byte(expandedData), &manifest); err != nil { From 18c82e5f70aab3b4ffaac1aac3ec224e94d3948b Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:06:50 +0300 Subject: [PATCH 08/20] fix(cli): use PATCH instead of PUT to prevent nulling omitted columns As noted by Copilot, using PUT with a struct-marshaled payload would overwrite unspecified/omitted fields with empty strings, causing the Broker to null out existing credentials and configurations. Refactored the CLI's apply logic to compute a JSON diff against the full live profile, sending only the modified fields via a PATCH request. --- nexus-cli/drift.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++ nexus-cli/main.go | 33 ++++++++++++------------------- 2 files changed, 61 insertions(+), 21 deletions(-) create mode 100644 nexus-cli/drift.go diff --git a/nexus-cli/drift.go b/nexus-cli/drift.go new file mode 100644 index 0000000..3832731 --- /dev/null +++ b/nexus-cli/drift.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" +) + +// computeDrift computes the difference between a desired Provider manifest +// and the live state returned by the Broker. It returns a boolean indicating +// if there is drift, and a map of the fields that need to be patched. +func computeDrift(desired Provider, live map[string]interface{}) (bool, map[string]interface{}) { + updates := make(map[string]interface{}) + drifted := false + + // Marshal and unmarshal desired to get a map reflecting exactly what + // would be sent as JSON (respecting json tags and omitempty). + b, _ := json.Marshal(desired) + var desiredMap map[string]interface{} + _ = json.Unmarshal(b, &desiredMap) + + for k, v := range desiredMap { + if k == "name" || k == "id" || k == "created_at" || k == "updated_at" { + continue + } + + liveVal, exists := live[k] + + // To handle subtle type differences from JSON unmarshaling (e.g., float64 vs int), + // we marshal both values and compare their JSON string representations. + vb, _ := json.Marshal(v) + lvb, _ := json.Marshal(liveVal) + + if !exists || string(vb) != string(lvb) { + // Special case: if desired is empty array/slice and live is null/missing, don't consider it drift + if string(vb) == "[]" && (string(lvb) == "null" || string(lvb) == "") { + continue + } + + // Special case: if desired is an empty string and live is missing/null + if string(vb) == `""` && (string(lvb) == "null" || string(lvb) == "") { + continue + } + + updates[k] = v + drifted = true + } + } + + return drifted, updates +} diff --git a/nexus-cli/main.go b/nexus-cli/main.go index e354021..d6edbad 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -10,7 +10,6 @@ import ( "log" "net/http" "os" - "reflect" "strings" "time" @@ -181,15 +180,18 @@ func runCommand(isPlanOnly bool) { fmt.Println("\n--- Execution Plan ---") toCreate := []Provider{} - toUpdate := make(map[string]Provider) // map ID to Provider - toDelete := []string{} // list of IDs + toUpdate := make(map[string]map[string]interface{}) // map ID to updates + toUpdateNames := make(map[string]string) // map ID to Name for logging + toDelete := []string{} // list of IDs toDeleteNames := []string{} for _, p := range manifest.Providers { if live, exists := liveProviderMap[p.Name]; exists { id := live["id"].(string) - if providerDrifted(p, live) { - toUpdate[id] = p + drifted, updates := computeDrift(p, live) + if drifted { + toUpdate[id] = updates + toUpdateNames[id] = p.Name fmt.Printf("~ UPDATE : %s\n", p.Name) } else { fmt.Printf("= OK : %s (no changes)\n", p.Name) @@ -273,16 +275,17 @@ func runCommand(isPlanOnly bool) { } } - for id, p := range toUpdate { - fmt.Printf("Updating %s... ", p.Name) + for id, updates := range toUpdate { + name := toUpdateNames[id] + fmt.Printf("Updating %s... ", name) - jsonData, err := json.Marshal(p) + jsonData, err := json.Marshal(updates) if err != nil { fmt.Printf("Failed to marshal: %v\n", err) continue } - req, err := http.NewRequest("PUT", brokerURL+"/providers/"+id, bytes.NewBuffer(jsonData)) + req, err := http.NewRequest("PATCH", brokerURL+"/providers/"+id, bytes.NewBuffer(jsonData)) if err != nil { fmt.Printf("Failed to create request: %v\n", err) continue @@ -330,15 +333,3 @@ func runCommand(isPlanOnly bool) { } } -// providerDrifted compares a manifest provider against the live state from the Broker. -// It normalises the live map into a Provider struct and deep-compares key fields, -// ignoring server-managed fields (id, created_at, updated_at). -func providerDrifted(desired Provider, live map[string]interface{}) bool { - var liveProvider Provider - if b, err := json.Marshal(live); err == nil { - _ = json.Unmarshal(b, &liveProvider) - } - // Clear server-managed fields before comparing - liveProvider.Name = desired.Name // name is the reconciliation key, always equal here - return !reflect.DeepEqual(desired, liveProvider) -} From 483dbc46828d0546741febf3f8addadb0057b313 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:13:05 +0300 Subject: [PATCH 09/20] fix(audit): return json.Marshal errors instead of silently discarding If event data fails to marshal, the error is now returned to callers rather than inserting a row with NULL event_data and losing audit context. --- nexus-broker/internal/audit/service.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nexus-broker/internal/audit/service.go b/nexus-broker/internal/audit/service.go index cde4333..ad34d53 100644 --- a/nexus-broker/internal/audit/service.go +++ b/nexus-broker/internal/audit/service.go @@ -2,6 +2,7 @@ package audit import ( "encoding/json" + "fmt" "net" "net/http" "strings" @@ -45,7 +46,11 @@ func (s *Service) Log(eventType string, connectionID *uuid.UUID, data map[string var eventDataJSON []byte if data != nil { - eventDataJSON, _ = json.Marshal(data) + var err error + eventDataJSON, err = json.Marshal(data) + if err != nil { + return fmt.Errorf("audit: failed to marshal event data: %w", err) + } } query := ` From ca9f1d24211bd9fc445d07c88cf44a33228bfc86 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:18:32 +0300 Subject: [PATCH 10/20] docs: align audit event types with actual code emissions The docs listed connection.created and connection.revoked, but the callback handler emits oauth_flow_completed, token_exchange_failed, token_retrieved, token_refresh_fatal, oauth_error, etc. Updated both broker.md and audit-log.md to reflect the real event type strings. --- docs/reference/audit-log.md | 13 +++++++++---- docs/services/broker.md | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/reference/audit-log.md b/docs/reference/audit-log.md index bcd1a66..38300ce 100644 --- a/docs/reference/audit-log.md +++ b/docs/reference/audit-log.md @@ -10,11 +10,16 @@ This provides a queryable history of who changed what and when, which is essenti | Event Type | Trigger | | :--- | :--- | -| `provider.created` | A new provider profile is registered | +| `provider.created` | A new provider profile is registered via `POST /providers` | | `provider.updated` | A provider's configuration is modified (`PUT` or `PATCH`) | | `provider.deleted` | A provider is deleted (by ID or by name) | -| `connection.created` | An OAuth callback completes successfully and a connection is established | -| `connection.revoked` | A connection is explicitly revoked | +| `oauth_flow_completed` | An OAuth callback completes successfully and a connection is established | +| `token_exchange_failed` | The authorization code → token exchange failed | +| `token_storage_failed` | Tokens were exchanged but could not be encrypted/stored | +| `token_retrieved` | A downstream service fetched a connection's token via `GET /connections/{id}/token` | +| `token_retrieval_failed` | A token fetch failed (not found, decryption error, inactive connection, etc.) | +| `token_refresh_fatal` | A refresh token was rejected by the provider (4xx), connection moved to `attention` | +| `oauth_error` | The provider returned an error on the OAuth callback (e.g. `access_denied`) | --- @@ -71,7 +76,7 @@ curl -s "http://localhost:8080/audit?event_type=provider.created&since=2026-05-0 { "id": "a1b2c3d4-...", "connection_id": "f5e6d7c8-...", - "event_type": "connection.created", + "event_type": "oauth_flow_completed", "event_data": "{\"provider_id\": \"...\", \"workspace_id\": \"ws-123\"}", "ip_address": "10.0.0.1", "user_agent": "nexus-gateway/1.0", diff --git a/docs/services/broker.md b/docs/services/broker.md index 0bb49c3..73f1272 100644 --- a/docs/services/broker.md +++ b/docs/services/broker.md @@ -33,7 +33,10 @@ Every control-plane mutation is recorded in the `audit_events` table via the `au - **`provider.created`** — logged on every successful `POST /providers` call. - **`provider.updated`** — logged on `PUT` and `PATCH` mutations. - **`provider.deleted`** — logged on deletion by ID or by name. -- **`connection.created`** — logged on every successful OAuth callback. +- **`oauth_flow_completed`** — logged on every successful OAuth callback (token exchange + storage). +- **`token_exchange_failed`**, **`token_storage_failed`**, etc. — logged on callback failures. +- **`token_retrieved`** — logged on every successful `GET /connections/{id}/token` call. +- **`token_refresh_fatal`** — logged when a token refresh fails permanently (4xx from provider). Audit events capture the **caller IP** (respecting `X-Forwarded-For`), **User-Agent**, and structured **event data** (provider ID, name, etc.). From 9232602eb5745fea28982c17d320ab932bdaad3f Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:19:14 +0300 Subject: [PATCH 11/20] =?UTF-8?q?docs:=20fix=20audit=20endpoint=20path=20i?= =?UTF-8?q?n=20security-model.md=20(/v1/audit=20=E2=86=92=20/audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/security-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/security-model.md b/docs/reference/security-model.md index dd2ec7b..1d6584e 100644 --- a/docs/reference/security-model.md +++ b/docs/reference/security-model.md @@ -53,7 +53,7 @@ Nexus maintains a tamper-evident **audit log** for all control-plane mutations. - **Structured event data** (provider ID, name, workspace ID) - The **caller IP address** and **User-Agent** -This audit log is queryable via the [`GET /v1/audit`](audit-log.md) endpoint and is the foundational building block for compliance, forensic analysis, and detecting unauthorized mutations. +This audit log is queryable via the [`GET /audit`](audit-log.md) endpoint and is the foundational building block for compliance, forensic analysis, and detecting unauthorized mutations. !!! tip "GitOps for Auditability" For the strongest audit posture, use [`nexus-cli`](../guides/security-as-code.md) to manage providers declaratively. Every `nexus-cli apply` run goes through git history AND generates audit log entries — giving you two independent sources of truth. From ee00e09733e67bf593d9c76d64436e61804a2510 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:19:57 +0300 Subject: [PATCH 12/20] =?UTF-8?q?docs:=20fix=20providers=20endpoint=20path?= =?UTF-8?q?=20in=20security-as-code.md=20(/v1/providers=20=E2=86=92=20/pro?= =?UTF-8?q?viders)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/security-as-code.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/security-as-code.md b/docs/guides/security-as-code.md index d1966cc..b88e3ad 100644 --- a/docs/guides/security-as-code.md +++ b/docs/guides/security-as-code.md @@ -11,7 +11,7 @@ The **`nexus-cli`** tool brings a GitOps-compatible, Terraform-style workflow to `nexus-cli` follows a **plan → confirm → apply** workflow: -1. **Fetches** the current live state from `GET /v1/providers`. +1. **Fetches** the current live state from `GET /providers`. 2. **Diffs** it against your `nexus-providers.yaml` manifest. 3. **Prints** a human-readable plan showing creates, updates, and orphaned providers. 4. **Applies** the changes only after you confirm with `yes` (or non-interactively in CI). From 4814ac256ed6a8d57f6a3e0e41853a58b0f52df0 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:22:20 +0300 Subject: [PATCH 13/20] perf(cli): fetch provider profiles concurrently with bounded worker pool Replaces the sequential N+1 fetch loop with a goroutine pool (max 5 concurrent requests) using a semaphore channel. This keeps plan/apply responsive as the provider count grows, without requiring a new bulk endpoint on the Broker. --- nexus-cli/main.go | 61 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/nexus-cli/main.go b/nexus-cli/main.go index d6edbad..0f81550 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "strings" + "sync" "time" "gopkg.in/yaml.v3" @@ -137,39 +138,77 @@ func runCommand(isPlanOnly bool) { log.Fatalf("Failed to decode live providers: %v", err) } - // Build live provider map: name → {id, full config} + // Build live provider map: name → full config. + // Fetch full profiles concurrently with a bounded worker pool to avoid N+1 latency. + type providerResult struct { + Name string + Profile map[string]interface{} + } + + const maxConcurrency = 5 + sem := make(chan struct{}, maxConcurrency) + var mu sync.Mutex + var wg sync.WaitGroup + var fetchErr error + liveProviderMap := make(map[string]map[string]interface{}) + for _, lp := range liveProviders { name, nameOk := lp["name"].(string) id, idOk := lp["id"].(string) - if nameOk && idOk { - // Fetch full profile for accurate drift detection + if !nameOk || !idOk { + continue + } + + wg.Add(1) + go func(name, id string) { + defer wg.Done() + sem <- struct{}{} // acquire + defer func() { <-sem }() // release + reqProfile, err := http.NewRequest("GET", brokerURL+"/providers/"+id, nil) if err != nil { - log.Fatalf("Failed to create request for provider %s: %v", name, err) + mu.Lock() + fetchErr = fmt.Errorf("failed to create request for provider %s: %w", name, err) + mu.Unlock() + return } setAPIKey(reqProfile, apiKey) respProfile, err := httpClient.Do(reqProfile) if err != nil { - log.Fatalf("Failed to fetch profile for provider %s: %v", name, err) + mu.Lock() + fetchErr = fmt.Errorf("failed to fetch profile for provider %s: %w", name, err) + mu.Unlock() + return } + defer respProfile.Body.Close() if respProfile.StatusCode != http.StatusOK { body, _ := io.ReadAll(respProfile.Body) - respProfile.Body.Close() - log.Fatalf("Failed to fetch profile for %s, status: %d, body: %s", name, respProfile.StatusCode, string(body)) + mu.Lock() + fetchErr = fmt.Errorf("failed to fetch profile for %s, status: %d, body: %s", name, respProfile.StatusCode, string(body)) + mu.Unlock() + return } var fullProfile map[string]interface{} if err := json.NewDecoder(respProfile.Body).Decode(&fullProfile); err != nil { - respProfile.Body.Close() - log.Fatalf("Failed to decode profile for %s: %v", name, err) + mu.Lock() + fetchErr = fmt.Errorf("failed to decode profile for %s: %w", name, err) + mu.Unlock() + return } - respProfile.Body.Close() + mu.Lock() liveProviderMap[name] = fullProfile - } + mu.Unlock() + }(name, id) + } + + wg.Wait() + if fetchErr != nil { + log.Fatalf("Error fetching provider profiles: %v", fetchErr) } manifestProviderMap := make(map[string]Provider) From 207fc7488402eeebc807d6772a8689914f4bbee0 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:25:37 +0300 Subject: [PATCH 14/20] feat(cli): show field-level diff in plan output for UPDATE actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan now prints each changed field with old → new values when drift is detected, making it clear exactly what will change before applying. Sensitive fields (client_secret, client_id) are masked with *** to prevent secret leakage in CI logs. --- nexus-cli/main.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/nexus-cli/main.go b/nexus-cli/main.go index 0f81550..483c169 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -232,6 +232,14 @@ func runCommand(isPlanOnly bool) { toUpdate[id] = updates toUpdateNames[id] = p.Name fmt.Printf("~ UPDATE : %s\n", p.Name) + for field, newVal := range updates { + oldVal := live[field] + if isSecretField(field) { + fmt.Printf(" %s: *** → ***\n", field) + } else { + fmt.Printf(" %s: %v → %v\n", field, formatVal(oldVal), formatVal(newVal)) + } + } } else { fmt.Printf("= OK : %s (no changes)\n", p.Name) } @@ -372,3 +380,34 @@ func runCommand(isPlanOnly bool) { } } + +// isSecretField returns true for fields that should be masked in plan output. +func isSecretField(field string) bool { + switch field { + case "client_secret", "client_id": + return true + } + return false +} + +// formatVal returns a human-readable string for a plan diff value. +func formatVal(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + if val == "" { + return `""` + } + return val + case []interface{}: + parts := make([]string, len(val)) + for i, item := range val { + parts[i] = fmt.Sprintf("%v", item) + } + return "[" + strings.Join(parts, ", ") + "]" + default: + return fmt.Sprintf("%v", v) + } +} From fb5ae72fdf5b8e04b5d15c9b417e76e7bf1a6f53 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:27:12 +0300 Subject: [PATCH 15/20] chore(cli): run go mod tidy to fix indirect annotation on yaml.v3 gopkg.in/yaml.v3 is directly imported in main.go but was marked as // indirect in go.mod. Running go mod tidy corrects this. --- nexus-cli/go.mod | 2 +- nexus-cli/go.sum | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/nexus-cli/go.mod b/nexus-cli/go.mod index 61e4d10..ef61965 100644 --- a/nexus-cli/go.mod +++ b/nexus-cli/go.mod @@ -2,4 +2,4 @@ module github.com/Prescott-Data/nexus-framework/nexus-cli go 1.21 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require gopkg.in/yaml.v3 v3.0.1 diff --git a/nexus-cli/go.sum b/nexus-cli/go.sum index 4bc0337..a62c313 100644 --- a/nexus-cli/go.sum +++ b/nexus-cli/go.sum @@ -1,3 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From bba086189b42b79e645807e2f980ab313258f9a4 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:31:56 +0300 Subject: [PATCH 16/20] fix(ci): pass provider credential secrets to CLI workflow env The manifest uses ${GOOGLE_CLIENT_ID}, ${GITHUB_CLIENT_ID}, etc. but the workflow only passed BROKER_BASE_URL and API_KEY, causing the fail-fast env var guard to abort. Added all four provider credential secrets to both the Plan and Apply step env blocks. --- .github/workflows/nexus-cli.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/nexus-cli.yml b/.github/workflows/nexus-cli.yml index 9c25628..05cc194 100644 --- a/.github/workflows/nexus-cli.yml +++ b/.github/workflows/nexus-cli.yml @@ -38,6 +38,10 @@ jobs: env: BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} API_KEY: ${{ secrets.BROKER_API_KEY }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GITHUB_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }} + GITHUB_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }} run: ./nexus-cli plan - name: Plan skipped (forked PR) @@ -49,4 +53,8 @@ jobs: env: BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} API_KEY: ${{ secrets.BROKER_API_KEY }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GITHUB_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }} + GITHUB_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }} run: ./nexus-cli apply --prune <<< "yes" From 29b113812d4b1aeaabd9c738dde95c50ce7f44aa Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:45:05 +0300 Subject: [PATCH 17/20] chore: remove CI workflow from open repo, update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nexus-cli workflow belongs in the internal repo, not the open-source framework. The CLI is a standalone operational tool — users run it from their own environments against whichever Broker they target. Updated security-as-code.md to show CI/CD as optional with a generic snippet instead of referencing a shipped workflow file. --- .github/workflows/nexus-cli.yml | 60 ---------------------------- docs/guides/security-as-code.md | 69 ++++++++++++--------------------- 2 files changed, 24 insertions(+), 105 deletions(-) delete mode 100644 .github/workflows/nexus-cli.yml diff --git a/.github/workflows/nexus-cli.yml b/.github/workflows/nexus-cli.yml deleted file mode 100644 index 05cc194..0000000 --- a/.github/workflows/nexus-cli.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Nexus CLI Provider Reconciliation - -on: - push: - branches: - - main - paths: - - 'nexus-cli/nexus-providers.yaml' - pull_request: - branches: - - main - paths: - - 'nexus-cli/nexus-providers.yaml' - -jobs: - plan-or-apply: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./nexus-cli - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.21' - cache-dependency-path: ./nexus-cli/go.sum - - - name: Build CLI - run: go build -o nexus-cli - - - name: Plan (Pull Request) - # Only run plan for PRs from the same repo — forked PRs cannot access repository secrets. - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - env: - BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} - API_KEY: ${{ secrets.BROKER_API_KEY }} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - GITHUB_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }} - GITHUB_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }} - run: ./nexus-cli plan - - - name: Plan skipped (forked PR) - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository - run: echo "ℹ️ Skipping live plan — this is a forked PR and repository secrets are not available." - - - name: Apply (Push to Main) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - env: - BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} - API_KEY: ${{ secrets.BROKER_API_KEY }} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - GITHUB_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }} - GITHUB_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }} - run: ./nexus-cli apply --prune <<< "yes" diff --git a/docs/guides/security-as-code.md b/docs/guides/security-as-code.md index b88e3ad..cbd3915 100644 --- a/docs/guides/security-as-code.md +++ b/docs/guides/security-as-code.md @@ -169,55 +169,34 @@ Updating google-workspace... OK --- -## CI/CD Integration - -The recommended pattern is to use `nexus-cli` in your GitHub Actions pipeline so that every change to the manifest goes through a code review + automated reconciliation cycle. - -A workflow is included at `.github/workflows/nexus-cli.yml` and runs automatically when `nexus-cli/nexus-providers.yaml` is modified: - -```yaml title=".github/workflows/nexus-cli.yml" -on: - pull_request: - paths: ['nexus-cli/nexus-providers.yaml'] - push: - branches: [main] - paths: ['nexus-cli/nexus-providers.yaml'] - -jobs: - plan-or-apply: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: '1.21' - - run: go build -o nexus-cli - working-directory: ./nexus-cli - - # On PRs: show a plan as a check - - name: Plan (Pull Request) - if: github.event_name == 'pull_request' - env: - BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} - API_KEY: ${{ secrets.BROKER_API_KEY }} - run: ./nexus-cli plan - - # On merge to main: apply with prune - - name: Apply (Push to Main) - if: github.event_name == 'push' - env: - BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} - API_KEY: ${{ secrets.BROKER_API_KEY }} - run: ./nexus-cli apply --prune <<< "yes" - working-directory: ./nexus-cli +## CI/CD Integration (Optional) + +`nexus-cli` is a standalone binary — you can run it from your laptop, a bastion host, or a CI pipeline. If you want to integrate it into your own CI/CD, here's a recommended pattern: + +- **On pull requests**: run `nexus-cli plan` as an informational check so reviewers can see what would change. +- **Apply manually**: use a `workflow_dispatch` trigger or run `nexus-cli apply` from a trusted environment when you're ready. + +> **Note:** Auto-applying on merge is discouraged. Provider configurations are live operational data — you should always review a plan before applying. + +### Example GitHub Actions Snippet + +```yaml +# Add this to your internal repo's workflow — not the open-source framework repo. +- name: Plan + env: + BROKER_BASE_URL: ${{ secrets.BROKER_BASE_URL }} + API_KEY: ${{ secrets.BROKER_API_KEY }} + # Add all env vars referenced in your manifest + run: ./nexus-cli plan ``` -### Required GitHub Secrets +### Required Environment Variables -| Secret | Description | +| Variable | Description | | :--- | :--- | -| `BROKER_BASE_URL` | URL of your production Nexus Broker | -| `BROKER_API_KEY` | API key for Broker authentication | +| `BROKER_BASE_URL` | URL of your target Nexus Broker (staging, prod, etc.) | +| `API_KEY` | API key for Broker authentication | +| `*_CLIENT_ID` / `*_CLIENT_SECRET` | Any provider credentials referenced via `${...}` in your manifest | --- From ba238c93922ef5e284e2cea89130c3e60659a2e8 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 16:49:12 +0300 Subject: [PATCH 18/20] fix: address Copilot review round 2 (14 comments) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providers.go: fix %s format verb with uuid.UUID → %v (4 sites) - main.go: use checked type assertions for live provider IDs (2 sites) - main.go: read and print response body on create/update/delete failures - drift.go: return errors from json.Marshal/Unmarshal instead of discarding - drift.go: fix mixed spaces/tabs indentation (gofmt) - audit_test.go: rename ClampedTo1000 → FallsBackToDefault to match behavior - audit-log.md: fix response example to reflect omitempty (omit null fields) - audit-log.md: add note explaining omitempty behavior --- docs/reference/audit-log.md | 5 ++-- nexus-broker/pkg/handlers/audit_test.go | 2 +- nexus-broker/pkg/handlers/providers.go | 8 +++--- nexus-cli/drift.go | 27 ++++++++++++-------- nexus-cli/main.go | 33 ++++++++++++++++++------- 5 files changed, 49 insertions(+), 26 deletions(-) diff --git a/docs/reference/audit-log.md b/docs/reference/audit-log.md index 38300ce..c6e6574 100644 --- a/docs/reference/audit-log.md +++ b/docs/reference/audit-log.md @@ -77,14 +77,13 @@ curl -s "http://localhost:8080/audit?event_type=provider.created&since=2026-05-0 "id": "a1b2c3d4-...", "connection_id": "f5e6d7c8-...", "event_type": "oauth_flow_completed", - "event_data": "{\"provider_id\": \"...\", \"workspace_id\": \"ws-123\"}", + "event_data": "{\"provider_id\": \"...\"}", "ip_address": "10.0.0.1", "user_agent": "nexus-gateway/1.0", "created_at": "2026-05-05T10:30:00Z" }, { "id": "b2c3d4e5-...", - "connection_id": null, "event_type": "provider.deleted", "event_data": "{\"provider_id\": \"...\", \"provider_name\": \"old-slack\"}", "ip_address": "192.168.1.5", @@ -94,6 +93,8 @@ curl -s "http://localhost:8080/audit?event_type=provider.created&since=2026-05-0 ] ``` +> **Note:** Fields with `omitempty` (`connection_id`, `event_data`, `ip_address`, `user_agent`) are omitted from the response when their value is null, rather than being rendered as `null`. + ### Field Descriptions | Field | Type | Description | diff --git a/nexus-broker/pkg/handlers/audit_test.go b/nexus-broker/pkg/handlers/audit_test.go index 2274d53..fc7f2b7 100644 --- a/nexus-broker/pkg/handlers/audit_test.go +++ b/nexus-broker/pkg/handlers/audit_test.go @@ -145,7 +145,7 @@ func TestAuditList_CustomLimit(t *testing.T) { } } -func TestAuditList_LimitAboveMax_ClampedTo1000(t *testing.T) { +func TestAuditList_LimitAboveMax_FallsBackToDefault(t *testing.T) { db, mock := newSqlxDB(t) defer db.Close() diff --git a/nexus-broker/pkg/handlers/providers.go b/nexus-broker/pkg/handlers/providers.go index 04347ac..b95047e 100644 --- a/nexus-broker/pkg/handlers/providers.go +++ b/nexus-broker/pkg/handlers/providers.go @@ -66,7 +66,7 @@ func (h *ProvidersHandler) Update(w http.ResponseWriter, r *http.Request) { if h.audit != nil { if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r); err != nil { - log.Printf("audit: failed to log provider.updated for provider_id=%s: %v", profile.ID, err) + log.Printf("audit: failed to log provider.updated for provider_id=%v: %v", profile.ID, err) } } @@ -95,7 +95,7 @@ func (h *ProvidersHandler) Patch(w http.ResponseWriter, r *http.Request) { if h.audit != nil { if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": id.String(), "updates": updates}, r); err != nil { - log.Printf("audit: failed to log provider.updated for provider_id=%s: %v", id, err) + log.Printf("audit: failed to log provider.updated for provider_id=%v: %v", id, err) } } @@ -118,7 +118,7 @@ func (h *ProvidersHandler) Delete(w http.ResponseWriter, r *http.Request) { if h.audit != nil { if err := h.audit.Log("provider.deleted", nil, map[string]interface{}{"provider_id": id.String()}, r); err != nil { - log.Printf("audit: failed to log provider.deleted for provider_id=%s: %v", id, err) + log.Printf("audit: failed to log provider.deleted for provider_id=%v: %v", id, err) } } @@ -173,7 +173,7 @@ func (h *ProvidersHandler) Register(w http.ResponseWriter, r *http.Request) { if h.audit != nil { if err := h.audit.Log("provider.created", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r); err != nil { - log.Printf("audit: failed to log provider.created for provider_id=%s: %v", profile.ID, err) + log.Printf("audit: failed to log provider.created for provider_id=%v: %v", profile.ID, err) } } diff --git a/nexus-cli/drift.go b/nexus-cli/drift.go index 3832731..195eb45 100644 --- a/nexus-cli/drift.go +++ b/nexus-cli/drift.go @@ -2,20 +2,27 @@ package main import ( "encoding/json" + "fmt" ) // computeDrift computes the difference between a desired Provider manifest // and the live state returned by the Broker. It returns a boolean indicating -// if there is drift, and a map of the fields that need to be patched. -func computeDrift(desired Provider, live map[string]interface{}) (bool, map[string]interface{}) { +// if there is drift, a map of the fields that need to be patched, and an error +// if serialization fails. +func computeDrift(desired Provider, live map[string]interface{}) (bool, map[string]interface{}, error) { updates := make(map[string]interface{}) drifted := false // Marshal and unmarshal desired to get a map reflecting exactly what // would be sent as JSON (respecting json tags and omitempty). - b, _ := json.Marshal(desired) + b, err := json.Marshal(desired) + if err != nil { + return false, nil, fmt.Errorf("failed to marshal desired provider: %w", err) + } var desiredMap map[string]interface{} - _ = json.Unmarshal(b, &desiredMap) + if err := json.Unmarshal(b, &desiredMap); err != nil { + return false, nil, fmt.Errorf("failed to unmarshal desired provider: %w", err) + } for k, v := range desiredMap { if k == "name" || k == "id" || k == "created_at" || k == "updated_at" { @@ -34,16 +41,16 @@ func computeDrift(desired Provider, live map[string]interface{}) (bool, map[stri if string(vb) == "[]" && (string(lvb) == "null" || string(lvb) == "") { continue } - - // Special case: if desired is an empty string and live is missing/null - if string(vb) == `""` && (string(lvb) == "null" || string(lvb) == "") { - continue - } + + // Special case: if desired is an empty string and live is missing/null + if string(vb) == `""` && (string(lvb) == "null" || string(lvb) == "") { + continue + } updates[k] = v drifted = true } } - return drifted, updates + return drifted, updates, nil } diff --git a/nexus-cli/main.go b/nexus-cli/main.go index 483c169..cad5d5c 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -226,8 +226,14 @@ func runCommand(isPlanOnly bool) { for _, p := range manifest.Providers { if live, exists := liveProviderMap[p.Name]; exists { - id := live["id"].(string) - drifted, updates := computeDrift(p, live) + id, ok := live["id"].(string) + if !ok { + log.Fatalf("Provider %s has invalid or missing 'id' in live state", p.Name) + } + drifted, updates, err := computeDrift(p, live) + if err != nil { + log.Fatalf("Failed to compute drift for provider %s: %v", p.Name, err) + } if drifted { toUpdate[id] = updates toUpdateNames[id] = p.Name @@ -252,7 +258,10 @@ func runCommand(isPlanOnly bool) { for name, live := range liveProviderMap { if _, exists := manifestProviderMap[name]; !exists { if *pruneFlag { - id := live["id"].(string) + id, ok := live["id"].(string) + if !ok { + log.Fatalf("Provider %s has invalid or missing 'id' in live state", name) + } toDelete = append(toDelete, id) toDeleteNames = append(toDeleteNames, name) fmt.Printf("- DELETE : %s\n", name) @@ -313,12 +322,14 @@ func runCommand(isPlanOnly bool) { fmt.Printf("Request failed: %v\n", err) continue } - resp.Body.Close() if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { + resp.Body.Close() fmt.Println("OK") } else { - fmt.Printf("FAILED (Status %d)\n", resp.StatusCode) + errBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) } } @@ -345,12 +356,14 @@ func runCommand(isPlanOnly bool) { fmt.Printf("Request failed: %v\n", err) continue } - resp.Body.Close() if resp.StatusCode == http.StatusOK { + resp.Body.Close() fmt.Println("OK") } else { - fmt.Printf("FAILED (Status %d)\n", resp.StatusCode) + errBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) } } @@ -370,12 +383,14 @@ func runCommand(isPlanOnly bool) { fmt.Printf("Request failed: %v\n", err) continue } - resp.Body.Close() if resp.StatusCode == http.StatusOK { + resp.Body.Close() fmt.Println("OK") } else { - fmt.Printf("FAILED (Status %d)\n", resp.StatusCode) + errBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) } } } From 79bf0eebaac1e0cfbec9ee362886e82438441050 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 17:08:01 +0300 Subject: [PATCH 19/20] fix: address Copilot review round 3 (5 comments) - Remove unused providerResult type from CLI (compile error) - Introduce audit.Logger interface for testability - Redact client_secret/client_id in PATCH audit log to prevent persisting secrets in audit_events table - Add TestRegisterProvider_AuditsCreation: verifies provider.created event is logged on successful registration - Add TestPatchProvider_AuditRedactsSecrets: verifies client_secret is replaced with [REDACTED] before audit logging Items 3 and 5 (PR description mentioning /v1/audit and shipped workflow) are PR description fixes to be updated on GitHub. --- nexus-broker/internal/audit/logger.go | 13 ++++ nexus-broker/pkg/handlers/providers.go | 16 +++- nexus-broker/pkg/handlers/providers_test.go | 83 +++++++++++++++++++++ nexus-cli/main.go | 4 - 4 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 nexus-broker/internal/audit/logger.go diff --git a/nexus-broker/internal/audit/logger.go b/nexus-broker/internal/audit/logger.go new file mode 100644 index 0000000..57e8efb --- /dev/null +++ b/nexus-broker/internal/audit/logger.go @@ -0,0 +1,13 @@ +package audit + +import ( + "net/http" + + "github.com/google/uuid" +) + +// Logger defines the interface for audit logging. +// Handlers depend on this interface so that a mock can be injected in tests. +type Logger interface { + Log(eventType string, connectionID *uuid.UUID, data map[string]interface{}, r *http.Request) error +} diff --git a/nexus-broker/pkg/handlers/providers.go b/nexus-broker/pkg/handlers/providers.go index b95047e..303c46b 100644 --- a/nexus-broker/pkg/handlers/providers.go +++ b/nexus-broker/pkg/handlers/providers.go @@ -18,11 +18,11 @@ import ( // ProvidersHandler handles provider-related HTTP requests type ProvidersHandler struct { store provider.ProfileStorer - audit *audit.Service + audit audit.Logger } // NewProvidersHandler creates a new providers handler -func NewProvidersHandler(store provider.ProfileStorer, auditSvc *audit.Service) *ProvidersHandler { +func NewProvidersHandler(store provider.ProfileStorer, auditSvc audit.Logger) *ProvidersHandler { return &ProvidersHandler{store: store, audit: auditSvc} } @@ -94,7 +94,17 @@ func (h *ProvidersHandler) Patch(w http.ResponseWriter, r *http.Request) { } if h.audit != nil { - if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": id.String(), "updates": updates}, r); err != nil { + // Redact sensitive fields — log only field names for credentials + redactedUpdates := make(map[string]interface{}) + for k, v := range updates { + switch k { + case "client_secret", "client_id": + redactedUpdates[k] = "[REDACTED]" + default: + redactedUpdates[k] = v + } + } + if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": id.String(), "updates": redactedUpdates}, r); err != nil { log.Printf("audit: failed to log provider.updated for provider_id=%v: %v", id, err) } } diff --git a/nexus-broker/pkg/handlers/providers_test.go b/nexus-broker/pkg/handlers/providers_test.go index 00020e9..6cffe70 100644 --- a/nexus-broker/pkg/handlers/providers_test.go +++ b/nexus-broker/pkg/handlers/providers_test.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "context" "encoding/json" "errors" "net/http" @@ -13,6 +14,8 @@ import ( "github.com/stretchr/testify/mock" "github.com/Prescott-Data/nexus-framework/nexus-broker/pkg/provider" + + "github.com/go-chi/chi/v5" ) // MockStore is a mock implementation of the provider.ProfileStorer interface. @@ -187,3 +190,83 @@ func TestRegisterProvider_InvalidJSON(t *testing.T) { // 4. Asserts the response is http.StatusBadRequest. assert.Equal(t, http.StatusBadRequest, rr.Code) } + +// --- Audit mock --- + +// MockAuditLogger is a mock implementation of the audit.Logger interface. +type MockAuditLogger struct { + mock.Mock +} + +func (m *MockAuditLogger) Log(eventType string, connectionID *uuid.UUID, data map[string]interface{}, r *http.Request) error { + args := m.Called(eventType, connectionID, data, r) + return args.Error(0) +} + +func TestRegisterProvider_AuditsCreation(t *testing.T) { + mockStore := new(MockStore) + mockAudit := new(MockAuditLogger) + handler := NewProvidersHandler(mockStore, mockAudit) + + expectedProfile := &provider.Profile{ + ID: uuid.New(), + Name: "Audited Provider", + AuthType: "oauth2", + } + mockStore.On("RegisterProfile", mock.AnythingOfType("string")).Return(expectedProfile, nil) + mockAudit.On("Log", "provider.created", (*uuid.UUID)(nil), mock.AnythingOfType("map[string]interface {}"), mock.AnythingOfType("*http.Request")).Return(nil) + + body := map[string]interface{}{"profile": map[string]interface{}{"name": "Audited Provider", "auth_type": "oauth2"}} + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "/providers", bytes.NewReader(jsonBody)) + + rr := httptest.NewRecorder() + handler.Register(rr, req) + + assert.Equal(t, http.StatusCreated, rr.Code) + mockAudit.AssertCalled(t, "Log", "provider.created", (*uuid.UUID)(nil), mock.AnythingOfType("map[string]interface {}"), mock.AnythingOfType("*http.Request")) + mockAudit.AssertNumberOfCalls(t, "Log", 1) +} + +func TestPatchProvider_AuditRedactsSecrets(t *testing.T) { + mockStore := new(MockStore) + mockAudit := new(MockAuditLogger) + handler := NewProvidersHandler(mockStore, mockAudit) + + testID := uuid.New() + mockStore.On("PatchProfile", testID, mock.AnythingOfType("map[string]interface {}")).Return(nil) + mockAudit.On("Log", "provider.updated", (*uuid.UUID)(nil), mock.AnythingOfType("map[string]interface {}"), mock.AnythingOfType("*http.Request")).Return(nil) + + updates := map[string]interface{}{ + "auth_url": "https://new.example.com/auth", + "client_secret": "super-secret-value", + } + jsonBody, _ := json.Marshal(updates) + + req, _ := http.NewRequest("PATCH", "/providers/"+testID.String(), bytes.NewReader(jsonBody)) + + // Use chi context to set URL params + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", testID.String()) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + rr := httptest.NewRecorder() + handler.Patch(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + mockAudit.AssertCalled(t, "Log", "provider.updated", (*uuid.UUID)(nil), mock.MatchedBy(func(data map[string]interface{}) bool { + updates, ok := data["updates"].(map[string]interface{}) + if !ok { + return false + } + // client_secret must be redacted + if updates["client_secret"] != "[REDACTED]" { + return false + } + // non-secret fields should be passed through + if updates["auth_url"] != "https://new.example.com/auth" { + return false + } + return true + }), mock.AnythingOfType("*http.Request")) +} diff --git a/nexus-cli/main.go b/nexus-cli/main.go index cad5d5c..5738d13 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -140,10 +140,6 @@ func runCommand(isPlanOnly bool) { // Build live provider map: name → full config. // Fetch full profiles concurrently with a bounded worker pool to avoid N+1 latency. - type providerResult struct { - Name string - Profile map[string]interface{} - } const maxConcurrency = 5 sem := make(chan struct{}, maxConcurrency) From 75cfe25b490eeb1cf3e79ea49c635c9c92bd9bd2 Mon Sep 17 00:00:00 2001 From: Sangalo Date: Tue, 5 May 2026 17:49:00 +0300 Subject: [PATCH 20/20] fix: final Copilot review pass - Sort plan diff keys for deterministic output across runs - Track apply failures and os.Exit(1) on partial failure (CI fix) - Normalize provider_id to .String() in all audit log payloads - Wire PatchProfile mock through testify m.Called() (was a dead stub) --- nexus-broker/pkg/handlers/providers.go | 4 +-- nexus-broker/pkg/handlers/providers_test.go | 4 +-- nexus-cli/main.go | 28 ++++++++++++++++++++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/nexus-broker/pkg/handlers/providers.go b/nexus-broker/pkg/handlers/providers.go index 303c46b..bc607f9 100644 --- a/nexus-broker/pkg/handlers/providers.go +++ b/nexus-broker/pkg/handlers/providers.go @@ -65,7 +65,7 @@ func (h *ProvidersHandler) Update(w http.ResponseWriter, r *http.Request) { } if h.audit != nil { - if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r); err != nil { + if err := h.audit.Log("provider.updated", nil, map[string]interface{}{"provider_id": profile.ID.String(), "name": profile.Name}, r); err != nil { log.Printf("audit: failed to log provider.updated for provider_id=%v: %v", profile.ID, err) } } @@ -182,7 +182,7 @@ func (h *ProvidersHandler) Register(w http.ResponseWriter, r *http.Request) { } if h.audit != nil { - if err := h.audit.Log("provider.created", nil, map[string]interface{}{"provider_id": profile.ID, "name": profile.Name}, r); err != nil { + if err := h.audit.Log("provider.created", nil, map[string]interface{}{"provider_id": profile.ID.String(), "name": profile.Name}, r); err != nil { log.Printf("audit: failed to log provider.created for provider_id=%v: %v", profile.ID, err) } } diff --git a/nexus-broker/pkg/handlers/providers_test.go b/nexus-broker/pkg/handlers/providers_test.go index 6cffe70..da4f0b4 100644 --- a/nexus-broker/pkg/handlers/providers_test.go +++ b/nexus-broker/pkg/handlers/providers_test.go @@ -53,8 +53,8 @@ func (m *MockStore) UpdateProfile(p *provider.Profile) error { } func (m *MockStore) PatchProfile(id uuid.UUID, updates map[string]interface{}) error { - // Mock implementation for testing - return nil + args := m.Called(id, updates) + return args.Error(0) } func (m *MockStore) DeleteProfile(id uuid.UUID) error { diff --git a/nexus-cli/main.go b/nexus-cli/main.go index 5738d13..244f1b0 100644 --- a/nexus-cli/main.go +++ b/nexus-cli/main.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "os" + "sort" "strings" "sync" "time" @@ -234,7 +235,14 @@ func runCommand(isPlanOnly bool) { toUpdate[id] = updates toUpdateNames[id] = p.Name fmt.Printf("~ UPDATE : %s\n", p.Name) - for field, newVal := range updates { + // Sort keys for deterministic plan output + fields := make([]string, 0, len(updates)) + for field := range updates { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + newVal := updates[field] oldVal := live[field] if isSecretField(field) { fmt.Printf(" %s: *** → ***\n", field) @@ -292,6 +300,8 @@ func runCommand(isPlanOnly bool) { fmt.Println("\n--- Applying Changes ---") + hadFailures := false + for _, p := range toCreate { fmt.Printf("Creating %s... ", p.Name) @@ -302,12 +312,14 @@ func runCommand(isPlanOnly bool) { jsonData, err := json.Marshal(payload) if err != nil { fmt.Printf("Failed to marshal: %v\n", err) + hadFailures = true continue } req, err := http.NewRequest("POST", brokerURL+"/providers", bytes.NewBuffer(jsonData)) if err != nil { fmt.Printf("Failed to create request: %v\n", err) + hadFailures = true continue } setAPIKey(req, apiKey) @@ -316,6 +328,7 @@ func runCommand(isPlanOnly bool) { resp, err := httpClient.Do(req) if err != nil { fmt.Printf("Request failed: %v\n", err) + hadFailures = true continue } @@ -326,6 +339,7 @@ func runCommand(isPlanOnly bool) { errBody, _ := io.ReadAll(resp.Body) resp.Body.Close() fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) + hadFailures = true } } @@ -336,12 +350,14 @@ func runCommand(isPlanOnly bool) { jsonData, err := json.Marshal(updates) if err != nil { fmt.Printf("Failed to marshal: %v\n", err) + hadFailures = true continue } req, err := http.NewRequest("PATCH", brokerURL+"/providers/"+id, bytes.NewBuffer(jsonData)) if err != nil { fmt.Printf("Failed to create request: %v\n", err) + hadFailures = true continue } setAPIKey(req, apiKey) @@ -350,6 +366,7 @@ func runCommand(isPlanOnly bool) { resp, err := httpClient.Do(req) if err != nil { fmt.Printf("Request failed: %v\n", err) + hadFailures = true continue } @@ -360,6 +377,7 @@ func runCommand(isPlanOnly bool) { errBody, _ := io.ReadAll(resp.Body) resp.Body.Close() fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) + hadFailures = true } } @@ -370,6 +388,7 @@ func runCommand(isPlanOnly bool) { req, err := http.NewRequest("DELETE", brokerURL+"/providers/"+id, nil) if err != nil { fmt.Printf("Failed to create request: %v\n", err) + hadFailures = true continue } setAPIKey(req, apiKey) @@ -377,6 +396,7 @@ func runCommand(isPlanOnly bool) { resp, err := httpClient.Do(req) if err != nil { fmt.Printf("Request failed: %v\n", err) + hadFailures = true continue } @@ -387,8 +407,14 @@ func runCommand(isPlanOnly bool) { errBody, _ := io.ReadAll(resp.Body) resp.Body.Close() fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) + hadFailures = true } } + + if hadFailures { + fmt.Println("\nApply completed with errors.") + os.Exit(1) + } }