Add device lock support (#21667)

* Add Device to LocketTarget proto

* Refactor LockTarget.Match

* Test lib/srv.ComputeLockTargets

* Test lib/services.LockTargetsFromTLSIdentity

* Test device lock during cert issuance

* Add device lock targets where applicable

* Add --device to `tctl lock`

* Fix godocs (unrelated)

* Review: Drop redundant comments
This commit is contained in:
Alan Parra
2023-02-13 14:46:11 +00:00
committed by GitHub
parent d1be9b1a57
commit 8c84d0a766
13 changed files with 1177 additions and 950 deletions
@@ -4141,6 +4141,10 @@ message LockTarget {
// AccessRequest specifies the UUID of an access request.
string AccessRequest = 7 [(gogoproto.jsontag) = "access_request,omitempty"];
// Device is the device ID of a trusted device.
// Requires Teleport Enterprise.
string Device = 8 [(gogoproto.jsontag) = "device,omitempty"];
}
// AddressCondition represents a set of addresses. Presently the addresses are specfied
+8 -22
View File
@@ -201,28 +201,14 @@ func (t LockTarget) Match(lock Lock) bool {
return false
}
lockTarget := lock.Target()
if t.User != "" && lockTarget.User != t.User {
return false
}
if t.Role != "" && lockTarget.Role != t.Role {
return false
}
if t.Login != "" && lockTarget.Login != t.Login {
return false
}
if t.Node != "" && lockTarget.Node != t.Node {
return false
}
if t.MFADevice != "" && lockTarget.MFADevice != t.MFADevice {
return false
}
if t.WindowsDesktop != "" && lockTarget.WindowsDesktop != t.WindowsDesktop {
return false
}
if t.AccessRequest != "" && lockTarget.AccessRequest != t.AccessRequest {
return false
}
return true
return (t.User == "" || lockTarget.User == t.User) &&
(t.Role == "" || lockTarget.Role == t.Role) &&
(t.Login == "" || lockTarget.Login == t.Login) &&
(t.Node == "" || lockTarget.Node == t.Node) &&
(t.MFADevice == "" || lockTarget.MFADevice == t.MFADevice) &&
(t.WindowsDesktop == "" || lockTarget.WindowsDesktop == t.WindowsDesktop) &&
(t.AccessRequest == "" || lockTarget.AccessRequest == t.AccessRequest) &&
(t.Device == "" || lockTarget.Device == t.Device)
}
// String returns string representation of the LockTarget.
+961 -914
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1521,6 +1521,7 @@ func (a *Server) AugmentContextUserCertificates(
username: identity.Username,
mfaVerified: identity.MFAVerified,
activeAccessRequests: identity.ActiveRequests,
deviceID: opts.DeviceExtensions.DeviceID, // Check lock against requested device.
}); err != nil {
return nil, trace.Wrap(err)
}
@@ -1651,6 +1652,7 @@ func (a *Server) generateUserCert(req certRequest) (*proto.Certs, error) {
username: req.user.GetName(),
mfaVerified: req.mfaVerified,
activeAccessRequests: req.activeRequests.AccessRequests,
deviceID: req.deviceExtensions.DeviceID,
}); err != nil {
return nil, trace.Wrap(err)
}
@@ -1960,6 +1962,9 @@ type verifyLocksForUserCertsReq struct {
// activeAccessRequests are the UUIDs of active access requests for the user.
// Eg: tlsca.Identity.ActiveRequests.
activeAccessRequests []string
// deviceID is the trusted device ID.
// Eg: tlsca.Identity.DeviceExtensions.DeviceID
deviceID string
}
// verifyLocksForUserCerts verifies if any locks are in place before issuing new
@@ -1971,7 +1976,7 @@ func (a *Server) verifyLocksForUserCerts(req verifyLocksForUserCertsReq) error {
lockTargets := []types.LockTarget{
{User: req.username},
{MFADevice: req.mfaVerified},
// TODO(codingllama): Verify device locks.
{Device: req.deviceID},
}
lockTargets = append(lockTargets,
services.RolesToLockTargets(checker.RoleNames())...,
+57 -8
View File
@@ -1333,7 +1333,7 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
// Authenticate.
// user1 covers most of the tests.
// user2 is mainly used to test mismatched certificates against user1.
// user3 is used to test locking.
// user3 is used to test user locks.
_, sshRaw1, xCert1, sshCert1, identity1 := authenticate(t, user1.GetName(), pass1)
_, sshRaw2, xCert2, _, _ := authenticate(t, user2.GetName(), pass2)
_, _, xCert3, _, identity3 := authenticate(t, user3.GetName(), pass3)
@@ -1619,7 +1619,7 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
{
name: "locked user",
x509Cert: xCert3,
identity: identity3, // user3 is locked!
identity: identity3, // user3 locked below.
createAuthCtx: func(ctx context.Context) (*Context, error) {
// Authorize user3...
authCtx, err := ctxFromAuthorize(ctx)
@@ -1639,7 +1639,7 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
// ...and lock them right after.
user3Lock, err := types.NewLock("user3-lock", types.LockSpecV2{
Target: lockTarget,
Message: "locked for testing",
Message: "user locked",
})
if err != nil {
return nil, err
@@ -1648,10 +1648,48 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
return nil, err
}
<-watcher.Events() // Wait for the lock to go through.
// Wait for the lock to propagate.
<-watcher.Events()
return authCtx, nil
},
wantErr: "locked for testing",
wantErr: "user locked",
},
{
name: "locked device",
x509Cert: xCert1,
identity: identity1, // device locked below.
createOpts: func(t *testing.T) *AugmentUserCertificateOpts {
opts := &AugmentUserCertificateOpts{
DeviceExtensions: &DeviceExtensions{
DeviceID: "bad-device-1",
AssetTag: "bad-device-tag",
CredentialID: "bad-device-credential",
},
}
// Create a target matching the device device.
lockTarget := types.LockTarget{
Device: opts.DeviceExtensions.DeviceID,
}
watcher, err := authServer.lockWatcher.Subscribe(ctx, lockTarget)
require.NoError(t, err, "Subscribe failed")
defer watcher.Close()
// Lock the device before returning opts.
lock, err := types.NewLock("bad-device-lock", types.LockSpecV2{
Target: lockTarget,
Message: "device locked",
})
require.NoError(t, err, "NewLock failed")
require.NoError(t,
authServer.UpsertLock(ctx, lock),
"NewLock failed")
// Wait for the lock to propagate.
<-watcher.Events()
return opts
},
wantErr: "device locked",
},
}
for _, test := range tests {
@@ -1859,8 +1897,9 @@ func TestGenerateUserCertWithLocks(t *testing.T) {
accessInfo := services.AccessInfoFromUser(user)
accessChecker, err := services.NewAccessChecker(accessInfo, p.clusterName.GetClusterName(), p.a)
require.NoError(t, err)
mfaID := "test-mfa-id"
requestID := "test-access-request"
const mfaID = "test-mfa-id"
const requestID = "test-access-request"
const deviceID = "deviceid1"
keygen := testauthority.New()
_, pub, err := keygen.GetNewKeyPairFromPool()
require.NoError(t, err)
@@ -1870,12 +1909,22 @@ func TestGenerateUserCertWithLocks(t *testing.T) {
mfaVerified: mfaID,
publicKey: pub,
activeRequests: services.RequestIDs{AccessRequests: []string{requestID}},
deviceExtensions: DeviceExtensions{
DeviceID: deviceID,
AssetTag: "assettag1",
CredentialID: "credentialid1",
},
}
_, err = p.a.generateUserCert(certReq)
require.NoError(t, err)
testTargets := append(
[]types.LockTarget{{User: user.GetName()}, {MFADevice: mfaID}, {AccessRequest: requestID}},
[]types.LockTarget{
{User: user.GetName()},
{MFADevice: mfaID},
{AccessRequest: requestID},
{Device: deviceID},
},
services.RolesToLockTargets(user.GetRoles())...,
)
for _, target := range testTargets {
+2 -1
View File
@@ -159,7 +159,8 @@ Loop:
if r, ok := c.Identity.(BuiltinRole); ok && r.Role == types.RoleNode {
lockTargets = append(lockTargets,
types.LockTarget{Node: r.GetServerID()},
types.LockTarget{Node: r.Identity.Username})
types.LockTarget{Node: r.Identity.Username},
)
}
return lockTargets
}
+4
View File
@@ -43,6 +43,9 @@ func TestContextLockTargets(t *testing.T) {
Identity: tlsca.Identity{
Username: "node.cluster",
Groups: []string{"role1", "role2"},
DeviceExtensions: tlsca.DeviceExtensions{
DeviceID: "device1",
},
},
},
UnmappedIdentity: WrapIdentity(tlsca.Identity{
@@ -57,6 +60,7 @@ func TestContextLockTargets(t *testing.T) {
{Role: "role1"},
{Role: "role2"},
{Role: "mapped-role"},
{Device: "device1"},
}
require.ElementsMatch(t, authContext.LockTargets(), expected)
}
+3
View File
@@ -48,6 +48,9 @@ func LockTargetsFromTLSIdentity(id tlsca.Identity) []types.LockTarget {
if id.MFAVerified != "" {
lockTargets = append(lockTargets, types.LockTarget{MFADevice: id.MFAVerified})
}
if id.DeviceExtensions.DeviceID != "" {
lockTargets = append(lockTargets, types.LockTarget{Device: id.DeviceExtensions.DeviceID})
}
lockTargets = append(lockTargets, AccessRequestsToLockTargets(id.ActiveRequests)...)
return lockTargets
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2023 Gravitational, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package services_test
import (
"testing"
"github.com/google/go-cmp/cmp"
"google.golang.org/protobuf/testing/protocmp"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/tlsca"
)
func TestLockTargetsFromTLSIdentity(t *testing.T) {
t.Run("all locks", func(t *testing.T) {
identity := tlsca.Identity{
Username: "llama",
Groups: []string{"access", "editor"},
MFAVerified: "mfa-device-id",
ActiveRequests: []string{"access-request-1", "access-request-2"},
DeviceExtensions: tlsca.DeviceExtensions{
DeviceID: "trusted-device-id",
},
}
// Test.
got := services.LockTargetsFromTLSIdentity(identity)
want := []types.LockTarget{
{User: identity.Username},
{MFADevice: identity.MFAVerified},
{Device: identity.DeviceExtensions.DeviceID},
}
// Insert roles at the start to match `got`s order.
// The test itself doesn't care about the order, it's just easier to test
// this way.
want = append(services.RolesToLockTargets(identity.Groups), want...)
for _, request := range identity.ActiveRequests {
want = append(want, types.LockTarget{AccessRequest: request})
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("LockTargetsFromTLSIdentity mismatch (-want +got)\n%s", diff)
}
})
}
+1 -1
View File
@@ -573,7 +573,7 @@ func (p *lockCollector) Subscribe(ctx context.Context, targets ...types.LockTarg
}
// CheckLockInForce returns an AccessDenied error if there is a lock in force
// matching at at least one of the targets.
// matching at least one of the targets.
func (p *lockCollector) CheckLockInForce(mode constants.LockingMode, targets ...types.LockTarget) error {
p.currentRW.RLock()
defer p.currentRW.RUnlock()
+8 -3
View File
@@ -1179,7 +1179,12 @@ func ComputeLockTargets(clusterName, serverID string, id IdentityContext) []type
{Login: id.Login},
{Node: serverID},
{Node: auth.HostFQDN(serverID, clusterName)},
{MFADevice: id.Certificate.Extensions[teleport.CertExtensionMFAVerified]},
}
if mfaDevice := id.Certificate.Extensions[teleport.CertExtensionMFAVerified]; mfaDevice != "" {
lockTargets = append(lockTargets, types.LockTarget{MFADevice: mfaDevice})
}
if trustedDevice := id.Certificate.Extensions[teleport.CertExtensionDeviceID]; trustedDevice != "" {
lockTargets = append(lockTargets, types.LockTarget{Device: trustedDevice})
}
roles := apiutils.Deduplicate(append(id.AccessChecker.RoleNames(), id.UnmappedRoles...))
lockTargets = append(lockTargets, services.RolesToLockTargets(roles)...)
@@ -1187,7 +1192,7 @@ func ComputeLockTargets(clusterName, serverID string, id IdentityContext) []type
return lockTargets
}
// SetRequest sets the ssh request that was issued by the client.
// SetSSHRequest sets the ssh request that was issued by the client.
// Will return an error if called more than once for a single server context.
func (c *ServerContext) SetSSHRequest(e *ssh.Request) error {
c.mu.Lock()
@@ -1200,7 +1205,7 @@ func (c *ServerContext) SetSSHRequest(e *ssh.Request) error {
return nil
}
// GetRequest returns the ssh request that was issued by the client and saved on
// GetSSHRequest returns the ssh request that was issued by the client and saved on
// this ServerContext by SetExecRequest, or an error if it has not been set.
func (c *ServerContext) GetSSHRequest() (*ssh.Request, error) {
c.mu.RLock()
+63
View File
@@ -23,6 +23,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"google.golang.org/protobuf/testing/protocmp"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/types"
@@ -219,3 +220,65 @@ func TestIdentityContext_GetUserMetadata(t *testing.T) {
})
}
}
func TestComputeLockTargets(t *testing.T) {
t.Run("all locks", func(t *testing.T) {
const clusterName = "mycluster"
const serverID = "myserver"
const mfaDevice = "my-mfa-device-1"
const trustedDevice = "my-trusted-device-1"
mappedRoles := []string{"access", "editor"}
unmappedRoles := []string{"unmapped-role-1", "unmapped-role-2", "access"}
accessRequests := []string{"access-request-1", "access-request-2"}
identityCtx := IdentityContext{
TeleportUser: "llama",
Impersonator: "alpaca",
Login: "camel",
Certificate: &ssh.Certificate{
Permissions: ssh.Permissions{
Extensions: map[string]string{
teleport.CertExtensionMFAVerified: mfaDevice,
teleport.CertExtensionDeviceID: trustedDevice,
},
},
},
AccessChecker: &fixedRolesChecker{
roleNames: mappedRoles,
},
UnmappedRoles: unmappedRoles,
ActiveRequests: accessRequests,
}
got := ComputeLockTargets(clusterName, serverID, identityCtx)
want := []types.LockTarget{
{User: identityCtx.TeleportUser},
{Login: identityCtx.Login},
{Node: serverID},
{Node: serverID + "." + clusterName},
{MFADevice: mfaDevice},
{Device: trustedDevice},
}
for _, role := range mappedRoles {
want = append(want, types.LockTarget{Role: role})
}
for _, role := range unmappedRoles[:len(unmappedRoles)-1] /* skip duplicate role */ {
want = append(want, types.LockTarget{Role: role})
}
for _, request := range accessRequests {
want = append(want, types.LockTarget{AccessRequest: request})
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("ComputeLockTargets mismatch (-want +got)\n%s", diff)
}
})
}
type fixedRolesChecker struct {
services.AccessChecker
roleNames []string
}
func (c *fixedRolesChecker) RoleNames() []string {
return c.roleNames
}
+1
View File
@@ -51,6 +51,7 @@ func (c *LockCommand) Initialize(app *kingpin.Application, config *service.Confi
c.mainCmd.Flag("mfa-device", "UUID of a user MFA device to disable.").StringVar(&c.spec.Target.MFADevice)
c.mainCmd.Flag("windows-desktop", "Name of a Windows desktop to disable.").StringVar(&c.spec.Target.WindowsDesktop)
c.mainCmd.Flag("access-request", "UUID of an access request to disable.").StringVar(&c.spec.Target.AccessRequest)
c.mainCmd.Flag("device", "UUID of a trusted device to disable.").StringVar(&c.spec.Target.Device)
c.mainCmd.Flag("message", "Message to display to locked-out users.").StringVar(&c.spec.Message)
c.mainCmd.Flag("expires", "Time point (RFC3339) when the lock expires.").StringVar(&c.expires)
c.mainCmd.Flag("ttl", "Time duration after which the lock expires.").DurationVar(&c.ttl)