Skip to content

Commit

Permalink
feat(backend): implement generate name for new access request (#34)
Browse files Browse the repository at this point in the history
Signed-off-by: Alexandre Gaudreault <[email protected]>
Signed-off-by: Leonardo Luz Almeida <[email protected]>
Co-authored-by: Leonardo Luz Almeida <[email protected]>
  • Loading branch information
agaudreault and leoluz authored Oct 16, 2024
1 parent 42f2f85 commit 5cac08b
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 7 deletions.
1 change: 1 addition & 0 deletions internal/backend/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ var (
// Service
var (
DefaultAccessRequestSort = defaultAccessRequestSort
GetAccessRequestPrefix = getAccessRequestPrefix
)
30 changes: 24 additions & 6 deletions internal/backend/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import (
"time"

api "github.com/argoproj-labs/ephemeral-access/api/ephemeral-access/v1alpha1"
"github.com/argoproj-labs/ephemeral-access/internal/pkg/generator"
"github.com/argoproj-labs/ephemeral-access/pkg/log"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
Expand Down Expand Up @@ -65,6 +65,13 @@ var requestStateOrder = map[api.Status]int{
api.ExpiredStatus: 4,
}

const (
// Same as https://github.com/kubernetes/apiserver/blob/v0.31.1/pkg/storage/names/generate.go#L46
maxNameLength = 63
randomLength = 5
MaxGeneratedNameLength = maxNameLength - randomLength
)

// NewDefaultService will return a new DefaultService instance.
func NewDefaultService(c Persister, l log.Logger, namespace string, arDuration time.Duration) *DefaultService {
return &DefaultService{
Expand Down Expand Up @@ -159,7 +166,7 @@ func (s *DefaultService) CreateAccessRequest(ctx context.Context, key *AccessReq
ar := &api.AccessRequest{
ObjectMeta: metav1.ObjectMeta{
Namespace: key.Namespace,
GenerateName: s.getAccessRequestPrefix(key.Username, roleName),
GenerateName: getAccessRequestPrefix(key.Username, roleName),
},
Spec: api.AccessRequestSpec{
Duration: metav1.Duration{
Expand All @@ -186,11 +193,22 @@ func (s *DefaultService) CreateAccessRequest(ctx context.Context, key *AccessReq
return ar, nil
}

func (s *DefaultService) getAccessRequestPrefix(username, roleName string) string {
prefix := strings.ToLower(fmt.Sprintf("%s-", "TODO"))
if len(validation.NameIsDNSSubdomain(prefix, true)) != 0 {
prefix = strings.ToLower("TODO-fallback-")
func getAccessRequestPrefix(username, roleName string) string {
// If username is an email, we don't care about the email domain
username, _, _ = strings.Cut(username, "@")

username = generator.ToDNS1123Subdomain(username)
roleName = generator.ToDNS1123Subdomain(roleName)

prefix := fmt.Sprintf("%s-%s-", username, roleName)

if MaxGeneratedNameLength-len(prefix) < 0 {
// If the prefix is too long, use the maximum length available
extraCharLength := 2 // the format adds 2 dashes
username, roleName = generator.ToMaxLength(username, roleName, MaxGeneratedNameLength-extraCharLength)
prefix = fmt.Sprintf("%s-%s-", username, roleName)
}

return prefix
}

Expand Down
72 changes: 71 additions & 1 deletion internal/backend/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"slices"
"strings"
"testing"
"time"

Expand All @@ -16,6 +17,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
Expand Down Expand Up @@ -67,7 +69,7 @@ func TestServiceCreateAccessRequest(t *testing.T) {
// Then
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "todo-", result.GetGenerateName())
assert.Equal(t, fmt.Sprintf("%s-%s-", key.Username, ab.Spec.RoleTemplateRef.Name), result.GetGenerateName())
assert.Equal(t, key.Namespace, result.GetNamespace())
assert.Equal(t, key.ApplicationName, result.Spec.Application.Name)
assert.Equal(t, key.ApplicationNamespace, result.Spec.Application.Namespace)
Expand Down Expand Up @@ -705,3 +707,71 @@ func Test_defaultAccessRequestSort(t *testing.T) {
require.Equal(t, third, items[2])
})
}

func Test_getAccessRequestPrefix(t *testing.T) {
tests := []struct {
name string
username string
roleName string
expected string
}{
{
name: "should use the first part of email",
username: "[email protected]",
roleName: "my-role",
expected: "test-my-role-",
},
{
name: "should not exceed max length",
username: "loremipsumdolorsitametconsecteturadipiscingelitsuspendissetempussemperleoeuvestibulumsemtincidunttinciduntvestibulumaccumsanmaurissedrisusdignissimaliq",
roleName: "uamaeneanabibendumtellusaeneandapibuslacusetinterdumfeugiatsuspendissevehiculaliberodignissimturpistincidunttristiquepraesentmolestietemporduieugravida",
expected: "loremipsumdolorsitametconsec-uamaeneanabibendumtellusaene-",
},
{
name: "should use the maximum amount available for username",
username: "loremipsumdolorsitametconsecteturadipiscingelitsuspendissetem",
roleName: "uamaenea",
expected: "loremipsumdolorsitametconsecteturadipiscingelits-uamaenea-",
},
{
name: "should use the maximum amount available for roleName",
username: "uamaenea",
roleName: "loremipsumdolorsitametconsecteturadipiscingelitsuspe",
expected: "uamaenea-loremipsumdolorsitametconsecteturadipiscingelits-",
},
{
name: "should not contain any invalid char",
username: "my.(user)[1234567890] +_)(*&^%$#@!~",
roleName: "a-role +_)(*&^%$#@!~",
expected: "my.user1234567890-a-role-",
},
{
name: "should not start with a dash",
username: "---username",
roleName: "---role",
expected: "username-role-",
},
{
name: "should not start with a period",
username: ".username",
roleName: "...role",
expected: "username-role-",
},
{
name: "should be lowercase",
username: "UserName",
roleName: "Role",
expected: "username-role-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := backend.GetAccessRequestPrefix(tt.username, tt.roleName)
validationErrors := validation.NameIsDNSSubdomain(got, true)

assert.Equal(t, tt.expected, got)
assert.Equalf(t, 0, len(validationErrors), fmt.Sprintf("Validation Errors: \n%s", strings.Join(validationErrors, "\n")))
assert.LessOrEqual(t, len(got), backend.MaxGeneratedNameLength)
})
}
}
40 changes: 40 additions & 0 deletions internal/pkg/generator/generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package generator

import (
"math"
"regexp"
"strings"
)

var (
DNS1123SubdomainInvalidCharSet = regexp.MustCompile("[^a-z0-9-.]")
DNS1123SubdomainInvalidStartChartSet = regexp.MustCompile("^[^a-z0-9]+")
)

// ToDNS1123Subdomain removes all invalid char for a DNS1123Subdomain string and transform it to a valid format
func ToDNS1123Subdomain(data string) string {
data = strings.ToLower(data)
data = DNS1123SubdomainInvalidCharSet.ReplaceAllString(data, "")
data = DNS1123SubdomainInvalidStartChartSet.ReplaceAllString(data, "")
return data
}

// ToMaxLength reduce the total length of the strings, balancing the length as evently as possible
func ToMaxLength(a, b string, max int) (string, string) {
aLength := len(a)
bLength := len(b)
halfLength := max / 2
remainder := max % 2
minLength := int(math.Min(float64(aLength), float64(bLength)))
available := int(math.Max(0, float64(halfLength-minLength)))

if aLength >= halfLength+available+remainder {
a = a[:halfLength+available+remainder]
remainder = 0
}
if bLength >= halfLength+available+remainder {
b = b[:halfLength+available+remainder]
remainder = 0
}
return a, b
}
124 changes: 124 additions & 0 deletions internal/pkg/generator/generator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package generator_test

import (
"testing"

"github.com/argoproj-labs/ephemeral-access/internal/pkg/generator"
"github.com/stretchr/testify/assert"
)

func TestToDNS1123Subdomain(t *testing.T) {
tests := []struct {
name string
data string
expected string
}{
{
name: "should not contain any invalid char",
data: "my.(user)[1234567890] +_)(*&^%$#@!~",
expected: "my.user1234567890",
},
{
name: "should not start with a dash",
data: "---username",
expected: "username",
},
{
name: "should not start with a period",
data: "...role",
expected: "role",
},
{
name: "should be lowercase",
data: "UserName",
expected: "username",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := generator.ToDNS1123Subdomain(tt.data); got != tt.expected {
t.Errorf("ToDNS1123Subdomain() = %v, want %v", got, tt.expected)
}
})
}
}

func TestToMaxLength(t *testing.T) {
type args struct {
a string
b string
max int
}
tests := []struct {
name string
args args
a string
b string
}{
{
name: "strings already balanced",
args: args{

a: "abcd",
b: "1234",
max: 99,
},
a: "abcd",
b: "1234",
},
{
name: "strings are balanced to max",
args: args{

a: "abcd",
b: "1234",
max: 4,
},
a: "ab",
b: "12",
},
{
name: "remainder to A if max is odd and both too long",
args: args{

a: "abcd",
b: "1234",
max: 5,
},
a: "abc",
b: "12",
},
{
name: "remainder to A if max is odd and A too long",
args: args{

a: "abcd",
b: "12",
max: 5,
},
a: "abc",
b: "12",
},
{
name: "remainder to B if max is odd and B too long",
args: args{

a: "ab",
b: "1234",
max: 5,
},
a: "ab",
b: "123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a, b := generator.ToMaxLength(tt.args.a, tt.args.b, tt.args.max)
assert.Equal(t, tt.a, a)
assert.Equal(t, tt.b, b)
if len(tt.args.a)+len(tt.args.b) >= tt.args.max {
assert.Equal(t, tt.args.max, len(a)+len(b))
}
})
}
}

0 comments on commit 5cac08b

Please sign in to comment.