Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 MinIO backend for Theia IDE deploy
type: application

# Chart version - bump for breaking changes
version: 0.2.3
version: 0.2.4

# Application version - matches the cache server version
appVersion: "0.1.0"
Expand Down
7 changes: 5 additions & 2 deletions chart/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ data:
write_timeout: 120s
{{- if .Values.tls.enabled }}
tls:
enabled: true
enabled: true
cert_file: /etc/certs/tls.crt
key_file: /etc/certs/tls.key
{{- end}}
Expand All @@ -31,7 +31,10 @@ data:
enabled: {{ .Values.auth.enabled }}
{{- if .Values.auth.enabled }}
users:
- username: {{ .Values.auth.username | quote }}
- username: {{ .Values.auth.reader.username | quote }}
role: "reader"
- username: {{ .Values.auth.writer.username | quote }}
role: "writer"
{{- end }}

logging:
Expand Down
29 changes: 22 additions & 7 deletions chart/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ spec:
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secrets
name: {{ .Release.Name }}-minio-secrets
key: minio-access-key
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secrets
name: {{ .Release.Name }}-minio-secrets
key: minio-secret-key
containers:
- name: cache-server
Expand All @@ -66,19 +66,34 @@ spec:
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secrets
name: {{ .Release.Name }}-minio-secrets
key: minio-access-key
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secrets
name: {{ .Release.Name }}-minio-secrets
key: minio-secret-key
{{- if .Values.auth.enabled }}
- name: CACHE_PASSWORD
- name: CACHE_READER_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secrets
key: cache-password
name: {{ .Release.Name }}-cache-reader
key: username
- name: CACHE_READER_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cache-reader
key: password
- name: CACHE_WRITER_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cache-writer
key: username
- name: CACHE_WRITER_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cache-writer
key: password
{{- end }}
args:
- --config
Expand Down
34 changes: 30 additions & 4 deletions chart/templates/secrets.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
{{- if .Values.enabled }}
# MinIO storage credentials
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-secrets
name: {{ .Release.Name }}-minio-secrets
labels:
app: {{ .Release.Name }}
type: Opaque
data:
minio-access-key: {{ "minioadmin" | b64enc | quote }}
minio-secret-key: {{ "minioadmin" | b64enc | quote }}
{{- if .Values.auth.enabled }}
cache-password: {{ .Values.auth.password | b64enc | quote }}
{{- end }}
{{- if .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 }}
{{- end }}
19 changes: 14 additions & 5 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"

# Storage configuration
storage:
Expand Down Expand Up @@ -99,4 +108,4 @@ reposilite:
subPath: configuration.shared.json
readOnly: true
- name: plugins
mountPath: /app/data/plugins
mountPath: /app/data/plugins
26 changes: 19 additions & 7 deletions src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"os"
"strings"
"time"

Expand Down Expand Up @@ -51,6 +52,7 @@ type AuthConfig struct {
type UserAuth struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Role string `mapstructure:"role"`
}

type MetricsConfig struct {
Expand Down Expand Up @@ -108,24 +110,34 @@ func Load(configPath string) (*Config, error) {
// Bind specific environment variables
v.BindEnv("storage.access_key", "MINIO_ACCESS_KEY")
v.BindEnv("storage.secret_key", "MINIO_SECRET_KEY")
v.BindEnv("auth.users.0.password", "CACHE_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
}
}
// Override user credentials from environment variables if set
overrideUserFromEnv(&cfg, 0, "CACHE_READER_USERNAME", "CACHE_READER_PASSWORD")
overrideUserFromEnv(&cfg, 1, "CACHE_WRITER_USERNAME", "CACHE_WRITER_PASSWORD")

return &cfg, nil
}

// overrideUserFromEnv overrides username and password for a specific user index
// from environment variables, if they are set.
func overrideUserFromEnv(cfg *Config, index int, usernameEnv, passwordEnv string) {
if index >= len(cfg.Auth.Users) {
return
}
if val := os.Getenv(usernameEnv); val != "" {
cfg.Auth.Users[index].Username = val
}
if val := os.Getenv(passwordEnv); val != "" {
cfg.Auth.Users[index].Password = val
}
}

func (c *Config) Validate() error {
if c.Storage.Endpoint == "" {
return fmt.Errorf("storage.endpoint is required")
Expand Down
35 changes: 29 additions & 6 deletions src/internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,21 @@ import (
"github.com/kevingruber/gradle-cache/internal/config"
)

// BasicAuth creates a middleware that validates HTTP Basic Authentication.
type userCredential struct {
Password string
Role string
}

// 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 BasicAuth(users []config.UserAuth) gin.HandlerFunc {
// Build a map for O(1) lookup
credentials := make(map[string]string, len(users))
credentials := make(map[string]userCredential, len(users))
for _, user := range users {
credentials[user.Username] = user.Password
credentials[user.Username] = userCredential{
Password: user.Password,
Role: user.Role,
}
}

return func(c *gin.Context) {
Expand All @@ -24,22 +33,36 @@ func BasicAuth(users []config.UserAuth) gin.HandlerFunc {
return
}

expectedPassword, userExists := credentials[username]
cred, userExists := credentials[username]
if !userExists {
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 {
if subtle.ConstantTimeCompare([]byte(password), []byte(cred.Password)) != 1 {
c.Header("WWW-Authenticate", `Basic realm="Gradle Build Cache"`)
c.AbortWithStatus(http.StatusUnauthorized)
return
}

// Store username in context for logging/metrics
// Store username and role in context for logging/metrics and authorization
c.Set("username", username)
c.Set("role", cred.Role)
c.Next()
}
}

// RequireRole creates a middleware that checks if the authenticated user
// has the required role. Returns 403 Forbidden if not.
func RequireRole(requiredRole string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists || role.(string) != requiredRole {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
}
}
9 changes: 8 additions & 1 deletion src/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,16 @@ func (s *Server) setupRoutes() {
cacheGroup.Use(middleware.BasicAuth(s.cfg.Auth.Users))
}

// GET and HEAD are accessible to all authenticated users (reader + writer)
cacheGroup.GET("/:key", cacheHandler.Get)
cacheGroup.PUT("/:key", cacheHandler.Put)
cacheGroup.HEAD("/:key", cacheHandler.Head)

// PUT requires the "writer" role
writeGroup := cacheGroup.Group("")
if s.cfg.Auth.Enabled {
writeGroup.Use(middleware.RequireRole("writer"))
}
writeGroup.PUT("/:key", cacheHandler.Put)
}

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