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
31 changes: 26 additions & 5 deletions cmd/api/api/builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe

// Parse multipart form fields
var sourceData []byte
var baseImageDigest, cacheScope, dockerfile string
var baseImageDigest, cacheScope, dockerfile, globalCacheKey string
var timeoutSeconds int
var isAdminBuild bool
var secrets []builds.SecretRef

for {
Expand Down Expand Up @@ -118,6 +119,24 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe
Message: "secrets must be a JSON array of {\"id\": \"...\", \"env_var\": \"...\"} objects",
}, nil
}
case "is_admin_build":
data, err := io.ReadAll(part)
if err != nil {
return oapi.CreateBuild400JSONResponse{
Code: "invalid_request",
Message: "failed to read is_admin_build field",
}, nil
}
isAdminBuild = string(data) == "true" || string(data) == "1"
Copy link

Choose a reason for hiding this comment

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

Missing authorization check for admin build privilege

High Severity

The is_admin_build parameter is accepted from any authenticated user without authorization verification. The OpenAPI description claims this is "operator-only," but no enforcement exists. Any authenticated user can set is_admin_build=true to gain push access to the global cache (cache/global/{key}), potentially poisoning shared cache content that all tenant builds consume.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but the endpoiont itself is protected by api key. I think this is fine for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I understand the point tho

case "global_cache_key":
data, err := io.ReadAll(part)
if err != nil {
return oapi.CreateBuild400JSONResponse{
Code: "invalid_request",
Message: "failed to read global_cache_key field",
}, nil
}
globalCacheKey = string(data)
}
part.Close()
}
Expand All @@ -134,10 +153,12 @@ func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRe

// Build domain request
domainReq := builds.CreateBuildRequest{
BaseImageDigest: baseImageDigest,
CacheScope: cacheScope,
Dockerfile: dockerfile,
Secrets: secrets,
BaseImageDigest: baseImageDigest,
CacheScope: cacheScope,
Dockerfile: dockerfile,
Secrets: secrets,
IsAdminBuild: isAdminBuild,
GlobalCacheKey: globalCacheKey,
}

// Apply timeout if provided
Expand Down
63 changes: 47 additions & 16 deletions lib/builds/builder_agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,19 @@ const (

// BuildConfig matches the BuildConfig type from lib/builds/types.go
type BuildConfig struct {
JobID string `json:"job_id"`
BaseImageDigest string `json:"base_image_digest,omitempty"`
RegistryURL string `json:"registry_url"`
RegistryToken string `json:"registry_token,omitempty"`
CacheScope string `json:"cache_scope,omitempty"`
SourcePath string `json:"source_path"`
Dockerfile string `json:"dockerfile,omitempty"`
BuildArgs map[string]string `json:"build_args,omitempty"`
Secrets []SecretRef `json:"secrets,omitempty"`
TimeoutSeconds int `json:"timeout_seconds"`
NetworkMode string `json:"network_mode"`
JobID string `json:"job_id"`
BaseImageDigest string `json:"base_image_digest,omitempty"`
RegistryURL string `json:"registry_url"`
RegistryToken string `json:"registry_token,omitempty"`
CacheScope string `json:"cache_scope,omitempty"`
SourcePath string `json:"source_path"`
Dockerfile string `json:"dockerfile,omitempty"`
BuildArgs map[string]string `json:"build_args,omitempty"`
Secrets []SecretRef `json:"secrets,omitempty"`
TimeoutSeconds int `json:"timeout_seconds"`
NetworkMode string `json:"network_mode"`
IsAdminBuild bool `json:"is_admin_build,omitempty"`
GlobalCacheKey string `json:"global_cache_key,omitempty"`
}

// SecretRef references a secret to inject during build
Expand Down Expand Up @@ -547,11 +549,40 @@ func runBuild(ctx context.Context, config *BuildConfig, logWriter io.Writer) (st
"--metadata-file", "/tmp/build-metadata.json",
}

// Add cache if scope is set
if config.CacheScope != "" {
cacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope)
args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", cacheRef))
args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", cacheRef))
// Two-tier cache implementation:
// 1. Import from global cache (if runtime specified) - always read-only for regular builds
// 2. Import from tenant cache (if cache scope specified)
// 3. Export to appropriate target based on build type

// Import from global cache (read-only for regular builds, read-write for admin builds)
if config.GlobalCacheKey != "" {
globalCacheRef := fmt.Sprintf("%s/cache/global/%s", config.RegistryURL, config.GlobalCacheKey)
args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", globalCacheRef))
log.Printf("Importing from global cache: %s", globalCacheRef)
}

// For regular builds, also import from tenant cache if scope is set
if !config.IsAdminBuild && config.CacheScope != "" {
tenantCacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope)
args = append(args, "--import-cache", fmt.Sprintf("type=registry,ref=%s,registry.insecure=true", tenantCacheRef))
log.Printf("Importing from tenant cache: %s", tenantCacheRef)
}

// Export cache based on build type
if config.IsAdminBuild {
// Admin build: export to global cache
if config.GlobalCacheKey != "" {
globalCacheRef := fmt.Sprintf("%s/cache/global/%s", config.RegistryURL, config.GlobalCacheKey)
args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", globalCacheRef))
log.Printf("Exporting to global cache (admin build): %s", globalCacheRef)
}
} else {
// Regular build: export to tenant cache
if config.CacheScope != "" {
tenantCacheRef := fmt.Sprintf("%s/cache/%s", config.RegistryURL, config.CacheScope)
args = append(args, "--export-cache", fmt.Sprintf("type=registry,ref=%s,mode=max,registry.insecure=true", tenantCacheRef))
log.Printf("Exporting to tenant cache: %s", tenantCacheRef)
}
}

// Add secret mounts
Expand Down
98 changes: 76 additions & 22 deletions lib/builds/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,34 +191,65 @@ func (m *manager) CreateBuild(ctx context.Context, req CreateBuildRequest, sourc
}

// Generate scoped registry token for this build
// Token grants push access to the build output repo and cache repo
allowedRepos := []string{fmt.Sprintf("builds/%s", id)}
if req.CacheScope != "" {
allowedRepos = append(allowedRepos, fmt.Sprintf("cache/%s", req.CacheScope))
}
// Token grants per-repo access based on build type:
// - Regular builds: push to builds/{id}, push to cache/{tenant}, pull from cache/global/{runtime}
// - Admin builds: push to builds/{id}, push to cache/global/{runtime}
tokenTTL := time.Duration(policy.TimeoutSeconds) * time.Second
if tokenTTL < 30*time.Minute {
tokenTTL = 30 * time.Minute // Minimum 30 minutes
}
registryToken, err := m.tokenGenerator.GeneratePushToken(id, allowedRepos, tokenTTL)

repoAccess := []RepoPermission{
{Repo: fmt.Sprintf("builds/%s", id), Scope: "push"},
}

if req.IsAdminBuild {
// Admin build: push access to global cache
if req.GlobalCacheKey != "" {
repoAccess = append(repoAccess, RepoPermission{
Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey),
Scope: "push",
})
}
} else {
// Regular tenant build
// Pull access to global cache (if runtime specified)
if req.GlobalCacheKey != "" {
repoAccess = append(repoAccess, RepoPermission{
Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey),
Scope: "pull",
})
}
// Push access to tenant cache (if cache scope specified)
if req.CacheScope != "" {
repoAccess = append(repoAccess, RepoPermission{
Repo: fmt.Sprintf("cache/%s", req.CacheScope),
Scope: "push",
})
}
}

registryToken, err := m.tokenGenerator.GenerateToken(id, repoAccess, tokenTTL)
if err != nil {
deleteBuild(m.paths, id)
return nil, fmt.Errorf("generate registry token: %w", err)
}

// Write build config for the builder agent
buildConfig := &BuildConfig{
JobID: id,
BaseImageDigest: req.BaseImageDigest,
RegistryURL: m.config.RegistryURL,
RegistryToken: registryToken,
CacheScope: req.CacheScope,
SourcePath: "/src",
Dockerfile: req.Dockerfile,
BuildArgs: req.BuildArgs,
Secrets: req.Secrets,
TimeoutSeconds: policy.TimeoutSeconds,
NetworkMode: policy.NetworkMode,
JobID: id,
BaseImageDigest: req.BaseImageDigest,
RegistryURL: m.config.RegistryURL,
RegistryToken: registryToken,
CacheScope: req.CacheScope,
SourcePath: "/src",
Dockerfile: req.Dockerfile,
BuildArgs: req.BuildArgs,
Secrets: req.Secrets,
TimeoutSeconds: policy.TimeoutSeconds,
NetworkMode: policy.NetworkMode,
IsAdminBuild: req.IsAdminBuild,
GlobalCacheKey: req.GlobalCacheKey,
}
if err := writeBuildConfig(m.paths, id, buildConfig); err != nil {
deleteBuild(m.paths, id)
Expand Down Expand Up @@ -1046,14 +1077,37 @@ func (m *manager) refreshBuildToken(buildID string, req *CreateBuildRequest) err
tokenTTL = 30 * time.Minute // Minimum 30 minutes
}

// Generate allowed repos list
allowedRepos := []string{fmt.Sprintf("builds/%s", buildID)}
if req.CacheScope != "" {
allowedRepos = append(allowedRepos, fmt.Sprintf("cache/%s", req.CacheScope))
// Generate per-repo access list (same logic as CreateBuild)
repoAccess := []RepoPermission{
{Repo: fmt.Sprintf("builds/%s", buildID), Scope: "push"},
}

if req.IsAdminBuild {
// Admin build: push access to global cache
if req.GlobalCacheKey != "" {
repoAccess = append(repoAccess, RepoPermission{
Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey),
Scope: "push",
})
}
} else {
// Regular tenant build
if req.GlobalCacheKey != "" {
repoAccess = append(repoAccess, RepoPermission{
Repo: fmt.Sprintf("cache/global/%s", req.GlobalCacheKey),
Scope: "pull",
})
}
if req.CacheScope != "" {
repoAccess = append(repoAccess, RepoPermission{
Repo: fmt.Sprintf("cache/%s", req.CacheScope),
Scope: "push",
})
}
}

// Generate fresh registry token
registryToken, err := m.tokenGenerator.GeneratePushToken(buildID, allowedRepos, tokenTTL)
registryToken, err := m.tokenGenerator.GenerateToken(buildID, repoAccess, tokenTTL)
if err != nil {
return fmt.Errorf("generate registry token: %w", err)
}
Expand Down
Loading