diff --git a/README.md b/README.md index 23f143b9..0563e19b 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,10 @@ node bin/service.js call --data-json '{"id":"123"}' The SDK also reads the same variable from `.env` in the current working directory. It reads only that key and does not inject other `.env` variables. This variable does not affect the daemon's `--runtime serve` or `--runtime invoke` protocol. When the daemon manages instances, it continues to pass config/secret through files and file descriptors. +## Admin authentication + +The daemon requires an Admin Token before enabling the control plane. On a new or upgraded data directory, set `OCTOBUS_BOOTSTRAP_ADMIN_TOKEN` to a high-entropy value for the first startup. The value is hashed and is not returned by the API; remove it from the process environment after bootstrap and manage subsequent tokens through the authenticated Admin API. + ## Development ### Architecture diff --git a/cmd/octobus/main.go b/cmd/octobus/main.go index cba29da2..3d13a95a 100644 --- a/cmd/octobus/main.go +++ b/cmd/octobus/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "io" "log/slog" @@ -17,6 +18,7 @@ import ( "octobus/internal/admin" "octobus/internal/cli" "octobus/internal/daemonlog" + "octobus/internal/domain" "octobus/internal/packageimport" "octobus/internal/protocol" "octobus/internal/server" @@ -125,7 +127,10 @@ func serve(opts serveOptions) error { if err := startupInventory(ctx, logger, st); err != nil { return err } - adminServer := &admin.Server{Store: st, Importer: &packageimport.Importer{DataDir: dataDir, Store: st}, Supervisor: sup, Gateway: gateway, AccessLogPath: filepath.Join(dataDir, accesslog.FileName), Logger: logger} + if err := initializeAdminAuth(ctx, st); err != nil { + return fmt.Errorf("initialize admin authentication: %w", err) + } + adminServer := &admin.Server{Store: st, Importer: &packageimport.Importer{DataDir: dataDir, Store: st}, Supervisor: sup, Gateway: gateway, AccessLogPath: filepath.Join(dataDir, accesslog.FileName), Logger: logger, RequireAdminToken: true} grpcServer := protocol.GRPCServer(gateway) publicServer := admin.NewHTTPServer(opts.addr, h2c.NewHandler(server.CombinedHandler(adminServer.Handler(), grpcServer, gateway), &http2.Server{})) publicListener, err := net.Listen("tcp", opts.addr) @@ -173,6 +178,22 @@ func shutdownSupervisor(logger *slog.Logger, sup *supervisor.Supervisor) { } } +func initializeAdminAuth(ctx context.Context, st *store.Store) error { + requires, err := st.AdminRequiresToken(ctx) + if err != nil { + return err + } + if requires { + return nil + } + secret := os.Getenv("OCTOBUS_BOOTSTRAP_ADMIN_TOKEN") + if secret == "" { + return errors.New("admin token authentication is not initialized; set OCTOBUS_BOOTSTRAP_ADMIN_TOKEN before starting the daemon") + } + _, err = st.AddAdminToken(ctx, domain.AdminToken{ID: "bootstrap-admin", Name: "Bootstrap admin"}, secret) + return err +} + func logStartupInventory(ctx context.Context, logger *slog.Logger, st *store.Store) error { logger = daemonlog.OrNop(logger) capsets, err := st.ListCapsets(ctx) diff --git a/cmd/octobus/main_test.go b/cmd/octobus/main_test.go index ab2daf76..c246b5de 100644 --- a/cmd/octobus/main_test.go +++ b/cmd/octobus/main_test.go @@ -32,9 +32,54 @@ func TestMain(m *testing.M) { runCmdHelper() return } + if os.Getenv("OCTOBUS_BOOTSTRAP_ADMIN_TOKEN") == "" { + if err := os.Setenv("OCTOBUS_BOOTSTRAP_ADMIN_TOKEN", "test-bootstrap-token"); err != nil { + panic(err) + } + defer os.Unsetenv("OCTOBUS_BOOTSTRAP_ADMIN_TOKEN") + } os.Exit(m.Run()) } +func TestInitializeAdminAuthBootstrapsOnlyWhenStoreIsEmpty(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "octobus.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + t.Setenv("OCTOBUS_BOOTSTRAP_ADMIN_TOKEN", "bootstrap-secret") + if err := initializeAdminAuth(context.Background(), st); err != nil { + t.Fatal(err) + } + requires, err := st.AdminRequiresToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if !requires { + t.Fatal("bootstrap token was not persisted") + } + ok, err := st.VerifyAdminToken(context.Background(), "bootstrap-secret") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("bootstrap token was not usable") + } +} + +func TestInitializeAdminAuthFailsClosedWithoutBootstrapToken(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "octobus.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + t.Setenv("OCTOBUS_BOOTSTRAP_ADMIN_TOKEN", "") + if err := initializeAdminAuth(context.Background(), st); err == nil || !strings.Contains(err.Error(), "OCTOBUS_BOOTSTRAP_ADMIN_TOKEN") { + t.Fatalf("missing bootstrap token error = %v", err) + } +} + func TestRootAddrFlagOverridesAdminCommands(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != "/admin/v1/status" { diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 59e2256c..d2089d53 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -35,6 +35,11 @@ type Server struct { Gateway *protocol.Gateway AccessLogPath string Logger *slog.Logger + + // RequireAdminToken is enabled by the daemon for production control-plane + // handlers. It is explicit so lightweight in-process test servers can keep + // using the handler without provisioning a token store. + RequireAdminToken bool } type serviceImporter interface { @@ -147,10 +152,14 @@ func (s *Server) adminTokenMiddleware(next echo.HandlerFunc) echo.HandlerFunc { if c.Request().URL.Path == "/admin/v1/status" { return next(c) } - requires, err := s.Store.AdminRequiresToken(c.Request().Context()) - if err != nil { - writeError(c.Response(), http.StatusInternalServerError, err.Error()) - return nil + requires := s.RequireAdminToken + if !requires { + var err error + requires, err = s.Store.AdminRequiresToken(c.Request().Context()) + if err != nil { + writeError(c.Response(), http.StatusInternalServerError, err.Error()) + return nil + } } if !requires { return next(c) diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go index 98235d40..017a87b7 100644 --- a/internal/admin/admin_test.go +++ b/internal/admin/admin_test.go @@ -747,6 +747,22 @@ func TestAdminServiceImportMultipartRecursiveAggregateAndValidation(t *testing.T }) } +func TestAdminTokenIsRequiredWhenDaemonEnablesControlPlaneAuth(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "octobus.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + srv := &Server{Store: st, RequireAdminToken: true} + req := httptest.NewRequest(http.MethodGet, "/admin/v1/services", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status without admin token = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + func TestAdminServiceImportMultipartRequiresAdminToken(t *testing.T) { ctx := context.Background() dataDir := t.TempDir()