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..cbd3915 --- /dev/null +++ b/docs/guides/security-as-code.md @@ -0,0 +1,209 @@ +# 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 /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 (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 Environment Variables + +| Variable | Description | +| :--- | :--- | +| `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 | + +--- + +## 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..c6e6574 --- /dev/null +++ b/docs/reference/audit-log.md @@ -0,0 +1,129 @@ +# 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 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) | +| `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`) | + +--- + +## Query the Audit Log + +``` +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 | +| :--- | :--- | :--- | +| `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": "oauth_flow_completed", + "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-...", + "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" + } +] +``` + +> **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 | +| :--- | :--- | :--- | +| `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..1d6584e 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 /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..73f1272 100644 --- a/docs/services/broker.md +++ b/docs/services/broker.md @@ -28,6 +28,20 @@ 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. +- **`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.). + +See the [Audit Log Reference](../reference/audit-log.md) for how to query events. + ## Environment Variables | Variable | Description | Default | @@ -35,5 +49,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: 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/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/internal/audit/service.go b/nexus-broker/internal/audit/service.go new file mode 100644 index 0000000..ad34d53 --- /dev/null +++ b/nexus-broker/internal/audit/service.go @@ -0,0 +1,67 @@ +package audit + +import ( + "encoding/json" + "fmt" + "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 — 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]) + if net.ParseIP(ip) != nil { + ipVal = &ip + } + } else { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && net.ParseIP(host) != nil { + ipVal = &host + } + } + + // Extract User-Agent + ua := r.Header.Get("User-Agent") + if ua != "" { + userAgent = &ua + } + } + + var eventDataJSON []byte + if data != nil { + var err error + eventDataJSON, err = json.Marshal(data) + if err != nil { + return fmt.Errorf("audit: failed to marshal event data: %w", err) + } + } + + 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/audit_test.go b/nexus-broker/pkg/handlers/audit_test.go new file mode 100644 index 0000000..fc7f2b7 --- /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_FallsBackToDefault(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 98d6f62..4651d8a 100644 --- a/nexus-broker/pkg/handlers/callback.go +++ b/nexus-broker/pkg/handlers/callback.go @@ -6,7 +6,7 @@ import ( "encoding/json" "fmt" "io" - "net" + "log" "net/http" "net/url" "strings" @@ -17,6 +17,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 +29,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 +47,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 +95,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 +835,19 @@ 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")) + 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 2da840e..bc607f9 100644 --- a/nexus-broker/pkg/handlers/providers.go +++ b/nexus-broker/pkg/handlers/providers.go @@ -3,9 +3,11 @@ package handlers import ( "encoding/json" "fmt" + "log" "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 +18,12 @@ import ( // ProvidersHandler handles provider-related HTTP requests type ProvidersHandler struct { store provider.ProfileStorer + audit audit.Logger } // NewProvidersHandler creates a new providers handler -func NewProvidersHandler(store provider.ProfileStorer) *ProvidersHandler { - return &ProvidersHandler{store: store} +func NewProvidersHandler(store provider.ProfileStorer, auditSvc audit.Logger) *ProvidersHandler { + return &ProvidersHandler{store: store, audit: auditSvc} } // Get handles GET /providers/{id} to retrieve a provider profile @@ -61,6 +64,12 @@ func (h *ProvidersHandler) Update(w http.ResponseWriter, r *http.Request) { return } + if h.audit != 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) + } + } + w.WriteHeader(http.StatusOK) } @@ -84,6 +93,22 @@ func (h *ProvidersHandler) Patch(w http.ResponseWriter, r *http.Request) { return } + if h.audit != 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) + } + } + w.WriteHeader(http.StatusOK) } @@ -101,6 +126,12 @@ func (h *ProvidersHandler) Delete(w http.ResponseWriter, r *http.Request) { return } + 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=%v: %v", id, err) + } + } + w.WriteHeader(http.StatusOK) } @@ -150,6 +181,12 @@ func (h *ProvidersHandler) Register(w http.ResponseWriter, r *http.Request) { return } + if h.audit != 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) + } + } + httputil.WriteJSON(w, http.StatusCreated, map[string]interface{}{ "id": profile.ID, "message": "Provider profile created successfully", @@ -205,6 +242,12 @@ func (h *ProvidersHandler) DeleteByName(w http.ResponseWriter, r *http.Request) return } + if h.audit != nil { + 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-broker/pkg/handlers/providers_test.go b/nexus-broker/pkg/handlers/providers_test.go index 8bf9abd..da4f0b4 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. @@ -50,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 { @@ -87,7 +90,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 +145,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 +173,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"))) @@ -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-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/.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/drift.go b/nexus-cli/drift.go new file mode 100644 index 0000000..195eb45 --- /dev/null +++ b/nexus-cli/drift.go @@ -0,0 +1,56 @@ +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, 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, err := json.Marshal(desired) + if err != nil { + return false, nil, fmt.Errorf("failed to marshal desired provider: %w", err) + } + var desiredMap map[string]interface{} + 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" { + 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, nil +} diff --git a/nexus-cli/go.mod b/nexus-cli/go.mod new file mode 100644 index 0000000..ef61965 --- /dev/null +++ b/nexus-cli/go.mod @@ -0,0 +1,5 @@ +module github.com/Prescott-Data/nexus-framework/nexus-cli + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/nexus-cli/go.sum b/nexus-cli/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/nexus-cli/go.sum @@ -0,0 +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= diff --git a/nexus-cli/main.go b/nexus-cli/main.go new file mode 100644 index 0000000..244f1b0 --- /dev/null +++ b/nexus-cli/main.go @@ -0,0 +1,450 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strings" + "sync" + "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"` + 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) + } +} + +// 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") + 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) + } + + 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 + 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 { + 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) + } + setAPIKey(req, apiKey) + + resp, err := httpClient.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) + } + + // Build live provider map: name → full config. + // Fetch full profiles concurrently with a bounded worker pool to avoid N+1 latency. + + 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 { + 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 { + 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 { + 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) + 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 { + mu.Lock() + fetchErr = fmt.Errorf("failed to decode profile for %s: %w", name, err) + mu.Unlock() + return + } + + 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) + for _, p := range manifest.Providers { + manifestProviderMap[p.Name] = p + } + + fmt.Println("\n--- Execution Plan ---") + + toCreate := []Provider{} + 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, 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 + fmt.Printf("~ UPDATE : %s\n", p.Name) + // 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) + } else { + fmt.Printf(" %s: %v → %v\n", field, formatVal(oldVal), formatVal(newVal)) + } + } + } else { + fmt.Printf("= OK : %s (no changes)\n", p.Name) + } + } else { + toCreate = append(toCreate, p) + fmt.Printf("+ CREATE : %s\n", p.Name) + } + } + + for name, live := range liveProviderMap { + if _, exists := manifestProviderMap[name]; !exists { + if *pruneFlag { + 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) + } else { + fmt.Printf("! ORPHAN : %s (would be deleted if --prune was passed)\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 ---") + + hadFailures := false + + 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) + 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) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Printf("Request failed: %v\n", err) + hadFailures = true + continue + } + + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { + resp.Body.Close() + fmt.Println("OK") + } else { + errBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) + hadFailures = true + } + } + + for id, updates := range toUpdate { + name := toUpdateNames[id] + fmt.Printf("Updating %s... ", name) + + 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) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Printf("Request failed: %v\n", err) + hadFailures = true + continue + } + + if resp.StatusCode == http.StatusOK { + resp.Body.Close() + fmt.Println("OK") + } else { + errBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + fmt.Printf("FAILED (Status %d): %s\n", resp.StatusCode, string(errBody)) + hadFailures = true + } + } + + 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) + hadFailures = true + continue + } + setAPIKey(req, apiKey) + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Printf("Request failed: %v\n", err) + hadFailures = true + continue + } + + if resp.StatusCode == http.StatusOK { + resp.Body.Close() + fmt.Println("OK") + } else { + 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) + } +} + + +// 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) + } +} 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