Device Trust: add required-for-humans mode (#56907)

* Device Trust: add `required-human` mode

* Add `device_trust.mode=required-human` to the test plan

* Add `IsBot` methods to TLS and SSH identities

* Fix `required-human` mode on roles

* Rename mode to `required-for-humans` for clarity

* Undo premature changes to device trust guides

* Default `deviceVerificationPassed` false to avoid accidents when adding new modes

* Handle `BotName` and `BotInstanceID` in `lib/auth/authtest`

* Fix stray reference in test

* Fix reference to `config.DestinationMemory`

* Handle empty device trust mode
This commit is contained in:
Dan Upton
2025-07-28 12:43:16 +00:00
committed by GitHub
parent 0a645282d5
commit dbb5f478d5
31 changed files with 363 additions and 48 deletions
+2
View File
@@ -1180,6 +1180,8 @@ tsh ssh node-that-requires-device-trust
- [ ] K8s Access
- [ ] App Access NOT enforced in global mode
- [ ] Desktop Access NOT enforced in global mode
- [ ] device_trust.mode="required-for-humans" enforces enrolled devices for
humans, but bots (e.g. `tbot`) function on any device
- [ ] Role-based authz enforces enrolled devices
(device_trust.mode="optional" and role.spec.options.device_trust_mode="required")
- [ ] SSH
+4
View File
@@ -294,6 +294,10 @@ const (
// DeviceTrustModeRequired enforces the presence of device extensions for
// sensitive endpoints.
DeviceTrustModeRequired DeviceTrustMode = "required"
// DeviceTrustModeRequiredForHumans enforces the presence of device
// extensions for sensitive endpoints if the user is human. In this mode,
// bots are exempt from device trust checks.
DeviceTrustModeRequiredForHumans DeviceTrustMode = "required-for-humans"
)
const (
@@ -2693,6 +2693,8 @@ message DeviceTrust {
// endpoints.
// - "required": enforces the presence of device extensions for sensitive
// endpoints.
// - "required-for-humans": enforces the presence of device extensions for
// sensitive endpoints, for human users only (bots are exempt).
//
// Mode is always "off" for OSS.
// Defaults to "optional" for Enterprise.
+2 -1
View File
@@ -837,7 +837,8 @@ func (c *AuthPreferenceV2) CheckAndSetDefaults() error {
case "": // OK, "default" mode. Varies depending on OSS or Enterprise.
case constants.DeviceTrustModeOff,
constants.DeviceTrustModeOptional,
constants.DeviceTrustModeRequired: // OK.
constants.DeviceTrustModeRequired,
constants.DeviceTrustModeRequiredForHumans: // OK.
default:
return trace.BadParameter("device trust mode %q not supported", dt.Mode)
}
@@ -710,6 +710,16 @@ func TestAuthPreferenceV2_CheckAndSetDefaults_deviceTrust(t *testing.T) {
},
},
},
{
name: "Mode=required-for-humans",
authPref: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
DeviceTrust: &types.DeviceTrust{
Mode: constants.DeviceTrustModeRequiredForHumans,
},
},
},
},
{
name: "Mode invalid",
authPref: &types.AuthPreferenceV2{
+2
View File
@@ -7736,6 +7736,8 @@ type DeviceTrust struct {
// endpoints.
// - "required": enforces the presence of device extensions for sensitive
// endpoints.
// - "required-for-humans": enforces the presence of device extensions for
// sensitive endpoints, for human users only (bots are exempt).
//
// Mode is always "off" for OSS.
// Defaults to "optional" for Enterprise.
@@ -73,10 +73,11 @@ the better the ongoing guarantees that the device itself is trustworthy.
## Device Trust enforcement
Enforcing Device Trust means configuring Teleport with Device Trust mode, i.e. applying
`device_trust_mode: required` rule, which tells Teleport Auth Service to only allow access
with a trusted and an authenticated device, in addition to establishing the user's identity and enforcing
the necessary roles.
Enforcing Device Trust means configuring Teleport with Device Trust mode, i.e.
applying a `device_trust_mode: required` or `device_trust_mode: required-for-humans`
rule, which tells Teleport Auth Service to only allow access with a trusted and
an authenticated device, in addition to establishing the user's identity and
enforcing the necessary roles.
Teleport supports two methods for device enforcement: Role-based
enforcement and Cluster-wide enforcement.
@@ -32,6 +32,9 @@ by the `device_trust_mode` authentication setting:
- `required` - enables device authentication and device-aware audit.
Additionally, it requires a trusted device for all SSH, Database and
Kubernetes connections.
- `required-for-humans` - enables device authentication and device-aware audit.
Additionally, it requires a trusted device for all SSH, Database and
Kubernetes connections, for human users only (bots are exempt).
### Prerequisites
(!docs/pages/includes/edition-prereqs-tabs.mdx edition="Teleport Enterprise"!)
@@ -52,8 +55,8 @@ works similarly to [`require_session_mfa`](../../admin-guides/access-controls/gu
To enforce authenticated device checks for a specific role when a user accesses
databases, Kubernetes clusters, and servers with Teleport, update the role with
the `device_trust_mode` field assigned to `"required"`. The following example
updates the preset `require-trusted-device` role:
the `device_trust_mode` field assigned to `"required"` or `"required-for-humans"`.
The following example updates the preset `require-trusted-device` role:
```yaml
kind: role
@@ -258,6 +258,9 @@ auth_service:
# - 'required' - enables device authentication and device-aware audit.
# Additionally, it requires a trusted device for all SSH, Database
# and Kubernetes connections.
# - 'required-for-humans' - enables device authentication and device-aware
# audit. Additionally, it requires a trusted device for all SSH, Database
# and Kubernetes connections, for human users only (bots are exempt).
mode: optional # always "off" for Teleport Community Edition
# Determines the default time to live for user certificates
+1 -1
View File
@@ -49,7 +49,7 @@ spec:
# clients and servers through the proxy
permit_x11_forwarding: true
# device_trust_mode enforces authenticated device access for assigned user of this role.
device_trust_mode: optional|required|off
device_trust_mode: optional|required|required-for-humans|off
# require_session_mfa require per-session MFA for any assigned user of this role
require_session_mfa: true
# mfa_verification_interval optionally defines the maximum duration that can elapse between successive MFA verifications.
@@ -62,7 +62,7 @@ user:
| `max_sessions` | Total number of session channels which can be established across a single SSH connection via Teleport | The lowest value takes precedence. |
| `enhanced_recording` | Indicates which events should be recorded by the BFP-based session recorder | |
| `permit_x11_forwarding` | Allow users to enable X11 forwarding with OpenSSH clients and servers | |
| `device_trust_mode` | Enforce authenticated device access for users assigned this role (`required`, `optional`, `off`). Applies only to the resources in the roles' allow field. | |
| `device_trust_mode` | Enforce authenticated device access for users assigned this role (`required`, `required-for-humans`, `optional`, `off`). Applies only to the resources in the roles' allow field. | |
| `require_session_mfa` | Enforce per-session MFA or PIV-hardware key restrictions on user login sessions (`no`, `yes`, `hardware_key`, `hardware_key_touch`). Applies only to the resources in the roles' allow field. | For per-session MFA, Logical "OR" i.e. evaluates to "yes" if at least one role requires session MFA |
| `mfa_verification_interval` | Define the maximum duration that can elapse between successive MFA verifications | The shortest interval wins |
| `lock` | Locking mode (`strict` or `best_effort`) | `strict` wins in case of conflict |
@@ -58,7 +58,7 @@ Optional:
- `auto_enroll` (Boolean) Enable device auto-enroll. Auto-enroll lets any user issue a device enrollment token for a known device that is not already enrolled. `tsh` takes advantage of auto-enroll to automatically enroll devices on user login, when appropriate. The effective cluster Mode still applies: AutoEnroll=true is meaningless if Mode="off".
- `ekcert_allowed_cas` (List of String) Allow list of EKCert CAs in PEM format. If present, only TPM devices that present an EKCert that is signed by a CA specified here may be enrolled (existing enrollments are unchanged). If not present, then the CA of TPM EKCerts will not be checked during enrollment, this allows any device to enroll.
- `mode` (String) Mode of verification for trusted devices. The following modes are supported: - "off": disables both device authentication and authorization. - "optional": allows both device authentication and authorization, but doesn't enforce the presence of device extensions for sensitive endpoints. - "required": enforces the presence of device extensions for sensitive endpoints. Mode is always "off" for OSS. Defaults to "optional" for Enterprise.
- `mode` (String) Mode of verification for trusted devices. The following modes are supported: - "off": disables both device authentication and authorization. - "optional": allows both device authentication and authorization, but doesn't enforce the presence of device extensions for sensitive endpoints. - "required": enforces the presence of device extensions for sensitive endpoints. - "required-for-humans": enforces the presence of device extensions for sensitive endpoints, for human users only (bots are exempt). Mode is always "off" for OSS. Defaults to "optional" for Enterprise.
### Nested Schema for `spec.hardware_key`
@@ -80,7 +80,7 @@ Optional:
- `auto_enroll` (Boolean) Enable device auto-enroll. Auto-enroll lets any user issue a device enrollment token for a known device that is not already enrolled. `tsh` takes advantage of auto-enroll to automatically enroll devices on user login, when appropriate. The effective cluster Mode still applies: AutoEnroll=true is meaningless if Mode="off".
- `ekcert_allowed_cas` (List of String) Allow list of EKCert CAs in PEM format. If present, only TPM devices that present an EKCert that is signed by a CA specified here may be enrolled (existing enrollments are unchanged). If not present, then the CA of TPM EKCerts will not be checked during enrollment, this allows any device to enroll.
- `mode` (String) Mode of verification for trusted devices. The following modes are supported: - "off": disables both device authentication and authorization. - "optional": allows both device authentication and authorization, but doesn't enforce the presence of device extensions for sensitive endpoints. - "required": enforces the presence of device extensions for sensitive endpoints. Mode is always "off" for OSS. Defaults to "optional" for Enterprise.
- `mode` (String) Mode of verification for trusted devices. The following modes are supported: - "off": disables both device authentication and authorization. - "optional": allows both device authentication and authorization, but doesn't enforce the presence of device extensions for sensitive endpoints. - "required": enforces the presence of device extensions for sensitive endpoints. - "required-for-humans": enforces the presence of device extensions for sensitive endpoints, for human users only (bots are exempt). Mode is always "off" for OSS. Defaults to "optional" for Enterprise.
### Nested Schema for `spec.hardware_key`
@@ -2217,7 +2217,7 @@ func GenSchemaAuthPreferenceV2(ctx context.Context) (github_com_hashicorp_terraf
Type: github_com_hashicorp_terraform_plugin_framework_types.ListType{ElemType: github_com_hashicorp_terraform_plugin_framework_types.StringType},
},
"mode": {
Description: "Mode of verification for trusted devices. The following modes are supported: - \"off\": disables both device authentication and authorization. - \"optional\": allows both device authentication and authorization, but doesn't enforce the presence of device extensions for sensitive endpoints. - \"required\": enforces the presence of device extensions for sensitive endpoints. Mode is always \"off\" for OSS. Defaults to \"optional\" for Enterprise.",
Description: "Mode of verification for trusted devices. The following modes are supported: - \"off\": disables both device authentication and authorization. - \"optional\": allows both device authentication and authorization, but doesn't enforce the presence of device extensions for sensitive endpoints. - \"required\": enforces the presence of device extensions for sensitive endpoints. - \"required-for-humans\": enforces the presence of device extensions for sensitive endpoints, for human users only (bots are exempt). Mode is always \"off\" for OSS. Defaults to \"optional\" for Enterprise.",
Optional: true,
Type: github_com_hashicorp_terraform_plugin_framework_types.StringType,
},
+9 -2
View File
@@ -2659,7 +2659,7 @@ func (a *Server) GenerateUserTestCertsWithContext(ctx context.Context, req Gener
return nil, nil, trace.Wrap(err)
}
certs, err := a.generateUserCert(ctx, certRequest{
certReq := certRequest{
user: userState,
ttl: req.TTL,
compatibility: req.Compatibility,
@@ -2681,7 +2681,14 @@ func (a *Server) GenerateUserTestCertsWithContext(ctx context.Context, req Gener
activeRequests: req.ActiveRequests,
kubernetesCluster: req.KubernetesCluster,
usage: req.Usage,
})
}
if botName, isBot := userState.GetLabel(types.BotLabel); isBot {
certReq.botName = botName
certReq.botInstanceID = uuid.NewString()
}
certs, err := a.generateUserCert(ctx, certReq)
if err != nil {
return nil, nil, trace.Wrap(err)
}
+40
View File
@@ -1096,6 +1096,21 @@ func TestGenerateUserCerts_deviceAuthz(t *testing.T) {
}))
require.NoError(t, err, "NewClient failed")
// Create bot user for testing.
botUser, _, err := authtest.CreateUserAndRole(testServer.Auth(), "wall-e", []string{"wall-e-role"}, nil)
require.NoError(t, err, "CreateUserAndRole failed")
botMeta := botUser.GetMetadata()
botMeta.Labels = map[string]string{
types.BotLabel: "wall-e",
}
botUser.SetMetadata(botMeta)
botUser, err = testServer.Auth().UpsertUser(ctx, botUser)
require.NoError(t, err)
botClient, err := testServer.NewClient(authtest.TestUser(botUser.GetName()))
require.NoError(t, err)
// updateAuthPref is a helper used throughout the test.
updateAuthPref := func(t *testing.T, modify func(ap types.AuthPreference)) {
authPref, err := authServer.GetAuthPreference(ctx)
@@ -1165,6 +1180,15 @@ func TestGenerateUserCerts_deviceAuthz(t *testing.T) {
Login: username,
},
}
botSSHReq := proto.UserCertsRequest{
SSHPublicKey: sshPub,
Username: botUser.GetName(),
Expires: expires,
RouteToCluster: clusterName,
NodeName: "mynode",
Usage: proto.UserCertsRequest_SSH,
SSHLogin: "llama",
}
assertSuccess := func(t *testing.T, err error) {
assert.NoError(t, err, "GenerateUserCerts error mismatch")
@@ -1257,6 +1281,22 @@ func TestGenerateUserCerts_deviceAuthz(t *testing.T) {
req: winReq,
assertErr: assertSuccess,
},
{
name: "nok: mode=required with bot",
clusterDeviceMode: constants.DeviceTrustModeRequired,
client: botClient,
req: botSSHReq,
skipSingleUseCerts: true,
assertErr: assertAccessDenied,
},
{
name: "ok: mode=required-for-humans with bot",
clusterDeviceMode: constants.DeviceTrustModeRequiredForHumans,
client: botClient,
req: botSSHReq,
skipSingleUseCerts: true,
assertErr: assertSuccess,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
+1
View File
@@ -365,6 +365,7 @@ func (c *Context) GetAccessState(authPref readonly.AuthPreference) services.Acce
state.EnableDeviceVerification = !c.disableDeviceRoleMode
state.DeviceVerified = isService || dtauthz.IsTLSDeviceVerified(&identity.DeviceExtensions)
state.IsBot = identity.IsBot()
return state
}
+28
View File
@@ -391,6 +391,9 @@ func TestAuthorizer_Authorize_deviceTrust(t *testing.T) {
AssetTag: "assettag1",
CredentialID: "credentialid1",
}
botUser := userWithoutExtensions
botUser.Identity.BotName = "wall-e"
botUser.Identity.BotInstanceID = uuid.NewString()
// Enterprise is necessary for mode=optional and mode=required to work.
modulestest.SetTestModules(t, modulestest.Modules{
@@ -416,6 +419,17 @@ func TestAuthorizer_Authorize_deviceTrust(t *testing.T) {
user: userWithoutExtensions,
wantErr: "access denied",
},
{
name: "nok: bot user and mode=required",
deviceMode: constants.DeviceTrustModeRequired,
user: botUser,
wantErr: "access denied",
},
{
name: "ok: bot user and mode=required-for-humans",
deviceMode: constants.DeviceTrustModeRequiredForHumans,
user: botUser,
},
{
name: "global mode disabled only",
deviceMode: constants.DeviceTrustModeRequired,
@@ -871,6 +885,20 @@ func TestContext_GetAccessState(t *testing.T) {
DeviceVerified: true, // Identity extensions
},
},
{
name: "bot user",
createAuthCtx: func() *authz.Context {
ctx := localCtx
localUser := ctx.Identity.(authz.LocalUser)
localUser.Identity.BotName = "wall-e"
ctx.Identity = localUser
return &ctx
},
want: services.AccessState{
EnableDeviceVerification: true,
IsBot: true,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
+20 -10
View File
@@ -48,7 +48,7 @@ func IsTLSDeviceVerified(ext *tlsca.DeviceExtensions) bool {
// VerifyTLSUser verifies if the TLS identity has the required extensions to
// fulfill the device trust configuration.
func VerifyTLSUser(ctx context.Context, dt *types.DeviceTrust, identity tlsca.Identity) error {
return verifyDeviceExtensions(ctx, dt, identity.Username, IsTLSDeviceVerified(&identity.DeviceExtensions))
return verifyDeviceExtensions(ctx, dt, identity.Username, identity.IsBot(), IsTLSDeviceVerified(&identity.DeviceExtensions))
}
// IsSSHDeviceVerified returns true if cert contains all required device
@@ -89,19 +89,29 @@ func VerifySSHUser(ctx context.Context, dt *types.DeviceTrust, ident *sshca.Iden
if ident == nil {
return trace.BadParameter("ssh identity required")
}
return verifyDeviceExtensions(ctx, dt, ident.Username, IsSSHDeviceVerified(ident))
return verifyDeviceExtensions(ctx, dt, ident.Username, ident.IsBot(), IsSSHDeviceVerified(ident))
}
func verifyDeviceExtensions(ctx context.Context, dt *types.DeviceTrust, username string, verified bool) error {
func verifyDeviceExtensions(ctx context.Context, dt *types.DeviceTrust, username string, isBot bool, verified bool) error {
mode := dtconfig.GetEnforcementMode(dt)
switch {
case mode != constants.DeviceTrustModeRequired:
return nil // OK, extensions not enforced.
case !verified:
var pass bool
switch mode {
case constants.DeviceTrustModeOff, constants.DeviceTrustModeOptional:
// OK, extensions not enforced.
pass = true
case constants.DeviceTrustModeRequiredForHumans:
// Humans must use trusted devices, bots can use untrusted devices.
pass = verified || isBot
case constants.DeviceTrustModeRequired:
// Only trusted devices allowed for bot human and bot users.
pass = verified
}
if !pass {
slog.DebugContext(ctx, "Device Trust: denied access for unidentified device", "user", username)
return trace.Wrap(ErrTrustedDeviceRequired)
default:
return nil
}
return nil
}
+32 -4
View File
@@ -127,25 +127,27 @@ func testIsDeviceVerified(t *testing.T, name string, fn func(ext *tlsca.DeviceEx
}
func TestVerifyTLSUser(t *testing.T) {
runVerifyUserTest(t, "VerifyTLSUser", func(dt *types.DeviceTrust, ext *tlsca.DeviceExtensions) error {
runVerifyUserTest(t, "VerifyTLSUser", func(dt *types.DeviceTrust, ext *tlsca.DeviceExtensions, botName string) error {
return authz.VerifyTLSUser(context.Background(), dt, tlsca.Identity{
Username: "llama",
DeviceExtensions: *ext,
BotName: botName,
})
})
}
func TestVerifySSHUser(t *testing.T) {
runVerifyUserTest(t, "VerifySSHUser", func(dt *types.DeviceTrust, ext *tlsca.DeviceExtensions) error {
runVerifyUserTest(t, "VerifySSHUser", func(dt *types.DeviceTrust, ext *tlsca.DeviceExtensions, botName string) error {
return authz.VerifySSHUser(context.Background(), dt, &sshca.Identity{
DeviceID: ext.DeviceID,
DeviceAssetTag: ext.AssetTag,
DeviceCredentialID: ext.CredentialID,
BotName: botName,
})
})
}
func runVerifyUserTest(t *testing.T, method string, verify func(dt *types.DeviceTrust, ext *tlsca.DeviceExtensions) error) {
func runVerifyUserTest(t *testing.T, method string, verify func(dt *types.DeviceTrust, ext *tlsca.DeviceExtensions, botName string) error) {
assertNoErr := func(t *testing.T, err error) {
assert.NoError(t, err, "%v mismatch", method)
}
@@ -166,6 +168,7 @@ func runVerifyUserTest(t *testing.T, method string, verify func(dt *types.Device
buildType string
dt *types.DeviceTrust
ext *tlsca.DeviceExtensions
isBot bool
assertErr func(t *testing.T, err error)
}{
{
@@ -229,6 +232,16 @@ func runVerifyUserTest(t *testing.T, method string, verify func(dt *types.Device
ext: userWithoutExtensions,
assertErr: assertDeniedErr,
},
{
name: "nok: Enterprise mode=required with bot",
buildType: modules.BuildEnterprise,
dt: &types.DeviceTrust{
Mode: constants.DeviceTrustModeRequired,
},
ext: userWithoutExtensions,
isBot: true,
assertErr: assertDeniedErr,
},
{
name: "Enterprise mode=required with extensions",
buildType: modules.BuildEnterprise,
@@ -238,6 +251,16 @@ func runVerifyUserTest(t *testing.T, method string, verify func(dt *types.Device
ext: userWithExtensions,
assertErr: assertNoErr,
},
{
name: "ok: Enterprise mode=required-for-humans with bot",
buildType: modules.BuildEnterprise,
dt: &types.DeviceTrust{
Mode: constants.DeviceTrustModeRequiredForHumans,
},
ext: userWithoutExtensions,
isBot: true,
assertErr: assertNoErr,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -245,7 +268,12 @@ func runVerifyUserTest(t *testing.T, method string, verify func(dt *types.Device
TestBuildType: test.buildType,
})
test.assertErr(t, verify(test.dt, test.ext))
var botName string
if test.isBot {
botName = "wall-e"
}
test.assertErr(t, verify(test.dt, test.ext, botName))
})
}
}
@@ -33,7 +33,8 @@ func CalculateTrustedDeviceRequirement(
getRoles func() ([]types.Role, error),
) (types.TrustedDeviceRequirement, error) {
// Required by cluster mode?
if dtconfig.GetEnforcementMode(dt) == constants.DeviceTrustModeRequired {
switch dtconfig.GetEnforcementMode(dt) {
case constants.DeviceTrustModeRequired, constants.DeviceTrustModeRequiredForHumans:
return types.TrustedDeviceRequirement_TRUSTED_DEVICE_REQUIREMENT_REQUIRED, nil
}
@@ -43,7 +44,8 @@ func CalculateTrustedDeviceRequirement(
return types.TrustedDeviceRequirement_TRUSTED_DEVICE_REQUIREMENT_UNSPECIFIED, trace.Wrap(err)
}
for _, role := range roles {
if role.GetOptions().DeviceTrustMode == constants.DeviceTrustModeRequired {
switch role.GetOptions().DeviceTrustMode {
case constants.DeviceTrustModeRequired, constants.DeviceTrustModeRequiredForHumans:
return types.TrustedDeviceRequirement_TRUSTED_DEVICE_REQUIREMENT_REQUIRED, nil
}
}
@@ -31,6 +31,8 @@ func TestCalculateTrustedDeviceRequirement(t *testing.T) {
assert.NoError(t, err)
deviceTrustRequiredRole, err := types.NewRole("device-trust-required", types.RoleSpecV6{Options: types.RoleOptions{DeviceTrustMode: constants.DeviceTrustModeRequired}})
assert.NoError(t, err)
deviceTrustRequiredHumanRole, err := types.NewRole("device-trust-required-for-humans", types.RoleSpecV6{Options: types.RoleOptions{DeviceTrustMode: constants.DeviceTrustModeRequiredForHumans}})
assert.NoError(t, err)
tests := []struct {
name string
@@ -62,6 +64,22 @@ func TestCalculateTrustedDeviceRequirement(t *testing.T) {
roles: []types.Role{deviceTrustRequiredRole, deviceTrustOptionalRole},
expectRequirement: types.TrustedDeviceRequirement_TRUSTED_DEVICE_REQUIREMENT_REQUIRED,
},
{
name: "required for humans by cluster but not by roles",
dt: &types.DeviceTrust{
Mode: constants.DeviceTrustModeRequiredForHumans,
},
roles: []types.Role{deviceTrustOptionalRole},
expectRequirement: types.TrustedDeviceRequirement_TRUSTED_DEVICE_REQUIREMENT_REQUIRED,
},
{
name: "required for humans by role but not by cluster",
dt: &types.DeviceTrust{
Mode: constants.DeviceTrustModeOptional,
},
roles: []types.Role{deviceTrustRequiredHumanRole},
expectRequirement: types.TrustedDeviceRequirement_TRUSTED_DEVICE_REQUIREMENT_REQUIRED,
},
}
for _, test := range tests {
+19 -3
View File
@@ -2686,7 +2686,7 @@ func (set RoleSet) checkAccess(r AccessCheckable, traits wrappers.Traits, state
// TODO(codingllama): Consider making EnableDeviceVerification opt-out instead
// of opt-in.
deviceAllowed := !state.EnableDeviceVerification || state.DeviceVerified
deviceTrusted := !state.EnableDeviceVerification || state.DeviceVerified
var errs []error
allowed := false
@@ -2742,7 +2742,7 @@ func (set RoleSet) checkAccess(r AccessCheckable, traits wrappers.Traits, state
// (and gets an early exit) or we need to check every applicable role to
// ensure the access is permitted.
if mfaAllowed && deviceAllowed {
if mfaAllowed && deviceTrusted {
logger.LogAttrs(ctx, logutils.TraceLevel, "Access to resource granted, allow rule in role matched",
slog.String("role", role.GetName()),
)
@@ -2758,7 +2758,19 @@ func (set RoleSet) checkAccess(r AccessCheckable, traits wrappers.Traits, state
}
// Device verification.
if !deviceAllowed && role.GetOptions().DeviceTrustMode == constants.DeviceTrustModeRequired {
var deviceVerificationPassed bool
switch role.GetOptions().DeviceTrustMode {
case constants.DeviceTrustModeOff, constants.DeviceTrustModeOptional, "":
// OK, extensions not enforced.
deviceVerificationPassed = true
case constants.DeviceTrustModeRequiredForHumans:
// Humans must use trusted devices, bots can use untrusted devices.
deviceVerificationPassed = deviceTrusted || state.IsBot
case constants.DeviceTrustModeRequired:
// Only trusted devices allowed for bot human and bot users.
deviceVerificationPassed = deviceTrusted
}
if !deviceVerificationPassed {
logger.LogAttrs(ctx, logutils.TraceLevel, "Access to resource denied, role requires a trusted device",
slog.String("role", role.GetName()),
)
@@ -3617,6 +3629,9 @@ type AccessState struct {
// It's recommended to set this in tandem with EnableDeviceVerification.
// See [dtauthz.IsTLSDeviceVerified] and [dtauthz.IsSSHDeviceVerified].
DeviceVerified bool
// IsBot determines whether the user certificate belongs to a bot. It's used
// when deciding whether to enforce device verification.
IsBot bool
}
// MFARequired determines when MFA is required for a user to access a resource.
@@ -3718,6 +3733,7 @@ func AccessStateFromSSHIdentity(ctx context.Context, ident *sshca.Identity, chec
state.EnableDeviceVerification = true
state.DeviceVerified = dtauthz.IsSSHDeviceVerified(ident)
state.IsBot = ident.IsBot()
return state, nil
}
+40
View File
@@ -6780,6 +6780,24 @@ func TestCheckAccessToKubernetes(t *testing.T) {
},
},
}
matchingLabelsRoleWithDeviceTrustForHumans := &types.RoleV6{
Metadata: types.Metadata{
Name: "matching-labels-devicetrust-humans",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
DeviceTrustMode: constants.DeviceTrustModeRequiredForHumans,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
KubernetesLabels: types.Labels{
"foo": apiutils.Strings{"bar"},
"baz": apiutils.Strings{"qux"},
},
},
},
}
noLabelsRole := &types.RoleV6{
Metadata: types.Metadata{
Name: "no-labels",
@@ -6919,6 +6937,28 @@ func TestCheckAccessToKubernetes(t *testing.T) {
},
hasAccess: true, // doesn't match device trust role
},
{
name: "role requires device trust for all, is bot",
roles: []*types.RoleV6{matchingLabelsRoleWithDeviceTrust},
cluster: clusterWithLabels,
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: false,
IsBot: true,
},
hasAccess: false,
},
{
name: "role requires device trust for humans, is bot",
roles: []*types.RoleV6{matchingLabelsRoleWithDeviceTrustForHumans},
cluster: clusterWithLabels,
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: false,
IsBot: true,
},
hasAccess: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
+1
View File
@@ -86,6 +86,7 @@ func (c *Session) GetAccessState(authPref readonly.AuthPreference) services.Acce
state.MFAVerified = c.Identity.IsMFAVerified()
state.EnableDeviceVerification = true
state.DeviceVerified = dtauthz.IsTLSDeviceVerified(&c.Identity.DeviceExtensions)
state.IsBot = c.Identity.IsBot()
return state
}
+13
View File
@@ -80,6 +80,19 @@ func TestSession_GetAccessState(t *testing.T) {
EnableDeviceVerification: true,
},
},
{
name: "bot",
session: common.Session{
Identity: tlsca.Identity{
BotName: "wall-e",
},
Checker: checker,
},
want: services.AccessState{
EnableDeviceVerification: true,
IsBot: true,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
+1
View File
@@ -81,6 +81,7 @@ func (c *SessionCtx) getAccessState(authPref types.AuthPreference) services.Acce
state.MFAVerified = c.Identity.IsMFAVerified()
state.EnableDeviceVerification = true
state.DeviceVerified = dtauthz.IsTLSDeviceVerified(&c.Identity.DeviceExtensions)
state.IsBot = c.Identity.IsBot()
return state
}
+42 -15
View File
@@ -23,6 +23,7 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/assert"
@@ -88,22 +89,24 @@ func TestSessionController_AcquireSessionContext(t *testing.T) {
clock := clockwork.NewFakeClock()
emitter := &eventstest.MockRecorderEmitter{}
minimalCfg := SessionControllerConfig{
Semaphores: mockSemaphores{},
AccessPoint: mockAccessPoint{
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{},
},
clusterName: &types.ClusterNameV2{
Spec: types.ClusterNameSpecV2{
ClusterName: "llama",
minimalCfg := func() SessionControllerConfig {
return SessionControllerConfig{
Semaphores: mockSemaphores{},
AccessPoint: mockAccessPoint{
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{},
},
clusterName: &types.ClusterNameV2{
Spec: types.ClusterNameSpecV2{
ClusterName: "llama",
},
},
},
},
LockEnforcer: mockLockEnforcer{},
Emitter: emitter,
Component: teleport.ComponentNode,
ServerID: "1234",
LockEnforcer: mockLockEnforcer{},
Emitter: emitter,
Component: teleport.ComponentNode,
ServerID: "1234",
}
}
minimalIdentity := IdentityContext{
@@ -118,7 +121,7 @@ func TestSessionController_AcquireSessionContext(t *testing.T) {
}
cfgWithDeviceMode := func(mode string) SessionControllerConfig {
cfg := minimalCfg
cfg := minimalCfg()
authPref, _ := cfg.AccessPoint.GetAuthPreference(context.Background())
authPref.(*types.AuthPreferenceV2).Spec.DeviceTrust = &types.DeviceTrust{
Mode: mode,
@@ -134,6 +137,14 @@ func TestSessionController_AcquireSessionContext(t *testing.T) {
idCtx.UnmappedIdentity.DeviceCredentialID = "credentialid1"
return idCtx
}
botIdentity := func() IdentityContext {
ident := *minimalIdentity.UnmappedIdentity
idCtx := minimalIdentity
idCtx.UnmappedIdentity = &ident
idCtx.UnmappedIdentity.BotName = "wall-e"
idCtx.UnmappedIdentity.BotInstanceID = uuid.NewString()
return idCtx
}
assertTrustedDeviceRequired := func(t *testing.T, _ context.Context, err error, _ *eventstest.MockRecorderEmitter) {
assert.ErrorContains(t, err, "device", "AcquireSessionContext returned an unexpected error")
assert.True(t, trace.IsAccessDenied(err), "AcquireSessionContext returned an error other than trace.AccessDeniedError: %T", err)
@@ -429,6 +440,22 @@ func TestSessionController_AcquireSessionContext(t *testing.T) {
assert.NoError(t, err, "AcquireSessionContext returned an unexpected error")
},
},
{
name: "device extensions enforced for bot",
buildType: modules.BuildEnterprise,
cfg: cfgWithDeviceMode(constants.DeviceTrustModeRequired),
identity: botIdentity(),
assertion: assertTrustedDeviceRequired,
},
{
name: "device extensions not enforced for bot with mode=required-for-humans",
buildType: modules.BuildEnterprise,
cfg: cfgWithDeviceMode(constants.DeviceTrustModeRequiredForHumans),
identity: botIdentity(),
assertion: func(t *testing.T, _ context.Context, err error, _ *eventstest.MockRecorderEmitter) {
assert.NoError(t, err, "AcquireSessionContext returned an unexpected error")
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
+5
View File
@@ -337,6 +337,11 @@ func (i *Identity) GetValidBefore() time.Time {
return validBefore
}
// IsBot returns whether this identity belongs to a bot.
func (id *Identity) IsBot() bool {
return id.BotName != ""
}
// DecodeIdentity decodes an ssh certificate into an identity.
func DecodeIdentity(cert *ssh.Certificate) (*Identity, error) {
ident := &Identity{
+45
View File
@@ -46,6 +46,7 @@ import (
"golang.org/x/crypto/ssh/knownhosts"
"github.com/gravitational/teleport/api/client"
"github.com/gravitational/teleport/api/constants"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
"github.com/gravitational/teleport/api/types"
@@ -1246,6 +1247,50 @@ func TestBotSSHMultiplexer(t *testing.T) {
}
}
func TestBotDeviceTrust(t *testing.T) {
t.Parallel()
ctx := context.Background()
log := logtest.NewLogger()
// Start a test server with `device.trust.mode="required-for-humans"`.
process := testenv.MakeTestServer(t,
defaultTestServerOpts(t, log),
testenv.WithAuthConfig(func(cfg *servicecfg.AuthConfig) {
cfg.Preference.SetDeviceTrust(&types.DeviceTrust{
Mode: constants.DeviceTrustModeRequiredForHumans,
})
}),
)
rootClient := testenv.MakeDefaultAuthClient(t, process)
// Run a bot with an identity output.
onboarding, _ := makeBot(t, rootClient, "test", "access")
botConfig := defaultBotConfig(
t, process, onboarding,
config.ServiceConfigs{
&config.IdentityOutput{
Destination: &destination.Memory{},
},
},
defaultBotConfigOpts{
useAuthServer: true,
insecure: true,
},
)
// If we're able to successfully run the bot, it means we could:
//
// 1. Join the cluster
// 2. Request an user certificate to "impersonate" the bot's roles
b := New(botConfig, log)
require.NoError(t, b.Run(ctx))
// Run it again to check renewing the bot's internal identity works too.
b = New(botConfig, log)
require.NoError(t, b.Run(ctx))
}
// TestBotJoiningURI ensures configured joining URIs work in place of
// traditional YAML onboarding config.
func TestBotJoiningURI(t *testing.T) {
+5
View File
@@ -1307,6 +1307,11 @@ func (id *Identity) IsMFAVerified() bool {
return id.MFAVerified != "" || id.PrivateKeyPolicy.MFAVerified()
}
// IsBot returns whether this identity belongs to a bot.
func (id *Identity) IsBot() bool {
return id.BotName != ""
}
// CertificateRequest is a X.509 signing certificate request
type CertificateRequest struct {
// Clock is a clock used to get current or test time