Files
teleport/lib/utils/parse/parse_test.go
T
Dan Upton 1e7abe0873 Support username in role templates and expressions (#64888)
Adds a new variable `user.metadata.name` that can be used in role templates:

```yaml
allow:
  node_labels:
    owner: '{{user.metadata.name}}'
```

Or in expressions:

```yaml
allow:
  node_labels_expression: |
    labels["owner"] == user.metadata.name
```

So that you can create roles that allow access to user-owned resources such as
"Connect My Computer" nodes, or the upcoming Beams feature.
2026-04-15 16:29:41 +00:00

586 lines
14 KiB
Go

/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package parse
import (
"regexp"
"testing"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
)
// TestVariable tests variable parsing
func TestVariable(t *testing.T) {
t.Parallel()
traits := map[string][]string{
"foo": {"foovalue"},
"bar": {"barvalue"},
"email": {"user@example.com"},
}
var tests = []struct {
title string
in string
err any
out string
}{
{
title: "no curly bracket prefix",
in: "external.foo}}",
err: &trace.BadParameterError{},
},
{
title: "invalid syntax",
in: `{{external.foo("bar")`,
err: &trace.BadParameterError{},
},
{
title: "invalid variable syntax",
in: "{{internal.}}",
err: &trace.BadParameterError{},
},
{
title: "invalid dot syntax",
in: "{{external..foo}}",
err: &trace.BadParameterError{},
},
{
title: "empty variable",
in: "{{}}",
err: &trace.BadParameterError{},
},
{
title: "string variable",
in: `{{"asdf"}}`,
out: "asdf",
},
{
title: "invalid int variable",
in: `{{123}}`,
err: &trace.BadParameterError{},
},
{
title: "incomplete variables are not allowed",
in: `{{internal}}`,
err: &trace.BadParameterError{},
},
{
title: "no curly bracket suffix",
in: "{{internal.foo",
err: &trace.BadParameterError{},
},
{
title: "too many levels of nesting in the variable",
in: "{{internal.foo.bar}}",
err: &trace.BadParameterError{},
},
{
title: "too many levels of nesting in the variable with property",
in: `{{internal.foo["bar"]}}`,
err: &trace.BadParameterError{},
},
{
title: "regexp function call not allowed",
in: `{{regexp.match(".*")}}`,
err: &trace.BadParameterError{},
},
{
title: "valid with brackets",
in: `{{internal["foo"]}}`,
out: "foovalue",
},
{
title: "string literal",
in: `foo`,
out: "foo",
},
{
title: "external with no brackets",
in: "{{external.foo}}",
out: "foovalue",
},
{
title: "invalid namespaces are allowed",
in: "{{foo.bar}}",
out: "barvalue",
},
{
title: "internal with no brackets",
in: "{{internal.bar}}",
out: "barvalue",
},
{
title: "internal with spaces removed",
in: " {{ internal.bar }} ",
out: "barvalue",
},
{
title: "variable with prefix and suffix",
in: " hello, {{ internal.bar }} there! ",
out: "hello, barvalue there!",
},
{
title: "variable with local function",
in: "{{email.local(internal.email)}}",
out: "user",
},
{
title: "regexp replace",
in: `{{regexp.replace(internal.foo, "^(.*)value$", "$1")}}`,
out: "foo",
},
{
title: "regexp replace with variable expression",
in: `{{regexp.replace(internal.foo, internal.bar, "baz")}}`,
err: &trace.BadParameterError{},
},
{
title: "regexp replace with variable replacement",
in: `{{regexp.replace(internal.foo, "bar", internal.baz)}}`,
err: &trace.BadParameterError{},
},
{
title: "regexp replace constant expression",
in: `{{regexp.replace("abc", "c", "z")}}`,
out: "abz",
},
{
title: "non existing function",
in: `{{regexp.replac("abc", "c", "z")}}`,
err: &trace.BadParameterError{},
},
{
title: "missing args",
in: `{{regexp.replace("abc", "c")}}`,
err: &trace.BadParameterError{},
},
{
title: "no args",
in: `{{regexp.replace()}}`,
err: &trace.BadParameterError{},
},
{
title: "extra args",
in: `{{regexp.replace("abc", "c", "x", "z")}}`,
err: &trace.BadParameterError{},
},
{
title: "invalid arg type",
in: `{{regexp.replace(regexp.match("a"), "c", "x")}}`,
err: &trace.BadParameterError{},
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
expr, err := NewTraitsTemplateExpression(tt.in)
if tt.err != nil {
require.ErrorAs(t, err, &tt.err)
return
}
require.NoError(t, err, trace.DebugReport(err))
result, err := expr.Interpolate(func(namespace, trait string) error {
return nil
}, traits)
require.NoError(t, err, trace.DebugReport(err))
require.Len(t, result, 1)
require.Equal(t, tt.out, result[0])
})
}
}
// TestInterpolate tests variable interpolation
func TestInterpolate(t *testing.T) {
t.Parallel()
errCheckIsNotFound := func(tt require.TestingT, err error, i ...any) {
require.True(tt, trace.IsNotFound(err), "expected not found error, got %v", err)
}
errCheckIsBadParameter := func(tt require.TestingT, err error, i ...any) {
require.True(tt, trace.IsBadParameter(err), "expected bad parameter error, got %v", err)
}
type result struct {
values []string
errCheck require.ErrorAssertionFunc
}
var tests = []struct {
title string
in string
traits map[string][]string
res result
}{
{
title: "mapped traits",
in: "{{external.foo}}",
traits: map[string][]string{"foo": {"a", "b"}, "bar": {"c"}},
res: result{values: []string{"a", "b"}},
},
{
title: "mapped traits with email.local",
in: "{{email.local(external.foo)}}",
traits: map[string][]string{"foo": {"Alice <alice@example.com>", "bob@example.com"}, "bar": {"c"}},
res: result{values: []string{"alice", "bob"}},
},
{
title: "missed traits",
in: "{{external.baz}}",
traits: map[string][]string{"foo": {"a", "b"}, "bar": {"c"}},
res: result{errCheck: errCheckIsNotFound, values: []string{}},
},
{
title: "legacy user.name trait alias",
in: "{{user.name}}",
traits: map[string][]string{"name": {"alice-from-trait"}},
res: result{values: []string{"alice-from-trait"}},
},
{
title: "traits with prefix and suffix",
in: "IAM#{{external.foo}};",
traits: map[string][]string{"foo": {"a", "b"}, "bar": {"c"}},
res: result{values: []string{"IAM#a;", "IAM#b;"}},
},
{
title: "error in mapping traits",
in: "{{email.local(external.foo)}}",
traits: map[string][]string{"foo": {"Alice <alice"}},
res: result{errCheck: errCheckIsBadParameter},
},
{
title: "literal expression",
in: "foo",
traits: map[string][]string{"foo": {"a", "b"}, "bar": {"c"}},
res: result{values: []string{"foo"}},
},
{
title: "regexp replacement with numeric match",
in: `{{regexp.replace(internal.foo, "bar-(.*)", "$1")}}`,
traits: map[string][]string{"foo": {"bar-baz"}},
res: result{values: []string{"baz"}},
},
{
title: "regexp replacement with named match",
in: `{{regexp.replace(internal.foo, "bar-(?P<suffix>.*)", "$suffix")}}`,
traits: map[string][]string{"foo": {"bar-baz"}},
res: result{values: []string{"baz"}},
},
{
title: "regexp replacement with multiple matches",
in: `{{regexp.replace(internal.foo, "foo-(.*)-(.*)", "$1.$2")}}`,
traits: map[string][]string{"foo": {"foo-bar-baz"}},
res: result{values: []string{"bar.baz"}},
},
{
title: "regexp replacement with no match",
in: `{{regexp.replace(internal.foo, "^bar-(.*)$", "$1-matched")}}`,
traits: map[string][]string{"foo": {"foo-test1", "bar-test2"}},
res: result{values: []string{"test2-matched"}},
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
expr, err := NewTraitsTemplateExpression(tt.in)
require.NoError(t, err)
noVarValidation := func(string, string) error {
return nil
}
values, err := expr.Interpolate(noVarValidation, tt.traits)
if tt.res.errCheck != nil {
tt.res.errCheck(t, err)
require.Empty(t, values)
return
}
require.NoError(t, err)
require.Equal(t, tt.res.values, values)
})
}
}
func TestInterpolateWithUser(t *testing.T) {
t.Parallel()
expr, err := NewTraitsTemplateExpression("hello-{{user.metadata.name}}")
require.NoError(t, err)
noVarValidation := func(string, string) error {
return nil
}
values, err := expr.InterpolateWithUser(noVarValidation, "alice", nil)
require.NoError(t, err)
require.Equal(t, []string{"hello-alice"}, values)
_, err = expr.InterpolateWithUser(noVarValidation, "", nil)
require.True(t, trace.IsNotFound(err), "expected not found error, got %v", err)
}
func TestInterpolateWithUserPreservesLegacyUserNameTrait(t *testing.T) {
t.Parallel()
expr, err := NewTraitsTemplateExpression("hello-{{user.name}}")
require.NoError(t, err)
noVarValidation := func(string, string) error {
return nil
}
values, err := expr.InterpolateWithUser(noVarValidation, "alice", map[string][]string{
"name": {"alice-from-trait"},
})
require.NoError(t, err)
require.Equal(t, []string{"hello-alice-from-trait"}, values)
}
// TestVarValidation tests that vars are validated during interpolation.
func TestVarValidation(t *testing.T) {
t.Parallel()
var tests = []struct {
title string
in string
traits map[string][]string
varValidation func(string, string) error
assertErr require.ErrorAssertionFunc
}{
{
title: "no validation",
in: "{{external.foo}}",
traits: map[string][]string{"foo": {"bar"}},
varValidation: func(namespace, name string) error {
return nil
},
assertErr: require.NoError,
},
{
title: "validate namespace ok",
in: "{{external.foo}}",
traits: map[string][]string{"foo": {"bar"}},
varValidation: func(namespace, name string) error {
if namespace != "external" {
return trace.BadParameter("")
}
return nil
},
assertErr: require.NoError,
},
{
title: "validate namespace error",
in: "{{internal.foo}}",
traits: map[string][]string{"foo": {"bar"}},
varValidation: func(namespace, name string) error {
if namespace != "external" {
return trace.BadParameter("")
}
return nil
},
assertErr: require.Error,
},
{
title: "variable found",
in: "{{external.foo}}",
traits: map[string][]string{"foo": {"bar"}},
varValidation: func(namespace, name string) error {
return nil
},
assertErr: require.NoError,
},
{
title: "variable not found",
in: "{{external.baz}}",
traits: map[string][]string{"foo": {"bar"}},
varValidation: func(namespace, name string) error {
return nil
},
assertErr: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
expr, err := NewTraitsTemplateExpression(tt.in)
require.NoError(t, err)
_, err = expr.Interpolate(tt.varValidation, tt.traits)
tt.assertErr(t, err)
})
}
}
func TestMatch(t *testing.T) {
t.Parallel()
tests := []struct {
title string
in string
err any
out MatchExpression
}{
{
title: "no curly bracket prefix",
in: `regexp.match(".*")}}`,
err: &trace.BadParameterError{},
},
{
title: "no curly bracket suffix",
in: `{{regexp.match(".*")`,
err: &trace.BadParameterError{},
},
{
title: "unknown function",
in: `{{regexp.surprise(".*")}}`,
err: &trace.BadParameterError{},
},
{
title: "bad regexp",
in: `{{regexp.match("+foo")}}`,
err: &trace.BadParameterError{},
},
{
title: "unknown namespace",
in: `{{surprise.match(".*")}}`,
err: &trace.BadParameterError{},
},
{
title: "not a boolean expression",
in: `{{email.local(external.email)}}`,
err: &trace.BadParameterError{},
},
{
title: "not a boolean variable",
in: `{{external.email}}`,
err: &trace.BadParameterError{},
},
{
title: "string literal",
in: `foo`,
out: MatchExpression{
matcher: regexpMatcher(`^foo$`),
},
},
{
title: "wildcard",
in: `foo*`,
out: MatchExpression{
matcher: regexpMatcher(`^foo(.*)$`),
},
},
{
title: "raw regexp",
in: `^foo.*$`,
out: MatchExpression{
matcher: regexpMatcher(`^foo.*$`),
},
},
{
title: "regexp.match simple call",
in: `{{regexp.match("foo")}}`,
out: MatchExpression{
matcher: regexpMatcher(`foo`),
},
},
{
title: "regexp.match call",
in: `foo-{{regexp.match("bar")}}-baz`,
out: MatchExpression{
prefix: "foo-",
matcher: regexpMatcher(`bar`),
suffix: "-baz",
},
},
{
title: "regexp.not_match call",
in: `foo-{{regexp.not_match("bar")}}-baz`,
out: MatchExpression{
prefix: "foo-",
matcher: regexpNotMatcher(`bar`),
suffix: "-baz",
},
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
matcher, err := NewMatcher(tt.in)
if tt.err != nil {
require.ErrorAs(t, err, &tt.err)
return
}
require.NoError(t, err)
require.Equal(t, tt.out, *matcher)
})
}
}
func TestMatchers(t *testing.T) {
t.Parallel()
tests := []struct {
title string
matcher string
in string
want bool
}{
{
title: "regexp matcher positive",
matcher: `{{regexp.match("foo")}}`,
in: "foo",
want: true,
},
{
title: "regexp matcher negative",
matcher: `{{regexp.match("bar")}}`,
in: "foo",
want: false,
},
{
title: "not matcher",
matcher: `{{regexp.not_match("bar")}}`,
in: "foo",
want: true,
},
{
title: "prefix/suffix matcher positive",
matcher: `foo-{{regexp.match("bar")}}-baz`,
in: "foo-bar-baz",
want: true,
},
{
title: "prefix/suffix matcher negative",
matcher: `foo-{{regexp.match("bar")}}-baz`,
in: "foo-foo-baz",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
matcher, err := NewMatcher(tt.matcher)
require.NoError(t, err)
got := matcher.Match(tt.in)
require.Equal(t, tt.want, got)
})
}
}
func regexpMatcher(match string) Matcher {
return matcher{regexp.MustCompile(match)}
}
func regexpNotMatcher(match string) Matcher {
return notMatcher{regexp.MustCompile(match)}
}