Skip to content

Commit

Permalink
chore: refactoring the accessrequest handling logic
Browse files Browse the repository at this point in the history
Signed-off-by: Leonardo Luz Almeida <[email protected]>
  • Loading branch information
leoluz committed Aug 30, 2024
1 parent 86ec0a4 commit faea01c
Show file tree
Hide file tree
Showing 4 changed files with 349 additions and 319 deletions.
8 changes: 6 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (

appprojectv1alpha1 "github.com/argoproj-labs/ephemeral-access/api/argoproj/v1alpha1"
ephemeralaccessv1alpha1 "github.com/argoproj-labs/ephemeral-access/api/ephemeral-access/v1alpha1"
"github.com/argoproj-labs/ephemeral-access/internal/accessrequest"
"github.com/argoproj-labs/ephemeral-access/internal/controller"
// +kubebuilder:scaffold:imports
)
Expand Down Expand Up @@ -127,9 +128,12 @@ func main() {
os.Exit(1)
}

service := accessrequest.NewService(mgr.GetClient())

if err = (&controller.AccessRequestReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Service: service,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "AccessRequest")
os.Exit(1)
Expand Down
330 changes: 330 additions & 0 deletions internal/accessrequest/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
package accessrequest

import (
"context"
"crypto/sha1"
"fmt"

argocd "github.com/argoproj-labs/ephemeral-access/api/argoproj/v1alpha1"
api "github.com/argoproj-labs/ephemeral-access/api/ephemeral-access/v1alpha1"
"github.com/argoproj-labs/ephemeral-access/internal/log"
"github.com/cnf/structhash"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
FieldOwnerEphemeralAccess = "ephemeral-access-controller"
)

type Service struct {
client.Client
}

func NewService(c client.Client) *Service {
return &Service{
Client: c,
}
}

// handlePermission will analyse the given ar and proceed with granting
// or removing Argo CD access for the subjects listed in the AccessRequest.
// The following validations will be executed:
// 1. Check if the given ar is expired. If so, subjects will be removed from
// the Argo CD role.
// 2. Check if the subjects are allowed to be assigned in the given AccessRequest
// target role. If so, it will proceed with grating Argo CD access. Otherwise
// it will return DeniedStatus.
//
// It will update the AccessRequest status accordingly with the situation.
func (s *Service) HandlePermission(ctx context.Context, ar *api.AccessRequest, app *argocd.Application, rt *api.RoleTemplate) (api.Status, error) {
logger := log.FromContext(ctx)

if ar.IsExpiring() {
logger.Info("AccessRequest is expired")
err := s.handleAccessExpired(ctx, ar, rt)
if err != nil {
return "", fmt.Errorf("error handling access expired: %w", err)
}
return api.ExpiredStatus, nil
}

resp, err := Allowed(ctx, ar, app)
if err != nil {
return "", fmt.Errorf("error verifying if subject is allowed: %w", err)
}
if !resp.Allowed {
rtHash := RoleTemplateHash(rt)
err = s.updateStatus(ctx, ar, api.DeniedStatus, resp.Message, rtHash)
if err != nil {
return "", fmt.Errorf("error updating access request status to denied: %w", err)
}
return api.DeniedStatus, nil
}

details := ""
status, err := s.grantArgoCDAccess(ctx, ar, rt)
if err != nil {
details = fmt.Sprintf("Error granting Argo CD Access: %s", err)
}
// only update status if the current state is different
if ar.Status.RequestState != status {
rtHash := RoleTemplateHash(rt)
err = s.updateStatus(ctx, ar, status, details, rtHash)
if err != nil {
return "", fmt.Errorf("error updating access request status to granted: %w", err)
}
}
return status, nil
}

// handleAccessExpired will remove the Argo CD access for the subject and
// update the AccessRequest status field.
func (s *Service) handleAccessExpired(ctx context.Context, ar *api.AccessRequest, rt *api.RoleTemplate) error {
err := s.RemoveArgoCDAccess(ctx, ar, rt)
if err != nil {
return fmt.Errorf("error removing access for expired request: %w", err)
}
hash := RoleTemplateHash(rt)
err = s.updateStatus(ctx, ar, api.ExpiredStatus, "", hash)
if err != nil {
return fmt.Errorf("error updating access request status to expired: %w", err)
}
return nil
}

// removeArgoCDAccess will remove the subjects in the given AccessRequest from
// the given ar.TargetRoleName from the Argo CD project referenced in the
// ar.Spec.AppProject. The AppProject update will be executed via a patch with
// optimistic lock enabled. It will retry in case of AppProject conflict is
// identied.
func (s *Service) RemoveArgoCDAccess(ctx context.Context, ar *api.AccessRequest, rt *api.RoleTemplate) error {
logger := log.FromContext(ctx)
logger.Info("Removing Argo CD Access")
projName := ar.Status.TargetProject
projNamespace := ar.GetNamespace()

return retry.RetryOnConflict(retry.DefaultRetry, func() error {
project, err := s.getProject(ctx, projName, projNamespace)
if err != nil {
e := fmt.Errorf("error getting Argo CD Project %s/%s: %w", projNamespace, projName, err)
return client.IgnoreNotFound(e)
}
patch := client.MergeFromWithOptions(project.DeepCopy(), client.MergeFromWithOptimisticLock{})

logger.Debug("Removing subjects from role")
removeSubjectsFromRole(project, ar, rt)
// this is necessary to make sure that the AppProject role managed by
// this controller is always in sync with what is defined in the
// RoleTemplate
updateProjectPolicies(project, ar, rt)

logger.Debug("Patching AppProject")
opts := []client.PatchOption{client.FieldOwner(FieldOwnerEphemeralAccess)}
err = s.Client.Patch(ctx, project, patch, opts...)
if err != nil {
return fmt.Errorf("error patching Argo CD Project %s/%s: %w", projNamespace, projName, err)
}
return nil
})
}

// grantArgoCDAccess will associate the given AccessRequest subjects in the
// Argo CD AppProject specified in the ar.Spec.AppProject in the role defined
// in ar.TargetRoleName. The AppProject update will be executed via a patch with
// optimistic lock enabled. It Will retry in case of AppProject conflict is
// identied.
func (s *Service) grantArgoCDAccess(ctx context.Context, ar *api.AccessRequest, rt *api.RoleTemplate) (api.Status, error) {
logger := log.FromContext(ctx)
logger.Info("Granting Argo CD Access")

projName := ar.Status.TargetProject
projNamespace := ar.GetNamespace()

err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
project, err := s.getProject(ctx, projName, projNamespace)
if err != nil {
return fmt.Errorf("error getting Argo CD Project %s/%s: %w", projNamespace, projName, err)
}
patch := client.MergeFromWithOptions(project.DeepCopy(), client.MergeFromWithOptimisticLock{})

logger.Debug("Adding subjects in role")
addSubjectsInRole(project, ar, rt)
// this is necessary to make sure that the AppProject role managed by
// this controller is always in sync with what is defined in the
// RoleTemplate
updateProjectPolicies(project, ar, rt)

logger.Debug("Patching AppProject")
opts := []client.PatchOption{client.FieldOwner("ephemeral-access-controller")}
err = s.Client.Patch(ctx, project, patch, opts...)
if err != nil {
return fmt.Errorf("error patching Argo CD Project %s/%s: %w", projNamespace, projName, err)
}

return nil
})
if err != nil {
return api.DeniedStatus, err
}
return api.GrantedStatus, nil
}

// TODO
func RoleTemplateHash(rt *api.RoleTemplate) string {
rtForHash := *&api.RoleTemplate{
TypeMeta: rt.TypeMeta,
ObjectMeta: metav1.ObjectMeta{
Name: rt.GetName(),
Namespace: rt.GetNamespace(),
},
Spec: api.RoleTemplateSpec{
Name: rt.Spec.Name,
Description: rt.Spec.Description,
Policies: rt.Spec.Policies,
},
}
return fmt.Sprintf("%x", sha1.Sum(structhash.Dump(rtForHash, 1)))
}

func (s *Service) getProject(ctx context.Context, name, ns string) (*argocd.AppProject, error) {
project := &argocd.AppProject{}
objKey := client.ObjectKey{
Namespace: ns,
Name: name,
}
err := s.Client.Get(ctx, objKey, project)
if err != nil {
return nil, err
}
return project, nil
}

// updateStatusWithRetry will retrieve the latest AccessRequest state before
// attempting to update its status. In case of conflict error, it will retry
// using the DefaultRetry backoff which has the following configs:
//
// Steps: 5, Duration: 10 milliseconds, Factor: 1.0, Jitter: 0.1
func (s *Service) updateStatusWithRetry(ctx context.Context, ar *api.AccessRequest, status api.Status, details string, rtHash string) error {
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
err := s.Client.Get(ctx, client.ObjectKeyFromObject(ar), ar)
if err != nil {
return err
}
return s.updateStatus(ctx, ar, status, details, rtHash)
})
}

// updateStatus will update the given AccessRequest status field with the
// given status and details.
func (s *Service) updateStatus(ctx context.Context, ar *api.AccessRequest, status api.Status, details string, rtHash string) error {
// if it is already updated skip
if ar.Status.RequestState == status && ar.Status.RoleTemplateHash == rtHash {
return nil
}
ar.UpdateStatusHistory(status, details)
ar.Status.RoleTemplateHash = rtHash
return s.Client.Status().Update(ctx, ar)
}

// removeSubjectsFromRole will iterate ovet the roles in the given project and
// remove the subjects from the given AccessRequest from the role specified in
// the ar.TargetRoleName.
func removeSubjectsFromRole(project *argocd.AppProject, ar *api.AccessRequest, rt *api.RoleTemplate) {
roleName := rt.AppProjectRoleName(ar.Spec.Application.Name, ar.Spec.Application.Namespace)
for idx, role := range project.Spec.Roles {
if role.Name == roleName {
groups := []string{}
for _, group := range role.Groups {
remove := false
for _, subject := range ar.Spec.Subjects {
if group == subject.Username {
remove = true
break
}
}
if !remove {
groups = append(groups, group)
}
}
project.Spec.Roles[idx].Groups = groups
}
}
}

// updateProjectPolicies will update the given project to match all Policies
// defined by the given RoleTemplate for the role name specified in the rt.
// It will also update the description and revoke any JWT tokens that were
// associated with this specific role. Noop if the given rt is nil.
func updateProjectPolicies(project *argocd.AppProject, ar *api.AccessRequest, rt *api.RoleTemplate) {
if rt == nil {
return
}
roleName := rt.AppProjectRoleName(ar.Spec.Application.Name, ar.Spec.Application.Namespace)
for idx, role := range project.Spec.Roles {
if role.Name == roleName {
project.Spec.Roles[idx].Description = rt.Spec.Description
project.Spec.Roles[idx].Policies = rt.Spec.Policies
project.Spec.Roles[idx].JWTTokens = []argocd.JWTToken{}
}
}
}

// addSubjectsInRole will associate the given AccessRequest subjects in the
// specific role in the given project.
func addSubjectsInRole(project *argocd.AppProject, ar *api.AccessRequest, rt *api.RoleTemplate) {
roleFound := false
roleName := rt.AppProjectRoleName(ar.Spec.Application.Name, ar.Spec.Application.Namespace)
for idx, role := range project.Spec.Roles {
if role.Name == roleName {
roleFound = true
for _, subject := range ar.Spec.Subjects {
hasAccess := false
for _, group := range role.Groups {
if group == subject.Username {
hasAccess = true
break
}
}
if !hasAccess {
project.Spec.Roles[idx].Groups = append(project.Spec.Roles[idx].Groups, subject.Username)
}
}
}
}
if !roleFound {
addRoleInProject(project, ar, rt)
}
}

// addRoleInProject will initialize the role owned by the ephemeral-access
// controller and associate it in the given project.
func addRoleInProject(project *argocd.AppProject, ar *api.AccessRequest, rt *api.RoleTemplate) {
groups := []string{}
for _, subject := range ar.Spec.Subjects {
groups = append(groups, subject.Username)
}
role := argocd.ProjectRole{
Name: rt.AppProjectRoleName(ar.Spec.Application.Name, ar.Spec.Application.Namespace),
Description: rt.Spec.Description,
Policies: rt.Spec.Policies,
Groups: groups,
}
project.Spec.Roles = append(project.Spec.Roles, role)
}

// AllowedResponse defines the response that will be returned by permission
// verifier plugins.
type AllowedResponse struct {
Allowed bool
Message string
}

// TODO
// 0. implement the plugin system
// 1. verify if user is sudoer
// 2. verify if CR is approved
func Allowed(ctx context.Context, ar *api.AccessRequest, app *argocd.Application) (AllowedResponse, error) {
return AllowedResponse{Allowed: true}, nil
}
Loading

0 comments on commit faea01c

Please sign in to comment.