diff --git a/cmd/api/handlers/features.go b/cmd/api/handlers/features.go index 4bd291fe..c6808536 100644 --- a/cmd/api/handlers/features.go +++ b/cmd/api/handlers/features.go @@ -30,8 +30,8 @@ func (h *HandlersApi) FeaturesHandler(w http.ResponseWriter, r *http.Request) { utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, FeaturesResponse{ Posture: h.PostureEnabled, ServiceConfig: h.ServiceConfigEnabled, - LogSinks: h.ServiceConfigEnabled, - AuthProviders: h.ServiceConfigEnabled, + LogSinks: h.LogSinksEnabled, + AuthProviders: h.AuthProvidersEnabled, Accelerated: h.OsqueryValues.Accelerated, Console: h.OsqueryValues.Query && h.OsqueryValues.Console, FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.FileExplorer, diff --git a/cmd/api/handlers/features_test.go b/cmd/api/handlers/features_test.go index e8903943..95c75029 100644 --- a/cmd/api/handlers/features_test.go +++ b/cmd/api/handlers/features_test.go @@ -46,6 +46,8 @@ func TestFeaturesHandlerReportsServiceConfigEnabled(t *testing.T) { } WithServiceConfigEnabled(true)(h) + WithLogSinksEnabled(true)(h) + WithAuthProvidersEnabled(true)(h) w = httptest.NewRecorder() h.FeaturesHandler(w, r) var on FeaturesResponse @@ -56,7 +58,10 @@ func TestFeaturesHandlerReportsServiceConfigEnabled(t *testing.T) { t.Fatalf("service config feature: got false want true") } if !on.LogSinks { - t.Fatalf("log_sinks feature: got false want true (shares the service-config gate)") + t.Fatalf("log_sinks feature: got false want true") + } + if !on.AuthProviders { + t.Fatalf("auth_providers feature: got false want true") } } diff --git a/cmd/api/handlers/handlers.go b/cmd/api/handlers/handlers.go index 2cf9faa5..b357dae7 100644 --- a/cmd/api/handlers/handlers.go +++ b/cmd/api/handlers/handlers.go @@ -55,11 +55,11 @@ type HandlersApi struct { // not registered in that case. LogSinks *logsinks.LogSinksManager // AuthProviders holds the live multi-provider registry (OIDC + SAML). - // nil when no providers are configured. Replaced atomically during - // hot-reload via AuthProviderRegistry.Replace. AuthProviders *AuthProviderRegistry AuthProviderMgr *authproviders.AuthProviderManager ServiceConfigEnabled bool + LogSinksEnabled bool + AuthProvidersEnabled bool ServiceCommands *servicecommands.Manager Activity activityReader GeoIP *geoip.GeoIPResolver @@ -251,6 +251,18 @@ func WithServiceConfigEnabled(enabled bool) HandlersOption { } } +func WithLogSinksEnabled(enabled bool) HandlersOption { + return func(h *HandlersApi) { + h.LogSinksEnabled = enabled + } +} + +func WithAuthProvidersEnabled(enabled bool) HandlersOption { + return func(h *HandlersApi) { + h.AuthProvidersEnabled = enabled + } +} + // WithMFA wires the second-factor manager, the (optional) WebAuthn relying // party and the deployment-wide requirement switch. func WithMFA(manager *mfa.Manager, webAuthn *mfa.WebAuthn, required bool, issuer string) HandlersOption { diff --git a/cmd/api/main.go b/cmd/api/main.go index 9aee7f68..0ff38dd1 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -456,9 +456,27 @@ func osctrlAPIService() { if err := serviceConfigMgr.ReportFile(config.ServiceAPI, flagParams.ConfigFilePath()); err != nil { log.Err(err).Msg("Error reporting service config file status") } + // Resolve the per-feature enabled flags. They default to true + // when nil (backwards compat: service-config-enabled controls + // the master gate, and each sub-feature can be independently + // disabled by setting its flag to false). + logSinksEnabled := true + if flagParams.Service.LogSinksEnabled != nil { + logSinksEnabled = *flagParams.Service.LogSinksEnabled + } + authProvidersEnabled := true + if flagParams.Service.AuthProvidersEnabled != nil { + authProvidersEnabled = *flagParams.Service.AuthProvidersEnabled + } if !flagParams.Service.ServiceConfigEnabled { log.Info().Msg("Service config API is disabled (enable with --service-config-enabled) — sections are still seeded and resolved, change the service_config rows or the YAML file directly") } + if !logSinksEnabled { + log.Info().Msg("Log sinks API is disabled (enable with --log-sinks-enabled) — rows can still be changed directly in the database") + } + if !authProvidersEnabled { + log.Info().Msg("Auth providers API is disabled (enable with --auth-providers-enabled) — rows can still be changed directly in the database") + } if flagParams.RateLimits == nil { flagParams.RateLimits = config.DefaultRateLimitsPtr() } @@ -544,6 +562,8 @@ func osctrlAPIService() { handlers.WithLogSinks(logSinksMgr), handlers.WithAuthProviders(authProviderRegistry, authProvidersMgr), handlers.WithServiceConfigEnabled(flagParams.Service.ServiceConfigEnabled), + handlers.WithLogSinksEnabled(logSinksEnabled), + handlers.WithAuthProvidersEnabled(authProvidersEnabled), handlers.WithServiceCommands(serviceCommandMgr), handlers.WithConfigPersist(persistConfig), handlers.WithActivityReader(activity.NewRedisStore(redis.Client, activity.DefaultPrefix, activity.DefaultRetentionDays, 8*24*time.Hour)), @@ -1005,8 +1025,16 @@ func osctrlAPIService() { muxAPI.Handle( "PATCH "+_apiPath(apiSettingsPath)+"/{service}/{name}", handlerAuthCheck(http.HandlerFunc(handlersApi.SettingPatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - // API: service config. The whole surface is opt-in: with - // --service-config-enabled off, none of these routes exist. Seeding + // Rate-limit the restart/apply endpoints to 3 per 10 minutes per IP + // by default — strict enough to prevent brute-forcing restarts, + // generous enough for an operator to retry after a failed restart. + // Rejections are audit-logged so SoC tooling sees attempted abuse. + restartLimiter := ratelimit.NewFromConfig(flagParams.RateLimits.ServiceConfigApply) + restartRateLimit := restartLimiter.HTTPMiddleware(ratelimit.KeyByIP, func(r *http.Request, key string) { + handlersApi.AuditLog.SettingsAction("", fmt.Sprintf("service-config apply rate limit exceeded from %s", key), utils.GetIP(r)) + }) + + // API: service config. Opt-in via --service-config-enabled. Seeding // YAML into the service_config rows and resolving them at startup // happen either way, so the rows stay the values the services run on // and can be changed directly in the database. @@ -1020,39 +1048,28 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiServiceConfigPath)+"/{service}", handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigServiceHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - // Literal-prefixed like /commands/{command_id}: "{service}/status" would - // conflict with it — neither pattern is more specific than the other, and - // ServeMux panics at registration. muxAPI.Handle( "GET "+_apiPath(apiServiceConfigPath)+"/status/{service}", handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigStatusHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "GET "+_apiPath(apiServiceConfigPath)+"/{service}/{section}", handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigSectionHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - // Rate-limit the restart endpoint to 3 per 10 minutes per IP by default - // — strict enough to prevent brute-forcing restarts, generous enough - // for an operator to retry after a failed restart. Rejections are - // audit-logged so SoC tooling sees attempted abuse. - restartLimiter := ratelimit.NewFromConfig(flagParams.RateLimits.ServiceConfigApply) - restartRateLimit := restartLimiter.HTTPMiddleware(ratelimit.KeyByIP, func(r *http.Request, key string) { - handlersApi.AuditLog.SettingsAction("", fmt.Sprintf("service-config apply rate limit exceeded from %s", key), utils.GetIP(r)) - }) muxAPI.Handle( "PUT "+_apiPath(apiServiceConfigPath)+"/{service}/{section}", handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiServiceConfigPath)+"/apply", restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigApplyHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) - // Persist writes a file rather than restarting anything, but it is the - // same class of privileged, disk-touching operation — same limiter. muxAPI.Handle( "POST "+_apiPath(apiServiceConfigPath)+"/persist", restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigPersistHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) + } - // API: log sinks. Shares the service-config feature gate. The - // apply endpoint reuses the same restart limiter since a log - // sink reload is the same class of privileged, runtime-affecting - // operation as a service-config apply. + // API: log sinks. Independently gated by --log-sinks-enabled. Does + // NOT require --service-config-enabled. The apply endpoint reuses + // the same restart limiter since a log sink reload is the same class + // of privileged operation. + if logSinksEnabled { muxAPI.Handle( "GET "+_apiPath(apiLogSinksPath), handlerAuthCheck(http.HandlerFunc(handlersApi.LogSinksListHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) @@ -1080,8 +1097,12 @@ func osctrlAPIService() { muxAPI.Handle( "POST "+_apiPath(apiLogSinksPath)+"/apply", restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.LogSinksApplyHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) + } - // API: auth providers. Shares the service-config feature gate. + // API: auth providers. Independently gated by + // --auth-providers-enabled. Does NOT require + // --service-config-enabled. + if authProvidersEnabled { muxAPI.Handle( "GET "+_apiPath(apiAuthProvidersPath), handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersListHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) diff --git a/pkg/config/flags.go b/pkg/config/flags.go index acd2b576..5513f402 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -283,6 +283,26 @@ func initServiceFlags(params *ServiceParameters) []cli.Flag { Sources: cli.EnvVars("SERVICE_CONFIG_ENABLED"), Destination: ¶ms.Service.ServiceConfigEnabled, }, + &cli.BoolFlag{ + Name: "log-sinks-enabled", + Value: true, + Usage: "Serve the log-sinks API and show the matching section in the SPA. Independent of --service-config-enabled. Set false to hide the log-sinks management UI.", + Sources: cli.EnvVars("LOG_SINKS_ENABLED"), + Action: func(ctx context.Context, cmd *cli.Command, b bool) error { + params.Service.LogSinksEnabled = &b + return nil + }, + }, + &cli.BoolFlag{ + Name: "auth-providers-enabled", + Value: true, + Usage: "Serve the auth-providers API and show the matching section in the SPA. Independent of --service-config-enabled. Set false to hide the auth-providers management UI.", + Sources: cli.EnvVars("AUTH_PROVIDERS_ENABLED"), + Action: func(ctx context.Context, cmd *cli.Command, b bool) error { + params.Service.AuthProvidersEnabled = &b + return nil + }, + }, &cli.BoolFlag{ Name: "mfa-required", Value: false, diff --git a/pkg/config/types.go b/pkg/config/types.go index 63464e9a..ff4fb9ee 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -134,6 +134,16 @@ type YAMLConfigurationService struct { // in the YAML file, and are picked up on the next restart. Consumed by // osctrl-api; osctrl-tls ignores it. ServiceConfigEnabled bool `yaml:"serviceConfigEnabled"` + // LogSinksEnabled controls whether the log-sinks API and SPA section + // exist. When false, the /api/v1/log-sinks routes are not registered + // and the SPA hides the section. Independent of ServiceConfigEnabled. + // Defaults to true (nil). + LogSinksEnabled *bool `yaml:"logSinksEnabled"` + // AuthProvidersEnabled controls whether the auth-providers API and + // SPA section exist. When false, the /api/v1/auth-providers routes + // are not registered and the SPA hides the section. Independent of + // ServiceConfigEnabled. Defaults to true (nil). + AuthProvidersEnabled *bool `yaml:"authProvidersEnabled"` // MFARequired makes a second authentication factor mandatory for // password logins. Users who have none are sent through enrollment at // their next login instead of being locked out. Service accounts are