Skip to content

Commit a772f61

Browse files
committed
Implement BillingEntity RBAC reconcile
Used a cron job since we never implemented watch for BEs.
1 parent 4e11da9 commit a772f61

File tree

7 files changed

+323
-76
lines changed

7 files changed

+323
-76
lines changed

apiserver/billing/rbac.go

Lines changed: 7 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"fmt"
66

77
"go.uber.org/multierr"
8-
rbacv1 "k8s.io/api/rbac/v1"
98
apimeta "k8s.io/apimachinery/pkg/api/meta"
109
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1110
"k8s.io/apimachinery/pkg/runtime"
@@ -15,6 +14,7 @@ import (
1514
"sigs.k8s.io/controller-runtime/pkg/client"
1615

1716
"github.com/appuio/control-api/apiserver/billing/odoostorage"
17+
"github.com/appuio/control-api/pkg/billingrbac"
1818
)
1919

2020
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles;clusterrolebindings,verbs=get;list;watch;create;delete;patch;update;edit
@@ -44,68 +44,12 @@ func (c *createRBACWrapper) Create(ctx context.Context, obj runtime.Object, crea
4444
return createdObj, fmt.Errorf("could not get name of created object: %w", err)
4545
}
4646

47-
viewRoleName := fmt.Sprintf("billingentities-%s-viewer", objName)
48-
viewRole := &rbacv1.ClusterRole{
49-
ObjectMeta: metav1.ObjectMeta{
50-
Name: viewRoleName,
51-
},
52-
Rules: []rbacv1.PolicyRule{
53-
{
54-
APIGroups: []string{"rbac.appuio.io"},
55-
Resources: []string{"billingentities"},
56-
Verbs: []string{"get"},
57-
ResourceNames: []string{objName},
58-
},
59-
},
60-
}
61-
viewRoleBinding := &rbacv1.ClusterRoleBinding{
62-
ObjectMeta: metav1.ObjectMeta{
63-
Name: viewRoleName,
64-
},
65-
Subjects: []rbacv1.Subject{},
66-
RoleRef: rbacv1.RoleRef{
67-
Kind: "ClusterRole",
68-
APIGroup: "rbac.authorization.k8s.io",
69-
Name: viewRoleName,
70-
},
71-
}
72-
adminRoleName := fmt.Sprintf("billingentities-%s-admin", objName)
73-
adminRole := &rbacv1.ClusterRole{
74-
ObjectMeta: metav1.ObjectMeta{
75-
Name: adminRoleName,
76-
},
77-
Rules: []rbacv1.PolicyRule{
78-
{
79-
APIGroups: []string{"rbac.appuio.io", "billing.appuio.io"},
80-
Resources: []string{"billingentities"},
81-
Verbs: []string{"get", "patch", "update", "edit"},
82-
ResourceNames: []string{objName},
83-
},
84-
{
85-
APIGroups: []string{"rbac.authorization.k8s.io"},
86-
Resources: []string{"clusterrolebindings"},
87-
Verbs: []string{"get", "edit", "update", "patch"},
88-
ResourceNames: []string{viewRoleName, adminRoleName},
89-
},
90-
},
91-
}
92-
adminRoleBinding := &rbacv1.ClusterRoleBinding{
93-
ObjectMeta: metav1.ObjectMeta{
94-
Name: adminRoleName,
95-
},
96-
Subjects: []rbacv1.Subject{
97-
{
98-
Kind: "User",
99-
APIGroup: "rbac.authorization.k8s.io",
100-
Name: user.GetName(),
101-
},
102-
},
103-
RoleRef: rbacv1.RoleRef{
104-
Kind: "ClusterRole",
105-
APIGroup: "rbac.authorization.k8s.io",
106-
Name: adminRoleName,
107-
},
108-
}
47+
ar, arb, vr, vrb := billingrbac.ClusterRoles(objName, billingrbac.ClusterRolesParams{
48+
AllowSubjectsToViewRole: true,
49+
50+
AdminUsers: []string{user.GetName()},
51+
})
52+
toCreate := []client.Object{ar, arb, vr, vrb}
10953

11054
rollback := func() error {
11155
if deleter, canDelete := c.Storage.(rest.GracefulDeleter); canDelete {
@@ -116,7 +60,6 @@ func (c *createRBACWrapper) Create(ctx context.Context, obj runtime.Object, crea
11660
return nil
11761
}
11862

119-
toCreate := []client.Object{viewRole, viewRoleBinding, adminRole, adminRoleBinding}
12063
created := make([]client.Object, 0, len(toCreate))
12164
var createErr error
12265
for _, obj := range toCreate {

config/rbac/controller/role.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ rules:
9797
- get
9898
- patch
9999
- update
100+
- apiGroups:
101+
- billing.appuio.io
102+
- rbac.appuio.io
103+
resources:
104+
- billingentities
105+
verbs:
106+
- '*'
100107
- apiGroups:
101108
- organization.appuio.io
102109
resources:
@@ -179,6 +186,8 @@ rules:
179186
- clusterroles
180187
verbs:
181188
- create
189+
- delete
190+
- edit
182191
- get
183192
- list
184193
- patch

controller.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ A user of APPUiO Cloud has invited you to join them. Follow https://portal.dev/i
4343
4444
APPUiO Cloud is a shared Kubernetes offering based on OpenShift provided by https://vshn.ch.
4545
46-
Unsure what to do next? Accept this invitation using the link above, login to one of the zones listed at https://portal.appuio.cloud/zones, deploy your application. A getting started guide on how to do so, is available at https://docs.appuio.cloud/user/tutorials/getting-started.html. To learn more about APPUiO Cloud in general, please visit https://appuio.cloud.
46+
Unsure what to do next? Accept this invitation using the link above, login to one of the zones listed at https://portal.appuio.cloud/zones, deploy your application. A getting started guide on how to do so, is available at https://docs.appuio.cloud/user/tutorials/getting-started.html. To learn more about APPUiO Cloud in general, please visit https://appuio.cloud.
4747
4848
If you have any problems or questions, please email us at [email protected].
4949
@@ -102,7 +102,8 @@ func ControllerCommand() *cobra.Command {
102102
billingEntityEmailBodyTemplate := cmd.Flags().String("billingentity-email-body-template", defaultBillingEntityEmailTemplate, "Body for billing entity modification update mails")
103103
billingEntityEmailRecipient := cmd.Flags().String("billingentity-email-recipient", "", "Recipient e-mail address for billing entity modification update mails")
104104
billingEntityEmailSubject := cmd.Flags().String("billingentity-email-subject", "An APPUiO Billing Entity has been updated", "Subject for billing entity modification update mails")
105-
billingEntityCronInterval := cmd.Flags().String("billingentity-email-cron-interval", "@every 1h", "Cron interval for how frequently billing entity update e-mails are sent")
105+
billingEntityEmailCronInterval := cmd.Flags().String("billingentity-email-cron-interval", "@every 1h", "Cron interval for how frequently billing entity update e-mails are sent")
106+
billingEntityRBACCronInterval := cmd.Flags().String("billingentity-rbac-cron-interval", "@every 3m", "Cron interval for how frequently billing entity rbac is reconciled")
106107

107108
saleOrderCompatMode := cmd.Flags().Bool("sale-order-compatibility-mode", false, "Whether to enable compatibility mode for Sales Orders. If enabled, odoo8 billing entity IDs are used to create sales orders in odoo16.")
108109
saleOrderStorage := cmd.Flags().String("sale-order-storage", "none", "Type of sale order storage to use. Valid values are `none` and `odoo16`")
@@ -212,9 +213,10 @@ func ControllerCommand() *cobra.Command {
212213
os.Exit(1)
213214
}
214215

215-
cron, err := setupCron(
216+
setupLog.Info("setting up email cron")
217+
emailCron, err := setupEmailCron(
216218
ctx,
217-
*billingEntityCronInterval,
219+
*billingEntityEmailCronInterval,
218220
mgr,
219221
beMailSender,
220222
*billingEntityEmailRecipient,
@@ -223,15 +225,30 @@ func ControllerCommand() *cobra.Command {
223225
setupLog.Error(err, "unable to setup email cron")
224226
os.Exit(1)
225227
}
226-
cron.Start()
228+
emailCron.Start()
229+
230+
setupLog.Info("setting up billing entity rbac cron")
231+
beRBACCron, err := setupBillingEntityRBACCron(
232+
ctx,
233+
*billingEntityRBACCronInterval,
234+
mgr,
235+
)
236+
if err != nil {
237+
setupLog.Error(err, "unable to setup rbac cron")
238+
os.Exit(1)
239+
}
240+
beRBACCron.Start()
227241

228242
setupLog.Info("starting manager")
229243
if err := mgr.Start(ctx); err != nil {
230244
setupLog.Error(err, "problem running manager")
231245
os.Exit(1)
232246
}
233247
setupLog.Info("Stopping...")
234-
<-cron.Stop().Done()
248+
ecs := emailCron.Stop()
249+
becs := beRBACCron.Stop()
250+
<-ecs.Done()
251+
<-becs.Done()
235252
}
236253

237254
return cmd
@@ -390,7 +407,7 @@ func setupManager(
390407
return mgr, err
391408
}
392409

393-
func setupCron(
410+
func setupEmailCron(
394411
ctx context.Context,
395412
crontab string,
396413
mgr ctrl.Manager,
@@ -406,7 +423,7 @@ func setupCron(
406423
)
407424

408425
metrics.Registry.MustRegister(bemail.GetMetrics())
409-
syncLog := ctrl.Log.WithName("cron")
426+
syncLog := ctrl.Log.WithName("email_cron")
410427

411428
c := cron.New()
412429
_, err := c.AddFunc(crontab, func() {
@@ -423,3 +440,21 @@ func setupCron(
423440
}
424441
return c, nil
425442
}
443+
444+
func setupBillingEntityRBACCron(
445+
ctx context.Context,
446+
crontab string,
447+
mgr ctrl.Manager,
448+
) (*cron.Cron, error) {
449+
450+
rbac := &controllers.BillingEntityRBACCronJob{Client: mgr.GetClient()}
451+
syncLog := ctrl.Log.WithName("be_rbac_cron")
452+
c := cron.New()
453+
_, err := c.AddFunc(crontab, func() {
454+
err := rbac.Run(ctx)
455+
if err != nil {
456+
syncLog.Error(err, "Error during periodic job")
457+
}
458+
})
459+
return c, err
460+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package controllers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"go.uber.org/multierr"
8+
"k8s.io/client-go/tools/record"
9+
"sigs.k8s.io/controller-runtime/pkg/client"
10+
"sigs.k8s.io/controller-runtime/pkg/log"
11+
12+
billingv1 "github.com/appuio/control-api/apis/billing/v1"
13+
"github.com/appuio/control-api/pkg/billingrbac"
14+
)
15+
16+
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles;clusterrolebindings,verbs=get;list;watch;create;delete;patch;update;edit
17+
// +kubebuilder:rbac:groups=rbac.appuio.io;billing.appuio.io,resources=billingentities,verbs=*
18+
19+
// BillingEntityRBACCronJob periodically checks billing entities and sends notification emails if appropriate
20+
type BillingEntityRBACCronJob struct {
21+
client.Client
22+
}
23+
24+
func NewBillingEntityRBACCronJob(client client.Client, eventRecorder record.EventRecorder) BillingEntityRBACCronJob {
25+
return BillingEntityRBACCronJob{
26+
Client: client,
27+
}
28+
}
29+
30+
// Run lists all BillingEntity resources and sends notification emails if needed.
31+
func (r *BillingEntityRBACCronJob) Run(ctx context.Context) error {
32+
log := log.FromContext(ctx).WithName("BillingEntityRBACCronJob")
33+
log.Info("Reconciling BillingEntity RBAC")
34+
35+
list := &billingv1.BillingEntityList{}
36+
err := r.Client.List(ctx, list)
37+
if err != nil {
38+
return fmt.Errorf("could not list billing entities: %w", err)
39+
}
40+
41+
var errors []error
42+
for _, be := range list.Items {
43+
log := log.WithValues("billingentity", be.Name)
44+
err := r.reconcile(ctx, &be)
45+
if err != nil {
46+
log.Error(err, "could not reconcile billing entity")
47+
errors = append(errors, err)
48+
}
49+
}
50+
return multierr.Combine(errors...)
51+
}
52+
53+
func (r *BillingEntityRBACCronJob) reconcile(ctx context.Context, be *billingv1.BillingEntity) error {
54+
ar, arBinding, vr, vrBinding := billingrbac.ClusterRoles(be.Name, billingrbac.ClusterRolesParams{
55+
AllowSubjectsToViewRole: true,
56+
})
57+
58+
arErr := r.Client.Patch(ctx, ar, client.Apply, client.ForceOwnership, client.FieldOwner("control-api"))
59+
arBinding.Subjects = nil // we don't want to manage the subjects
60+
arBindingErr := r.Client.Patch(ctx, arBinding, client.Apply, client.ForceOwnership, client.FieldOwner("control-api"))
61+
vrErr := r.Client.Patch(ctx, vr, client.Apply, client.ForceOwnership, client.FieldOwner("control-api"))
62+
vrBinding.Subjects = nil // we don't want to manage the subjects
63+
vrBindingErr := r.Client.Patch(ctx, vrBinding, client.Apply, client.ForceOwnership, client.FieldOwner("control-api"))
64+
65+
return multierr.Combine(arErr, arBindingErr, vrErr, vrBindingErr)
66+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package controllers_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
rbacv1 "k8s.io/api/rbac/v1"
10+
"k8s.io/apimachinery/pkg/types"
11+
12+
. "github.com/appuio/control-api/controllers"
13+
)
14+
15+
func Test_BillingEntityRBACCronJob_Run(t *testing.T) {
16+
ctx := context.Background()
17+
18+
be := baseBillingEntity()
19+
c := prepareTest(t, be)
20+
21+
subject := &BillingEntityRBACCronJob{
22+
Client: c,
23+
}
24+
25+
require.NoError(t, subject.Run(ctx))
26+
27+
var adminRole rbacv1.ClusterRole
28+
var adminRoleBinding rbacv1.ClusterRoleBinding
29+
adminRoleName := fmt.Sprintf("billingentities-%s-admin", be.Name)
30+
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: adminRoleName}, &adminRole), "admin role should be created")
31+
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: adminRoleName}, &adminRoleBinding), "admin role binding should be created")
32+
33+
var viewerRole rbacv1.ClusterRole
34+
var viewerRoleBinding rbacv1.ClusterRoleBinding
35+
viewerRoleName := fmt.Sprintf("billingentities-%s-viewer", be.Name)
36+
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: viewerRoleName}, &viewerRole), "viewer role should be created")
37+
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: viewerRoleName}, &viewerRoleBinding), "viewer role binding should be created")
38+
39+
testSubjects := []rbacv1.Subject{
40+
{
41+
APIGroup: "rbac.authorization.k8s.io",
42+
Kind: "User",
43+
Name: "testuser",
44+
},
45+
}
46+
viewerRoleBinding.Subjects = testSubjects
47+
require.NoError(t, c.Update(ctx, &viewerRoleBinding))
48+
49+
require.NoError(t, subject.Run(ctx))
50+
51+
require.NoError(t, c.Get(ctx, types.NamespacedName{Name: viewerRoleName}, &viewerRoleBinding))
52+
require.Equal(t, testSubjects, viewerRoleBinding.Subjects, "role bindings should not be changed")
53+
}

0 commit comments

Comments
 (0)