Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
112ab91
fix(auth): support ABAC-enabled registries by removing wildcard scopes
balcsida Aug 26, 2025
8506189
chore: remove unused refreshTokenForCatalog function
balcsida Aug 26, 2025
fec4ab2
test: add comprehensive ABAC registry test scripts
balcsida Aug 26, 2025
fbf2e53
fix: improve ABAC test script with proper authentication and Docker f…
balcsida Aug 26, 2025
8595d07
fix(test): correct ACR CLI binary path and add timeout for hanging co…
balcsida Aug 26, 2025
f6f2582
fix(test): add credentials to all ACR CLI commands in test scrips
balcsida Aug 26, 2025
989e303
fix(test): improve batch tag deletion test to check for specific tag …
balcsida Aug 26, 2025
70195ac
fix(test): comprehensive test fixes - exact pattern matching, debug o…
balcsida Aug 26, 2025
6426caa
fix(test): improve batch deletion with retry logic and fix regex esca…
balcsida Aug 26, 2025
3dff247
fix(test): resolve integer comparison errors and simplify tag deletio…
balcsida Aug 26, 2025
97eed9c
fix(test): final comprehensive fixes - exact pattern matching, proper…
balcsida Aug 26, 2025
49686b0
fix(test): add error handling for ACR CLI segfaults and integer compa…
balcsida Aug 26, 2025
3340d35
fix(test): improve integer handling in concurrent tests and fix v1 pa…
balcsida Aug 26, 2025
ddb5164
optimize(test): reduce test dataset sizes to prevent timeouts and imp…
balcsida Aug 26, 2025
e2ff253
fix(auth): address review comments - maintain backward compatibility …
balcsida Aug 26, 2025
f0e545a
feat(auth): implement scope tracking for ABAC registries
balcsida Aug 26, 2025
2abb822
chore: fix linting errors
balcsida Aug 27, 2025
b2379c2
feat: improve ABAC registry handling
balcsida Sep 16, 2025
b88473d
feat(test): fix tests
balcsida Sep 16, 2025
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
49 changes: 45 additions & 4 deletions cmd/repository/image_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,18 @@ func GetRepositoryAndTagRegex(filter string) (string, string, error) {
// CollectTagFilters collects all matching repos and collects the associated tag filters
func CollectTagFilters(ctx context.Context, rawFilters []string, client acrapi.BaseClientAPI, regexMatchTimeout int64, repoPageSize int32) (map[string]string, error) {
allRepoNames, err := GetAllRepositoryNames(ctx, client, repoPageSize)
isABACRegistry := false

// If catalog listing fails (common in ABAC registries), we'll handle filters differently
if err != nil {
return nil, err
// Check if this is likely an ABAC registry catalog listing permission issue
if strings.Contains(err.Error(), "UNAUTHORIZED") || strings.Contains(err.Error(), "401") ||
strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "FORBIDDEN") {
isABACRegistry = true
allRepoNames = []string{} // Start with empty list for ABAC handling
} else {
return nil, err // Return other errors as-is
}
}

tagFilters := map[string]string{}
Expand All @@ -114,10 +124,26 @@ func CollectTagFilters(ctx context.Context, rawFilters []string, client acrapi.B
if err != nil {
return nil, err
}
repoNames, err := GetMatchingRepos(allRepoNames, "^"+repoRegex+"$", regexMatchTimeout)
if err != nil {
return nil, err

var repoNames []string

if isABACRegistry {
// For ABAC registries, treat repository patterns as exact names if they don't contain regex metacharacters
// This handles common cases where users specify exact repository names
if isLikelyExactRepoName(repoRegex) {
repoNames = []string{repoRegex}
} else {
// For complex repo patterns in ABAC registries, we can't list repositories,
// so return an error with helpful message
return nil, fmt.Errorf("ABAC registry detected: complex repository patterns (%s) require catalog listing permissions. Use exact repository names or add 'Container Registry Repository Catalog Lister' role", repoRegex)
}
} else {
repoNames, err = GetMatchingRepos(allRepoNames, "^"+repoRegex+"$", regexMatchTimeout)
if err != nil {
return nil, err
}
}

for _, repoName := range repoNames {
if _, ok := tagFilters[repoName]; ok {
// To only iterate through a repo once a big regex filter is made of all the filters of a particular repo.
Expand All @@ -131,6 +157,20 @@ func CollectTagFilters(ctx context.Context, rawFilters []string, client acrapi.B
return tagFilters, nil
}

// isLikelyExactRepoName checks if a repository pattern is likely an exact repository name
// rather than a regex pattern by looking for common regex metacharacters
func isLikelyExactRepoName(repoPattern string) bool {
// Common regex metacharacters that would indicate this is a pattern, not an exact name
regexChars := []string{".", "*", "+", "?", "^", "$", "[", "]", "(", ")", "|", "\\", "{", "}"}

for _, char := range regexChars {
if strings.Contains(repoPattern, char) {
return false
}
}
return true
}

// GetLastTagFromResponse extracts the last tag from pagination headers in the response.
func GetLastTagFromResponse(resultTags *acr.RepositoryTagsType) string {
// The lastTag is updated to keep the for loop going.
Expand Down Expand Up @@ -187,6 +227,7 @@ func GetUntaggedManifests(ctx context.Context, poolSize int, acrClient api.AcrCL
for resultManifests != nil && resultManifests.ManifestsAttributes != nil {
manifests := *resultManifests.ManifestsAttributes
for _, manifest := range manifests {
manifest := manifest // capture range variable for goroutines
// In the rare event that we run into an error with the errgroup while still doing the manifest acquisition loop,
// we need to check if the context is done to break out of the loop early.
if ctx.Err() != nil {
Expand Down
185 changes: 157 additions & 28 deletions internal/api/acrsdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package api
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"strings"
"time"
Expand Down Expand Up @@ -38,6 +39,7 @@ const (
", " + manifestOCIImageIndexContentType +
", " + manifestImageContentType +
", " + manifestListContentType
registryCatalogScope = "registry:catalog:*"
)

// The AcrCLIClient is the struct that will be in charge of doing the http requests to the registry.
Expand All @@ -52,6 +54,10 @@ type AcrCLIClient struct {
// accessTokenExp refers to the expiration time for the access token, it is in a unix time format represented by a
// 64 bit integer.
accessTokenExp int64
// currentScopes tracks the scopes that the current token was issued for
currentScopes []string
// isABAC indicates if this is an ABAC-enabled registry that requires repository-specific scopes
isABAC bool
}

// LoginURL returns the FQDN for a registry.
Expand Down Expand Up @@ -94,16 +100,32 @@ func newAcrCLIClientWithBasicAuth(loginURL string, username string, password str
func newAcrCLIClientWithBearerAuth(loginURL string, refreshToken string) (AcrCLIClient, error) {
newAcrCLIClient := newAcrCLIClient(loginURL)
ctx := context.Background()
accessTokenResponse, err := newAcrCLIClient.AutorestClient.GetAcrAccessToken(ctx, loginURL, "registry:catalog:* repository:*:*", refreshToken)
// Try to get a token with both catalog and repository wildcard scope for non-ABAC registries
// This maintains backward compatibility while supporting ABAC registries
scope := registryCatalogScope + " repository:*:pull"
accessTokenResponse, err := newAcrCLIClient.AutorestClient.GetAcrAccessToken(ctx, loginURL, scope, refreshToken)
isABAC := false
if err != nil {
return newAcrCLIClient, err
// If the above fails (likely ABAC registry), fallback to catalog-only scope
// Repository-specific scopes will be requested when needed
accessTokenResponse, err = newAcrCLIClient.AutorestClient.GetAcrAccessToken(ctx, loginURL, registryCatalogScope, refreshToken)
if err != nil {
return newAcrCLIClient, err
}
isABAC = true
}
token := &adal.Token{
AccessToken: *accessTokenResponse.AccessToken,
RefreshToken: refreshToken,
}
newAcrCLIClient.token = token
newAcrCLIClient.isABAC = isABAC
newAcrCLIClient.AutorestClient.Authorizer = autorest.NewBearerAuthorizer(token)

// Parse and store the scopes from the token
scopes, _ := getScopesFromToken(token.AccessToken)
newAcrCLIClient.currentScopes = scopes

// The expiration time is stored in the struct to make it easy to determine if a token is expired.
exp, err := getExpiration(token.AccessToken)
if err != nil {
Expand Down Expand Up @@ -153,9 +175,9 @@ func GetAcrCLIClientWithAuth(loginURL string, username string, password string,
return &acrClient, nil
}

// refreshAcrCLIClientToken obtains a new token and gets its expiration time.
func refreshAcrCLIClientToken(ctx context.Context, c *AcrCLIClient) error {
accessTokenResponse, err := c.AutorestClient.GetAcrAccessToken(ctx, c.loginURL, "repository:*:*", c.token.RefreshToken)
// refreshAcrCLIClientToken obtains a new token with the specified scope and gets its expiration time.
func refreshAcrCLIClientToken(ctx context.Context, c *AcrCLIClient, scope string) error {
accessTokenResponse, err := c.AutorestClient.GetAcrAccessToken(ctx, c.loginURL, scope, c.token.RefreshToken)
if err != nil {
return err
}
Expand All @@ -165,6 +187,11 @@ func refreshAcrCLIClientToken(ctx context.Context, c *AcrCLIClient) error {
}
c.token = token
c.AutorestClient.Authorizer = autorest.NewBearerAuthorizer(token)

// Parse and store the new scopes from the refreshed token
scopes, _ := getScopesFromToken(token.AccessToken)
c.currentScopes = scopes

exp, err := getExpiration(token.AccessToken)
if err != nil {
return err
Expand All @@ -173,21 +200,115 @@ func refreshAcrCLIClientToken(ctx context.Context, c *AcrCLIClient) error {
return nil
}

// getExpiration is used to obtain the expiration out of a jwt token.
func getExpiration(token string) (int64, error) {
parser := jwt.Parser{SkipClaimsValidation: true}
mapC := jwt.MapClaims{}
// Since we only need the expiration time there is no need for verifying the signature of the token.
_, _, err := parser.ParseUnverified(token, mapC)
// refreshTokenForRepository obtains a new token scoped to a specific repository with all permissions.
// This supports both ABAC and non-ABAC registries.
func refreshTokenForRepository(ctx context.Context, c *AcrCLIClient, repoName string) error {
// For ABAC-enabled registries, we need to specify exact permissions
// Using pull,push,delete covers all necessary operations
scope := fmt.Sprintf("%s repository:%s:pull,push,delete", registryCatalogScope, repoName)
return refreshAcrCLIClientToken(ctx, c, scope)
}

// getExpiration is used to obtain the expiration out of a jwt token using proper JWT methods.
func getExpiration(tokenStr string) (int64, error) {
// Parse the token without verification to extract claims
token, _, err := jwt.NewParser().ParseUnverified(tokenStr, jwt.MapClaims{})
if err != nil {
return 0, err
}
if fExp, ok := mapC["exp"].(float64); ok {

claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return 0, errors.New("unable to parse token claims")
}

if fExp, ok := claims["exp"].(float64); ok {
return int64(fExp), nil
}
return 0, errors.New("unable to obtain expiration date for token")
}

// getScopesFromToken extracts the access scopes from a JWT token using proper JWT methods
func getScopesFromToken(tokenStr string) ([]string, error) {
// Parse the token without verification to extract claims
token, _, err := jwt.NewParser().ParseUnverified(tokenStr, jwt.MapClaims{})
if err != nil {
return nil, err
}

claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("unable to parse token claims")
}

// ACR tokens typically have "access" claim with scopes
if access, ok := claims["access"]; ok {
if accessList, ok := access.([]interface{}); ok {
var scopes []string
for _, item := range accessList {
if accessMap, ok := item.(map[string]interface{}); ok {
if scope, ok := accessMap["type"].(string); ok {
scopeStr := scope
if name, ok := accessMap["name"].(string); ok {
scopeStr += ":" + name
}
if actions, ok := accessMap["actions"].([]interface{}); ok {
var actionStrs []string
for _, action := range actions {
if actionStr, ok := action.(string); ok {
actionStrs = append(actionStrs, actionStr)
}
}
if len(actionStrs) > 0 {
scopeStr += ":" + strings.Join(actionStrs, ",")
}
}
scopes = append(scopes, scopeStr)
}
}
}
return scopes, nil
}
}

// Fallback: check for "scope" claim (some implementations use this)
if scope, ok := claims["scope"].(string); ok {
return strings.Split(scope, " "), nil
}

return []string{}, nil
}

// hasRequiredScope checks if the current token has the required scope for a repository operation
func (c *AcrCLIClient) hasRequiredScope(repoName string) bool {
if c.token == nil || len(c.currentScopes) == 0 {
// No token or no scopes tracked
return false
}

// Check if we have a wildcard repository scope (for non-ABAC registries)
for _, scope := range c.currentScopes {
if scope == "repository:*:pull" || scope == "repository:*:*" {
return true
}
// Check for specific repository scope
if strings.HasPrefix(scope, fmt.Sprintf("repository:%s:", repoName)) {
// Check if we have at least pull permission
parts := strings.Split(scope, ":")
if len(parts) >= 3 {
permissions := strings.Split(parts[2], ",")
for _, perm := range permissions {
if perm == "pull" || perm == "push" || perm == "delete" || perm == "*" {
return true
}
}
}
}
}

return false
}

// isExpired return true when the token inside an acrClient is expired and a new should be requested.
func (c *AcrCLIClient) isExpired() bool {
if c.token == nil {
Expand All @@ -200,8 +321,9 @@ func (c *AcrCLIClient) isExpired() bool {

// GetAcrTags list the tags of a repository with their attributes.
func (c *AcrCLIClient) GetAcrTags(ctx context.Context, repoName string, orderBy string, last string) (*acrapi.RepositoryTagsType, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand All @@ -215,8 +337,9 @@ func (c *AcrCLIClient) GetAcrTags(ctx context.Context, repoName string, orderBy

// DeleteAcrTag deletes the tag by reference.
func (c *AcrCLIClient) DeleteAcrTag(ctx context.Context, repoName string, reference string) (*autorest.Response, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand All @@ -229,8 +352,9 @@ func (c *AcrCLIClient) DeleteAcrTag(ctx context.Context, repoName string, refere

// GetAcrManifests list all the manifest in a repository with their attributes.
func (c *AcrCLIClient) GetAcrManifests(ctx context.Context, repoName string, orderBy string, last string) (*acrapi.Manifests, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand All @@ -243,8 +367,9 @@ func (c *AcrCLIClient) GetAcrManifests(ctx context.Context, repoName string, ord

// DeleteManifest deletes a manifest using the digest as a reference.
func (c *AcrCLIClient) DeleteManifest(ctx context.Context, repoName string, reference string) (*autorest.Response, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand All @@ -258,8 +383,9 @@ func (c *AcrCLIClient) DeleteManifest(ctx context.Context, repoName string, refe
// GetManifest fetches a manifest (could be a Manifest List or a v2 manifest) and returns it as a byte array.
// This is used when a manifest list is wanted, first the bytes are obtained and then unmarshalled into a new struct.
func (c *AcrCLIClient) GetManifest(ctx context.Context, repoName string, reference string) ([]byte, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand Down Expand Up @@ -298,8 +424,9 @@ func (c *AcrCLIClient) GetManifest(ctx context.Context, repoName string, referen

// GetAcrManifestAttributes gets the attributes of a manifest.
func (c *AcrCLIClient) GetAcrManifestAttributes(ctx context.Context, repoName string, reference string) (*acrapi.ManifestAttributes, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand All @@ -312,8 +439,9 @@ func (c *AcrCLIClient) GetAcrManifestAttributes(ctx context.Context, repoName st

// UpdateAcrTagAttributes updates tag attributes to enable/disable deletion and writing.
func (c *AcrCLIClient) UpdateAcrTagAttributes(ctx context.Context, repoName string, reference string, value *acrapi.ChangeableAttributes) (*autorest.Response, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand All @@ -326,8 +454,9 @@ func (c *AcrCLIClient) UpdateAcrTagAttributes(ctx context.Context, repoName stri

// UpdateAcrManifestAttributes updates manifest attributes to enable/disable deletion and writing.
func (c *AcrCLIClient) UpdateAcrManifestAttributes(ctx context.Context, repoName string, reference string, value *acrapi.ChangeableAttributes) (*autorest.Response, error) {
if c.isExpired() {
if err := refreshAcrCLIClientToken(ctx, c); err != nil {
// Check if token is expired OR if we don't have the required scope for this repository
if c.isExpired() || (c.isABAC && !c.hasRequiredScope(repoName)) {
if err := refreshTokenForRepository(ctx, c, repoName); err != nil {
return nil, err
}
}
Expand Down
Loading