Skip to content

Commit 0cdfffa

Browse files
committed
feat: add improved registry enforcement
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
2 parents e9f3170 + 6120885 commit 0cdfffa

7 files changed

Lines changed: 186 additions & 57 deletions

File tree

api/v1beta2/rule_status_type.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,8 @@ type RuleStatusStatus struct {
3333
// +kubebuilder:subresource:status
3434
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Ready Status"
3535
// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Ready Message"
36-
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"
3736
type RuleStatus struct {
38-
metav1.TypeMeta `json:",inline"`
39-
40-
// +optional
37+
metav1.TypeMeta `json:",inline"`
4138
metav1.ObjectMeta `json:"metadata,omitzero"`
4239

4340
// +optional

charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@ spec:
2323
jsonPath: .status.conditions[?(@.type=="Ready")].message
2424
name: Message
2525
type: string
26-
- description: Age
27-
jsonPath: .metadata.creationTimestamp
28-
name: Age
29-
type: date
3026
name: v1beta2
3127
schema:
3228
openAPIV3Schema:
@@ -308,6 +304,8 @@ spec:
308304
required:
309305
- conditions
310306
type: object
307+
required:
308+
- metadata
311309
type: object
312310
served: true
313311
storage: true

e2e/rules_registry_test.go

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label(
122122
},
123123
},
124124
{
125+
NamespaceSelector: &metav1.LabelSelector{
126+
MatchLabels: map[string]string{
127+
"negate": "true",
128+
},
129+
},
125130
NamespaceRuleBodyNamespace: rules.NamespaceRuleBodyNamespace{
126131
Enforce: rules.NamespaceRuleEnforceBody{
127132
Action: rules.ActionTypeDeny,
@@ -138,11 +143,6 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label(
138143
},
139144
},
140145
},
141-
NamespaceSelector: &metav1.LabelSelector{
142-
MatchLabels: map[string]string{
143-
"negate": "true",
144-
},
145-
},
146146
},
147147
},
148148
},
@@ -370,6 +370,36 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label(
370370
})
371371
})
372372

373+
It("stores namespace-selector matched negated regex rules as independent status rule blocks", func() {
374+
ns := NewNamespace("", map[string]string{
375+
"negate": "true",
376+
meta.TenantLabel: tnt.GetName(),
377+
})
378+
379+
NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
380+
NamespaceIsPartOfTenant(tnt, ns).Should(Succeed())
381+
382+
expectNamespaceStatusRules(ns.GetName(), []expectedStatusRule{
383+
{
384+
action: rules.ActionTypeAllow,
385+
expressions: []string{"harbor/.*"},
386+
},
387+
{
388+
action: rules.ActionTypeDeny,
389+
expressions: []string{"harbor/customer/.*"},
390+
},
391+
{
392+
action: rules.ActionTypeAudit,
393+
expressions: []string{"audit/.*"},
394+
},
395+
{
396+
action: rules.ActionTypeDeny,
397+
expressions: []string{"trusted/.*"},
398+
negated: []bool{true},
399+
},
400+
})
401+
})
402+
373403
It("allows a broad matching allow rule", func() {
374404
ns := NewNamespace("", map[string]string{
375405
meta.TenantLabel: tnt.GetName(),
@@ -472,6 +502,29 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label(
472502
)
473503
})
474504

505+
It("applies negated regex rules using the nested regex expression", func() {
506+
ns := NewNamespace("", map[string]string{
507+
"negate": "true",
508+
meta.TenantLabel: tnt.GetName(),
509+
})
510+
511+
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
512+
513+
NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
514+
NamespaceIsPartOfTenant(tnt, ns).Should(Succeed())
515+
516+
allowed := restrictedPod("negate-trusted-allowed", "trusted/team/app:1", corev1.PullIfNotPresent)
517+
createPodAndExpectAllowed(cs, ns.Name, allowed)
518+
519+
denied := restrictedPod("negate-untrusted-denied", "untrusted/team/app:1", corev1.PullIfNotPresent)
520+
createPodAndExpectDenied(cs, ns.Name, denied,
521+
"containers[0]",
522+
"untrusted/team/app:1",
523+
"denied",
524+
"trusted/.*",
525+
)
526+
})
527+
475528
It("evaluates init containers with the same multi-rule action semantics", func() {
476529
ns := NewNamespace("", map[string]string{
477530
meta.TenantLabel: tnt.GetName(),

hack/distro/capsule/example-setup/tenants.yaml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,12 @@ spec:
9393
action: "deny"
9494
registries:
9595
- url: "harbor/.*"
96-
9796
- enforce:
9897
action: "allow"
9998
registries:
10099
- url: "harbor/customer/.*"
101100
policy:
102101
- "Never"
103-
104-
105102
- namespaceSelector:
106103
matchExpressions:
107104
- key: env

internal/cache/registries.go

Lines changed: 64 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,13 @@ func (c *RegistryRuleSetCache) GetOrBuild(specRules []rules.OCIRegistry) (rs *Ru
113113
return built, false, nil
114114
}
115115

116+
// Match matches a reference against target, regex and pullPolicy.
117+
// Admission deny/allow/audit evaluation should usually use MatchReference instead,
118+
// because it needs to distinguish "regex matched but pullPolicy is forbidden" from
119+
// "regex did not match".
116120
func (c *RegistryRuleSetCache) Match(
117121
specRules []rules.OCIRegistry,
118-
image string,
122+
reference string,
119123
pullPolicy corev1.PullPolicy,
120124
target rules.RegistryValidationTarget,
121125
) (*CompiledRule, error) {
@@ -128,12 +132,13 @@ func (c *RegistryRuleSetCache) Match(
128132
return nil, nil
129133
}
130134

131-
return c.MatchRuleSet(rs, image, pullPolicy, target)
135+
return c.MatchRuleSet(rs, reference, pullPolicy, target)
132136
}
133137

138+
// MatchRuleSet matches a reference against target, regex and pullPolicy.
134139
func (c *RegistryRuleSetCache) MatchRuleSet(
135140
rs *RuleSet,
136-
image string,
141+
reference string,
137142
pullPolicy corev1.PullPolicy,
138143
target rules.RegistryValidationTarget,
139144
) (*CompiledRule, error) {
@@ -165,7 +170,46 @@ func (c *RegistryRuleSetCache) MatchRuleSet(
165170
return nil, err
166171
}
167172

168-
if compiled.MatchString(image) {
173+
if compiled.MatchString(reference) {
174+
return rule, nil
175+
}
176+
}
177+
178+
return nil, nil
179+
}
180+
181+
// MatchReference matches a reference against target and regex only.
182+
// It intentionally does not check pullPolicy.
183+
func (c *RegistryRuleSetCache) MatchReference(
184+
rs *RuleSet,
185+
reference string,
186+
target rules.RegistryValidationTarget,
187+
) (*CompiledRule, error) {
188+
if c == nil {
189+
return nil, fmt.Errorf("registry rule set cache is nil")
190+
}
191+
192+
if c.regexCache == nil {
193+
return nil, fmt.Errorf("regex cache is nil")
194+
}
195+
196+
if rs == nil {
197+
return nil, nil
198+
}
199+
200+
for i := range rs.Compiled {
201+
rule := &rs.Compiled[i]
202+
203+
if !rule.MatchesTarget(target) {
204+
continue
205+
}
206+
207+
compiled, _, err := c.regexCache.GetOrCompile(rule.Expression)
208+
if err != nil {
209+
return nil, err
210+
}
211+
212+
if compiled.MatchString(reference) {
169213
return rule, nil
170214
}
171215
}
@@ -174,6 +218,10 @@ func (c *RegistryRuleSetCache) MatchRuleSet(
174218
}
175219

176220
func (c *RegistryRuleSetCache) Stats() int {
221+
if c == nil {
222+
return 0
223+
}
224+
177225
c.mu.RLock()
178226
defer c.mu.RUnlock()
179227

@@ -182,6 +230,10 @@ func (c *RegistryRuleSetCache) Stats() int {
182230

183231
// activeIDs: set of ids currently referenced by RuleStatus in cluster.
184232
func (c *RegistryRuleSetCache) PruneActive(activeIDs map[string]struct{}) int {
233+
if c == nil {
234+
return 0
235+
}
236+
185237
c.mu.Lock()
186238
defer c.mu.Unlock()
187239

@@ -267,6 +319,10 @@ func (c *RegistryRuleSetCache) HashRules(specRules []rules.OCIRegistry) string {
267319

268320
// Has is useful in tests and debugging.
269321
func (c *RegistryRuleSetCache) Has(id string) bool {
322+
if c == nil {
323+
return false
324+
}
325+
270326
c.mu.RLock()
271327
defer c.mu.RUnlock()
272328

@@ -276,47 +332,14 @@ func (c *RegistryRuleSetCache) Has(id string) bool {
276332
}
277333

278334
func (c *RegistryRuleSetCache) Reset() {
279-
c.mu.Lock()
280-
defer c.mu.Unlock()
281-
282-
c.rs = make(map[string]*RuleSet)
283-
}
284-
285-
func (c *RegistryRuleSetCache) MatchReference(
286-
rs *RuleSet,
287-
reference string,
288-
target rules.RegistryValidationTarget,
289-
) (*CompiledRule, error) {
290335
if c == nil {
291-
return nil, fmt.Errorf("registry rule set cache is nil")
336+
return
292337
}
293338

294-
if c.regexCache == nil {
295-
return nil, fmt.Errorf("regex cache is nil")
296-
}
297-
298-
if rs == nil {
299-
return nil, nil
300-
}
301-
302-
for i := range rs.Compiled {
303-
rule := &rs.Compiled[i]
304-
305-
if !rule.MatchesTarget(target) {
306-
continue
307-
}
308-
309-
compiled, _, err := c.regexCache.GetOrCompile(rule.Expression)
310-
if err != nil {
311-
return nil, err
312-
}
313-
314-
if compiled.MatchString(reference) {
315-
return rule, nil
316-
}
317-
}
339+
c.mu.Lock()
340+
defer c.mu.Unlock()
318341

319-
return nil, nil
342+
c.rs = make(map[string]*RuleSet)
320343
}
321344

322345
// InsertForTest can be behind a build tag if you prefer, but it is fine to keep simple.

pkg/api/registry.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2020-2026 Project Capsule Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package api
5+
6+
import corev1 "k8s.io/api/core/v1"
7+
8+
// +kubebuilder:validation:Enum=Always;Never;IfNotPresent
9+
type ImagePullPolicySpec string
10+
11+
func (i ImagePullPolicySpec) String() string {
12+
return string(i)
13+
}
14+
15+
// +kubebuilder:validation:Enum=pod/images;pod/volumes
16+
type RegistryValidationTarget string
17+
18+
const (
19+
ValidateImages RegistryValidationTarget = "pod/images"
20+
ValidateVolumes RegistryValidationTarget = "pod/volumes"
21+
)
22+
23+
// +kubebuilder:object:generate=true
24+
type OCIRegistry struct {
25+
// OCI Registry endpoint, is treated as regular expression.
26+
Registry string `json:"url,omitzero"`
27+
28+
// Allowed PullPolicy for the given registry. Supplying no value allows all policies.
29+
// +optional
30+
// +kubebuilder:validation:Items:Enum=Always;Never;IfNotPresent
31+
Policy []corev1.PullPolicy `json:"policy,omitempty"`
32+
33+
// Requesting Resources
34+
//+kubebuilder:default:={pod/images,pod/volumes}
35+
Validation []RegistryValidationTarget `json:"validation,omitempty"`
36+
}

pkg/api/zz_generated.deepcopy.go

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)