Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion chart/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: A Gradle Build Cache server with Redis backend for Theia IDE deploy
type: application

# Chart version - bump for breaking changes
version: 0.3.0
version: 0.3.1

# Application version - matches the cache server version
appVersion: "0.1.0"
Expand Down
27 changes: 27 additions & 0 deletions chart/templates/auth-secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{{- if .Values.enabled }}
# Read-only cache credentials (for Theia IDE / students)
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-cache-reader
labels:
app: {{ .Release.Name }}
role: reader
type: kubernetes.io/basic-auth
data:
username: {{ .Values.auth.reader.username | b64enc | quote }}
password: {{ .Values.auth.reader.password | b64enc | quote }}
---
# Read-write cache credentials (for CI/CD, admin, cache pre-warming)
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-cache-writer
labels:
app: {{ .Release.Name }}
role: writer
type: kubernetes.io/basic-auth
data:
username: {{ .Values.auth.writer.username | b64enc | quote }}
password: {{ .Values.auth.writer.password | b64enc | quote }}
{{- end }}
6 changes: 4 additions & 2 deletions chart/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ data:
auth:
enabled: {{ .Values.auth.enabled }}
{{- if .Values.auth.enabled }}
users:
- username: {{ .Values.auth.username | quote }}
reader:
username: {{ .Values.auth.reader.username | quote }}
writer:
username: {{ .Values.auth.writer.username | quote }}
{{- end }}

logging:
Expand Down
11 changes: 8 additions & 3 deletions chart/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,16 @@ spec:
name: {{ .Release.Name }}-redis-secret
key: redis-password
{{- if .Values.auth.enabled }}
- name: CACHE_PASSWORD
- name: CACHE_READER_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-redis-secret
key: cache-password
name: {{ .Release.Name }}-cache-reader
key: password
- name: CACHE_WRITER_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cache-writer
key: password
{{- end }}
args:
- --config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,4 @@ data:
{{- else }}
redis-password: {{ randAlphaNum 32 | b64enc}}
{{- end }}
cache-password: {{ .Values.auth.password | b64enc | quote}}
{{- end }}
17 changes: 13 additions & 4 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ image:
tag: "main"
pullPolicy: IfNotPresent

# Authentication credentials
# Authentication credentials (role-based)
# reader: read-only access (for Theia IDE / students)
# writer: read-write access (for CI/CD, admin, cache pre-warming)
auth:
enabled: true
username: "gradle"
# IMPORTANT: Change this password in production!
password: "changeme"
reader:
username: "reader"
# IMPORTANT: Change this password in production!
# optimaly from github envrionment secrets
password: "changeme-reader"
writer:
username: "writer"
# IMPORTANT: Change this password in production!
# optimaly from github envrionment secrets
password: "changeme-writer"

# Resource limits
resources:
Expand Down
29 changes: 11 additions & 18 deletions src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ type CacheConfig struct {
}

type AuthConfig struct {
Enabled bool `mapstructure:"enabled"`
Users []UserAuth `mapstructure:"users"`
Enabled bool `mapstructure:"enabled"`
Reader UserAuth `mapstructure:"reader"`
Writer UserAuth `mapstructure:"writer"`
}

type UserAuth struct {
Expand Down Expand Up @@ -106,37 +107,29 @@ func Load(configPath string) (*Config, error) {
// Bind specific environment variables
v.BindEnv("storage.password", "REDIS_PASSWORD")

v.BindEnv("auth.users.0.password", "CACHE_PASSWORD")
v.BindEnv("auth.reader.password", "CACHE_READER_PASSWORD")
v.BindEnv("auth.writer.password", "CACHE_WRITER_PASSWORD")

v.BindEnv("sentry.dsn", "SENTRY_DSN")

var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}

// Handle CACHE_PASSWORD environment variable for default user
if cachePassword := v.GetString("CACHE_PASSWORD"); cachePassword != "" {
if len(cfg.Auth.Users) > 0 {
cfg.Auth.Users[0].Password = cachePassword
}
}

return &cfg, nil
}

func (c *Config) Validate() error {
if c.Storage.Addr == "" {
return fmt.Errorf("storage.addr is required")
}
if c.Auth.Enabled && len(c.Auth.Users) == 0 {
return fmt.Errorf("auth.users is required when auth is enabled")
}
for i, user := range c.Auth.Users {
if user.Username == "" {
return fmt.Errorf("auth.users[%d].username is required", i)
if c.Auth.Enabled {
if c.Auth.Reader.Username == "" || c.Auth.Reader.Password == "" {
return fmt.Errorf("auth.reader.username and auth.reader.password are required when auth is enabled")
}
if user.Password == "" {
return fmt.Errorf("auth.users[%d].password is required", i)
if c.Auth.Writer.Username == "" || c.Auth.Writer.Password == "" {
return fmt.Errorf("auth.writer.username and auth.writer.password are required when auth is enabled")
}
}
if c.Server.TLS.Enabled {
Expand Down
27 changes: 12 additions & 15 deletions src/internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,9 @@ import (
"github.com/kevingruber/gradle-cache/internal/config"
)

// BasicAuth creates a middleware that validates HTTP Basic Authentication.
func BasicAuth(users []config.UserAuth) gin.HandlerFunc {
// Build a map for O(1) lookup
credentials := make(map[string]string, len(users))
for _, user := range users {
credentials[user.Username] = user.Password
}
// BasicAuth creates a middleware that validates HTTP Basic Authentication
// and stores the user's role in the gin context.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stale doc comment references BasicAuth instead of CacheAuth.

Fix
-// BasicAuth creates a middleware that validates HTTP Basic Authentication
-// and stores the user's role in the gin context.
+// CacheAuth creates a middleware that validates HTTP Basic Authentication
+// with role-based access control (reader/writer).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// BasicAuth creates a middleware that validates HTTP Basic Authentication
// and stores the user's role in the gin context.
// CacheAuth creates a middleware that validates HTTP Basic Authentication
// with role-based access control (reader/writer).
🤖 Prompt for AI Agents
In `@src/internal/middleware/auth.go` around lines 11 - 12, Update the stale doc
comment that references BasicAuth to correctly describe CacheAuth: find the
top-of-file or above the CacheAuth function declaration (symbol CacheAuth) in
auth.go and replace "BasicAuth" with "CacheAuth" and adjust wording if necessary
so the comment accurately describes that the middleware validates cached
authentication and stores the user's role in the gin context.

func CacheAuth(auth config.AuthConfig, requireWriter bool) gin.HandlerFunc {

return func(c *gin.Context) {
username, password, ok := c.Request.BasicAuth()
Expand All @@ -24,22 +20,23 @@ func BasicAuth(users []config.UserAuth) gin.HandlerFunc {
return
}

expectedPassword, userExists := credentials[username]
if !userExists {
// Check credentials
isReader := username == auth.Reader.Username &&
subtle.ConstantTimeCompare([]byte(password), []byte(auth.Reader.Password)) == 1
isWriter := username == auth.Writer.Username &&
subtle.ConstantTimeCompare([]byte(password), []byte(auth.Writer.Password)) == 1

if !isReader && !isWriter {
c.Header("WWW-Authenticate", `Basic realm="Gradle Build Cache"`)
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// Use constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) != 1 {
c.Header("WWW-Authenticate", `Basic realm="Gradle Build Cache"`)
c.AbortWithStatus(http.StatusUnauthorized)
if requireWriter && !isWriter {
c.AbortWithStatus(http.StatusForbidden)
return
}

// Store username in context for logging/metrics
c.Set("username", username)
c.Next()
}
}
18 changes: 12 additions & 6 deletions src/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,19 @@ func (s *Server) setupRoutes() {

// Create cache group with optional auth
cacheGroup := s.router.Group("/cache")
if s.cfg.Auth.Enabled {
cacheGroup.Use(middleware.BasicAuth(s.cfg.Auth.Users))
}

cacheGroup.GET("/:key", cacheHandler.Get)
cacheGroup.PUT("/:key", cacheHandler.Put)
cacheGroup.HEAD("/:key", cacheHandler.Head)
cacheGroup.GET("/:key", s.cacheAuth(false), cacheHandler.Get)
cacheGroup.HEAD("/:key", s.cacheAuth(false), cacheHandler.Head)
cacheGroup.PUT("/:key", s.cacheAuth(true), cacheHandler.Put)
}

func (s *Server) cacheAuth(requireWriter bool) gin.HandlerFunc {
if !s.cfg.Auth.Enabled {
return func(c *gin.Context) {
c.Next()
}
}
return middleware.CacheAuth(s.cfg.Auth, requireWriter)
}

// handlePing is a simple health check endpoint.
Expand Down