Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/api/handlers/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion cmd/api/handlers/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
}

Expand Down
16 changes: 14 additions & 2 deletions cmd/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 41 additions & 20 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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.
Expand All @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
20 changes: 20 additions & 0 deletions pkg/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,26 @@ func initServiceFlags(params *ServiceParameters) []cli.Flag {
Sources: cli.EnvVars("SERVICE_CONFIG_ENABLED"),
Destination: &params.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,
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading