Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/cloud/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Package cloud holds the rules that are specific to Temporal Cloud rather than
// to any Temporal Service.
//
// Today that means namespace validation. Cloud identifies a namespace as
// "<name>.<account-id>", a shape self-hosted deployments do not impose, so
// [ValidateNamespace] checks a string against it before the proxy uses that
// string to address a Cloud upstream.
package cloud
25 changes: 25 additions & 0 deletions internal/cloud/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package cloud

import (
"net"
"strings"
)

// endpointSuffix is the domain Temporal Cloud serves its endpoints from.
const endpointSuffix = ".tmprl.cloud"

// IsEndpoint reports whether hostPort addresses Temporal Cloud. The port is
// optional, and a template action in place of the host is tolerated, since a
// templated address is rendered per request but keeps its domain.
//
// This recognizes per-namespace and regional endpoints. Private-link endpoints
// use per-VPC hostnames that carry no Cloud domain, so those have to be declared
// rather than detected.
func IsEndpoint(hostPort string) bool {
host := hostPort
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}

return strings.HasSuffix(host, endpointSuffix)
}
37 changes: 37 additions & 0 deletions internal/cloud/endpoint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cloud_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/temporalio/temporal-proxy/internal/cloud"
)

func TestIsEndpoint(t *testing.T) {
t.Parallel()

tests := []struct {
name string
hostPort string
want bool
}{
{name: "per-namespace endpoint", hostPort: "quickstart.a1b2c.tmprl.cloud:7233", want: true},
{name: "regional endpoint", hostPort: "us-west-2.aws.tmprl.cloud:7233", want: true},
{name: "templated host", hostPort: "{{ .RemoteNamespace }}.tmprl.cloud:7233", want: true},
{name: "no port", hostPort: "quickstart.a1b2c.tmprl.cloud", want: true},
{name: "self-hosted", hostPort: "localhost:7233"},
{name: "private-link hostname", hostPort: "vpce-0abc123.vpce-svc-0def456.us-east-1.vpce.amazonaws.com:7233"},
{name: "lookalike domain", hostPort: "evil-tmprl.cloud:7233"},
{name: "bare domain", hostPort: "tmprl.cloud:7233"},
{name: "empty"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

require.Equal(t, tt.want, cloud.IsEndpoint(tt.hostPort))
})
}
}
101 changes: 101 additions & 0 deletions internal/cloud/namespace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cloud

import (
"errors"
"fmt"
"regexp"
"strings"

"github.com/temporalio/temporal-proxy/pkg/validation"
)

const (
nameMinLen = 2
nameMaxLen = 39

accountIDMinLen = 5
accountIDMaxLen = 20
)

// start with letter, end with letter/number, contain only a-z0-9-
var nsNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*[a-z0-9]$`)

// ValidateAccountID checks that id is shaped like the account-id label of a
// Temporal Cloud namespace.
//
// See: https://docs.temporal.io/cloud/namespaces for details
func ValidateAccountID(id string) error {
return validation.Validate("", accountIDRule(id))
}

// ValidateNamespace checks that ns is a well-formed Temporal Cloud namespace
// identifier, meaning "<name>.<account-id>". Every broken rule is reported, not
// just the first, so a caller can show the whole story at once.
//
// See: https://docs.temporal.io/cloud/namespaces for details
func ValidateNamespace(ns string) error {
// Exactly one separator: without it there is no account id, and with more
// than one there is no telling which label is which.
name, accountID, found := strings.Cut(ns, ".")
malformed := !found || strings.Contains(accountID, ".")

return validation.Validate(
"",
validation.WhenRules(
func() bool { return malformed },
func() validation.Errors {
return validation.Errors{{
Field: "id",
Message: "is malformed. Should be <name>.<account-id>",
}}
},
),
validation.WhenRules(
func() bool { return !malformed },
validation.Field(
"name",
name,
validation.Required[string](),
size(nameMinLen, nameMaxLen),
func(v string) error {
if v != strings.ToLower(v) {
return errors.New("must be lowercase")
}

return nil
},
func(v string) error {
if !nsNameRegex.MatchString(v) {
return errors.New("must begin with a letter, end with a letter or number, and contain only letters, numbers, and the hyphen")
}

return nil
},
),
accountIDRule(accountID),
),
)
}

// accountIDRule builds the account-id checks, shared by [ValidateAccountID] and
// the second label of [ValidateNamespace] so the rule is defined once.
func accountIDRule(id string) validation.Rule {
return validation.Field(
"account-id",
id,
validation.Required[string](),
size(accountIDMinLen, accountIDMaxLen),
)
}

// size builds a check rejecting strings shorter than n or longer than m, both
// inclusive.
func size(n, m int) validation.Check[string] {
return func(s string) error {
if len(s) < n || len(s) > m {
return fmt.Errorf("must be between %d and %d characters", n, m)
}

return nil
}
}
191 changes: 191 additions & 0 deletions internal/cloud/namespace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package cloud_test

import (
"errors"
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/temporalio/temporal-proxy/internal/cloud"
"github.com/temporalio/temporal-proxy/pkg/validation"
)

const (
malformedID = "is malformed. Should be <name>.<account-id>"
nameShape = "must begin with a letter, end with a letter or number, and contain only letters, numbers, and the hyphen"
nameSize = "must be between 2 and 39 characters"
accountSize = "must be between 5 and 20 characters"
)

func TestValidateAccountID(t *testing.T) {
t.Parallel()

tests := []struct {
name string
id string
wantErrs []validation.Error
}{
{name: "typical account id", id: "a1b2c"},
{name: "longest", id: strings.Repeat("a", 20)},
{
name: "empty",
id: "",
wantErrs: []validation.Error{
{Field: "account-id", Message: "is required"},
{Field: "account-id", Message: accountSize},
},
},
{
name: "too short",
id: "a1b2",
wantErrs: []validation.Error{{Field: "account-id", Message: accountSize}},
},
{
name: "too long",
id: strings.Repeat("a", 21),
wantErrs: []validation.Error{{Field: "account-id", Message: accountSize}},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := cloud.ValidateAccountID(tt.id)
if len(tt.wantErrs) == 0 {
require.NoError(t, err)
return
}

var errs validation.Errors
require.True(t, errors.As(err, &errs), "expected validation.Errors, got %T", err)
require.ElementsMatch(t, tt.wantErrs, []validation.Error(errs))
})
}
}

func TestValidateNamespace(t *testing.T) {
t.Parallel()

tests := []struct {
name string
ns string
wantErrs []validation.Error
}{
{name: "typical namespace", ns: "my-namespace.a2dd6"},
{name: "digits in name", ns: "ns1.a2dd6"},
{name: "shortest name", ns: "ab.a2dd6"},
{name: "longest name", ns: strings.Repeat("a", 39) + ".a2dd6"},
{name: "longest account id", ns: "myns." + strings.Repeat("a", 20)},
{name: "account id is not name-shaped", ns: "myns.A_2xy"},
{
name: "empty",
ns: "",
wantErrs: []validation.Error{{Field: "id", Message: malformedID}},
},
{
name: "no separator",
ns: "mynamespace",
wantErrs: []validation.Error{{Field: "id", Message: malformedID}},
},
{
name: "extra separator",
ns: "my.name.space",
wantErrs: []validation.Error{{Field: "id", Message: malformedID}},
},
{
name: "missing name",
ns: ".a2dd6",
wantErrs: []validation.Error{
{Field: "name", Message: "is required"},
{Field: "name", Message: nameSize},
{Field: "name", Message: nameShape},
},
},
{
name: "missing account id",
ns: "myns.",
wantErrs: []validation.Error{
{Field: "account-id", Message: "is required"},
{Field: "account-id", Message: accountSize},
},
},
{
name: "name too short",
ns: "a.a2dd6",
wantErrs: []validation.Error{
{Field: "name", Message: nameSize},
{Field: "name", Message: nameShape},
},
},
{
name: "name too long",
ns: strings.Repeat("a", 40) + ".a2dd6",
wantErrs: []validation.Error{{Field: "name", Message: nameSize}},
},
{
name: "uppercase name",
ns: "My-NS.a2dd6",
wantErrs: []validation.Error{
{Field: "name", Message: "must be lowercase"},
{Field: "name", Message: nameShape},
},
},
{
name: "name starts with a hyphen",
ns: "-myns.a2dd6",
wantErrs: []validation.Error{{Field: "name", Message: nameShape}},
},
{
name: "name ends with a hyphen",
ns: "myns-.a2dd6",
wantErrs: []validation.Error{{Field: "name", Message: nameShape}},
},
{
name: "name starts with a digit",
ns: "1myns.a2dd6",
wantErrs: []validation.Error{{Field: "name", Message: nameShape}},
},
{
name: "underscore in name",
ns: "my_ns.a2dd6",
wantErrs: []validation.Error{{Field: "name", Message: nameShape}},
},
{
name: "account id too short",
ns: "myns.a2dd",
wantErrs: []validation.Error{{Field: "account-id", Message: accountSize}},
},
{
name: "account id too long",
ns: "myns." + strings.Repeat("a", 21),
wantErrs: []validation.Error{{Field: "account-id", Message: accountSize}},
},
{
name: "both labels bad",
ns: "-.abc",
wantErrs: []validation.Error{
{Field: "name", Message: nameSize},
{Field: "name", Message: nameShape},
{Field: "account-id", Message: accountSize},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := cloud.ValidateNamespace(tt.ns)
if len(tt.wantErrs) == 0 {
require.NoError(t, err)
return
}

var errs validation.Errors
require.True(t, errors.As(err, &errs), "expected validation.Errors, got %T", err)
require.ElementsMatch(t, tt.wantErrs, []validation.Error(errs))
})
}
}
Loading