diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000..1a79f4c --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,137 @@ +package identity + +import ( + "errors" + "strings" +) + +const ( + // Scheme is the URI scheme for krypton identities. + Scheme = "kryptonid" + + // invalidChars contains characters that are not allowed in domain names, + // field types, or field names. + invalidChars = "*/\t\n\r " +) + +var ( + // ErrInvalidScheme is returned when parsing a URI with wrong scheme. + ErrInvalidScheme = errors.New("invalid scheme") + + // ErrEmptyDomain is returned when a domain is empty. + ErrEmptyDomain = errors.New("domain cannot be empty") + + // ErrInvalidCharacters is returned when a domain, field type, or field name + // contains characters from invalidChars. + ErrInvalidCharacters = errors.New("contains invalid characters") + + // ErrInvalidPath is returned when the path has an incomplete type/name pair. + ErrInvalidPath = errors.New("invalid path: must have even number of fields (type/name pairs)") + + // ErrEmptyField is returned when a field type or name is empty. + ErrEmptyField = errors.New("field type and name must be non-empty") +) + +type ( + // URI is the string representation of a krypton identity. + URI string + + // Domain identifies a trust boundary for identities. + Domain string + + // Field represents a type/name pair in the identity path. + Field struct { + Type string + Name string + } + + // Identity is a kryptonid:// URI representing any entity in Krypton. + Identity struct { + Domain Domain + Fields []Field + } +) + +// Validate validates that the domain is non-empty and does not contain invalid characters. +func (d Domain) Validate() error { + if d == "" { + return ErrEmptyDomain + } + if strings.ContainsAny(string(d), invalidChars) { + return ErrInvalidCharacters + } + return nil +} + +// Validate validates that type and name are non-empty and do not contain invalid characters. +func (f Field) Validate() error { + for _, v := range []string{f.Type, f.Name} { + if v == "" { + return ErrEmptyField + } + if strings.ContainsAny(v, invalidChars) { + return ErrInvalidCharacters + } + } + return nil +} + +// URI returns the kryptonid:// string representation. +func (id *Identity) URI() URI { + var b strings.Builder + + b.WriteString(Scheme) + b.WriteString("://") + b.WriteString(string(id.Domain)) + + for _, f := range id.Fields { + b.WriteByte('/') + b.WriteString(f.Type) + b.WriteByte('/') + b.WriteString(f.Name) + } + + return URI(b.String()) +} + +// Parse parses a kryptonid:// URI into an Identity. +// Returns an error if the scheme is wrong, domain is empty or invalid, +// or the path has an odd number of fields (incomplete type/name pair). +// +// Example URI: kryptonid://acme-corp/node/root +func Parse(uri URI) (Identity, error) { + after, found := strings.CutPrefix(string(uri), Scheme+"://") + if !found { + return Identity{}, ErrInvalidScheme + } + + d, path, _ := strings.Cut(after, "/") + if err := Domain(d).Validate(); err != nil { + return Identity{}, err + } + + if path == "" { + return Identity{ + Domain: Domain(d), + }, nil + } + + parts := strings.Split(path, "/") + if len(parts)%2 != 0 { + return Identity{}, ErrInvalidPath + } + + fields := make([]Field, 0, len(parts)/2) + for i := 0; i < len(parts); i += 2 { + f := Field{Type: parts[i], Name: parts[i+1]} + if err := f.Validate(); err != nil { + return Identity{}, err + } + fields = append(fields, f) + } + + return Identity{ + Domain: Domain(d), + Fields: fields, + }, nil +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000..a1cfc9b --- /dev/null +++ b/internal/identity/identity_test.go @@ -0,0 +1,278 @@ +package identity_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/openkcm/krypton/internal/identity" +) + +func TestDomainValidate(t *testing.T) { + tests := []struct { + name string + domain identity.Domain + expErr error + }{ + { + name: "valid simple domain", + domain: "acme-corp", + }, + { + name: "valid domain with dots", + domain: "my.org.example", + }, + { + name: "empty domain", + domain: "", + expErr: identity.ErrEmptyDomain, + }, + { + name: "domain with wildcard", + domain: "acme*", + expErr: identity.ErrInvalidCharacters, + }, + { + name: "domain with slash", + domain: "acme/corp", + expErr: identity.ErrInvalidCharacters, + }, + { + name: "domain with space", + domain: "acme corp", + expErr: identity.ErrInvalidCharacters, + }, + { + name: "domain with tab", + domain: "acme\tcorp", + expErr: identity.ErrInvalidCharacters, + }, + { + name: "domain with newline", + domain: "acme\ncorp", + expErr: identity.ErrInvalidCharacters, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // when + err := tt.domain.Validate() + + // then + if tt.expErr != nil { + assert.ErrorIs(t, err, tt.expErr) + return + } + assert.NoError(t, err) + }) + } +} + +func TestFieldValidate(t *testing.T) { + tests := []struct { + name string + field identity.Field + expErr error + }{ + { + name: "valid field", + field: identity.Field{Type: "node", Name: "root"}, + }, + { + name: "empty type", + field: identity.Field{Type: "", Name: "root"}, + expErr: identity.ErrEmptyField, + }, + { + name: "empty name", + field: identity.Field{Type: "node", Name: ""}, + expErr: identity.ErrEmptyField, + }, + { + name: "type with wildcard", + field: identity.Field{Type: "no*de", Name: "root"}, + expErr: identity.ErrInvalidCharacters, + }, + { + name: "name with slash", + field: identity.Field{Type: "node", Name: "ro/ot"}, + expErr: identity.ErrInvalidCharacters, + }, + { + name: "type with space", + field: identity.Field{Type: "no de", Name: "root"}, + expErr: identity.ErrInvalidCharacters, + }, + { + name: "name with tab", + field: identity.Field{Type: "node", Name: "ro\tot"}, + expErr: identity.ErrInvalidCharacters, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // when + err := tt.field.Validate() + + // then + if tt.expErr != nil { + assert.ErrorIs(t, err, tt.expErr) + return + } + assert.NoError(t, err) + }) + } +} + +func TestIdentityURI(t *testing.T) { + tests := []struct { + name string + identity identity.Identity + expURI identity.URI + }{ + { + name: "domain only", + identity: identity.Identity{Domain: "acme-corp"}, + expURI: "kryptonid://acme-corp", + }, + { + name: "single field", + identity: identity.Identity{ + Domain: "acme-corp", + Fields: []identity.Field{{Type: "node", Name: "root"}}, + }, + expURI: "kryptonid://acme-corp/node/root", + }, + { + name: "multiple fields", + identity: identity.Identity{ + Domain: "acme-corp", + Fields: []identity.Field{ + {Type: "node", Name: "root"}, + {Type: "service", Name: "kms"}, + }, + }, + expURI: "kryptonid://acme-corp/node/root/service/kms", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // when + got := tt.identity.URI() + + // then + assert.Equal(t, tt.expURI, got) + }) + } +} + +func TestParse(t *testing.T) { + tests := []struct { + name string + uri identity.URI + expIdentity identity.Identity + expErr error + }{ + { + name: "domain only", + uri: "kryptonid://acme-corp", + expIdentity: identity.Identity{Domain: "acme-corp"}, + }, + { + name: "single field", + uri: "kryptonid://acme-corp/node/root", + expIdentity: identity.Identity{ + Domain: "acme-corp", + Fields: []identity.Field{{Type: "node", Name: "root"}}, + }, + }, + { + name: "multiple fields", + uri: "kryptonid://acme-corp/node/root/service/kms", + expIdentity: identity.Identity{ + Domain: "acme-corp", + Fields: []identity.Field{ + {Type: "node", Name: "root"}, + {Type: "service", Name: "kms"}, + }, + }, + }, + { + name: "wrong scheme", + uri: "https://acme-corp/node/root", + expErr: identity.ErrInvalidScheme, + }, + { + name: "no scheme", + uri: "acme-corp/node/root", + expErr: identity.ErrInvalidScheme, + }, + { + name: "empty domain", + uri: "kryptonid:///node/root", + expErr: identity.ErrEmptyDomain, + }, + { + name: "invalid domain with wildcard", + uri: "kryptonid://acme*corp/node/root", + expErr: identity.ErrInvalidCharacters, + }, + { + name: "odd number of path segments", + uri: "kryptonid://acme-corp/node", + expErr: identity.ErrInvalidPath, + }, + { + name: "three path segments", + uri: "kryptonid://acme-corp/node/root/service", + expErr: identity.ErrInvalidPath, + }, + { + name: "field with wildcard in type", + uri: "kryptonid://acme-corp/no*de/root", + expErr: identity.ErrInvalidCharacters, + }, + { + name: "field with empty name segment", + uri: "kryptonid://acme-corp/node/", + expErr: identity.ErrEmptyField, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // when + got, err := identity.Parse(tt.uri) + + // then + if tt.expErr != nil { + assert.ErrorIs(t, err, tt.expErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expIdentity, got) + }) + } +} + +func TestParseRoundTrip(t *testing.T) { + // given + original := identity.Identity{ + Domain: "acme-corp", + Fields: []identity.Field{ + {Type: "node", Name: "root"}, + {Type: "region", Name: "eu-west-1"}, + }, + } + + // when + uri := original.URI() + parsed, err := identity.Parse(uri) + + // then + assert.NoError(t, err) + assert.Equal(t, original, parsed) +}