diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 4460d00..98ad6c5 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -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" diff --git a/chart/templates/auth-secrets.yaml b/chart/templates/auth-secrets.yaml new file mode 100644 index 0000000..9291768 --- /dev/null +++ b/chart/templates/auth-secrets.yaml @@ -0,0 +1,27 @@ +{{- if and .Values.enabled .Values.auth.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 }} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 18c19f2..bef45c2 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -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: diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index d796058..b5ee6bb 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -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 diff --git a/chart/templates/secrets.yaml b/chart/templates/redis-secret.yaml similarity index 90% rename from chart/templates/secrets.yaml rename to chart/templates/redis-secret.yaml index 6dfc22c..7b62ecd 100644 --- a/chart/templates/secrets.yaml +++ b/chart/templates/redis-secret.yaml @@ -15,5 +15,4 @@ data: {{- else }} redis-password: {{ randAlphaNum 32 | b64enc}} {{- end }} - cache-password: {{ .Values.auth.password | b64enc | quote}} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 594b01a..fead370 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -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: diff --git a/src/internal/config/config.go b/src/internal/config/config.go index ef37078..d797ce3 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -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 { @@ -106,7 +107,9 @@ 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 @@ -114,13 +117,6 @@ func Load(configPath string) (*Config, error) { 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 } @@ -128,15 +124,12 @@ 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 { diff --git a/src/internal/middleware/auth.go b/src/internal/middleware/auth.go index 2c63288..df86b48 100644 --- a/src/internal/middleware/auth.go +++ b/src/internal/middleware/auth.go @@ -8,13 +8,8 @@ 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 - } +// CacheAuth creates a middleware that validates HTTP Basic Authentication +func CacheAuth(auth config.AuthConfig, requireWriter bool) gin.HandlerFunc { return func(c *gin.Context) { username, password, ok := c.Request.BasicAuth() @@ -24,22 +19,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() } } diff --git a/src/internal/server/server.go b/src/internal/server/server.go index 7d024f1..e92a2f8 100644 --- a/src/internal/server/server.go +++ b/src/internal/server/server.go @@ -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.