Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v17] Implementing granular SSH port forwarding access controls #50241

Merged
merged 1 commit into from
Dec 13, 2024
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
3 changes: 0 additions & 3 deletions api/types/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -1069,9 +1069,6 @@ func (r *RoleV6) CheckAndSetDefaults() error {
if r.Spec.Options.MaxSessionTTL.Value() == 0 {
r.Spec.Options.MaxSessionTTL = NewDuration(defaults.MaxCertDuration)
}
if r.Spec.Options.PortForwarding == nil {
r.Spec.Options.PortForwarding = NewBoolOption(true)
}
if len(r.Spec.Options.BPF) == 0 {
r.Spec.Options.BPF = defaults.EnhancedEvents()
}
Expand Down
79 changes: 77 additions & 2 deletions lib/auth/grpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import (
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/types/installers"
"github.com/gravitational/teleport/api/types/wrappers"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/lib/auth/accessmonitoringrules/accessmonitoringrulesv1"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/auth/autoupdate/autoupdatev1"
Expand Down Expand Up @@ -1975,11 +1976,58 @@ func (g *GRPCServer) DeleteAllKubernetesServers(ctx context.Context, req *authpb
// version for some features of the role returns a shallow copy of the given
// role downgraded for compatibility with the older version.
func maybeDowngradeRole(ctx context.Context, role *types.RoleV6) (*types.RoleV6, error) {
// Teleport 16 supports all role features that Teleport 15 does,
// so no downgrade is necessary.
clientVersionString, ok := metadata.ClientVersionFromContext(ctx)
if !ok {
// This client is not reporting its version via gRPC metadata. Teleport
// clients have been reporting their version for long enough that older
// clients won't even support v6 roles at all, so this is likely a
// third-party client, and we shouldn't assume that downgrading the role
// will do more good than harm.
return role, nil
}

clientVersion, err := semver.NewVersion(clientVersionString)
if err != nil {
return nil, trace.BadParameter("unrecognized client version: %s is not a valid semver", clientVersionString)
}

role = maybeDowngradeRoleSSHPortForwarding(role, clientVersion)
return role, nil
}

var minSupportedSSHPortForwardingVersions = map[int64]semver.Version{
17: {Major: 17, Minor: 1, Patch: 0},
}

func maybeDowngradeRoleSSHPortForwarding(role *types.RoleV6, clientVersion *semver.Version) *types.RoleV6 {
sshPortForwarding := role.GetOptions().SSHPortForwarding
if sshPortForwarding == nil || (sshPortForwarding.Remote == nil && sshPortForwarding.Local == nil) {
return role
}

minSupportedVersion, ok := minSupportedSSHPortForwardingVersions[clientVersion.Major]
if ok {
if supported, err := utils.MinVerWithoutPreRelease(clientVersion.String(), minSupportedVersion.String()); supported || err != nil {
return role
}
}

role = apiutils.CloneProtoMsg(role)
options := role.GetOptions()

//nolint:staticcheck // this field is preserved for backwards compatibility
options.PortForwarding = types.NewBoolOption(services.RoleSet{role}.CanPortForward())
role.SetOptions(options)
reason := fmt.Sprintf(`Client version %q does not support granular SSH port forwarding. Role %q will be downgraded `+
`to simple port forwarding rules instead. In order to support granular SSH port forwarding, all clients must be `+
`updated to version %q or higher.`, clientVersion, role.GetName(), minSupportedVersion)
if role.Metadata.Labels == nil {
role.Metadata.Labels = make(map[string]string, 1)
}
role.Metadata.Labels[types.TeleportDowngradedLabel] = reason
return role
}

// GetRole retrieves a role by name.
func (g *GRPCServer) GetRole(ctx context.Context, req *authpb.GetRoleRequest) (*types.RoleV6, error) {
auth, err := g.authenticate(ctx)
Expand Down Expand Up @@ -2062,6 +2110,15 @@ func (g *GRPCServer) CreateRole(ctx context.Context, req *authpb.CreateRoleReque
return nil, trace.Wrap(err)
}

// This check *must* happen at the RPC layer rather than somewhere like ValidateRole or CheckAndSetDefaults. We want to prevent role
// creation and updates from defining both port_forwarding and ssh_port_forwarding for the same role. However, when making effective
// roles available to nodes it should be possible for both fields to be assigned in order to maintain backwards compatibility with older
// agents (similar to a role downgrade).
//nolint:staticcheck // this field is preserved for backwards compatibility, but shouldn't be used going forward
if req.Role.GetOptions().SSHPortForwarding != nil && req.Role.GetOptions().PortForwarding != nil {
return nil, trace.BadParameter("options define both 'port_forwarding' and 'ssh_port_forwarding', only one can be set")
}

if err = services.ValidateRole(req.Role); err != nil {
return nil, trace.Wrap(err)
}
Expand Down Expand Up @@ -2089,6 +2146,15 @@ func (g *GRPCServer) UpdateRole(ctx context.Context, req *authpb.UpdateRoleReque
return nil, trace.Wrap(err)
}

// This check *must* happen at the RPC layer rather than somewhere like ValidateRole or CheckAndSetDefaults. We want to prevent role
// creation and updates from defining both port_forwarding and ssh_port_forwarding for the same role. However, when making effective
// roles available to nodes it should be possible for both fields to be assigned in order to maintain backwards compatibility with older
// agents (similar to a role downgrade).
//nolint:staticcheck // this field is preserved for backwards compatibility, but shouldn't be used going forward
if req.Role.GetOptions().SSHPortForwarding != nil && req.Role.GetOptions().PortForwarding != nil {
return nil, trace.BadParameter("options define both 'port_forwarding' and 'ssh_port_forwarding', only one can be set")
}

if err = services.ValidateRole(req.Role); err != nil {
return nil, trace.Wrap(err)
}
Expand Down Expand Up @@ -2116,6 +2182,15 @@ func (g *GRPCServer) UpsertRoleV2(ctx context.Context, req *authpb.UpsertRoleReq
return nil, trace.Wrap(err)
}

// This check *must* happen at the RPC layer rather than somewhere like ValidateRole or CheckAndSetDefaults. We want to prevent role
// creation and updates from defining both port_forwarding and ssh_port_forwarding for the same role. However, when making effective
// roles available to nodes it should be possible for both fields to be assigned in order to maintain backwards compatibility with older
// agents (similar to a role downgrade).
//nolint:staticcheck // this field is preserved for backwards compatibility, but shouldn't be used going forward
if req.Role.GetOptions().SSHPortForwarding != nil && req.Role.GetOptions().PortForwarding != nil {
return nil, trace.BadParameter("options define both 'port_forwarding' and 'ssh_port_forwarding', only one can be set")
}

if err = services.ValidateRole(req.Role); err != nil {
return nil, trace.Wrap(err)
}
Expand Down
250 changes: 250 additions & 0 deletions lib/auth/grpcserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import (
clusterconfigpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/clusterconfig/v1"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/internalutils/stream"
"github.com/gravitational/teleport/api/metadata"
"github.com/gravitational/teleport/api/mfa"
"github.com/gravitational/teleport/api/observability/tracing"
"github.com/gravitational/teleport/api/types"
Expand Down Expand Up @@ -4563,6 +4564,255 @@ func TestGRPCServer_GetInstallers(t *testing.T) {
}
}

func TestRoleVersions(t *testing.T) {
t.Parallel()
srv := newTestTLSServer(t)

newRole := func(name string, version string, spec types.RoleSpecV6) types.Role {
role, err := types.NewRoleWithVersion(name, version, spec)
meta := role.GetMetadata()
role.SetMetadata(meta)
require.NoError(t, err)
return role
}

enabledRole := newRole("test_role_enabled", types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindRole, services.RW()),
},
},
Options: types.RoleOptions{
SSHPortForwarding: &types.SSHPortForwarding{
Remote: &types.SSHRemotePortForwarding{
Enabled: types.NewBoolOption(true),
},
Local: &types.SSHLocalPortForwarding{
Enabled: types.NewBoolOption(true),
},
},
},
})

disabledRole := newRole("test_role_disabled", types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindRole, services.RW()),
},
},
Options: types.RoleOptions{
SSHPortForwarding: &types.SSHPortForwarding{
Remote: &types.SSHRemotePortForwarding{
Enabled: types.NewBoolOption(false),
},
Local: &types.SSHLocalPortForwarding{
Enabled: types.NewBoolOption(false),
},
},
},
})

undefinedRole := newRole("test_role_implicit", types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindRole, services.RW()),
},
},
})

user, err := CreateUser(context.Background(), srv.Auth(), "user", enabledRole, disabledRole, undefinedRole)
require.NoError(t, err)

client, err := srv.NewClient(TestUser(user.GetName()))
require.NoError(t, err)

for _, tc := range []struct {
desc string
clientVersions []string
expectError bool
inputRole types.Role
expectedRole types.Role
expectDowngraded bool
}{
{
desc: "up to date - enabled",
clientVersions: []string{
"17.1.0", "17.1.0-dev", "",
},
inputRole: enabledRole,
expectedRole: enabledRole,
},
{
desc: "up to date - disabled",
clientVersions: []string{
"17.1.0", "17.1.0-dev", "",
},
inputRole: disabledRole,
expectedRole: disabledRole,
},
{
desc: "up to date - undefined",
clientVersions: []string{
"17.1.0", "17.1.0-dev", "",
},
inputRole: undefinedRole,
expectedRole: undefinedRole,
},
{
desc: "downgrade SSH access control granularity - enabled",
clientVersions: []string{
"17.0.0",
},
inputRole: enabledRole,
expectedRole: newRole(enabledRole.GetName(), types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindRole, services.RW()),
},
},
Options: types.RoleOptions{
PortForwarding: types.NewBoolOption(true),
SSHPortForwarding: enabledRole.GetOptions().SSHPortForwarding,
},
}),
expectDowngraded: true,
},
{
desc: "downgrade SSH access control granularity - disabled",
clientVersions: []string{
"17.0.0",
},
inputRole: disabledRole,
expectedRole: newRole(disabledRole.GetName(), types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindRole, services.RW()),
},
},
Options: types.RoleOptions{
PortForwarding: types.NewBoolOption(false),
SSHPortForwarding: disabledRole.GetOptions().SSHPortForwarding,
},
}),
expectDowngraded: true,
},
{
desc: "downgrade SSH access control granularity - undefined",
clientVersions: []string{
"17.0.0",
},
inputRole: undefinedRole,
expectedRole: undefinedRole,
expectDowngraded: false,
},
} {
t.Run(tc.desc, func(t *testing.T) {
for _, clientVersion := range tc.clientVersions {
t.Run(clientVersion, func(t *testing.T) {
// setup client metadata
ctx := context.Background()
if clientVersion == "" {
ctx = context.WithValue(ctx, metadata.DisableInterceptors{}, struct{}{})
} else {
ctx = metadata.AddMetadataToContext(ctx, map[string]string{
metadata.VersionKey: clientVersion,
})
}

checkRole := func(gotRole types.Role) {
t.Helper()
if tc.expectError {
return
}
require.Empty(t, cmp.Diff(tc.expectedRole, gotRole,
cmpopts.IgnoreFields(types.Metadata{}, "Revision", "Labels")))
// The downgraded label value won't match exactly because it
// includes the client version, so just check it's not empty
// and ignore it in the role diff.
if tc.expectDowngraded {
require.NotEmpty(t, gotRole.GetMetadata().Labels[types.TeleportDowngradedLabel])
}
}
checkErr := func(err error) {
t.Helper()
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}

// Test GetRole
gotRole, err := client.GetRole(ctx, tc.inputRole.GetName())
checkErr(err)
checkRole(gotRole)

// Test GetRoles
gotRoles, err := client.GetRoles(ctx)
checkErr(err)
if !tc.expectError {
foundTestRole := false
for _, gotRole := range gotRoles {
if gotRole.GetName() != tc.inputRole.GetName() {
continue
}
checkRole(gotRole)
foundTestRole = true
break
}
require.True(t, foundTestRole, "GetRoles result does not include expected role")
}

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

// Test WatchEvents
watcher, err := client.NewWatcher(ctx, types.Watch{Name: "roles", Kinds: []types.WatchKind{{Kind: types.KindRole}}})
require.NoError(t, err)
defer watcher.Close()

// Swallow the init event
e := <-watcher.Events()
require.Equal(t, types.OpInit, e.Type)

// Re-upsert the role so that the watcher sees it, do this
// on the auth server directly to avoid the
// TeleportDowngradedLabel check in ServerWithRoles
tc.inputRole, err = srv.Auth().UpsertRole(ctx, tc.inputRole)
require.NoError(t, err)

gotRole, err = func() (types.Role, error) {
for {
select {
case <-watcher.Done():
return nil, watcher.Error()
case e := <-watcher.Events():
if gotRole, ok := e.Resource.(types.Role); ok && gotRole.GetName() == tc.inputRole.GetName() {
return gotRole, nil
}
}
}
}()
checkErr(err)
checkRole(gotRole)

if !tc.expectError {
// Try to re-upsert the role we got. If it was
// downgraded, it should be rejected due to the
// TeleportDowngradedLabel
_, err = client.UpsertRole(ctx, gotRole)
if tc.expectDowngraded {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}
})
}
})
}
}

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

Expand Down
Loading
Loading