Deleting your own user succeeded, and logged you out doing it: local_sessions.user_id is ON DELETE CASCADE, so the delete took the caller's own live session with it. Nothing refused this. The last-owner guard is the only thing in the path, and it passes cleanly as soon as a second owner exists -- which is exactly the state you are in just after creating one. The way back in was then whatever other account happened to exist, and -seed-admin could not help: it skipped whenever *any* local user was present, so the command documented as the way to create an administrator refused precisely when there was no usable one, because some other account still existed. It now asks whether the admin account itself is missing, which is what its own help text always claimed, and what makes it useful as recovery rather than only as first-run bootstrap. TestCanDeleteAnOwnerWhenAnotherRemains signed in as admin1 and deleted admin1, asserting 204 -- it encoded the lockout as intended behaviour. It now deletes the other owner, which is what it meant to cover, and a new test holds the refusal in place. runSeedAdmin takes a small interface so the bootstrap path is tested without a Postgres pool; it had no tests before. Signed-off-by: John Coffey <[email protected]>
344 lines
13 KiB
Go
344 lines
13 KiB
Go
// Command api is Cairn OBS's query API: a single POST /query endpoint
|
|
// accepting either the pipe syntax or raw SQL, compiled and routed
|
|
// across ClickHouse and search by internal/querylang. See
|
|
// queryapi and /docs/query-language-design.md for why this is
|
|
// plain REST rather than the pinned gRPC+gateway pattern.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
// Embeds the IANA tz database in the binary. This image is
|
|
// distroless/static -- it has no /usr/share/zoneinfo at all, so
|
|
// time.LoadLocation would fail for every zone except UTC, and
|
|
// localauth's timezone validation would reject every real name a
|
|
// user could pick. ~450KB of binary for a feature whose whole job is
|
|
// knowing what "America/New_York" means.
|
|
_ "time/tzdata"
|
|
|
|
"github.com/ClickHouse/clickhouse-go/v2"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/cairnobs/cairnobs/api/agents"
|
|
"github.com/cairnobs/cairnobs/api/ai/aiapi"
|
|
"github.com/cairnobs/cairnobs/api/ai/grounding"
|
|
"github.com/cairnobs/cairnobs/api/ai/provider/ollama"
|
|
"github.com/cairnobs/cairnobs/api/ai/router"
|
|
"github.com/cairnobs/cairnobs/api/authz"
|
|
"github.com/cairnobs/cairnobs/api/dashboards"
|
|
"github.com/cairnobs/cairnobs/api/httpserver"
|
|
"github.com/cairnobs/cairnobs/api/internal/config"
|
|
"github.com/cairnobs/cairnobs/api/localauth"
|
|
"github.com/cairnobs/cairnobs/api/logretention"
|
|
"github.com/cairnobs/cairnobs/api/queryapi"
|
|
"github.com/cairnobs/cairnobs/api/querylang/executor"
|
|
"github.com/cairnobs/cairnobs/api/searchclient"
|
|
)
|
|
|
|
// groundingRefreshInterval matches chwriter.Registry/search's
|
|
// ActiveTenantTracker's own one-minute refresh cadence -- no strong
|
|
// reason for a different number, and consistency means one interval to
|
|
// reason about across every "sample something periodically" mechanism
|
|
// in this codebase, not several slightly different ones.
|
|
const groundingRefreshInterval = time.Minute
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
logger.Error("loading config", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
for _, w := range cfg.DevCredentialWarnings() {
|
|
logger.Warn(w)
|
|
}
|
|
|
|
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
|
|
// flag anyone runs by hand. The api image is distroless (no shell,
|
|
// no wget/curl -- see api/Dockerfile), so docker-compose's
|
|
// healthcheck execs this binary against itself instead of an
|
|
// external tool. Exits before any ClickHouse/Postgres/search dial,
|
|
// since those aren't what "is the HTTP server up" is asking.
|
|
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
|
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
|
|
}
|
|
|
|
// -seed-admin: a one-shot action, not part of the normal server
|
|
// startup path -- mirrors enterprise-api's -provision-tenant shape
|
|
// (declare, flag.Parse(), short-circuit before the rest of main's
|
|
// dependencies matter to it). See runSeedAdmin's doc comment.
|
|
seedAdmin := flag.Bool("seed-admin", false, "create the default local-auth admin user with a random password if that account does not exist, print it once, and exit")
|
|
flag.Parse()
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
conn, err := clickhouse.Open(&clickhouse.Options{
|
|
Addr: []string{cfg.ClickHouse.Addr},
|
|
Auth: clickhouse.Auth{
|
|
Database: cfg.ClickHouse.Database,
|
|
Username: cfg.ClickHouse.Username,
|
|
Password: cfg.ClickHouse.Password,
|
|
},
|
|
})
|
|
if err != nil {
|
|
logger.Error("opening clickhouse connection", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if err := conn.Ping(ctx); err != nil {
|
|
logger.Error("pinging clickhouse", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
search, err := searchclient.Dial(cfg.SearchGRPCAddr)
|
|
if err != nil {
|
|
logger.Error("dialing search service", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer search.Close()
|
|
|
|
pgDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.Postgres.Username, cfg.Postgres.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
|
|
pgPool, err := pgxpool.New(ctx, pgDSN)
|
|
if err != nil {
|
|
logger.Error("opening postgres pool", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pgPool.Close()
|
|
|
|
if err := pgPool.Ping(ctx); err != nil {
|
|
logger.Error("pinging postgres", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if *seedAdmin {
|
|
os.Exit(runSeedAdmin(ctx, logger, os.Stdout, localauth.NewStore(pgPool)))
|
|
}
|
|
|
|
// authorizer is nil (RequireRole* becomes a no-op) unless
|
|
// ENTERPRISE_AUTH_URL or LOCAL_AUTH_ENABLED is configured -- matches
|
|
// Phase 0-3 behavior for a single-tenant deployment with neither
|
|
// enterprise/ nor local login turned on. EnterpriseAuthURL wins if
|
|
// both were somehow set -- a deployment with real SSO configured has
|
|
// no use for a second, local auth mechanism (see LocalAuthConfig's
|
|
// doc comment).
|
|
var authorizer authz.Authorizer
|
|
var localAuthStore *localauth.Store
|
|
switch {
|
|
case cfg.EnterpriseAuthURL != "":
|
|
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
|
|
case cfg.LocalAuth.Enabled:
|
|
localAuthStore = localauth.NewStore(pgPool)
|
|
authorizer = localauth.NewAuthorizer(localAuthStore)
|
|
}
|
|
|
|
sqlRunner := executor.NewChRunner(conn)
|
|
// audit logging is nil (a no-op) until Phase 4 task 5 wires in
|
|
// enterprise/internal/audit -- see queryapi.AuditLogger's doc comment.
|
|
queryHandler := queryapi.NewHandler(logger, sqlRunner, search, cfg.QueryTimeout, nil, authorizer)
|
|
// permissions is nil -- api/cmd/api is single-tenant/core; the
|
|
// enterprise-supplied dashboard_permissions store is only wired in
|
|
// by enterprise/cmd/enterprise-api (see dashboards.PermissionStore's
|
|
// doc comment). Ownership/Admin access still work via
|
|
// canEditDashboard's nil-permissions fallback -- only the "granted"
|
|
// half of the matrix's "(own/granted)" qualifier is unavailable here.
|
|
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer, nil)
|
|
// Same pgPool as dashboards above -- agents reads/writes the table
|
|
// ingest's internal/agentregistry upserts on every CheckIn RPC (see
|
|
// /docs/agent-management-design.md). Nothing here requires
|
|
// AGENT_REGISTRY_POSTGRES_ADDR to be set on ingest; these routes work
|
|
// unconditionally, they'll just show an empty inventory if ingest
|
|
// hasn't been configured to record check-ins.
|
|
// nil command logger: core has no enterprise/internal/audit
|
|
// implementation to log lifecycle commands against, same posture as
|
|
// queryHandler's/aiHandler's nil audit loggers above.
|
|
agentsHandler := agents.NewHandler(logger, agents.NewStore(pgPool), authorizer, nil)
|
|
|
|
// Same conn sqlRunner above already wraps -- logretention issues its
|
|
// own purpose-built statements against the `logs` table directly
|
|
// rather than going through sqlRunner's SELECT-only RunSQL (see
|
|
// logretention.Store's doc comment). Same pgPool as dashboards/agents
|
|
// above for the owner-only retention floor (logretention.
|
|
// AgentRetentionStore reads agents.ConfigOverride.LogRetentionDays
|
|
// out of the same `agents` table agentsHandler manages).
|
|
logRetentionHandler := logretention.NewHandler(logger, logretention.NewStore(conn), logretention.NewAgentRetentionStore(pgPool), authorizer)
|
|
|
|
// One shared mux, CORS applied once around the whole thing -- see
|
|
// httpserver's doc comment for why this changed from each
|
|
// handler wrapping itself individually.
|
|
mux := http.NewServeMux()
|
|
queryHandler.RegisterRoutes(mux)
|
|
dashboardsHandler.RegisterRoutes(mux)
|
|
agentsHandler.RegisterRoutes(mux)
|
|
logRetentionHandler.RegisterRoutes(mux)
|
|
|
|
// Only registered when local auth is actually enabled -- see
|
|
// localauth.Handler.RegisterRoutes' doc comment for why a disabled
|
|
// deployment gets a plain 404 on /auth/* rather than a dedicated
|
|
// "feature off" response.
|
|
if localAuthStore != nil {
|
|
localauthHandler := localauth.NewHandler(logger, localAuthStore, authorizer, cfg.LocalAuth.SessionTTL, localauth.CookieConfig{
|
|
Domain: cfg.LocalAuth.CookieDomain,
|
|
Secure: cfg.LocalAuth.CookieSecure,
|
|
})
|
|
localauthHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// AI routes (Phase 7) are only registered at all when OLLAMA_BASE_URL
|
|
// is set -- an unconfigured deployment gets a plain 404 on /ai/*
|
|
// rather than every request failing against an unreachable
|
|
// localhost:11434, matching "no cloud dependency required for the
|
|
// default deployment" by not forcing a *local* model dependency on a
|
|
// deployment that doesn't want AI features either.
|
|
if cfg.AI.OllamaBaseURL != "" {
|
|
groundingSvc := grounding.New(sqlRunner)
|
|
groundingSvc.StartRefreshing(ctx, groundingRefreshInterval, func(err error) {
|
|
logger.Warn("grounding refresh failed", "error", err)
|
|
})
|
|
|
|
defaultProvider := ollama.New(cfg.AI.OllamaBaseURL, cfg.AI.OllamaModel)
|
|
aiRouter := router.New(defaultProvider)
|
|
if cfg.AI.OllamaFastModel != "" && cfg.AI.OllamaFastModel != cfg.AI.OllamaModel {
|
|
aiRouter.SetOperation(router.OpComplete, ollama.New(cfg.AI.OllamaBaseURL, cfg.AI.OllamaFastModel))
|
|
}
|
|
|
|
// nil interaction logger: core has no enterprise/internal/audit
|
|
// implementation to log translate/fix/optimize interactions
|
|
// against, same posture as queryHandler's nil audit logger above.
|
|
aiHandler := aiapi.NewHandler(logger, aiRouter, groundingSvc, authorizer, nil)
|
|
aiHandler.RegisterRoutes(mux)
|
|
logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel)
|
|
}
|
|
|
|
// Once an authorizer is live, requests carry a session cookie/bearer
|
|
// token that must survive a cross-origin browser fetch --
|
|
// WithCredentialedCORS is WithCORS's sibling for exactly that (see
|
|
// httpserver/cors.go). This also fixes a latent gap: previously,
|
|
// enterprise mode applied plain WithCORS here despite needing
|
|
// cookies too.
|
|
corsHandler := httpserver.WithCORS(mux, cfg.CORSAllowedOrigin)
|
|
if authorizer != nil {
|
|
corsHandler = httpserver.WithCredentialedCORS(mux, cfg.CORSAllowedOrigin)
|
|
}
|
|
srv := &http.Server{
|
|
Addr: cfg.HTTPListenAddr,
|
|
Handler: corsHandler,
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
logger.Info("api listening", "addr", cfg.HTTPListenAddr)
|
|
errCh <- srv.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
logger.Error("graceful shutdown failed", "error", err)
|
|
}
|
|
case err := <-errCh:
|
|
if err != nil && err != http.ErrServerClosed {
|
|
logger.Error("server exited with error", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// seedAdminUsername is the account -seed-admin creates, and the one it
|
|
// checks for before deciding it has nothing to do.
|
|
const seedAdminUsername = "admin"
|
|
|
|
// seedStore is the slice of *localauth.Store that runSeedAdmin uses,
|
|
// named here so the bootstrap path can be tested without a Postgres
|
|
// pool behind it.
|
|
type seedStore interface {
|
|
UsernameExists(ctx context.Context, username string) (bool, error)
|
|
CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*localauth.User, error)
|
|
}
|
|
|
|
// runSeedAdmin is the operator action that bootstraps local login on a
|
|
// fresh deployment: idempotent (a no-op if the admin account already
|
|
// exists, safe to run on every deploy per the runbook), so there's no
|
|
// separate "has this already run" flag to track. The generated
|
|
// password is printed to stdout exactly once and never stored in
|
|
// plaintext anywhere -- losing it means resetting it
|
|
// (POST /auth/users/{id}/reset-password), not recovering it.
|
|
//
|
|
// The check is specifically for the admin account rather than for any
|
|
// local user, which is what it used to be. That older test made this
|
|
// command useless in the situation it is most needed: an operator who
|
|
// no longer has a working administrator account, but whose deployment
|
|
// still contains other users, was told "already provisioned" and left
|
|
// with nothing to do.
|
|
func runSeedAdmin(ctx context.Context, logger *slog.Logger, stdout io.Writer, store seedStore) int {
|
|
exists, err := store.UsernameExists(ctx, seedAdminUsername)
|
|
if err != nil {
|
|
logger.Error("checking for an existing admin user", "error", err)
|
|
return 1
|
|
}
|
|
if exists {
|
|
fmt.Fprintf(stdout, "%q user already exists, skipping\n", seedAdminUsername)
|
|
return 0
|
|
}
|
|
|
|
buf := make([]byte, 20)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
logger.Error("generating random password", "error", err)
|
|
return 1
|
|
}
|
|
password := base64.RawURLEncoding.EncodeToString(buf)
|
|
|
|
hash, err := localauth.HashPassword(password)
|
|
if err != nil {
|
|
logger.Error("hashing password", "error", err)
|
|
return 1
|
|
}
|
|
if _, err := store.CreateUser(ctx, seedAdminUsername, hash, authz.RoleOwner); err != nil {
|
|
logger.Error("creating admin user", "error", err)
|
|
return 1
|
|
}
|
|
|
|
fmt.Fprintln(stdout, "created default admin user:")
|
|
fmt.Fprintf(stdout, " username: %s\n", seedAdminUsername)
|
|
fmt.Fprintf(stdout, " password: %s\n", password)
|
|
fmt.Fprintln(stdout, "this password will not be shown again -- save it now.")
|
|
return 0
|
|
}
|
|
|
|
// runHealthcheck GETs its own /healthz and returns an exit code, for
|
|
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
|
|
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --
|
|
// "localhost" replaces a bare host part since that's this same
|
|
// container reaching itself, not another service.
|
|
func runHealthcheck(listenAddr string) int {
|
|
addr := listenAddr
|
|
if strings.HasPrefix(addr, ":") {
|
|
addr = "localhost" + addr
|
|
}
|
|
client := http.Client{Timeout: 3 * time.Second}
|
|
resp, err := client.Get("http://" + addr + "/healthz")
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|