diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b574f71..70d26ca 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -67,4 +67,4 @@ jobs: ./csax users get ci-test@example.com ./csax users list ./csax stats - ./csax health \ No newline at end of file + ./csax health diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f49722..bbf0808 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,4 +65,4 @@ jobs: generate_release_notes: true name: Release ${{ github.ref_name }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index f9e7d39..678f3c5 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,64 @@ csax sessions revoke --user csax sessions revoke-all --user csax audit tail --user [--limit N] csax audit search --event [--limit N] # system-wide, across all users +csax oauth providers list [--json] # which providers have client ID/secret set +csax oauth test # round-trips the provider's real endpoints before a live user hits it +csax ai query "" [--json] # read-only, allowlisted lookups over users/sessions/audit_events +csax ai logs "" # natural-language audit event search, summarized — never acts +csax ai audit # flags likely misconfigurations from a fixed checklist — never auto-applies anything csax stats # total users, active sessions, etc. csax health csax version ``` +## Optional: OAuth admin commands + +`oauth providers list` and `oauth test` read the SAME env vars `api` +uses for its own OAuth config — they check the real values production +uses, not a separate csax-only copy: + +``` +BASE_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET +``` + +`oauth users get`/`oauth unlink` (managing which providers a specific +user has linked) are designed but not built yet — they need two new +`cryden` engine facades that don't exist as of this CLI's current +`cryden` dependency version. + +## Optional: AI-assisted admin commands + +`ai query`/`ai logs`/`ai audit` need their own config, on top of the +usual `.env`: + +``` +AI_PROVIDER=groq # or "openrouter" — both speak the same OpenAI-compatible chat completions shape +AI_API_KEY_ENV=GROQ_API_KEY # the NAME of the env var holding your key — the key itself lives wherever that env var is set, never in .env +AI_MODEL=... # any model id your chosen provider serves +READONLY_DATABASE_URL=... # a SEPARATE connection string, pointing at a Postgres role that is physically read-only +``` + +`READONLY_DATABASE_URL` is the real safety boundary for `ai query`/`ai +logs` — even a bug in the underlying allowlist validation can't cause +a write if the credential itself is incapable of one. Don't point it +at the same role `DATABASE_URL` uses. + +`ai audit` works even without any AI config — the checklist itself is +plain Go, not model-generated; the model is only used to add a short +prioritization narrative on top, which is skipped silently if AI isn't +configured. + +None of the `ai` commands ever execute an action a finding surfaces — +`ai audit` never applies a fix, and `ai logs` never revokes a session +or locks an account it flags. Anything like that is a suggestion in +the output text, run yourself as a separate, explicit command. + ## Design notes - Every command uses either the engine's public API/store methods, or — for a small number of read-only, system-wide commands the engine's store interfaces don't support (`users list`, `stats`, `audit search`) — direct SQL against the known Postgres schema, the same way `csax migrate` already does. No CrydenSync engine Go code was modified or added specifically to support the CLI. - `MIGRATIONS_DIR` (default `./migrations`) should point at a folder containing both CrydenSync's own migration files and your app's own — `csax migrate` treats them the same, just files matching `*.up.sql`/`*.down.sql`, run in filename order. -- No CLI framework dependency (no Cobra) — deliberately dependency-light, same philosophy as the engine itself. +- No CLI framework dependency (no Cobra) — deliberately dependency-light, same philosophy as the engine itself. This is also why `oauth`/`ai` help text lives in `usage()` by hand rather than being generated — there's no framework here to generate it from. +- `ai` commands are the one part of csax that calls out to something other than this deployment's own Postgres — the `ai.LLMProvider` interface ships zero implementations upstream in `cryden`; csax brings its own (`llmProvider` in `aiprovider.go`, an OpenAI-chat-completions-shaped client — works with Groq, OpenRouter, or any other provider speaking that same shape via `AI_PROVIDER`), same pattern as `notify.EmailSender`. - Colored output by default (auto-disabled when not writing to a real terminal). Commands returning structured data (`users get`, `users list`, `sessions list`) support `--json` for scripting. ## License diff --git a/aiprovider.go b/aiprovider.go new file mode 100644 index 0000000..739b6d7 --- /dev/null +++ b/aiprovider.go @@ -0,0 +1,174 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/crydensync/cryden/v2/ai" +) + +// llmProvider is csax's ai.LLMProvider implementation. It's a thin +// OpenAI-chat-completions-shaped client — Groq, OpenRouter, and most +// other hosted providers all speak this same request/response shape, +// so supporting another one is a base-URL entry here, not a new +// client. ai.LLMProvider ships zero implementations upstream in +// cryden by design — this is the consumer bringing its own provider +// and key, same pattern as notify.EmailSender and logger.Logger. +type llmProvider struct { + baseURL string + apiKey string + model string +} + +// chatCompletionsBaseURL maps AI_PROVIDER to its endpoint. Adding a +// new OpenAI-compatible provider is one line here. +var chatCompletionsBaseURL = map[string]string{ + "groq": "https://api.groq.com/openai/v1/chat/completions", + "openrouter": "https://openrouter.ai/api/v1/chat/completions", +} + +func newLLMProvider(cfg csaxConfig) (*llmProvider, error) { + if cfg.AIProvider == "" { + return nil, fmt.Errorf("AI_PROVIDER is not set (expected one of: groq, openrouter)") + } + baseURL, ok := chatCompletionsBaseURL[cfg.AIProvider] + if !ok { + known := make([]string, 0, len(chatCompletionsBaseURL)) + for k := range chatCompletionsBaseURL { + known = append(known, k) + } + return nil, fmt.Errorf("unknown AI_PROVIDER %q (expected one of: %s)", cfg.AIProvider, strings.Join(known, ", ")) + } + if cfg.AIAPIKeyEnv == "" { + return nil, fmt.Errorf("AI_API_KEY_ENV is not set — point it at the env var holding your %s API key", cfg.AIProvider) + } + apiKey := os.Getenv(cfg.AIAPIKeyEnv) + if apiKey == "" { + return nil, fmt.Errorf("env var %s (named by AI_API_KEY_ENV) is not set or empty", cfg.AIAPIKeyEnv) + } + model := cfg.AIModel + if model == "" { + return nil, fmt.Errorf("AI_MODEL is not set") + } + return &llmProvider{baseURL: baseURL, apiKey: apiKey, model: model}, nil +} + +// ParseQueryIntent asks the model to translate natural language into +// JSON matching ai.QueryIntent's shape. The model's raw output is +// still just text at this point — untrusted — and gets validated +// against the real allowlist by ai.ExecuteQuery AFTER this returns, +// never trusted just because it parsed as valid JSON. +func (p *llmProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) { + systemPrompt := strings.TrimSpace(` +You translate an admin's natural-language request into a JSON object with this exact shape: +{"entity": "users|sessions|audit_events", "filters": [{"field": "...", "operator": "=|>|<|contains", "value": "..."}], "aggregate": "|count|group_by", "group_by": "", "limit": 0} +Only use these fields per entity: +- users: id, email, failed_attempts, locked_until, created_at +- sessions: id, user_id, ip, user_agent, created_at, revoked_at +- audit_events: id, type, user_id, ip, created_at +Never invent a field or entity outside these lists. Reply with ONLY the JSON object, no other text, no markdown fences. +`) + + raw, err := p.chatCompletion(ctx, systemPrompt, naturalLanguage) + if err != nil { + return ai.QueryIntent{}, err + } + + var parsed struct { + Entity string `json:"entity"` + Filters []ai.QueryFilter `json:"filters"` + Aggregate string `json:"aggregate"` + GroupBy string `json:"group_by"` + Limit int `json:"limit"` + } + if err := json.Unmarshal([]byte(stripCodeFence(raw)), &parsed); err != nil { + return ai.QueryIntent{}, fmt.Errorf("model did not return valid JSON: %w", err) + } + return ai.QueryIntent{ + Entity: parsed.Entity, + Filters: parsed.Filters, + Aggregate: parsed.Aggregate, + GroupBy: parsed.GroupBy, + Limit: parsed.Limit, + }, nil +} + +// Summarize is used by `csax ai logs` and `csax ai audit` — plain +// text in, plain text out, no QueryIntent structure needed since +// those commands aren't building a database query from the model's +// output, just asking it to narrate something csax already fetched +// itself. +func (p *llmProvider) Summarize(ctx context.Context, systemPrompt, userContent string) (string, error) { + return p.chatCompletion(ctx, systemPrompt, userContent) +} + +func (p *llmProvider) chatCompletion(ctx context.Context, systemPrompt, userContent string) (string, error) { + reqBody := map[string]any{ + "model": p.model, + "messages": []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userContent}, + }, + } + body, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("could not reach the AI provider: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("AI provider request failed: status %d: %s", resp.StatusCode, string(respBody)) + } + + var parsed struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", err + } + if len(parsed.Choices) == 0 { + return "", fmt.Errorf("OpenRouter returned no choices") + } + return parsed.Choices[0].Message.Content, nil +} + +// stripCodeFence handles the common case of a model wrapping its +// JSON in ```json ... ``` despite being told not to — defensive, not +// load-bearing: ai.validateIntent still runs on whatever this +// produces, so a model that ignores instructions in some OTHER way +// still can't produce an unsafe query, just an error. +func stripCodeFence(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "```json") + s = strings.TrimPrefix(s, "```") + s = strings.TrimSuffix(s, "```") + return strings.TrimSpace(s) +} diff --git a/cmd_ai_audit.go b/cmd_ai_audit.go new file mode 100644 index 0000000..7b6a17f --- /dev/null +++ b/cmd_ai_audit.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "fmt" +) + +// auditFinding is produced entirely by plain Go logic below — the +// model never decides what counts as a finding, it only explains and +// prioritizes findings that already exist. This is the deliberate +// split from the design: "the checklist itself is fixed Go code, not +// model-generated." +type auditFinding struct { + Severity string // "HIGH", "MEDIUM", "OK" + Message string +} + +// runAuditChecklist is pure, deterministic, and needs no AI provider +// to run — worth being able to run standalone later if `csax ai +// audit` ever gets a non-AI sibling command. +func runAuditChecklist(cfg csaxConfig) []auditFinding { + var findings []auditFinding + + if cfg.JWTSecret == "" { + findings = append(findings, auditFinding{"HIGH", "JWT_SECRET is not set."}) + } else if len(cfg.JWTSecret) < 32 { + findings = append(findings, auditFinding{"HIGH", fmt.Sprintf("JWT secret is %d characters — recommend 32+ for HS256.", len(cfg.JWTSecret))}) + } else { + findings = append(findings, auditFinding{"OK", "JWT secret length looks reasonable."}) + } + + if cfg.ReadOnlyDBURL == "" { + findings = append(findings, auditFinding{"MEDIUM", "READONLY_DATABASE_URL is not set — ai query/logs are unavailable until it is."}) + } else if cfg.ReadOnlyDBURL == cfg.DatabaseURL { + findings = append(findings, auditFinding{"HIGH", "READONLY_DATABASE_URL is identical to DATABASE_URL — this must point at a genuinely read-only role, not the same writable connection."}) + } else { + findings = append(findings, auditFinding{"OK", "A separate read-only database connection is configured."}) + } + + if cfg.BaseURL == "" { + findings = append(findings, auditFinding{"MEDIUM", "BASE_URL is not set — OAuth callback URLs cannot be computed."}) + } else { + findings = append(findings, auditFinding{"OK", "BASE_URL is set."}) + } + + oauthConfigured := (cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "") || (cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "") + if !oauthConfigured { + findings = append(findings, auditFinding{"OK", "No OAuth providers configured — nothing to check there yet."}) + } + + return findings +} + +// cmdAIAudit implements `csax ai audit`. No --fix flag, deliberately +// — per the standing constraint, if one is ever added it must show a +// diff and require per-change confirmation, never a batch apply. +// This command only ever prints; it changes nothing. +func cmdAIAudit(cfg csaxConfig) { + findings := runAuditChecklist(cfg) + + for _, f := range findings { + icon := "✓" + color := green + if f.Severity == "HIGH" { + icon, color = "⚠", red + } else if f.Severity == "MEDIUM" { + icon, color = "⚠", yellow + } + fmt.Printf("%s %-6s %s\n", color(icon), f.Severity, f.Message) + } + + flagged := 0 + for _, f := range findings { + if f.Severity != "OK" { + flagged++ + } + } + fmt.Printf("\n%d check(s), %d flagged. No changes made.\n", len(findings), flagged) + + // The narrative layer is optional — audit still works, and still + // changes nothing, even if AI isn't configured at all. + provider, err := newLLMProvider(cfg) + if err != nil || flagged == 0 { + return + } + narrative, err := provider.Summarize(context.Background(), + "You are prioritizing a fixed list of security configuration findings for a system administrator. "+ + "You do not add new findings or invent facts not present in the list. Explain, in 2-3 sentences, "+ + "which flagged item to fix first and why, in plain language.", + formatFindingsForSummary(findings)) + if err == nil && narrative != "" { + fmt.Println("\n" + narrative) + } +} + +func formatFindingsForSummary(findings []auditFinding) string { + out := "" + for _, f := range findings { + out += f.Severity + ": " + f.Message + "\n" + } + return out +} diff --git a/cmd_ai_logs.go b/cmd_ai_logs.go new file mode 100644 index 0000000..d244719 --- /dev/null +++ b/cmd_ai_logs.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/crydensync/cryden/v2/ai" + "github.com/crydensync/cryden/v2/store/postgres" +) + +// auditOnlyProvider wraps llmProvider and forces the parsed +// intent's Entity to "audit_events" regardless of what the model +// returned. Resolves the open design question from the earlier +// design doc: `ai logs` reuses the same QueryIntent/ExecuteQuery +// machinery as `ai query` rather than a separate narrower type, since +// the entity restriction is simpler to enforce this way — one +// validated path, not two. +type auditOnlyProvider struct { + inner *llmProvider +} + +func (p auditOnlyProvider) ParseQueryIntent(ctx context.Context, naturalLanguage string) (ai.QueryIntent, error) { + intent, err := p.inner.ParseQueryIntent(ctx, naturalLanguage) + if err != nil { + return ai.QueryIntent{}, err + } + intent.Entity = "audit_events" // this command is audit-only by definition — never trust the model's entity choice here + return intent, nil +} + +// cmdAILogs implements `csax ai logs ""`. It only +// ever reads via the same read-only SafeQueryStore as `ai query` and +// summarizes — nothing here suggests an action that gets executed +// automatically. If the summary mentions revoking a session or +// locking an account, that's plain text the operator acts on +// themselves with a separate, explicit command. +func cmdAILogs(cfg csaxConfig, naturalLanguage string) { + readonlyDB, provider := mustAISetup(cfg) + defer readonlyDB.Close() + + store := postgres.NewSafeQueryStore(readonlyDB) + result, err := ai.ExecuteQuery(context.Background(), store, auditOnlyProvider{inner: provider}, naturalLanguage) + if err != nil { + fmt.Println(red("Log search failed: " + err.Error())) + os.Exit(1) + } + + if len(result.Rows) == 0 { + fmt.Println(dim("No matching audit events.")) + return + } + + summary, err := provider.Summarize(context.Background(), + "You summarize a list of audit log events for a system administrator in 2-3 plain-language sentences. "+ + "Only describe patterns you can see in the data given. Never suggest the administrator take an action you're not certain about; "+ + "if a corrective action seems relevant, phrase it as a suggestion, never as something already done.", + formatRowsForSummary(result)) + if err != nil { + fmt.Println(yellow("(could not generate a summary: " + err.Error() + ")")) + } else { + fmt.Println(summary) + fmt.Println() + } + + printQueryResult(result, false) +} + +func formatRowsForSummary(result ai.QueryResult) string { + out := "" + for _, row := range result.Rows { + for i, v := range row { + out += result.Columns[i] + "=" + v + " " + } + out += "\n" + } + return out +} diff --git a/cmd_ai_query.go b/cmd_ai_query.go new file mode 100644 index 0000000..a615334 --- /dev/null +++ b/cmd_ai_query.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/crydensync/cryden/v2/ai" + "github.com/crydensync/cryden/v2/store/postgres" +) + +// cmdAIQuery implements `csax ai query ""`. Three +// distinct failure messages on purpose — an unsafe intent, a provider +// failure, and a DB failure are different problems for the operator, +// and conflating them into one generic "query failed" would make this +// harder to debug than just writing raw SQL would have been. +func cmdAIQuery(cfg csaxConfig, naturalLanguage string, jsonOutput bool) { + readonlyDB, provider := mustAISetup(cfg) + defer readonlyDB.Close() + + store := postgres.NewSafeQueryStore(readonlyDB) + result, err := ai.ExecuteQuery(context.Background(), store, provider, naturalLanguage) + if err != nil { + if errors.Is(err, ai.ErrUnsafeQueryIntent) { + // Deliberately does NOT echo the model's raw output or + // the specific rejected field/entity back to the + // terminal — that's an easy vector for confusing or + // misleading text to end up in a script's output. + fmt.Println(red("That request can't be translated into an allowed query.")) + fmt.Println(dim("Try rephrasing, or ask about one of: users, sessions, audit_events.")) + os.Exit(1) + } + fmt.Println(red("Query failed: " + err.Error())) + os.Exit(1) + } + + printQueryResult(result, jsonOutput) +} + +func printQueryResult(result ai.QueryResult, jsonOutput bool) { + if jsonOutput { + out := map[string]any{"columns": result.Columns, "rows": result.Rows} + b, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(b)) + return + } + + if len(result.Rows) == 0 { + fmt.Println(dim("No rows.")) + return + } + for _, col := range result.Columns { + fmt.Printf("%-24s", col) + } + fmt.Println() + for _, row := range result.Rows { + for _, v := range row { + fmt.Printf("%-24s", v) + } + fmt.Println() + } + fmt.Printf("\n%d row(s).\n", len(result.Rows)) +} + +// mustAISetup builds the two things every ai command needs: a real +// LLMProvider and a read-only-role Postgres connection for +// SafeQueryStore. Exits with a clear message if either isn't +// configured — ai commands are the one part of csax that's entirely +// optional to set up, so a missing config here should never look like +// a crash. +func mustAISetup(cfg csaxConfig) (*sql.DB, *llmProvider) { + if cfg.ReadOnlyDBURL == "" { + fmt.Println(red("READONLY_DATABASE_URL is not set.")) + fmt.Println(dim("AI-assisted queries require a SEPARATE connection string pointing at a read-only Postgres role — this is the real safety boundary, not just the allowlist check. See the docs for setting one up.")) + os.Exit(1) + } + provider, err := newLLMProvider(cfg) + if err != nil { + fmt.Println(red("AI is not configured: " + err.Error())) + os.Exit(1) + } + + db, err := sql.Open("postgres", cfg.ReadOnlyDBURL) + if err != nil { + fmt.Println(red("could not open read-only DB connection: " + err.Error())) + os.Exit(1) + } + if err := db.Ping(); err != nil { + fmt.Println(red("could not reach read-only DB: " + err.Error())) + os.Exit(1) + } + return db, provider +} diff --git a/cmd_oauth.go b/cmd_oauth.go new file mode 100644 index 0000000..9e4e708 --- /dev/null +++ b/cmd_oauth.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +// oauthProviderStatus mirrors what api's own provider() switch checks +// — kept independent (not imported from api, they're separate repos) +// but deliberately using the exact same env var names, so this +// command reports on the real values production actually uses. +type oauthProviderStatus struct { + Name string + ClientIDSet bool + ClientSecretSet bool + TokenURL string + discoveryCheckAddr string // used by cmdOAuthTest only +} + +func oauthProviders(cfg csaxConfig) []oauthProviderStatus { + return []oauthProviderStatus{ + { + Name: "google", + ClientIDSet: cfg.GoogleClientID != "", + ClientSecretSet: cfg.GoogleClientSecret != "", + TokenURL: "https://oauth2.googleapis.com/token", + discoveryCheckAddr: "https://accounts.google.com/.well-known/openid-configuration", + }, + { + Name: "github", + ClientIDSet: cfg.GitHubClientID != "", + ClientSecretSet: cfg.GitHubClientSecret != "", + TokenURL: "https://github.com/login/oauth/access_token", + discoveryCheckAddr: "https://github.com/login/oauth/authorize", + }, + } +} + +func cmdOAuthProvidersList(cfg csaxConfig, jsonOutput bool) { + providers := oauthProviders(cfg) + + if jsonOutput { + out := make([]map[string]any, 0, len(providers)) + for _, p := range providers { + configured := p.ClientIDSet && p.ClientSecretSet + out = append(out, map[string]any{ + "provider": p.Name, + "configured": configured, + "client_id_set": p.ClientIDSet, + "client_secret_set": p.ClientSecretSet, + }) + } + b, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(b)) + return + } + + fmt.Printf("%-10s %-12s %-16s %-18s\n", "PROVIDER", "CONFIGURED", "CLIENT ID SET", "CLIENT SECRET SET") + for _, p := range providers { + configured := p.ClientIDSet && p.ClientSecretSet + configuredStr := yesNo(configured) + if configured { + configuredStr = green(configuredStr) + } else { + configuredStr = dim(configuredStr) + } + fmt.Printf("%-10s %-12s %-16s %-18s\n", p.Name, configuredStr, yesNo(p.ClientIDSet), yesNo(p.ClientSecretSet)) + } + if cfg.BaseURL == "" { + fmt.Println(yellow("\nwarning: BASE_URL is not set — provider redirect URIs cannot be computed correctly")) + } +} + +// cmdOAuthTest round-trips a provider's real endpoints using the +// actual configured client ID/secret/redirect URI, WITHOUT a live +// user. This exists because OAuth's most common failure — a +// redirect-URI mismatch — otherwise only surfaces in production +// against a real person mid-login. +func cmdOAuthTest(cfg csaxConfig, providerName string) { + providers := oauthProviders(cfg) + var p *oauthProviderStatus + for i := range providers { + if providers[i].Name == providerName { + p = &providers[i] + break + } + } + if p == nil { + fmt.Println(red("unknown provider: " + providerName + " (expected: google, github)")) + os.Exit(1) + } + + fmt.Printf("Testing %s...\n\n", p.Name) + ok := true + + if p.ClientIDSet && p.ClientSecretSet { + fmt.Println(green("✓") + " client ID and secret present") + } else { + fmt.Println(red("✗") + " client ID and/or secret missing") + ok = false + } + + if cfg.BaseURL == "" { + fmt.Println(red("✗") + " BASE_URL is not set — cannot compute a redirect URI") + ok = false + } else { + redirectURI := cfg.BaseURL + "/v1/oauth/" + p.Name + "/callback" + fmt.Println(dim(" redirect_uri would be: " + redirectURI)) + fmt.Println(yellow(" ⚠ csax cannot verify this matches what's registered in " + p.Name + "'s console — check that manually")) + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(p.discoveryCheckAddr) + if err != nil { + fmt.Println(red("✗") + " could not reach " + p.Name + "'s endpoint: " + err.Error()) + ok = false + } else { + resp.Body.Close() + // A reachable response — even a 4xx, since we're not sending + // real credentials on this GET — proves network/DNS/TLS all + // work, which is the actual thing worth checking here before + // a real user hits it. + fmt.Println(green("✓") + " reached " + p.Name + "'s endpoint") + } + + fmt.Println() + if ok { + fmt.Println(green("This provider looks ready. The one thing csax cannot verify automatically is whether the redirect_uri above is registered exactly as shown in " + p.Name + "'s console — mismatches there are the #1 real-world OAuth failure.")) + } else { + fmt.Println(red("This provider is not ready yet — see the ✗ items above.")) + os.Exit(1) + } +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/config.go b/config.go index 5fdc12a..acfcb12 100644 --- a/config.go +++ b/config.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "os" + "strings" _ "github.com/lib/pq" @@ -15,6 +16,22 @@ type csaxConfig struct { DatabaseURL string JWTSecret string MigrationsDir string + + // OAuth — deliberately the SAME env var names api's config uses, + // so `csax oauth test` checks the actual values production uses, + // not a separate csax-only copy that could drift out of sync. + BaseURL string + GoogleClientID string + GoogleClientSecret string + GitHubClientID string + GitHubClientSecret string + + // AI — all optional. ai commands fail with a clear message if + // these aren't set, rather than csax refusing to start at all. + AIProvider string // e.g. "openrouter" + AIAPIKeyEnv string // name of the env var holding the API key — never the key itself, so it's not persisted in .env in plaintext by `config init` + AIModel string + ReadOnlyDBURL string // separate connection string, MUST point at a read-only Postgres role — this is the real safety boundary, not just ai.validateIntent } func loadConfig() (csaxConfig, error) { @@ -24,6 +41,17 @@ func loadConfig() (csaxConfig, error) { DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("JWT_SECRET"), MigrationsDir: os.Getenv("MIGRATIONS_DIR"), + + BaseURL: strings.TrimRight(os.Getenv("BASE_URL"), "/"), + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), + GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + + AIProvider: os.Getenv("AI_PROVIDER"), + AIAPIKeyEnv: os.Getenv("AI_API_KEY_ENV"), + AIModel: os.Getenv("AI_MODEL"), + ReadOnlyDBURL: os.Getenv("READONLY_DATABASE_URL"), } if cfg.MigrationsDir == "" { cfg.MigrationsDir = "./migrations" diff --git a/go.mod b/go.mod index 8f3cb12..d833c1d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/crydensync/csax go 1.25.0 require ( - github.com/crydensync/cryden/v2 v2.0.0 + github.com/crydensync/cryden/v2 v2.1.0 github.com/lib/pq v1.12.3 ) diff --git a/go.sum b/go.sum index cfbe649..c4fc1b2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/crydensync/cryden/v2 v2.0.0 h1:PgTQxo12nsMLaSS3gqO5i0UeNH4h4h/5eYvv7vKA630= -github.com/crydensync/cryden/v2 v2.0.0/go.mod h1:kkLk2779IPHbbj7aBmieH2jZRkwWGzyTmHHWKojAQEQ= +github.com/crydensync/cryden/v2 v2.1.0 h1:qU35YuSI2g6pQd+b0s0XcQrWuDVTtEyH53ly61C2qtI= +github.com/crydensync/cryden/v2 v2.1.0/go.mod h1:kkLk2779IPHbbj7aBmieH2jZRkwWGzyTmHHWKojAQEQ= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/main.go b/main.go index 8d7d44b..d498629 100644 --- a/main.go +++ b/main.go @@ -24,9 +24,17 @@ Usage: csax sessions revoke-all --user csax audit tail --user [--limit N] csax audit search --event [--limit N] + csax oauth providers list [--json] + csax oauth test + csax ai query "" [--json] + csax ai logs "" + csax ai audit csax stats csax health - csax version`) + csax version + +oauth and ai commands are optional — see README for the env vars each +one needs. Run any command with no further args for its specific usage.`) } func main() { @@ -172,6 +180,62 @@ func main() { os.Exit(1) } + case "oauth": + cfg := mustLoadConfig() + if len(os.Args) < 3 { + fmt.Println("usage: csax oauth providers list | test ") + os.Exit(1) + } + switch os.Args[2] { + case "providers": + if len(os.Args) < 4 || os.Args[3] != "list" { + fmt.Println("usage: csax oauth providers list [--json]") + os.Exit(1) + } + fs := flag.NewFlagSet("oauth providers list", flag.ExitOnError) + jsonOut := fs.Bool("json", false, "output as JSON") + fs.Parse(os.Args[4:]) + cmdOAuthProvidersList(cfg, *jsonOut) + case "test": + if len(os.Args) < 4 { + fmt.Println("usage: csax oauth test ") + os.Exit(1) + } + cmdOAuthTest(cfg, os.Args[3]) + default: + fmt.Println("usage: csax oauth providers list | test ") + os.Exit(1) + } + + case "ai": + cfg := mustLoadConfig() + if len(os.Args) < 3 { + fmt.Println(`usage: csax ai query "" | logs "" | audit`) + os.Exit(1) + } + switch os.Args[2] { + case "query": + if len(os.Args) < 4 { + fmt.Println(`usage: csax ai query "" [--json]`) + os.Exit(1) + } + fs := flag.NewFlagSet("ai query", flag.ExitOnError) + jsonOut := fs.Bool("json", false, "output as JSON") + fs.Parse(os.Args[4:]) + cmdAIQuery(cfg, os.Args[3], *jsonOut) + case "logs": + if len(os.Args) < 4 { + fmt.Println(`usage: csax ai logs ""`) + os.Exit(1) + } + cmdAILogs(cfg, os.Args[3]) + case "audit": + cmdAIAudit(cfg) + default: + fmt.Println(`usage: csax ai query "" | logs "" | audit`) + os.Exit(1) + } + case "stats": cfg := mustLoadConfig() db := mustConnect(cfg) diff --git a/migrations/0002_oauth_identities.down.sql b/migrations/0002_oauth_identities.down.sql new file mode 100644 index 0000000..e2eec80 --- /dev/null +++ b/migrations/0002_oauth_identities.down.sql @@ -0,0 +1,3 @@ +-- 0002_oauth_identities.down.sql + +DROP TABLE oauth_identities; diff --git a/migrations/0002_oauth_identities.up.sql b/migrations/0002_oauth_identities.up.sql new file mode 100644 index 0000000..408be77 --- /dev/null +++ b/migrations/0002_oauth_identities.up.sql @@ -0,0 +1,16 @@ +-- 0002_oauth_identities.up.sql + +CREATE TABLE oauth_identities ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + external_id TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Backstops OAuthStore.GetByProviderID and is the real guard + -- against ever double-linking the same external account. + UNIQUE (provider, external_id) +); + +CREATE INDEX idx_oauth_identities_user_id ON oauth_identities(user_id); +