Skip to content

Commit cc57649

Browse files
Merge pull request #1877 from rabi/OSPRH-28852
Auto-detect sigstore ClusterImagePolicy for EDPM signature verification
2 parents d7452fc + 922466f commit cc57649

6 files changed

Lines changed: 710 additions & 10 deletions

File tree

bindata/rbac/rbac.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ rules:
304304
- apiGroups:
305305
- config.openshift.io
306306
resources:
307+
- clusterimagepolicies
307308
- imagedigestmirrorsets
308309
- images
309310
- networks

config/rbac/role.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ rules:
255255
- apiGroups:
256256
- config.openshift.io
257257
resources:
258+
- clusterimagepolicies
258259
- imagedigestmirrorsets
259260
- images
260261
- networks

internal/controller/dataplane/openstackdataplanenodeset_controller.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ func (r *OpenStackDataPlaneNodeSetReconciler) GetLogger(ctx context.Context) log
133133

134134
// RBAC for ImageContentSourcePolicy and MachineConfig
135135
// +kubebuilder:rbac:groups="operator.openshift.io",resources=imagecontentsourcepolicies,verbs=get;list;watch
136+
// +kubebuilder:rbac:groups="config.openshift.io",resources=clusterimagepolicies,verbs=get;list;watch
136137
// +kubebuilder:rbac:groups="config.openshift.io",resources=imagedigestmirrorsets,verbs=get;list;watch
137138
// +kubebuilder:rbac:groups="config.openshift.io",resources=images,verbs=get;list;watch
138139
// +kubebuilder:rbac:groups="machineconfiguration.openshift.io",resources=machineconfigs,verbs=get;list;watch

internal/dataplane/inventory.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,27 +145,38 @@ func GenerateNodeSetInventory(ctx context.Context, helper *helper.Helper,
145145
registryConfig, err := util.GetMCRegistryConf(ctx, helper)
146146
if err != nil {
147147
// CRD not installed (non-OpenShift or no MCO) - log warning and continue.
148-
// This allows graceful degradation when running on non-OpenShift clusters.
149148
// Users can manually configure registries.conf via ansibleVars.
150149
if util.IsNoMatchError(err) {
151-
helper.GetLogger().Info("Disconnected environment detected but MachineConfig CRD not available. "+
152-
"Registry configuration will not be propagated to dataplane nodes. "+
153-
"You may need to configure registries.conf manually using ansibleVars "+
154-
"(edpm_podman_disconnected_ocp and edpm_podman_registries_conf).",
150+
helper.GetLogger().Info("MachineConfig CRD not available; registry config will not be propagated",
155151
"error", err.Error())
156152
} else {
157-
// CRD exists but resource not found, or other errors (network issues,
158-
// permissions, etc.) - return the error. If MCO is installed but the
159-
// registry MachineConfig doesn't exist, this indicates a misconfiguration.
160153
return "", fmt.Errorf("failed to get MachineConfig registry configuration: %w", err)
161154
}
162155
} else {
163156
helper.GetLogger().Info("Mirror registries detected via IDMS/ICSP. Using OCP registry configuration.")
164-
165-
// Use OCP registries.conf for mirror registry deployments
166157
nodeSetGroup.Vars["edpm_podman_registries_conf"] = registryConfig
167158
nodeSetGroup.Vars["edpm_podman_disconnected_ocp"] = hasMirrorRegistries
168159
}
160+
161+
mirrorScopes, sourceByMirror, err := util.GetMirrorRegistryScopes(ctx, helper)
162+
if err != nil {
163+
return "", fmt.Errorf("failed to get mirror registries for sigstore verification: %w", err)
164+
}
165+
166+
sigstorePolicy, err := util.GetSigstoreImagePolicy(ctx, helper, mirrorScopes, sourceByMirror)
167+
if err != nil {
168+
return "", fmt.Errorf("failed to get ClusterImagePolicy for sigstore verification: %w", err)
169+
}
170+
if sigstorePolicy != nil {
171+
nodeSetGroup.Vars["edpm_container_signature_verification"] = true
172+
nodeSetGroup.Vars["edpm_container_signature_registry_mappings"] = sigstorePolicy.RegistryMappings
173+
nodeSetGroup.Vars["edpm_container_signature_cosign_key_data"] = sigstorePolicy.CosignKeyData
174+
if sigstorePolicy.SignedPrefix != "" {
175+
nodeSetGroup.Vars["edpm_container_signature_signed_prefix"] = sigstorePolicy.SignedPrefix
176+
}
177+
} else {
178+
helper.GetLogger().Info("No matching ClusterImagePolicy found; skipping sigstore verification")
179+
}
169180
}
170181

171182
// add TLS ansible variable

internal/dataplane/util/image_registry.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ import (
55
"encoding/base64"
66
"encoding/json"
77
"fmt"
8+
"sort"
89
"strings"
910

1011
ocpconfigv1 "github.com/openshift/api/config/v1"
1112
mc "github.com/openshift/api/machineconfiguration/v1"
1213
ocpicsp "github.com/openshift/api/operator/v1alpha1"
1314
"github.com/openstack-k8s-operators/lib-common/modules/common/helper"
1415
corev1 "k8s.io/api/core/v1"
16+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1517
k8s_errors "k8s.io/apimachinery/pkg/api/errors"
1618
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
20+
"k8s.io/apimachinery/pkg/runtime/schema"
1721
"k8s.io/apimachinery/pkg/types"
1822
)
1923

@@ -65,6 +69,78 @@ func HasMirrorRegistries(ctx context.Context, helper *helper.Helper) (bool, erro
6569
return false, nil
6670
}
6771

72+
func sortedSetKeys(set map[string]struct{}) []string {
73+
if len(set) == 0 {
74+
return nil
75+
}
76+
result := make([]string, 0, len(set))
77+
for k := range set {
78+
result = append(result, k)
79+
}
80+
sort.Strings(result)
81+
return result
82+
}
83+
84+
// GetMirrorRegistryScopes returns the configured mirror scopes and their
85+
// source registry mapping, preferring IDMS and falling back to ICSP.
86+
// The returned scopes are normalized and de-duplicated for policy matching.
87+
// The sourceByMirror map links each mirror scope back to its IDMS/ICSP source.
88+
func GetMirrorRegistryScopes(ctx context.Context, helper *helper.Helper) ([]string, map[string]string, error) {
89+
idmsList := &ocpconfigv1.ImageDigestMirrorSetList{}
90+
if err := helper.GetClient().List(ctx, idmsList); err != nil {
91+
if !IsNoMatchError(err) {
92+
return nil, nil, err
93+
}
94+
} else {
95+
scopes := map[string]struct{}{}
96+
sourceByMirror := map[string]string{}
97+
for _, idms := range idmsList.Items {
98+
for _, mirrorSet := range idms.Spec.ImageDigestMirrors {
99+
source := normalizeImageScope(string(mirrorSet.Source))
100+
for _, mirror := range mirrorSet.Mirrors {
101+
m := normalizeImageScope(string(mirror))
102+
if m != "" {
103+
scopes[m] = struct{}{}
104+
if source != "" {
105+
sourceByMirror[m] = source
106+
}
107+
}
108+
}
109+
}
110+
}
111+
if result := sortedSetKeys(scopes); len(result) > 0 {
112+
return result, sourceByMirror, nil
113+
}
114+
}
115+
116+
icspList := &ocpicsp.ImageContentSourcePolicyList{}
117+
if err := helper.GetClient().List(ctx, icspList); err != nil {
118+
if !IsNoMatchError(err) {
119+
return nil, nil, err
120+
}
121+
} else {
122+
scopes := map[string]struct{}{}
123+
sourceByMirror := map[string]string{}
124+
for _, icsp := range icspList.Items {
125+
for _, mirrorSet := range icsp.Spec.RepositoryDigestMirrors {
126+
source := normalizeImageScope(mirrorSet.Source)
127+
for _, mirror := range mirrorSet.Mirrors {
128+
m := normalizeImageScope(mirror)
129+
if m != "" {
130+
scopes[m] = struct{}{}
131+
if source != "" {
132+
sourceByMirror[m] = source
133+
}
134+
}
135+
}
136+
}
137+
}
138+
return sortedSetKeys(scopes), sourceByMirror, nil
139+
}
140+
141+
return nil, nil, nil
142+
}
143+
68144
// IsNoMatchError checks if the error indicates that a CRD/resource type doesn't exist
69145
func IsNoMatchError(err error) bool {
70146
errStr := err.Error()
@@ -151,6 +227,217 @@ func getMachineConfig(ctx context.Context, helper *helper.Helper) (mc.MachineCon
151227
return masterMachineConfig, nil
152228
}
153229

230+
// RegistryMapping pairs a mirror registry with its upstream source from IDMS/ICSP.
231+
type RegistryMapping struct {
232+
Mirror string `json:"mirror"`
233+
Source string `json:"source"`
234+
}
235+
236+
// SigstorePolicyInfo contains the EDPM-relevant parts of a ClusterImagePolicy.
237+
// A single RemapIdentity signedPrefix covers all mirrors under the same registry
238+
// root — the container runtime replaces only the prefix, preserving namespace paths.
239+
type SigstorePolicyInfo struct {
240+
RegistryMappings []RegistryMapping
241+
CosignKeyData string
242+
SignedPrefix string
243+
}
244+
245+
const (
246+
clusterImagePolicyCRDName = "clusterimagepolicies.config.openshift.io"
247+
clusterImagePolicyGroup = "config.openshift.io"
248+
clusterImagePolicyKind = "ClusterImagePolicy"
249+
clusterImagePolicyV1 = "v1"
250+
clusterImagePolicyV1Alpha1 = "v1alpha1"
251+
publicKeyRootOfTrustPolicyType = "PublicKey"
252+
remapIdentityMatchPolicy = "RemapIdentity"
253+
)
254+
255+
func normalizeImageScope(scope string) string {
256+
return strings.TrimSuffix(strings.TrimSpace(scope), "/")
257+
}
258+
259+
func clusterImagePolicyScopeMatchesMirror(policyScope string, mirrorScope string) bool {
260+
policyScope = normalizeImageScope(policyScope)
261+
mirrorScope = normalizeImageScope(mirrorScope)
262+
263+
if policyScope == "" || mirrorScope == "" {
264+
return false
265+
}
266+
267+
if strings.HasPrefix(policyScope, "*.") {
268+
mirrorHostPort := strings.SplitN(mirrorScope, "/", 2)[0]
269+
mirrorHost := strings.SplitN(mirrorHostPort, ":", 2)[0]
270+
suffix := strings.TrimPrefix(policyScope, "*")
271+
return strings.HasSuffix(mirrorHost, suffix)
272+
}
273+
274+
return mirrorScope == policyScope || strings.HasPrefix(mirrorScope, policyScope+"/")
275+
}
276+
277+
func getServedClusterImagePolicyVersion(ctx context.Context, helper *helper.Helper) (string, error) {
278+
crd := &apiextensionsv1.CustomResourceDefinition{}
279+
if err := helper.GetClient().Get(ctx, types.NamespacedName{Name: clusterImagePolicyCRDName}, crd); err != nil {
280+
if k8s_errors.IsNotFound(err) || IsNoMatchError(err) {
281+
return "", nil
282+
}
283+
return "", err
284+
}
285+
286+
for _, preferredVersion := range []string{clusterImagePolicyV1, clusterImagePolicyV1Alpha1} {
287+
for _, version := range crd.Spec.Versions {
288+
if version.Name == preferredVersion && version.Served {
289+
return preferredVersion, nil
290+
}
291+
}
292+
}
293+
294+
return "", nil
295+
}
296+
297+
func listClusterImagePolicies(
298+
ctx context.Context,
299+
helper *helper.Helper,
300+
version string,
301+
) (*unstructured.UnstructuredList, error) {
302+
// Use an unstructured client here because ClusterImagePolicy may be served as
303+
// either v1 or v1alpha1 depending on the cluster; binding to a typed v1 API
304+
// would fail on clusters that do not serve v1 yet.
305+
policyList := &unstructured.UnstructuredList{}
306+
policyList.SetGroupVersionKind(schema.GroupVersionKind{
307+
Group: clusterImagePolicyGroup,
308+
Version: version,
309+
Kind: clusterImagePolicyKind + "List",
310+
})
311+
312+
if err := helper.GetClient().List(ctx, policyList); err != nil {
313+
if IsNoMatchError(err) {
314+
return nil, nil
315+
}
316+
return nil, err
317+
}
318+
319+
return policyList, nil
320+
}
321+
322+
// GetSigstoreImagePolicy checks if OCP has a ClusterImagePolicy configured
323+
// with sigstore signature verification for one of the mirror registries in use.
324+
// sourceByMirror maps each mirror scope to its upstream source registry (from IDMS/ICSP).
325+
// Returns policy info if a relevant policy is found, nil if no policy exists.
326+
// Returns nil without error if the ClusterImagePolicy CRD is not installed.
327+
func GetSigstoreImagePolicy(ctx context.Context, helper *helper.Helper, mirrorScopes []string, sourceByMirror map[string]string) (*SigstorePolicyInfo, error) {
328+
if len(mirrorScopes) == 0 {
329+
return nil, nil
330+
}
331+
332+
version, err := getServedClusterImagePolicyVersion(ctx, helper)
333+
if err != nil {
334+
return nil, err
335+
}
336+
if version == "" {
337+
return nil, nil
338+
}
339+
340+
policyList, err := listClusterImagePolicies(ctx, helper, version)
341+
if err != nil {
342+
return nil, err
343+
}
344+
if policyList == nil {
345+
return nil, nil
346+
}
347+
348+
var matches []string
349+
var match *SigstorePolicyInfo
350+
351+
for _, policy := range policyList.Items {
352+
if policy.GetName() == "openshift" {
353+
continue
354+
}
355+
356+
policyType, found, err := unstructured.NestedString(policy.Object, "spec", "policy", "rootOfTrust", "policyType")
357+
if err != nil {
358+
return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s policyType: %w", policy.GetName(), err)
359+
}
360+
if !found || policyType != publicKeyRootOfTrustPolicyType {
361+
continue
362+
}
363+
364+
keyData, found, err := unstructured.NestedString(policy.Object, "spec", "policy", "rootOfTrust", "publicKey", "keyData")
365+
if err != nil {
366+
return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s keyData: %w", policy.GetName(), err)
367+
}
368+
if !found || len(keyData) == 0 {
369+
continue
370+
}
371+
372+
scopes, found, err := unstructured.NestedStringSlice(policy.Object, "spec", "scopes")
373+
if err != nil {
374+
return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s scopes: %w", policy.GetName(), err)
375+
}
376+
if !found || len(scopes) == 0 {
377+
continue
378+
}
379+
380+
signedPrefix := ""
381+
matchPolicy, found, err := unstructured.NestedString(policy.Object, "spec", "policy", "signedIdentity", "matchPolicy")
382+
if err != nil {
383+
return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s matchPolicy: %w", policy.GetName(), err)
384+
}
385+
if found && matchPolicy == remapIdentityMatchPolicy {
386+
signedPrefix, _, err = unstructured.NestedString(
387+
policy.Object,
388+
"spec", "policy", "signedIdentity", "remapIdentity", "signedPrefix",
389+
)
390+
if err != nil {
391+
return nil, fmt.Errorf("failed to parse ClusterImagePolicy %s signedPrefix: %w", policy.GetName(), err)
392+
}
393+
}
394+
395+
matchedMirrorScopes := map[string]struct{}{}
396+
for _, scope := range scopes {
397+
policyScope := normalizeImageScope(scope)
398+
if policyScope == "" {
399+
continue
400+
}
401+
402+
for _, mirrorScope := range mirrorScopes {
403+
if clusterImagePolicyScopeMatchesMirror(policyScope, mirrorScope) {
404+
matchedMirrorScopes[normalizeImageScope(mirrorScope)] = struct{}{}
405+
}
406+
}
407+
}
408+
if len(matchedMirrorScopes) == 0 {
409+
continue
410+
}
411+
412+
sortedMirrors := sortedSetKeys(matchedMirrorScopes)
413+
414+
mappings := make([]RegistryMapping, 0, len(sortedMirrors))
415+
for _, m := range sortedMirrors {
416+
mappings = append(mappings, RegistryMapping{
417+
Mirror: m,
418+
Source: sourceByMirror[m],
419+
})
420+
}
421+
422+
matches = append(matches, fmt.Sprintf("%s (%s)", policy.GetName(), strings.Join(sortedMirrors, ", ")))
423+
match = &SigstorePolicyInfo{
424+
RegistryMappings: mappings,
425+
CosignKeyData: keyData,
426+
SignedPrefix: signedPrefix,
427+
}
428+
}
429+
430+
if len(matches) > 1 {
431+
sort.Strings(matches)
432+
return nil, fmt.Errorf(
433+
"expected exactly one ClusterImagePolicy matching mirror registries, found %d: %s",
434+
len(matches), strings.Join(matches, ", "),
435+
)
436+
}
437+
438+
return match, nil
439+
}
440+
154441
// GetMirrorRegistryCACerts retrieves CA certificates from image.config.openshift.io/cluster.
155442
// Returns nil without error if:
156443
// - not on OpenShift (Image CRD doesn't exist)

0 commit comments

Comments
 (0)