feat(manager): an empty permissions list grants all permissions (#3889)

Signed-off-by: Gaius <gaius.qi@gmail.com>
This commit is contained in:
Gaius 2025-03-14 13:31:47 +08:00 committed by GitHub
parent 5004b4342b
commit fbd88da02e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 69 additions and 32 deletions

@ -1 +1 @@
Subproject commit 316cb541dde8b32f66c291b66d6347ec17cb1cca Subproject commit beff3f87b3b2b9f1161984b21aca984b448b3d93

View File

@ -27,7 +27,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"time" "time"
machineryv1tasks "github.com/RichardKnop/machinery/v1/tasks" machineryv1tasks "github.com/RichardKnop/machinery/v1/tasks"
@ -73,9 +72,6 @@ var defaultHTTPTransport = &http.Transport{
IdleConnTimeout: 120 * time.Second, IdleConnTimeout: 120 * time.Second,
} }
// accessURLPattern is the pattern of access url.
var accessURLPattern, _ = regexp.Compile("^(.*)://(.*)/v2/(.*)/manifests/(.*)")
// Preheat is an interface for preheat job. // Preheat is an interface for preheat job.
type Preheat interface { type Preheat interface {
// CreatePreheat creates a preheat job. // CreatePreheat creates a preheat job.

View File

@ -3,8 +3,12 @@ package job
import ( import (
"errors" "errors"
"fmt" "fmt"
"regexp"
) )
// accessURLRegexp is the regular expression for parsing access url.
var accessURLRegexp, _ = regexp.Compile("^(.*)://(.*)/v2/(.*)/manifests/(.*)")
// preheatImage is image information for preheat. // preheatImage is image information for preheat.
type preheatImage struct { type preheatImage struct {
protocol string protocol string
@ -23,7 +27,7 @@ func (p *preheatImage) blobsURL(digest string) string {
// parseManifestURL parses manifest url. // parseManifestURL parses manifest url.
func parseManifestURL(url string) (*preheatImage, error) { func parseManifestURL(url string) (*preheatImage, error) {
r := accessURLPattern.FindStringSubmatch(url) r := accessURLRegexp.FindStringSubmatch(url)
if len(r) != 5 { if len(r) != 5 {
return nil, errors.New("parse access url failed") return nil, errors.New("parse access url failed")
} }

View File

@ -33,6 +33,8 @@ import (
) )
var ( var (
// oapiResourceRegexp is a regular expression to extract the resource type from the path.
// Example: /oapi/v1/jobs/1 -> jobs.
oapiResourceRegexp = regexp.MustCompile(`^/oapi/v[0-9]+/([-_a-zA-Z]*)[/.*]*`) oapiResourceRegexp = regexp.MustCompile(`^/oapi/v[0-9]+/([-_a-zA-Z]*)[/.*]*`)
) )
@ -45,6 +47,7 @@ func PersonalAccessToken(gdb *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusUnauthorized, ErrorResponse{ c.JSON(http.StatusUnauthorized, ErrorResponse{
Message: http.StatusText(http.StatusUnauthorized), Message: http.StatusText(http.StatusUnauthorized),
}) })
c.Abort() c.Abort()
return return
} }
@ -53,51 +56,56 @@ func PersonalAccessToken(gdb *gorm.DB) gin.HandlerFunc {
personalAccessToken := tokenFields[1] personalAccessToken := tokenFields[1]
var token models.PersonalAccessToken var token models.PersonalAccessToken
if err := gdb.WithContext(c).Where("token = ?", personalAccessToken).First(&token).Error; err != nil { if err := gdb.WithContext(c).Where("token = ?", personalAccessToken).First(&token).Error; err != nil {
logger.Errorf("Invalid personal access token attempt: %s, error: %v", c.Request.URL.Path, err) logger.Errorf("invalid personal access token attempt: %s, error: %v", c.Request.URL.Path, err)
c.JSON(http.StatusUnauthorized, ErrorResponse{ c.JSON(http.StatusUnauthorized, ErrorResponse{
Message: http.StatusText(http.StatusUnauthorized), Message: http.StatusText(http.StatusUnauthorized),
}) })
c.Abort() c.Abort()
return return
} }
// Check if the token is active. // Check if the token is active.
if token.State != models.PersonalAccessTokenStateActive { if token.State != models.PersonalAccessTokenStateActive {
logger.Errorf("Inactive token used: %s, token name: %s, user_id: %d", c.Request.URL.Path, token.Name, token.UserID) logger.Errorf("inactive token used: %s, token name: %s, user_id: %d", c.Request.URL.Path, token.Name, token.UserID)
c.JSON(http.StatusForbidden, ErrorResponse{ c.JSON(http.StatusForbidden, ErrorResponse{
Message: "Token is inactive", Message: "Token is inactive",
}) })
c.Abort() c.Abort()
return return
} }
// Check if the token has expired. // Check if the token has expired.
if time.Now().After(token.ExpiredAt) { if time.Now().After(token.ExpiredAt) {
logger.Errorf("Expired token used: %s, token name: %s, user_id: %d, expired: %v", logger.Errorf("expired token used: %s, token name: %s, user_id: %d, expired: %v",
c.Request.URL.Path, token.Name, token.UserID, token.ExpiredAt) c.Request.URL.Path, token.Name, token.UserID, token.ExpiredAt)
c.JSON(http.StatusForbidden, ErrorResponse{ c.JSON(http.StatusForbidden, ErrorResponse{
Message: "Token has expired", Message: "Token has expired",
}) })
c.Abort() c.Abort()
return return
} }
// Check if the token's scopes include the required resource type. // Check if the token's scopes include the required resource type.
hasScope := false requiredPermission, err := requiredPermission(c.Request.URL.Path)
resourceType := getAPIResourceType(c.Request.URL.Path) if err != nil {
for _, scope := range token.Scopes { logger.Errorf("failed to extract resource type from path: %s, error: %v", c.Request.URL.Path, err)
if scope == resourceType { c.JSON(http.StatusForbidden, ErrorResponse{
hasScope = true Message: fmt.Sprintf("Failed to extract resource type from path: %s", c.Request.URL.Path),
break })
}
c.Abort()
return
} }
if !hasScope { if !hasPermission(token.Scopes, requiredPermission) {
logger.Errorf("Insufficient scope token used: %s, token name: %s, user_id: %d, required: %s, available: %v", logger.Errorf("insufficient scope token used %s. Required permission: %s", token.Name, requiredPermission)
c.Request.URL.Path, token.Name, token.UserID, resourceType, token.Scopes)
c.JSON(http.StatusForbidden, ErrorResponse{ c.JSON(http.StatusForbidden, ErrorResponse{
Message: fmt.Sprintf("Token doesn't have permission to access this resource. Required scope: %s", resourceType), Message: fmt.Sprintf("Token doesn't have permission to access this resource. Required permission: %s", requiredPermission),
}) })
c.Abort() c.Abort()
return return
} }
@ -106,23 +114,42 @@ func PersonalAccessToken(gdb *gorm.DB) gin.HandlerFunc {
} }
} }
// getAPIResourceType extracts the resource type from the path. // hasPermission checks if the required permission exists in the provided permissions list.
// For example: /oapi/v1/jobs -> job, /oapi/v1/clusters -> cluster. // For backward compatibility, an empty permissions list grants all permissions.
func getAPIResourceType(path string) string { // This allows existing systems that don't have explicit permissions set to continue
// working without interruption.
//
// Returns true if:
// 1. The permissions list is empty (backward compatibility mode)
// 2. The requiredPermission is found in the permissions list
func hasPermission(permissions []string, requiredPermission string) bool {
if len(permissions) == 0 {
return true
}
for _, permission := range permissions {
if permission == requiredPermission {
return true
}
}
return false
}
// requiredPermission extracts the resource type from the path and returns the required permission.
func requiredPermission(path string) (string, error) {
matches := oapiResourceRegexp.FindStringSubmatch(path) matches := oapiResourceRegexp.FindStringSubmatch(path)
if len(matches) != 2 { if len(matches) != 2 {
return "" return "", fmt.Errorf("failed to extract resource type from path: %s", path)
} }
resource := strings.ToLower(matches[1]) resource := strings.ToLower(matches[1])
switch resource { switch resource {
case "jobs": case "jobs":
return types.PersonalAccessTokenScopeJob return types.PersonalAccessTokenScopeJob, nil
case "clusters": case "clusters":
return types.PersonalAccessTokenScopeCluster return types.PersonalAccessTokenScopeCluster, nil
case "preheats":
return types.PersonalAccessTokenScopePreheat
default: default:
return resource return "", fmt.Errorf("unsupported resource type: %s", resource)
} }
} }

View File

@ -27,6 +27,10 @@ import (
) )
func (s *service) CreatePersonalAccessToken(ctx context.Context, json types.CreatePersonalAccessTokenRequest) (*models.PersonalAccessToken, error) { func (s *service) CreatePersonalAccessToken(ctx context.Context, json types.CreatePersonalAccessTokenRequest) (*models.PersonalAccessToken, error) {
if len(json.Scopes) == 0 {
json.Scopes = types.DefaultPersonalAccessTokenScopes
}
personalAccessToken := models.PersonalAccessToken{ personalAccessToken := models.PersonalAccessToken{
Name: json.Name, Name: json.Name,
BIO: json.BIO, BIO: json.BIO,
@ -58,6 +62,10 @@ func (s *service) DestroyPersonalAccessToken(ctx context.Context, id uint) error
} }
func (s *service) UpdatePersonalAccessToken(ctx context.Context, id uint, json types.UpdatePersonalAccessTokenRequest) (*models.PersonalAccessToken, error) { func (s *service) UpdatePersonalAccessToken(ctx context.Context, id uint, json types.UpdatePersonalAccessTokenRequest) (*models.PersonalAccessToken, error) {
if len(json.Scopes) == 0 {
json.Scopes = types.DefaultPersonalAccessTokenScopes
}
personalAccessToken := models.PersonalAccessToken{} personalAccessToken := models.PersonalAccessToken{}
if err := s.db.WithContext(ctx).Preload("User").First(&personalAccessToken, id).Updates(models.PersonalAccessToken{ if err := s.db.WithContext(ctx).Preload("User").First(&personalAccessToken, id).Updates(models.PersonalAccessToken{
BIO: json.BIO, BIO: json.BIO,

View File

@ -19,9 +19,6 @@ package types
import "time" import "time"
const ( const (
// PersonalAccessTokenScopePreheat represents the personal access token whose scope is preheat.
PersonalAccessTokenScopePreheat = "preheat"
// PersonalAccessTokenScopeJob represents the personal access token whose scope is job. // PersonalAccessTokenScopeJob represents the personal access token whose scope is job.
PersonalAccessTokenScopeJob = "job" PersonalAccessTokenScopeJob = "job"
@ -29,6 +26,11 @@ const (
PersonalAccessTokenScopeCluster = "cluster" PersonalAccessTokenScopeCluster = "cluster"
) )
var (
// DefaultPersonalAccessTokenScopes represents the default scopes of personal access token.
DefaultPersonalAccessTokenScopes = []string{PersonalAccessTokenScopeJob, PersonalAccessTokenScopeCluster}
)
type CreatePersonalAccessTokenRequest struct { type CreatePersonalAccessTokenRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
BIO string `json:"bio" binding:"omitempty"` BIO string `json:"bio" binding:"omitempty"`