Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cc9f80d
chore(dvcr): upgrade distribution registry 2.8.3 to 3.0.0
danilrwx Jul 3, 2026
728e78b
feat(dvcr): add multi-tenant authorization policy core
danilrwx Jul 3, 2026
7612787
feat(dvcr): add distribution v3 AccessController glue and TokenReview
danilrwx Jul 3, 2026
c2ec0c1
feat(dvcr): build the dvcr-k8s auth plugin into the registry binary
danilrwx Jul 3, 2026
67c160f
feat(dvcr): add node-puller credential and tenant-authz feature flag
danilrwx Jul 3, 2026
70659ac
feat(dvcr): wire dvcr-k8s auth backend behind the tenant-authz flag
danilrwx Jul 3, 2026
97b49dd
feat(dvcr): authenticate importer/uploader Pods with SA token
danilrwx Jul 3, 2026
c8454c4
feat(dvcr): use SA-token auth for VirtualDisk CDI imports
danilrwx Jul 3, 2026
67c0a5a
fix(dvcr): grant admin to ServiceAccounts in the module namespace
danilrwx Jul 3, 2026
3a677f8
refactor(dvcr): verify scoped JWT tokens instead of TokenReview
danilrwx Jul 3, 2026
6f86f52
feat(controller): add scoped DVCR token signer
danilrwx Jul 3, 2026
72cf783
feat(controller): mint scoped DVCR tokens for importer/uploader/CDI
danilrwx Jul 3, 2026
5c723b6
refactor(dvcr-artifact): drop projected SA token auth
danilrwx Jul 3, 2026
5665f18
feat(hooks): generate ECDSA keypair for scoped DVCR tokens
danilrwx Jul 3, 2026
27c7ce0
feat(dvcr): wire scoped-token JWT auth into manifests
danilrwx Jul 3, 2026
ff2b03a
refactor(dvcr): simplify scoped-token plumbing
danilrwx Jul 3, 2026
fbb4c01
docs(dvcr): reword token TTL comment
danilrwx Jul 3, 2026
59abbd0
refactor(dvcr): drop redundant comments
danilrwx Jul 3, 2026
64a4b62
chore(dvcr): bump distribution to v3.1.1
danilrwx Jul 3, 2026
37c51f5
fix(dvcr): reject x5c/jwk-signed scoped tokens in registry authz
danilrwx Jul 3, 2026
1851b57
fix(dvcr): mint scoped DataVolume token independent of AuthSecret
danilrwx Jul 3, 2026
432b848
fix(dvcr): generate registry passwords with crypto/rand
danilrwx Jul 3, 2026
d096ab0
fix(dvcr): regenerate token keypair when public key is bad or mismatched
danilrwx Jul 3, 2026
f0cdae1
docs(dvcr): note go-jose dependency in registry graft comment
danilrwx Jul 3, 2026
a2122af
fix(dvcr): refresh scoped token secret before it expires
danilrwx Jul 3, 2026
c4c3d31
test(dvcr): cover JWT verifier against real distribution backend
danilrwx Jul 3, 2026
03b7e6c
test(dvcr): cover token keypair validation on mismatch
danilrwx Jul 3, 2026
2c8b7a6
refactor(dvcr): refresh scoped token in its last hour, not at half TTL
danilrwx Jul 3, 2026
5703ea6
refactor(dvcr): tighten comments on scoped-token code
danilrwx Jul 3, 2026
7f0ed50
refactor(core, dvcr): always enable per-namespace registry authorization
danilrwx Jul 6, 2026
5a27998
test(images/vi): expect per-namespace dvcr auth secret name
danilrwx Jul 6, 2026
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 build/components/versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ firmware:
core:
3p-kubevirt: v1.6.2-v12n.53
3p-containerized-data-importer: v1.60.3-v12n.21
distribution: 2.8.3
distribution: 3.1.1
package:
acl: v2.3.1
bzip2: bzip2-1.0.8
Expand Down
269 changes: 269 additions & 0 deletions images/dvcr/dvcr-k8s-auth/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Build tag dvcr_registry: this file depends on the distribution registry module
// and is only compiled inside the werf DVCR build (which passes -tags dvcr_registry).
// The dependency-free policy in policy.go stays unit-testable without it.

//go:build dvcr_registry

package dvcrk8s

import (
"crypto"
"crypto/subtle"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net/http"
"os"
"strings"

jose "github.com/go-jose/go-jose/v4"

"github.com/distribution/distribution/v3/registry/auth"
"github.com/distribution/distribution/v3/registry/auth/token"
)

// jwtSigningAlgs are the JWS algorithms accepted for scoped tokens. ES256 only:
// tokens are minted by the virtualization-controller with an ECDSA P-256 key.
var jwtSigningAlgs = []jose.SignatureAlgorithm{jose.ES256}

func init() {
if err := auth.Register("dvcr-k8s", auth.InitFunc(newAccessController)); err != nil {
log.Printf("failed to register dvcr-k8s auth: %v", err)
}
}

type accessController struct {
realm string

adminUsername string
adminPassword []byte

pullerUsername string
pullerPassword []byte

jwtIssuer string
jwtAudience string
trustedKeys map[string]crypto.PublicKey
}

// newAccessController builds the controller from the `auth: { dvcr-k8s: {...} }`
// config block. Options:
//
// realm string (WWW-Authenticate realm)
// adminusername string
// adminpasswordfile string (path to the admin read-write password)
// pullerusername string
// pullerpasswordfile string (path to the node-puller password)
// jwtissuer string (accepted token issuer)
// jwtaudience string (accepted token audience)
// jwtpublickeyfile string (PEM PKIX public key that signs scoped tokens)
// jwtkeyid string (key id the token header must reference, default "dvcr")
func newAccessController(options map[string]interface{}) (auth.AccessController, error) {
realm, err := optString(options, "realm", "dvcr")
if err != nil {
return nil, err
}

adminUser, err := optString(options, "adminusername", "admin")
if err != nil {
return nil, err
}
adminPass, err := readSecretFile(options, "adminpasswordfile")
if err != nil {
return nil, err
}

pullerUser, err := optString(options, "pullerusername", "node-puller")
if err != nil {
return nil, err
}
pullerPass, err := readSecretFile(options, "pullerpasswordfile")
if err != nil {
return nil, err
}

jwtIssuer, err := optString(options, "jwtissuer", "")
if err != nil {
return nil, err
}
jwtAudience, err := optString(options, "jwtaudience", "")
if err != nil {
return nil, err
}
keyID, err := optString(options, "jwtkeyid", "dvcr")
if err != nil {
return nil, err
}
keyPath, err := optString(options, "jwtpublickeyfile", "")
if err != nil {
return nil, err
}
if keyPath == "" {
return nil, errors.New("dvcr-k8s: jwtpublickeyfile is required")
}
pub, err := loadPublicKey(keyPath)
if err != nil {
return nil, fmt.Errorf("dvcr-k8s: load jwt public key: %w", err)
}

return &accessController{
realm: realm,
adminUsername: adminUser,
adminPassword: adminPass,
pullerUsername: pullerUser,
pullerPassword: pullerPass,
jwtIssuer: jwtIssuer,
jwtAudience: jwtAudience,
trustedKeys: map[string]crypto.PublicKey{keyID: pub},
}, nil
}

// Authorized implements auth.AccessController for distribution v3.
func (ac *accessController) Authorized(req *http.Request, accessRecords ...auth.Access) (*auth.Grant, error) {
username, password, ok := req.BasicAuth()
if !ok || password == "" {
return nil, &challenge{realm: ac.realm, err: auth.ErrInvalidCredential}
}

subject, name, err := ac.classify(username, password)
if err != nil {
// Bad credential -> 401 challenge so the client may retry with valid creds.
return nil, &challenge{realm: ac.realm, err: err}
}

if !Authorize(subject, toPolicyAccess(accessRecords)) {
// Authenticated but not permitted -> 403 (plain error, not a challenge).
return nil, fmt.Errorf("dvcr-k8s: access denied for %q: %w", name, auth.ErrAuthenticationFailure)
}

return &auth.Grant{User: auth.UserInfo{Name: name}}, nil
}

// classify maps a Basic credential to an authorization Subject. The static admin
// and node-puller passwords are matched in constant time; any other credential is
// treated as a signed scoped token, its password verified as a JWT (fail-closed).
func (ac *accessController) classify(username, password string) (Subject, string, error) {
if len(ac.adminPassword) > 0 && username == ac.adminUsername &&
subtle.ConstantTimeCompare([]byte(password), ac.adminPassword) == 1 {
return Subject{Role: RoleAdmin}, username, nil
}

if len(ac.pullerPassword) > 0 && username == ac.pullerUsername &&
subtle.ConstantTimeCompare([]byte(password), ac.pullerPassword) == 1 {
return Subject{Role: RolePuller}, username, nil
}

grants, err := ac.verifyJWT(password)
if err != nil {
return Subject{}, "", fmt.Errorf("scoped token: %w", err)
}
return Subject{Role: RoleScoped, Grants: grants}, "scoped:" + username, nil
}

// verifyJWT parses and verifies a scoped token, reusing distribution's own token
// verification (signature, issuer, audience, nbf/exp), and returns its grants.
func (ac *accessController) verifyJWT(raw string) ([]Grant, error) {
tok, err := token.NewToken(raw, jwtSigningAlgs)
if err != nil {
return nil, err
}
claims, err := tok.Verify(token.VerifyOptions{
TrustedIssuers: []string{ac.jwtIssuer},
AcceptedAudiences: []string{ac.jwtAudience},
TrustedKeys: ac.trustedKeys,
// Non-nil empty pool: distribution tries a token's x5c/jwk chain before the
// pinned kid, and with nil Roots that chain validates against the system CAs.
// An empty pool rejects every chain, leaving only the pinned key.
Roots: x509.NewCertPool(),
})
if err != nil {
return nil, err
}
grants := make([]Grant, 0, len(claims.Access))
for _, ra := range claims.Access {
if ra == nil {
continue
}
grants = append(grants, Grant{Type: ra.Type, Name: ra.Name, Actions: ra.Actions})
}
return grants, nil
}

func toPolicyAccess(records []auth.Access) []Access {
out := make([]Access, len(records))
for i, r := range records {
out[i] = Access{Type: r.Type, Name: r.Name, Action: r.Action}
}
return out
}

// challenge implements auth.Challenge for a 401 Basic auth response.
type challenge struct {
realm string
err error
}

func (ch *challenge) SetHeaders(_ *http.Request, w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", ch.realm))
}

func (ch *challenge) Error() string {
return fmt.Sprintf("dvcr-k8s basic auth challenge for realm %q: %v", ch.realm, ch.err)
}

func loadPublicKey(path string) (crypto.PublicKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("no PEM block found")
}
return x509.ParsePKIXPublicKey(block.Bytes)
}

func optString(options map[string]interface{}, key, def string) (string, error) {
v, present := options[key]
if !present {
return def, nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("dvcr-k8s: option %q must be a string", key)
}
return s, nil
}

func readSecretFile(options map[string]interface{}, key string) ([]byte, error) {
path, err := optString(options, key, "")
if err != nil {
return nil, err
}
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("dvcr-k8s: read %q from %s: %w", key, path, err)
}
return []byte(strings.TrimSpace(string(data))), nil
}
Loading
Loading