Skip to content

Commit

Permalink
feat(backend): implement general api and service logic (#22)
Browse files Browse the repository at this point in the history
* add type and pseudocode

Signed-off-by: Alexandre Gaudreault <[email protected]>

* dump

Signed-off-by: Alexandre Gaudreault <[email protected]>

* create logic

Signed-off-by: Alexandre Gaudreault <[email protected]>

* create api call

Signed-off-by: Alexandre Gaudreault <[email protected]>

* extract validation to service

Signed-off-by: Alexandre Gaudreault <[email protected]>

* implement API logic

Signed-off-by: Alexandre Gaudreault <[email protected]>

* use query params

Signed-off-by: Alexandre Gaudreault <[email protected]>

* query meant list. use subpath

Signed-off-by: Alexandre Gaudreault <[email protected]>

* fix struct ptr

Signed-off-by: Alexandre Gaudreault <[email protected]>

* cleanup api

Signed-off-by: Alexandre Gaudreault <[email protected]>

* fix build errors

Signed-off-by: Alexandre Gaudreault <[email protected]>

* log all 500 errors

Signed-off-by: Alexandre Gaudreault <[email protected]>

* fix existing tests

Signed-off-by: Alexandre Gaudreault <[email protected]>

* dist

Signed-off-by: Alexandre Gaudreault <[email protected]>

* return api object

Signed-off-by: Alexandre Gaudreault <[email protected]>

* index field

Signed-off-by: Alexandre Gaudreault <[email protected]>

* implement backend api unit tests

Signed-off-by: Alexandre Gaudreault <[email protected]>

* service setup

Signed-off-by: Alexandre Gaudreault <[email protected]>

* impl mocks for service test

Signed-off-by: Alexandre Gaudreault <[email protected]>

* get access request by role

Signed-off-by: Alexandre Gaudreault <[email protected]>

* add service test cases

Signed-off-by: Alexandre Gaudreault <[email protected]>

* set rolename in status

Signed-off-by: Alexandre Gaudreault <[email protected]>

* index ab

Signed-off-by: Alexandre Gaudreault <[email protected]>

* sort unit test

Signed-off-by: Alexandre Gaudreault <[email protected]>

* persister tests

Signed-off-by: Alexandre Gaudreault <[email protected]>

* safe indexing

Signed-off-by: Alexandre Gaudreault <[email protected]>

* fix eprsister test on cache delay

Signed-off-by: Alexandre Gaudreault <[email protected]>

* service tests

Signed-off-by: Alexandre Gaudreault <[email protected]>

* fix merge

Signed-off-by: Alexandre Gaudreault <[email protected]>

* add invalid status

Signed-off-by: Alexandre Gaudreault <[email protected]>

* test template rendering

Signed-off-by: Alexandre Gaudreault <[email protected]>

* removed unused get api call

Signed-off-by: Alexandre Gaudreault <[email protected]>

* code review changes

Signed-off-by: Alexandre Gaudreault <[email protected]>

* self review

Signed-off-by: Alexandre Gaudreault <[email protected]>

---------

Signed-off-by: Alexandre Gaudreault <[email protected]>
  • Loading branch information
agaudreault authored Oct 8, 2024
1 parent e85a9fb commit a65f03f
Show file tree
Hide file tree
Showing 26 changed files with 3,141 additions and 275 deletions.
127 changes: 127 additions & 0 deletions api/ephemeral-access/v1alpha1/accessbinding_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
Copyright 2024.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1alpha1

import (
"fmt"
"strings"
"text/template"

"github.com/expr-lang/expr"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

// AccessBinding is the Schema for the accessbindings API
// +kubebuilder:object:root=true
type AccessBinding struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec AccessBindingSpec `json:"spec,omitempty"`
}

// AccessBindingList contains a list of AccessBinding
// +kubebuilder:object:root=true
type AccessBindingList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`

Items []AccessBinding `json:"items"`
}

// AccessBindingSpec defines the desired state of AccessBinding
type AccessBindingSpec struct {
// RoleTemplateRef is the reference to the RoleTemplate this bindings grants access to
// +kubebuilder:validation:Required
RoleTemplateRef RoleTemplateReference `json:"roleTemplateRef"`
// Subjects is list of strings, supporting go template, that a user's group claims must match at least one of to be allowed
Subjects []string `json:"subjects"`
// If is a condition that must be true to evaluate the subjects
If *string `json:"if,omitempty"`
// Ordinal defines an ordering number of this role compared to others
Ordinal int `json:"ordinal,omitempty"`
// FriendlyName defines a name for this role
// +kubebuilder:validation:MaxLength=512
FriendlyName *string `json:"friendlyName,omitempty"`
}

// RoleTemplateReference is a reference to a RoleTemplate
type RoleTemplateReference struct {
// Name of the role template object
// +kubebuilder:validation:Required
Name string `json:"name"`
}

// RenderSubjects renders the access bindings subjects when the If condition is evaluated to true
func (ab *AccessBinding) RenderSubjects(app, project *unstructured.Unstructured) ([]string, error) {
if len(ab.Spec.Subjects) == 0 {
return nil, nil
}

values := map[string]interface{}{
"app": app.Object,
"application": app.Object,
"project": project.Object,
}

if ab.Spec.If != nil {
out, err := expr.Eval(*ab.Spec.If, values)
if err != nil {
return nil, fmt.Errorf("failed to evaluate binding condition '%s': %w", *ab.Spec.If, err)
}
switch condResult := out.(type) {
case bool:
if !condResult {
// No need to render template, condition is false
return nil, nil
}
default:
return nil, fmt.Errorf("binding condition '%s' evaluated to non-boolean value", *ab.Spec.If)
}
}

subStr := strings.Join(ab.Spec.Subjects, "\n")
subTmpl, err := template.New("subjects").Parse(subStr)
if err != nil {
return nil, fmt.Errorf("error parsing AccessBinding subjects: %w", err)
}
p, err := ab.execTemplate(subTmpl, values)
if err != nil {
return nil, fmt.Errorf("error rendering AccessBinding subjects: %w", err)
}
subjects := strings.Split(p, "\n")

return subjects, nil
}

func (ab *AccessBinding) execTemplate(
tmpl *template.Template,
values any,
) (string, error) {
var s strings.Builder
err := tmpl.Execute(&s, values)
if err != nil {
return "", err
}
return s.String(), nil
}

func init() {
SchemeBuilder.Register(&AccessBinding{}, &AccessBindingList{})
}
117 changes: 117 additions & 0 deletions api/ephemeral-access/v1alpha1/accessbinding_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package v1alpha1_test

import (
"reflect"
"testing"

"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/test/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
)

func TestAccessBinding_RenderSubjects(t *testing.T) {
app, err := utils.ToUnstructured(&v1alpha1.Application{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
"test": "hello",
},
},
})
require.NoError(t, err)
project, err := utils.ToUnstructured(&v1alpha1.AppProject{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
"test": "world",
},
},
})
require.NoError(t, err)

tests := []struct {
name string
If *string
subjects []string
expected []string
errorContains string
}{
{
name: "sucessfully return rendered subjects",
subjects: []string{
`{{ index .application.metadata.annotations "test" }}`,
`{{ index .project.metadata.annotations "test" }}`,
},
expected: []string{"hello", "world"},
},
{
name: "return nil if subjects are nil",
subjects: nil,
expected: nil,
},
{
name: "return nil if subjects are empty",
subjects: []string{},
expected: nil,
},
{
name: "return nil if If condition is false",
If: ptr.To("false"),
subjects: []string{"value"},
expected: nil,
},
{
name: "return error if If condition is invalid",
If: ptr.To("invalid.golang"),
subjects: []string{"value"},
errorContains: "failed to evaluate binding condition",
},
{
name: "return error if If condition is not a boolean",
If: ptr.To("1 + 1"),
subjects: []string{"value"},
errorContains: "evaluated to non-boolean value",
},
{
name: "return error if subjects template is invalid",
subjects: []string{"{{"},
errorContains: "error parsing AccessBinding subjects",
},
{
name: "return error if subjects template rendering is invalid",
subjects: []string{`{{ index .notAnObject "key" }}`},
errorContains: "error rendering AccessBinding subjects",
},
{
name: "can render app as an alias",
subjects: []string{"{{ .app.metadata.name }}"},
expected: []string{"test"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ab := &api.AccessBinding{
Spec: api.AccessBindingSpec{
If: tt.If,
Subjects: tt.subjects,
},
}
got, err := ab.RenderSubjects(app, project)
if err != nil {
if tt.errorContains != "" {
assert.ErrorContains(t, err, tt.errorContains)
} else {
assert.NoError(t, err)
}
return
}
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("AccessBinding.RenderSubjects() = %v, want %v", got, tt.expected)
}
})
}
}
20 changes: 16 additions & 4 deletions api/ephemeral-access/v1alpha1/accessrequest_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ type AccessRequestSpec struct {
// to once the access is approved
// +kubebuilder:validation:Required
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
// +kubebuilder:validation:MaxLength=512
RoleTemplateName string `json:"roleTemplateName"`
Role TargetRole `json:"role"`
// Application defines the Argo CD Application to assign the elevated
// permission
// +kubebuilder:validation:Required
Expand All @@ -67,15 +66,27 @@ type AccessRequestSpec struct {
Subject Subject `json:"subject"`
}

// TargetApplication defines the Argo CD AppProject to assign the elevated
// permission
// TargetApplication defines the Argo CD AppProject to assign the elevated permission
type TargetApplication struct {
// Name refers to the Argo CD Application name
Name string `json:"name"`
// Namespace refers to the namespace where the Argo CD Application lives
Namespace string `json:"namespace"`
}

// TargetRole defines the role that is requested
type TargetRole struct {
// TemplateName defines the role template the user will be assigned
// +kubebuilder:validation:MaxLength=512
// +kubebuilder:validation:Required
TemplateName string `json:"templateName"`
// Ordinal defines an ordering number of this role compared to others
Ordinal int `json:"ordinal,omitempty"`
// FriendlyName defines a name for this role
// +kubebuilder:validation:MaxLength=512
FriendlyName *string `json:"friendlyName,omitempty"`
}

// Subject defines the user details to get elevated permissions assigned
type Subject struct {
// Username refers to the entity requesting the elevated permission
Expand All @@ -88,6 +99,7 @@ type AccessRequestStatus struct {
TargetProject string `json:"targetProject,omitempty"`
ExpiresAt *metav1.Time `json:"expiresAt,omitempty"`
RoleTemplateHash string `json:"roleTemplateHash,omitempty"`
RoleName string `json:"roleName,omitempty"`
History []AccessRequestHistory `json:"history,omitempty"`
}

Expand Down
Loading

0 comments on commit a65f03f

Please sign in to comment.