Skip to content

Commit c64166d

Browse files
committed
feat(api): Add endpoint-level authentication and visibility control
Implement endpoint-level authentication mode where authentication is optional but filters visible data based on public/private status: **API Changes:** - Add global vs endpoint-level auth routing in api.go - Update EndpointStatuses and EndpointStatus handlers with auth checks - Add cache key separation for public/private data - Update badge endpoints to respect visibility **Configuration:** - Add Public field to Endpoint, ExternalEndpoint, Result, and Status - Add EndpointStatusVisibility DTO for visibility management - Extend security config with Level field (global/endpoint) - Implement IsAuthenticated() for Basic Auth - Add IsGlobal() helper method **Features:** - Routes conditionally protected based on security.level - Unauthenticated users see only public endpoints - Authenticated users see all endpoints - Cache keys include auth status to prevent data leakage
1 parent 0a746a9 commit c64166d

File tree

9 files changed

+143
-19
lines changed

9 files changed

+143
-19
lines changed

api/api.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,18 +118,33 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
118118
// PROTECTED ROUTES //
119119
//////////////////////
120120
// ORDER IS IMPORTANT: all routes applied AFTER the security middleware will require authn
121-
protectedAPIRouter := apiRouter.Group("/")
122121
if cfg.Security != nil {
123122
if err := cfg.Security.RegisterHandlers(app); err != nil {
124123
panic(err)
125124
}
126-
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
127-
panic(err)
125+
if cfg.Security.IsGlobal() {
126+
// Global auth: protect all API routes with middleware
127+
protectedAPIRouter := apiRouter.Group("/")
128+
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
129+
panic(err)
130+
}
131+
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
132+
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
133+
protectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
134+
protectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
135+
} else {
136+
// Endpoint-level auth: routes are accessible but data is filtered
137+
unprotectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
138+
unprotectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
139+
unprotectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
140+
unprotectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
128141
}
142+
} else {
143+
// No security: all routes accessible
144+
unprotectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
145+
unprotectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
146+
unprotectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
147+
unprotectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
129148
}
130-
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
131-
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
132-
protectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
133-
protectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
134149
return app
135150
}

api/badge.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ func HealthBadge(c *fiber.Ctx) error {
119119
return c.Status(400).SendString("invalid key encoding")
120120
}
121121
pagingConfig := paging.NewEndpointStatusParams()
122-
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
122+
status, err := store.Get().GetEndpointStatusByKey(key, true, pagingConfig.WithResults(1, 1))
123+
123124
if err != nil {
124125
if errors.Is(err, common.ErrEndpointNotFound) {
125126
return c.Status(404).SendString(err.Error())
@@ -148,7 +149,7 @@ func HealthBadgeShields(c *fiber.Ctx) error {
148149
return c.Status(400).SendString("invalid key encoding")
149150
}
150151
pagingConfig := paging.NewEndpointStatusParams()
151-
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
152+
status, err := store.Get().GetEndpointStatusByKey(key, true, pagingConfig.WithResults(1, 1))
152153
if err != nil {
153154
if errors.Is(err, common.ErrEndpointNotFound) {
154155
return c.Status(404).SendString(err.Error())

api/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,21 @@ type ConfigHandler struct {
1616

1717
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
1818
hasOIDC := false
19+
hasBasic := false
20+
authLevel := ""
1921
isAuthenticated := true // Default to true if no security config is set
2022
if handler.securityConfig != nil {
2123
hasOIDC = handler.securityConfig.OIDC != nil
24+
hasBasic = handler.securityConfig.Basic != nil
25+
authLevel = handler.securityConfig.Level
2226
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
2327
}
2428

2529
// Prepare response with announcements
2630
response := map[string]interface{}{
2731
"oidc": hasOIDC,
32+
"basic": hasBasic,
33+
"authLevel": authLevel,
2834
"authenticated": isAuthenticated,
2935
}
3036
// Add announcements if available, otherwise use empty slice

api/endpoint_status.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ import (
2121
// Due to how intensive this operation can be on the storage, this function leverages a cache.
2222
func EndpointStatuses(cfg *config.Config) fiber.Handler {
2323
return func(c *fiber.Ctx) error {
24+
// If no security is configured, user is considered authenticated (full access)
25+
// If security is configured at endpoint level, check authentication status
26+
userAuthenticated := cfg.Security == nil || cfg.Security.IsAuthenticated(c)
2427
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
25-
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
28+
// Include authentication status in cache key to separate public/private cached results
29+
cacheKey := fmt.Sprintf("endpoint-status-%d-%d-auth-%t", page, pageSize, userAuthenticated)
30+
value, exists := cache.Get(cacheKey)
2631
var data []byte
2732
if !exists {
28-
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
33+
endpointStatuses, err := store.Get().GetAllEndpointStatuses(!userAuthenticated, paging.NewEndpointStatusParams().WithResults(page, pageSize))
2934
if err != nil {
3035
logr.Errorf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
3136
return c.Status(500).SendString(err.Error())
@@ -42,7 +47,7 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
4247
logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
4348
return c.Status(500).SendString("unable to marshal object to JSON")
4449
}
45-
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
50+
cache.SetWithTTL(cacheKey, data, cacheTTL)
4651
} else {
4752
data = value.([]byte)
4853
}
@@ -86,13 +91,14 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
8691
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name
8792
func EndpointStatus(cfg *config.Config) fiber.Handler {
8893
return func(c *fiber.Ctx) error {
94+
userAuthenticated := cfg.Security == nil || cfg.Security.IsAuthenticated(c)
8995
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
9096
key, err := url.QueryUnescape(c.Params("key"))
9197
if err != nil {
9298
logr.Errorf("[api.EndpointStatus] Failed to decode key: %s", err.Error())
9399
return c.Status(400).SendString("invalid key encoding")
94100
}
95-
endpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
101+
endpointStatus, err := store.Get().GetEndpointStatusByKey(key, !userAuthenticated, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
96102
if err != nil {
97103
if errors.Is(err, common.ErrEndpointNotFound) {
98104
return c.Status(404).SendString(err.Error())

config/endpoint/endpoint.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ type Endpoint struct {
137137
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
138138
LastReminderSent time.Time `yaml:"-"`
139139

140+
// Public mark an endpoint as public, unauthenticated users will be able to see them
141+
Public bool `yaml:"public,omitempty"`
142+
140143
///////////////////////
141144
// SUITE-ONLY FIELDS //
142145
///////////////////////
@@ -288,7 +291,7 @@ func (e *Endpoint) EvaluateHealth() *Result {
288291

289292
// EvaluateHealthWithContext sends a request to the endpoint's URL with context support and evaluates the conditions
290293
func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
291-
result := &Result{Success: true, Errors: []string{}}
294+
result := &Result{Success: true, Errors: []string{}, Public: e.Public}
292295
// Preprocess the endpoint with context if provided
293296
processedEndpoint := e
294297
if context != nil {

config/endpoint/external_endpoint.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ type ExternalEndpoint struct {
4848

4949
// NumberOfSuccessesInARow is the number of successful evaluations in a row
5050
NumberOfSuccessesInARow int `yaml:"-"`
51+
52+
// Public mark an endpoint as public, unauthenticated users will be able to see them
53+
Public bool `yaml:"public,omitempty"`
5154
}
5255

5356
// ValidateAndSetDefaults validates the ExternalEndpoint and sets the default values

config/endpoint/result.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ type Result struct {
6161
// Name of the endpoint (ONLY USED FOR SUITES)
6262
// Group is not needed because it's inherited from the suite
6363
Name string `json:"name,omitempty"`
64+
65+
// Public mark an endpoint as public, unauthenticated users will be able to see them
66+
Public bool
6467
}
6568

6669
// AddError adds an error to the result's list of errors.

config/endpoint/status.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ type Status struct {
2020
// Events is a list of events
2121
Events []*Event `json:"events,omitempty"`
2222

23+
// Public mark an endpoint as public, unauthenticated users will be able to see them
24+
Public bool `yaml:"public,omitempty"`
25+
2326
// Uptime information on the endpoint's uptime
2427
//
2528
// Used by the memory store.
@@ -29,13 +32,30 @@ type Status struct {
2932
}
3033

3134
// NewStatus creates a new Status
32-
func NewStatus(group, name string) *Status {
35+
func NewStatus(group, name string, public bool) *Status {
3336
return &Status{
3437
Name: name,
3538
Group: group,
3639
Key: key.ConvertGroupAndNameToKey(group, name),
3740
Results: make([]*Result, 0),
3841
Events: make([]*Event, 0),
42+
Public: public,
3943
Uptime: NewUptime(),
4044
}
4145
}
46+
47+
48+
// EndpointStatusVisibility is a DTO used to manage an EndpointStatus visibility
49+
type EndpointStatusVisibility struct {
50+
// Key of the Endpoint
51+
Key string
52+
// Public mark an endpoint as public, unauthenticated users will be able to see them
53+
Public bool
54+
}
55+
56+
func NewEndpointStatusVisibility(key string, public bool) *EndpointStatusVisibility {
57+
return &EndpointStatusVisibility{
58+
Key: key,
59+
Public: public,
60+
}
61+
}

security/config.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,37 @@ const (
1818
cookieNameSession = "gatus_session"
1919
)
2020

21+
const (
22+
authLevelGlobal = "global"
23+
authLevelEndpoint = "endpoint"
24+
)
25+
2126
// Config is the security configuration for Gatus
2227
type Config struct {
2328
Basic *BasicConfig `yaml:"basic,omitempty"`
24-
OIDC *OIDCConfig `yaml:"oidc,omitempty"`
25-
29+
OIDC *OIDCConfig `yaml:"oidc,omitempty"`
30+
Level string `yaml:"level,omitempty"`
31+
2632
gate *g8.Gate
2733
}
2834

2935
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
3036
func (c *Config) ValidateAndSetDefaults() bool {
31-
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
37+
basicValid := c.Basic != nil && c.Basic.isValid()
38+
oauthValid := c.OIDC != nil && c.OIDC.ValidateAndSetDefaults()
39+
40+
// Set default level
41+
if c.Level == "" {
42+
c.Level = authLevelGlobal
43+
}
44+
levelValid := c.Level == authLevelGlobal || c.Level == authLevelEndpoint
45+
46+
return levelValid && (basicValid || oauthValid)
47+
}
48+
49+
// IsGlobal tells wether the auth is global or at endpoint level
50+
func (c *Config) IsGlobal() bool {
51+
return c.Level == authLevelGlobal
3252
}
3353

3454
// RegisterHandlers registers all handlers required based on the security configuration
@@ -96,7 +116,7 @@ func (c *Config) ApplySecurityMiddleware(router fiber.Router) error {
96116
// If the Config does not warrant authentication, it will always return true.
97117
func (c *Config) IsAuthenticated(ctx *fiber.Ctx) bool {
98118
if c.gate != nil {
99-
// TODO: Update g8 to support fasthttp natively? (see g8's fasthttp branch)
119+
// OIDC authentication check
100120
request, err := adaptor.ConvertRequest(ctx, false)
101121
if err != nil {
102122
logr.Errorf("[security.IsAuthenticated] Unexpected error converting request: %v", err)
@@ -105,6 +125,53 @@ func (c *Config) IsAuthenticated(ctx *fiber.Ctx) bool {
105125
token := c.gate.ExtractTokenFromRequest(request)
106126
_, hasSession := sessions.Get(token)
107127
return hasSession
128+
} else if c.Basic != nil {
129+
// Basic Auth authentication check
130+
authHeader := ctx.Get("Authorization")
131+
if authHeader == "" {
132+
return false
133+
}
134+
// Parse the Basic Auth header manually (fasthttp doesn't have BasicAuth() helper)
135+
// Format: "Basic base64(username:password)"
136+
const prefix = "Basic "
137+
if len(authHeader) < len(prefix) {
138+
return false
139+
}
140+
if authHeader[:len(prefix)] != prefix {
141+
return false
142+
}
143+
// Decode the base64 credentials
144+
decoded, err := base64.StdEncoding.DecodeString(authHeader[len(prefix):])
145+
if err != nil {
146+
return false
147+
}
148+
// Split username:password
149+
credentials := string(decoded)
150+
colonIndex := -1
151+
for i := 0; i < len(credentials); i++ {
152+
if credentials[i] == ':' {
153+
colonIndex = i
154+
break
155+
}
156+
}
157+
if colonIndex == -1 {
158+
return false
159+
}
160+
username := credentials[:colonIndex]
161+
password := credentials[colonIndex+1:]
162+
163+
// Validate credentials
164+
var decodedBcryptHash []byte
165+
if len(c.Basic.PasswordBcryptHashBase64Encoded) > 0 {
166+
decodedBcryptHash, err = base64.URLEncoding.DecodeString(c.Basic.PasswordBcryptHashBase64Encoded)
167+
if err != nil {
168+
return false
169+
}
170+
if username != c.Basic.Username || bcrypt.CompareHashAndPassword(decodedBcryptHash, []byte(password)) != nil {
171+
return false
172+
}
173+
return true
174+
}
108175
}
109176
return false
110177
}

0 commit comments

Comments
 (0)