Skip to content

feat(approval-expiration): approvals can now expire and notifications are sent before they do#387

Open
julius-malcovsky wants to merge 7 commits into
mainfrom
feat/approval-expiry
Open

feat(approval-expiration): approvals can now expire and notifications are sent before they do#387
julius-malcovsky wants to merge 7 commits into
mainfrom
feat/approval-expiry

Conversation

@julius-malcovsky
Copy link
Copy Markdown
Contributor

Notification templates will be added in a separate request

… are sent before they do

Co-authored-by: Björn Kottner <BjoernKarma@users.noreply.github.com>
Co-authored-by: Ismael Garba <iagarba@users.noreply.github.com>
Co-authored-by: Stefan Siber <stefan-ctrl@users.noreply.github.com>
Co-authored-by: Ron Gummich <ron96g@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 4, 2026 13:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an approval-expiration feature by adding an Expired approval state, a new ApprovalExpiration CRD/controller to drive expiration, and reminder-notification plumbing so expiring approvals can notify stakeholders ahead of time.

Changes:

  • Add ApprovalExpiration CRD + controller/handler to track expiration dates, trigger expiration, and send reminders.
  • Extend the Approval FSM/API types to support Expired and a system-only Expire action (plus re-approval from Expired).
  • Add notification placeholders + a reminder notification sender, and introduce an env-driven expiration config loader.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
approval/internal/webhook/v1/approval_webhook.go Adds webhook validation intended to block manual transitions to Expired.
approval/internal/webhook/v1/approval_webhook_test.go Adds tests around the new “system-only expire” validation behavior.
approval/internal/handler/util/notification.go Adds reminder-specific placeholders, fields, and a SendReminderNotification helper.
approval/internal/handler/approvalexpiration/handler.go Implements the core expiration/reminder logic for ApprovalExpiration objects.
approval/internal/handler/approvalexpiration/handler_test.go Unit tests for reminder eligibility and next-reconcile scheduling logic.
approval/internal/handler/approval/handler.go Integrates expiration lifecycle management into the Approval handler and hides system-only transitions from status.
approval/internal/handler/approval/fsm.go Extends FSM transitions to include Expire and allow actions from Expired.
approval/internal/controller/suite_test.go Registers the new ApprovalExpirationReconciler in the controller test suite.
approval/internal/controller/approvalexpiration_controller.go Adds the new controller reconciler wiring and RBAC markers.
approval/internal/controller/approvalexpiration_controller_test.go Integration tests for Approval↔ApprovalExpiration lifecycle behavior.
approval/internal/config/expiration.go Adds env-backed expiration/reminder configuration loading.
approval/internal/condition/condition.go Adds an “Expired” condition helper.
approval/config/rbac/role.yaml Grants RBAC permissions for approvalexpirations resources.
approval/config/crd/bases/approval.cp.ei.telekom.de_approvals.yaml Updates the Approval CRD enum to include Expired.
approval/config/crd/bases/approval.cp.ei.telekom.de_approvalexpirations.yaml Introduces the new ApprovalExpiration CRD manifest.
approval/cmd/main.go Loads expiration config and registers the new controller.
approval/api/v1/zz_generated.deepcopy.go Adds autogenerated deepcopy functions for ApprovalExpiration types.
approval/api/v1/common_types.go Adds the Expire action constant (and related type support).
approval/api/v1/builder/builder.go Treats Expired approvals as non-provisionable (denied) during build decisions.
approval/api/v1/approvalexpiration_types.go Adds API types for the new ApprovalExpiration resource.
approval/api/v1/approval_types.go Extends kubebuilder validation enums to include the Expired state.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread approval/internal/handler/approvalexpiration/handler.go
Comment thread approval/internal/handler/approvalexpiration/handler.go Outdated
Comment thread approval/internal/handler/approvalexpiration/handler.go Outdated
Comment thread approval/internal/handler/approvalexpiration/handler.go Outdated
Comment thread approval/internal/handler/approval/handler.go Outdated
Comment on lines +132 to +145
// validateExpireTransition blocks manual transitions to EXPIRED state (system-only action)
func validateExpireTransition(newObj *approvalv1.Approval) error {
if newObj.Spec.State != approvalv1.ApprovalStateExpired {
return nil
}

// Check if this is a legitimate system transition
for _, decision := range newObj.Spec.Decisions {
if decision.Name == "System" && decision.ResultingState == approvalv1.ApprovalStateExpired {
return nil // Valid system transition
}
}

return apierrors.NewBadRequest("Expire action is system-only and cannot be triggered manually")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point by AI ;)

Comment thread approval/internal/handler/util/notification.go Outdated
Comment thread approval/internal/handler/util/notification.go
Comment thread approval/internal/handler/util/notification.go
Comment thread approval/internal/handler/util/notification.go
daysRemaining = 0
case now.After(ae.Spec.DailyReminder.Time):
reminderType = "daily"
daysRemaining = int64(ae.Spec.Expiration.Time.Sub(now).Hours() / 24)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: this looks as something what can be in "utils" package.

Copy link
Copy Markdown
Member

@ron96g ron96g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks solid. Just a few questions/open points.

Add some diagram that explains the logic in more details? Maybe inside of the README similiar to the other digrams there.

Comment thread approval/api/v1/builder/builder.go
Expiration metav1.Time `json:"expiration"`

// WeeklyReminder is the date from which weekly reminders start
WeeklyReminder metav1.Time `json:"weeklyReminder"`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the reminder logic in common for this? That was implemented for graceful-client-secret-rotation?

Comment thread approval/internal/config/expiration.go Outdated
LastWeeksWithDailyReminder: viper.GetInt("EXPIRATION_DAILY_REMINDER_WEEKS"),
}

if config.ExpirationPeriodMonths <= 0 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be done using https://github.com/go-playground/validator, however, the bigger question here is, can we integrate this into the common-config logic? The story to align config-management is still in the backlog, but can we already think about this?

Comment thread approval/internal/config/expiration.go

// SetupWithManager sets up the controller with the Manager.
func (r *ApprovalExpirationReconciler) SetupWithManager(mgr ctrl.Manager) error {
r.Recorder = mgr.GetEventRecorderFor("approvalexpiration-controller")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API is deprecated and we must migrate at some point. Maybe already use the new one for newer controllers? Dont forget about the kubebuilder:rbac annotation


// filterSystemActions removes system-only actions (Expire) from the available transitions
func filterSystemActions(transitions approvalv1.AvailableTransitions) approvalv1.AvailableTransitions {
filtered := make(approvalv1.AvailableTransitions, 0, len(transitions))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could allocate +1 slots if Expire is not added

func NewExpiredCondition() metav1.Condition {
return metav1.Condition{
Type: "Approved",
Status: metav1.ConditionTrue,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct?


// Check if this is a legitimate system transition
for _, decision := range newObj.Spec.Decisions {
if decision.Name == "System" && decision.ResultingState == approvalv1.ApprovalStateExpired {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should "System" be a const somewhere?

Comment on lines +132 to +145
// validateExpireTransition blocks manual transitions to EXPIRED state (system-only action)
func validateExpireTransition(newObj *approvalv1.Approval) error {
if newObj.Spec.State != approvalv1.ApprovalStateExpired {
return nil
}

// Check if this is a legitimate system transition
for _, decision := range newObj.Spec.Decisions {
if decision.Name == "System" && decision.ResultingState == approvalv1.ApprovalStateExpired {
return nil // Valid system transition
}
}

return apierrors.NewBadRequest("Expire action is system-only and cannot be triggered manually")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point by AI ;)

julius-malcovsky and others added 4 commits May 7, 2026 11:38
Co-authored-by: Björn Kottner <BjoernKarma@users.noreply.github.com>
Co-authored-by: Ismael Garba <iagarba@users.noreply.github.com>
Co-authored-by: Stefan Siber <stefan-ctrl@users.noreply.github.com>
Co-authored-by: Ron Gummich <ron96g@users.noreply.github.com>
Co-authored-by: Björn Kottner <BjoernKarma@users.noreply.github.com>
Co-authored-by: Ismael Garba <iagarba@users.noreply.github.com>
Co-authored-by: Stefan Siber <stefan-ctrl@users.noreply.github.com>
Co-authored-by: Ron Gummich <ron96g@users.noreply.github.com>
… denied

Co-authored-by: Björn Kottner <BjoernKarma@users.noreply.github.com>
Co-authored-by: Ismael Garba <iagarba@users.noreply.github.com>
Co-authored-by: Stefan Siber <stefan-ctrl@users.noreply.github.com>
Co-authored-by: Ron Gummich <ron96g@users.noreply.github.com>
ApprovalStateGranted ApprovalState = "Granted"
ApprovalStateRejected ApprovalState = "Rejected"
ApprovalStateSuspended ApprovalState = "Suspended"
ApprovalStateExpired ApprovalState = "Expired"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why remove the State?

}

// LoadExpirationConfig loads the expiration configuration from environment variables
func LoadExpirationConfig() (*ExpirationConfig, error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this clash with our current controller-config? I think we should either re-use the stuff there or create a new local Viper-Instance here, but mixing them could result in bugs

var thresholds []reminder.Threshold

// Weekly reminder threshold (if configured)
if weeklyReminderMonths > 0 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why configure it like this and not via a object? I think Viper is capable of loading complex data like JSON-objects and lists... Maybe check this.


// eventsRecorderAdapter adapts events.EventRecorder (new API) to record.EventRecorder (old API)
// for compatibility with common controller until it's migrated to the new API.
type eventsRecorderAdapter struct {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this here? I mean, I see the comment but for the time being we can just use both the old and new API until we can fully migrate common to the new one.

//
// SPDX-License-Identifier: Apache-2.0

package controller //nolint:dupl // approval and approvalrequest controllers are intentionally similar
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about removing this but if the linter does not complain, fine by me


ae := &approvalv1.ApprovalExpiration{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s--expiration", approval.Name),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] suffix not really needed as CR kind already is called ApprovalExpiration

Actor Actor
Action string
// Expiration-specific fields
ExpirationDate string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not split it to keep it more clear what is present in which scenario

purposeStringBuilder.WriteString("reminder")
purposeStringBuilder.WriteString(DELIMITER)
purposeStringBuilder.WriteString(string(data.Actor))
purpose := purposeStringBuilder.String()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there already a template for this?

nameStringBuilder.WriteString(data.Target.GetName())
name := nameStringBuilder.String()

notificationBuilder := builder.New().
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit of a controversial thing but can we actually send the same Notification to both teams? Like "hey teams, you share a subscription which is about to expire. As team-provider please re-grant" or something?


// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (a *ApprovalCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj *approvalv1.Approval) (warnings admission.Warnings, err error) {
func (a *ApprovalCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj *approvalv1.Approval) (warnings admission.Warnings, err error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If linter does not care, I also do not care

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants