Skip to content

Commit 6724230

Browse files
authored
Implement access control for cross-namespace backup restoration (#400)
Restored backups from other namespaces are only allowed if the source namespace has the authorized access label. Change-Id: I409b4ffb32a9c94a335597eff0a5c0c46d3ba962
1 parent 229d4b5 commit 6724230

5 files changed

Lines changed: 259 additions & 5 deletions

File tree

oracle/config/rbac/role.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ rules:
126126
- get
127127
- list
128128
- update
129+
- apiGroups:
130+
- ""
131+
resources:
132+
- namespaces
133+
verbs:
134+
- get
135+
- list
136+
- watch
129137
- apiGroups:
130138
- ""
131139
resources:

oracle/controllers/instancecontroller/instance_controller.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func (r *InstanceReconciler) Scheme() *runtime.Scheme {
8282
// +kubebuilder:rbac:groups=storage.k8s.io,resources=storageclasses,verbs=get;list;watch
8383
// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;create;update
8484
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete
85+
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch
8586

8687
// +kubebuilder:rbac:groups=oracle.db.anthosapis.com,resources=databases,verbs=get;list;watch;create;update;patch;delete
8788
// +kubebuilder:rbac:groups=oracle.db.anthosapis.com,resources=databases/status,verbs=get;update;patch

oracle/controllers/instancecontroller/instance_controller_restore.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import (
3737
"github.com/GoogleCloudPlatform/elcarro-oracle-operator/oracle/pkg/k8s"
3838
)
3939

40+
const crossNSBackupRefAccessLabel = "allow-cross-namespace-backup-reference"
41+
4042
// Reconciler for restore logic.
4143
// Invoked when Spec.Restore is present.
4244
// State transition:
@@ -389,6 +391,15 @@ func (r *InstanceReconciler) findBackupForRestore(ctx context.Context, inst v1al
389391
if inst.Spec.Restore.BackupID != "" || inst.Spec.Restore.PITRRestore != nil {
390392
return nil, fmt.Errorf("preflight check: specify only one of BackupID/BackupRef/PITRRestore")
391393
}
394+
395+
/*
396+
For cross-namespace backup requests, ensure the target namespace permits
397+
cross-namespace references; otherwise, fail the request and set the status condition to False
398+
*/
399+
if err := r.checkCrossNSPermission(ctx, inst); err != nil {
400+
return nil, err
401+
}
402+
392403
// find backup based on BackupRef
393404
if err := r.Get(ctx, types.NamespacedName{Name: backupRef.Name, Namespace: backupRef.Namespace}, &backup); err != nil {
394405
return nil, fmt.Errorf("preflight check: failed to get backup for a restore: %v, backupRef: %v", err, backupRef)
@@ -425,6 +436,24 @@ func (r *InstanceReconciler) findBackupForRestore(ctx context.Context, inst v1al
425436
return &backup, nil
426437
}
427438

439+
func (r *InstanceReconciler) checkCrossNSPermission(ctx context.Context, inst v1alpha1.Instance) error {
440+
if inst.Namespace == inst.Spec.Restore.BackupRef.Namespace {
441+
return nil
442+
}
443+
444+
namespace := inst.Spec.Restore.BackupRef.Namespace
445+
ns := &corev1.Namespace{}
446+
if err := r.Client.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
447+
return fmt.Errorf("failed to get namespace %q: %w", namespace, err)
448+
449+
}
450+
451+
if val, ok := ns.Labels[crossNSBackupRefAccessLabel]; !ok || val != "true" {
452+
return fmt.Errorf("namespace %q does not allow cross-namespace backup references (label '%s' must be set to 'true')", namespace, crossNSBackupRefAccessLabel)
453+
}
454+
return nil
455+
}
456+
428457
// restorePhysical runs the pre-flight checks and if all is good
429458
// it makes a gRPC call to a PhysicalRestore.
430459
func (r *InstanceReconciler) restorePhysical(ctx context.Context, inst v1alpha1.Instance, backup *v1alpha1.Backup, req ctrl.Request, log logr.Logger) (*lropb.Operation, error) {

oracle/controllers/instancecontroller/instance_controller_restore_test.go

Lines changed: 213 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/go-logr/logr"
1111
. "github.com/onsi/ginkgo"
1212
. "github.com/onsi/gomega"
13+
corev1 "k8s.io/api/core/v1"
1314
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1415
"k8s.io/client-go/util/retry"
1516
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -28,18 +29,20 @@ func testInstanceRestore() {
2829
interval = time.Millisecond * 15
2930
)
3031
var (
31-
Namespace string
32-
InstanceName string
33-
BackupName string
34-
BackupID string
35-
ObjKey client.ObjectKey
32+
Namespace string
33+
CrossNamespacePrefix string
34+
InstanceName string
35+
BackupName string
36+
BackupID string
37+
ObjKey client.ObjectKey
3638
)
3739

3840
var fakeDatabaseClient *testhelpers.FakeDatabaseClient
3941
oldPreflightFunc := restorePhysicalPreflightCheck
4042

4143
BeforeEach(func() {
4244
Namespace = "default"
45+
CrossNamespacePrefix = "cross-namespace-"
4346
InstanceName = testhelpers.RandName("test-instance-restore")
4447
BackupName = testhelpers.RandName("test-backup")
4548
BackupID = testhelpers.RandName("test-backup-id")
@@ -67,6 +70,23 @@ func testInstanceRestore() {
6770
ctx := context.Background()
6871
restoreRequestTime := metav1.Now()
6972

73+
createNamespace := func(ctx context.Context, k8sClient client.Client, name string, crossNSLabel bool) {
74+
// 1. Define the Namespace object
75+
ns := &corev1.Namespace{
76+
ObjectMeta: metav1.ObjectMeta{
77+
Name: name,
78+
},
79+
}
80+
81+
if crossNSLabel {
82+
ns.ObjectMeta.Labels = map[string]string{
83+
crossNSBackupRefAccessLabel: "true",
84+
}
85+
}
86+
87+
testhelpers.K8sCreateWithRetry(k8sClient, ctx, ns)
88+
}
89+
7090
createInstanceAndStartRestore := func(mode testhelpers.FakeOperationStatus) (*v1alpha1.Instance, *v1alpha1.Backup) {
7191
instance := createSimpleInstance(ctx, InstanceName, Namespace, timeout, interval)
7292
backup := createSimpleRMANBackup(ctx, InstanceName, BackupName, BackupID, Namespace)
@@ -91,6 +111,36 @@ func testInstanceRestore() {
91111
return instance, backup
92112
}
93113

114+
createInstanceAndStartRestoreWithBackupRef := func(mode testhelpers.FakeOperationStatus, backupNS string) (*v1alpha1.Instance, *v1alpha1.Backup) {
115+
instance := createSimpleInstance(ctx, InstanceName, Namespace, timeout, interval)
116+
var backup *v1alpha1.Backup
117+
if backupNS != "" {
118+
backup = createSimpleRMANBackup(ctx, InstanceName, BackupName, BackupID, backupNS)
119+
120+
}
121+
By("invoking RMAN restore for the Instance with backupRef")
122+
123+
// configure fakeDatabaseClient to be in requested mode
124+
fakeDatabaseClient.SetNextGetOperationStatus(mode)
125+
Expect(retry.RetryOnConflict(retry.DefaultBackoff, func() error {
126+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
127+
return err
128+
}
129+
instance.Spec.Restore = &v1alpha1.RestoreSpec{
130+
BackupRef: &v1alpha1.BackupReference{
131+
Namespace: backupNS,
132+
Name: BackupName,
133+
},
134+
BackupType: "Physical",
135+
Force: true,
136+
RequestTime: restoreRequestTime,
137+
}
138+
return k8sClient.Update(ctx, instance)
139+
})).Should(Succeed())
140+
141+
return instance, backup
142+
}
143+
94144
// extract happy path restore test part into a function for reuse
95145
// in multiple tests
96146
testCaseHappyPathLRORestore := func() (*v1alpha1.Instance, *v1alpha1.Backup) {
@@ -405,6 +455,164 @@ func testInstanceRestore() {
405455
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, ObjKey, instance)
406456
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, client.ObjectKey{Namespace: Namespace, Name: BackupName}, backup)
407457
})
458+
459+
It("it should restore successfully with backupRef of the same namespace", func() {
460+
fakeDatabaseClient.SetAsyncPhysicalRestore(false)
461+
instance, backup := createInstanceAndStartRestoreWithBackupRef(testhelpers.StatusDone, Namespace)
462+
463+
By("checking that instance status is Ready")
464+
Eventually(func() (metav1.ConditionStatus, error) {
465+
return getConditionStatus(ctx, ObjKey, k8s.Ready)
466+
}, timeout, interval).Should(Equal(metav1.ConditionTrue))
467+
468+
Expect(fakeDatabaseClient.DeleteOperationCalledCnt()).Should(Equal(0))
469+
470+
By("checking that instance Restore section is deleted")
471+
Eventually(func() error {
472+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
473+
return err
474+
}
475+
if instance.Spec.Restore != nil {
476+
return fmt.Errorf("expected update has not yet happened")
477+
}
478+
return nil
479+
}, timeout, interval).Should(Succeed())
480+
481+
By("checking that instance Status.Description is updated")
482+
Eventually(func() error {
483+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
484+
return err
485+
}
486+
if !strings.HasPrefix(instance.Status.Description, "Restored on") {
487+
return fmt.Errorf("%q does not have expected prefix", instance.Status.Description)
488+
}
489+
return nil
490+
}, timeout, interval).Should(Succeed())
491+
492+
By("checking that instance maintenance lock is released")
493+
// Instance object should be fresh at this point, no need to retry
494+
Expect(instance.Status.LockedByController).Should(Equal(""))
495+
496+
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, ObjKey, instance)
497+
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, client.ObjectKey{Namespace: Namespace, Name: BackupName}, backup)
498+
})
499+
500+
It("it should restore fail with cross-namespace backupRef if the cross-namespace backupRef specifies a namespace that does not exist.", func() {
501+
fakeDatabaseClient.SetAsyncPhysicalRestore(false)
502+
instance, _ := createInstanceAndStartRestoreWithBackupRef(testhelpers.StatusDone, "")
503+
504+
By("checking that instance Restore section is deleted")
505+
Eventually(func() error {
506+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
507+
return err
508+
}
509+
if instance.Spec.Restore != nil {
510+
return fmt.Errorf("expected update has not yet happened")
511+
}
512+
return nil
513+
}, timeout, interval).Should(Succeed())
514+
515+
By("checking that instance Status.Description is updated")
516+
Eventually(func() error {
517+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
518+
return err
519+
}
520+
cond := k8s.FindCondition(instance.Status.Conditions, k8s.Ready)
521+
if !strings.HasPrefix(cond.Message, "Could not find a matching backup") {
522+
return fmt.Errorf("%q does not have expected prefix in the condition", cond.Message)
523+
}
524+
return nil
525+
}, timeout, interval).Should(Succeed())
526+
527+
By("checking that instance maintenance lock is released")
528+
// Instance object should be fresh at this point, no need to retry
529+
Expect(instance.Status.LockedByController).Should(Equal(""))
530+
531+
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, ObjKey, instance)
532+
})
533+
534+
It("it should restore fail with cross-namespace backupRef without cross-namespace backupRef access label", func() {
535+
fakeDatabaseClient.SetAsyncPhysicalRestore(false)
536+
// Create a different namespace
537+
crossNS := CrossNamespacePrefix + "1"
538+
createNamespace(ctx, k8sClient, crossNS, false)
539+
540+
instance, backup := createInstanceAndStartRestoreWithBackupRef(testhelpers.StatusDone, crossNS)
541+
542+
By("checking that instance Restore section is deleted")
543+
Eventually(func() error {
544+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
545+
return err
546+
}
547+
if instance.Spec.Restore != nil {
548+
return fmt.Errorf("expected update has not yet happened")
549+
}
550+
return nil
551+
}, timeout, interval).Should(Succeed())
552+
553+
By("checking that instance Status.Description is updated")
554+
Eventually(func() error {
555+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
556+
return err
557+
}
558+
cond := k8s.FindCondition(instance.Status.Conditions, k8s.Ready)
559+
if !strings.HasPrefix(cond.Message, "Could not find a matching backup") {
560+
return fmt.Errorf("%q does not have expected prefix in the condition", cond.Message)
561+
}
562+
return nil
563+
}, timeout, interval).Should(Succeed())
564+
565+
By("checking that instance maintenance lock is released")
566+
// Instance object should be fresh at this point, no need to retry
567+
Expect(instance.Status.LockedByController).Should(Equal(""))
568+
569+
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, ObjKey, instance)
570+
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, client.ObjectKey{Namespace: crossNS, Name: BackupName}, backup)
571+
})
572+
573+
It("it should restore successfully with cross-namespace backupRef if the cross namespace is labeled with allow-cross-namespace-backup-reference", func() {
574+
fakeDatabaseClient.SetAsyncPhysicalRestore(false)
575+
// Create a different namespace
576+
crossNS := CrossNamespacePrefix + "2"
577+
createNamespace(ctx, k8sClient, crossNS, true)
578+
579+
instance, backup := createInstanceAndStartRestoreWithBackupRef(testhelpers.StatusDone, crossNS)
580+
By("checking that instance status is Ready")
581+
Eventually(func() (metav1.ConditionStatus, error) {
582+
return getConditionStatus(ctx, ObjKey, k8s.Ready)
583+
}, timeout, interval).Should(Equal(metav1.ConditionTrue))
584+
585+
Expect(fakeDatabaseClient.DeleteOperationCalledCnt()).Should(Equal(0))
586+
587+
By("checking that instance Restore section is deleted")
588+
Eventually(func() error {
589+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
590+
return err
591+
}
592+
if instance.Spec.Restore != nil {
593+
return fmt.Errorf("expected update has not yet happened")
594+
}
595+
return nil
596+
}, timeout, interval).Should(Succeed())
597+
598+
By("checking that instance Status.Description is updated")
599+
Eventually(func() error {
600+
if err := k8sClient.Get(ctx, ObjKey, instance); err != nil {
601+
return err
602+
}
603+
if !strings.HasPrefix(instance.Status.Description, "Restored on") {
604+
return fmt.Errorf("%q does not have expected prefix", instance.Status.Description)
605+
}
606+
return nil
607+
}, timeout, interval).Should(Succeed())
608+
609+
By("checking that instance maintenance lock is released")
610+
// Instance object should be fresh at this point, no need to retry
611+
Expect(instance.Status.LockedByController).Should(Equal(""))
612+
613+
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, ObjKey, instance)
614+
testhelpers.K8sDeleteWithRetry(k8sClient, ctx, client.ObjectKey{Namespace: crossNS, Name: BackupName}, backup)
615+
})
408616
}
409617

410618
func createSimpleRMANBackup(ctx context.Context, instanceName string, backupName string, backupID string, namespace string) *v1alpha1.Backup {

oracle/operator.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3865,6 +3865,14 @@ rules:
38653865
- get
38663866
- list
38673867
- update
3868+
- apiGroups:
3869+
- ""
3870+
resources:
3871+
- namespaces
3872+
verbs:
3873+
- get
3874+
- list
3875+
- watch
38683876
- apiGroups:
38693877
- ""
38703878
resources:

0 commit comments

Comments
 (0)