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
16 changes: 10 additions & 6 deletions cmd/api/handlers/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (

// FeaturesResponse advertises server-side feature switches consumed by the SPA.
type FeaturesResponse struct {
Posture bool `json:"posture"`
Accelerated bool `json:"accelerated"`
FileExplorer bool `json:"file_explorer"`
Posture bool `json:"posture"`
// ServiceConfig gates the whole Service Config section in the SPA. When
// false the /api/v1/service-config routes are not registered at all.
ServiceConfig bool `json:"service_config"`
Accelerated bool `json:"accelerated"`
FileExplorer bool `json:"file_explorer"`
}

// FeaturesHandler — GET /api/v1/features.
Expand All @@ -19,8 +22,9 @@ func (h *HandlersApi) FeaturesHandler(w http.ResponseWriter, r *http.Request) {
utils.DebugHTTPDump(h.DebugHTTP, r, false)
}
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, FeaturesResponse{
Posture: h.PostureEnabled,
Accelerated: h.OsqueryValues.Accelerated,
FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.Accelerated && h.OsqueryValues.FileExplorer,
Posture: h.PostureEnabled,
ServiceConfig: h.ServiceConfigEnabled,
Accelerated: h.OsqueryValues.Accelerated,
FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.Accelerated && h.OsqueryValues.FileExplorer,
})
}
26 changes: 26 additions & 0 deletions cmd/api/handlers/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ func TestFeaturesHandlerReportsPostureDisabledByDefault(t *testing.T) {
}
}

func TestFeaturesHandlerReportsServiceConfigEnabled(t *testing.T) {
h := &HandlersApi{}
r := httptest.NewRequest(http.MethodGet, "/api/v1/features", nil)
w := httptest.NewRecorder()
h.FeaturesHandler(w, r)

var off FeaturesResponse
if err := json.Unmarshal(w.Body.Bytes(), &off); err != nil {
t.Fatalf("decode: %v", err)
}
if off.ServiceConfig {
t.Fatalf("service config feature: got true want false")
}

WithServiceConfigEnabled(true)(h)
w = httptest.NewRecorder()
h.FeaturesHandler(w, r)
var on FeaturesResponse
if err := json.Unmarshal(w.Body.Bytes(), &on); err != nil {
t.Fatalf("decode: %v", err)
}
if !on.ServiceConfig {
t.Fatalf("service config feature: got false want true")
}
}

func TestFeaturesHandlerReportsPostureEnabled(t *testing.T) {
h := &HandlersApi{}
WithPostureEnabled(true)(h)
Expand Down
60 changes: 35 additions & 25 deletions cmd/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,35 @@ type HandlersApi struct {
// writes to the DB, this is the legacy GORM-backed reader. nil falls
// back to h.DB via NewDBLogReader at call time so existing tests
// that only wire WithDB keep working.
LogReader logging.LogReader
Users *users.UserManager
Tags *tags.TagManager
Envs *environments.EnvManager
EnvCache *environments.EnvCache
Nodes *nodes.NodeManager
Queries *queries.Queries
Console *console.Manager
FileExplorer *fileexplorer.Manager
Carves *carves.Carves
Settings *settings.Settings
ServiceConfig *serviceconfig.ServiceConfigManager
ServiceCommands *servicecommands.Manager
Activity activityReader
GeoIP *geoip.GeoIPResolver
Posture *posture.PostureManager
PostureEnabled bool
ServiceVersion string
ServiceName string
AuditLog *auditlog.AuditLogManager
ApiConfig *config.APIConfiguration
DebugHTTP *zerolog.Logger
DebugHTTPConfig *config.YAMLConfigurationDebug
OsqueryTables []types.OsqueryTable
OsqueryValues config.YAMLConfigurationOsquery
LogReader logging.LogReader
Users *users.UserManager
Tags *tags.TagManager
Envs *environments.EnvManager
EnvCache *environments.EnvCache
Nodes *nodes.NodeManager
Queries *queries.Queries
Console *console.Manager
FileExplorer *fileexplorer.Manager
Carves *carves.Carves
Settings *settings.Settings
ServiceConfig *serviceconfig.ServiceConfigManager
// ServiceConfigEnabled mirrors service.serviceConfigEnabled. When false
// the service-config routes are never registered and the SPA hides the
// section; the rows are still seeded and resolved at every boot.
ServiceConfigEnabled bool
ServiceCommands *servicecommands.Manager
Activity activityReader
GeoIP *geoip.GeoIPResolver
Posture *posture.PostureManager
PostureEnabled bool
ServiceVersion string
ServiceName string
AuditLog *auditlog.AuditLogManager
ApiConfig *config.APIConfiguration
DebugHTTP *zerolog.Logger
DebugHTTPConfig *config.YAMLConfigurationDebug
OsqueryTables []types.OsqueryTable
OsqueryValues config.YAMLConfigurationOsquery
// JWTSecret is the HMAC key used by pkg/auth state-cookie
// helpers. Populated via WithJWTSecret at handler init. Same
// bytes the Users manager signs user JWTs with; the auth
Expand Down Expand Up @@ -208,6 +212,12 @@ func WithPostureEnabled(enabled bool) HandlersOption {
}
}

func WithServiceConfigEnabled(enabled bool) HandlersOption {
return func(h *HandlersApi) {
h.ServiceConfigEnabled = enabled
}
}

func WithVersion(version string) HandlersOption {
return func(h *HandlersApi) {
h.ServiceVersion = version
Expand Down
86 changes: 48 additions & 38 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@ func osctrlAPIService() {
if err := serviceConfigMgr.ReportFile(config.ServiceAPI, flagParams.ConfigFilePath()); err != nil {
log.Err(err).Msg("Error reporting service config file status")
}
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 flagParams.RateLimits == nil {
flagParams.RateLimits = config.DefaultRateLimitsPtr()
}
Expand Down Expand Up @@ -483,6 +486,7 @@ func osctrlAPIService() {
handlers.WithCarves(filecarves),
handlers.WithSettings(settingsmgr),
handlers.WithServiceConfig(serviceConfigMgr),
handlers.WithServiceConfigEnabled(flagParams.Service.ServiceConfigEnabled),
handlers.WithServiceCommands(serviceCommandMgr),
handlers.WithConfigPersist(persistConfig),
handlers.WithActivityReader(activity.NewRedisStore(redis.Client, activity.DefaultPrefix, activity.DefaultRetentionDays, 8*24*time.Hour)),
Expand Down Expand Up @@ -934,44 +938,50 @@ func osctrlAPIService() {
muxAPI.Handle(
"PATCH "+_apiPath(apiSettingsPath)+"/{service}/{name}",
handlerAuthCheck(http.HandlerFunc(handlersApi.SettingPatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
// API: service config (phase 1 read + phase 2 editable PUT + apply)
// 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(
"GET "+_apiPath(apiServiceConfigPath),
handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
muxAPI.Handle(
"GET "+_apiPath(apiServiceConfigPath)+"/commands/{command_id}",
handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceCommandHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
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))
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: service config. The whole surface is opt-in: with
// --service-config-enabled off, none of these routes exist. 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.
if flagParams.Service.ServiceConfigEnabled {
muxAPI.Handle(
"GET "+_apiPath(apiServiceConfigPath),
handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
muxAPI.Handle(
"GET "+_apiPath(apiServiceConfigPath)+"/commands/{command_id}",
handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceCommandHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))
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: audit log
if flagParams.Service.AuditLog {
muxAPI.Handle(
Expand Down
9 changes: 9 additions & 0 deletions deploy/config/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ service:
# in the environment and is intended for local-dev only — it impersonates
# super-admin on every request. Production deployments MUST use `jwt`.
auth: jwt
# Serve /api/v1/service-config and show the Service Config section in
# the SPA, where sections can be read, edited, written back to this file
# and applied with a restart. This switch does not change how config is
# loaded: every boot seeds the sections of this file into the
# service_config table and then resolves those rows back over them, so
# the service always runs on the stored values. When false (default)
# none of the routes are registered and the SPA hides the section —
# change the rows directly in the database, or this file, and restart.
serviceConfigEnabled: false
# Write security-relevant API actions to audit_logs.
auditLog: true
# Comma-separated CIDR list whose X-Real-IP / X-Forwarded-For headers
Expand Down
6 changes: 6 additions & 0 deletions deploy/config/tls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ service:
host: osctrl.net
# Valid value: "none". osquery authentication uses enroll secrets and node_key.
auth: none
# Only used by osctrl-api, which serves the service-config endpoints;
# kept here so the service configuration shape is complete and can
# round-trip through the API. osctrl-tls always seeds the sections of
# this file into the service_config table and resolves those rows back
# at startup, so it runs on the stored values either way.
serviceConfigEnabled: false
# Write security-relevant TLS activity, such as enroll failures, to audit_logs.
auditLog: true
# Comma-separated CIDR list whose X-Real-IP / X-Forwarded-For headers
Expand Down
2 changes: 2 additions & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ services:
- LOGGER_DB_SAME=true
- SERVICE_LOG_FORMAT=console
- SERVICE_POSTURE_ENABLED=true
- SERVICE_CONFIG_ENABLED=true
#### Database settings ####
- DB_HOST=osctrl-postgres
- DB_NAME=${POSTGRES_DB_NAME}
Expand Down Expand Up @@ -103,6 +104,7 @@ services:
- SERVICE_LOGGER=db
- SERVICE_LOG_FORMAT=console
- SERVICE_POSTURE_ENABLED=true
- SERVICE_CONFIG_ENABLED=true
#### osquery settings ####
- OSQUERY_TABLES=/usr/src/app/deploy/osquery/data/${OSQUERY_VERSION}.json
- OSQUERY_ACCELERATED=true
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { apiFetch } from './client';

export interface Features {
posture: boolean;
service_config: boolean;
accelerated: boolean;
file_explorer: boolean;
}
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/components/chrome/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Logo } from '$/components/atoms/Logo';
import { EnvSwitcher } from './EnvSwitcher';
import { listEnvironments } from '$/api/environments';
import { getMe } from '$/api/users';
import { getFeatures } from '$/api/features';
import type { EnvAccess } from '$/api/types';

interface NavItemProps {
Expand Down Expand Up @@ -110,6 +111,14 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps
retry: 1,
});
const isSuperAdmin = me?.admin === true;
// Service Config is opt-in server-side (service.serviceConfigEnabled).
// When it is off the endpoints do not exist, so hide the entry rather
// than link to a page that can only fail.
const { data: features } = useQuery({
queryKey: ['features'],
queryFn: () => getFeatures(),
staleTime: 5 * 60_000,
});
// currentEnv is the SPA's name-of-env; permissions are keyed by
// env UUID. We need to translate name → UUID via the envs list.
// Fall back to "no access" when the lookup hasn't resolved yet.
Expand Down Expand Up @@ -422,7 +431,7 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps
>
Settings
</NavItem>
<NavItem
{features?.service_config && <NavItem
collapsed={collapsed}
active={isServiceConfigActive}
to="/_app/config/api"
Expand All @@ -434,7 +443,7 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps
}
>
Service Config
</NavItem>
</NavItem>}
</nav>
</>
) : (
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/features/environments/EnvConfigPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe('EnvConfigPage', () => {
data: '{"options":{"logger_plugin":"tls"}}',
});
mockGetPostureProfiles.mockResolvedValue([]);
mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false, file_explorer: false });
mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false });
});

it('loads the fully rendered tab from the assembled config endpoint', async () => {
Expand Down Expand Up @@ -224,7 +224,7 @@ describe('EnvConfigPage', () => {

it('loads posture profiles only after opening the picker', async () => {
const user = userEvent.setup();
mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false });
mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false });

renderWithProviders();

Expand All @@ -243,7 +243,7 @@ describe('EnvConfigPage', () => {

it('distinguishes a profile load failure from an empty profile list', async () => {
const user = userEvent.setup();
mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false });
mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false });
mockGetPostureProfiles.mockRejectedValue(new Error('profiles unavailable'));

renderWithProviders();
Expand Down
Loading
Loading