diff --git a/.github/ISSUE_TEMPLATE/testplan.md b/.github/ISSUE_TEMPLATE/testplan.md index 76ccf6b54c7fc..76820919ba23a 100644 --- a/.github/ISSUE_TEMPLATE/testplan.md +++ b/.github/ISSUE_TEMPLATE/testplan.md @@ -740,6 +740,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 diff --git a/api/constants/constants.go b/api/constants/constants.go index 140cea0bfd374..885cb81fc0ba0 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -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 ( diff --git a/api/proto/teleport/legacy/types/types.proto b/api/proto/teleport/legacy/types/types.proto index a038047133dfb..26b90ebded69c 100644 --- a/api/proto/teleport/legacy/types/types.proto +++ b/api/proto/teleport/legacy/types/types.proto @@ -2658,6 +2658,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. diff --git a/api/types/authentication.go b/api/types/authentication.go index 1f83b1386f1cb..d02a3519288a4 100644 --- a/api/types/authentication.go +++ b/api/types/authentication.go @@ -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) } diff --git a/api/types/authentication_authpreference_test.go b/api/types/authentication_authpreference_test.go index b6efaae1946b1..1f674985fa7b1 100644 --- a/api/types/authentication_authpreference_test.go +++ b/api/types/authentication_authpreference_test.go @@ -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{ diff --git a/api/types/types.pb.go b/api/types/types.pb.go index d44196e222881..34fa4b0533029 100644 --- a/api/types/types.pb.go +++ b/api/types/types.pb.go @@ -7605,6 +7605,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. diff --git a/docs/pages/identity-governance/device-trust/device-trust.mdx b/docs/pages/identity-governance/device-trust/device-trust.mdx index 9da3c75b97319..aa1190b1d384b 100644 --- a/docs/pages/identity-governance/device-trust/device-trust.mdx +++ b/docs/pages/identity-governance/device-trust/device-trust.mdx @@ -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. diff --git a/docs/pages/identity-governance/device-trust/enforcing-device-trust.mdx b/docs/pages/identity-governance/device-trust/enforcing-device-trust.mdx index cf310913544a3..ed911e4957034 100644 --- a/docs/pages/identity-governance/device-trust/enforcing-device-trust.mdx +++ b/docs/pages/identity-governance/device-trust/enforcing-device-trust.mdx @@ -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 diff --git a/docs/pages/includes/config-reference/auth-service.yaml b/docs/pages/includes/config-reference/auth-service.yaml index 4281b00b6ddab..0a22e25476fc2 100644 --- a/docs/pages/includes/config-reference/auth-service.yaml +++ b/docs/pages/includes/config-reference/auth-service.yaml @@ -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 diff --git a/docs/pages/includes/role-spec.mdx b/docs/pages/includes/role-spec.mdx index fa5a353d226f2..dc233be308947 100644 --- a/docs/pages/includes/role-spec.mdx +++ b/docs/pages/includes/role-spec.mdx @@ -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. diff --git a/docs/pages/reference/access-controls/roles.mdx b/docs/pages/reference/access-controls/roles.mdx index 437029c201164..68ab3d6a8193d 100644 --- a/docs/pages/reference/access-controls/roles.mdx +++ b/docs/pages/reference/access-controls/roles.mdx @@ -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 | diff --git a/docs/pages/reference/terraform-provider/data-sources/auth_preference.mdx b/docs/pages/reference/terraform-provider/data-sources/auth_preference.mdx index d58312bcc434e..fe843d1b0871c 100644 --- a/docs/pages/reference/terraform-provider/data-sources/auth_preference.mdx +++ b/docs/pages/reference/terraform-provider/data-sources/auth_preference.mdx @@ -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` diff --git a/docs/pages/reference/terraform-provider/resources/auth_preference.mdx b/docs/pages/reference/terraform-provider/resources/auth_preference.mdx index ff2e4d7b16f59..28311f4de733f 100644 --- a/docs/pages/reference/terraform-provider/resources/auth_preference.mdx +++ b/docs/pages/reference/terraform-provider/resources/auth_preference.mdx @@ -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` diff --git a/integrations/terraform/tfschema/types_terraform.go b/integrations/terraform/tfschema/types_terraform.go index 668e4cb7e33b4..2a2200c60581d 100644 --- a/integrations/terraform/tfschema/types_terraform.go +++ b/integrations/terraform/tfschema/types_terraform.go @@ -2201,7 +2201,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, }, diff --git a/lib/auth/auth.go b/lib/auth/auth.go index a462bd37155fe..66eae9e7177d2 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -2589,7 +2589,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, @@ -2611,7 +2611,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) } diff --git a/lib/auth/grpcserver_test.go b/lib/auth/grpcserver_test.go index e0a05f63eed7a..c2c1504acdceb 100644 --- a/lib/auth/grpcserver_test.go +++ b/lib/auth/grpcserver_test.go @@ -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) { diff --git a/lib/authz/permissions.go b/lib/authz/permissions.go index 6ef4fadfa6692..cd3fce28402f7 100644 --- a/lib/authz/permissions.go +++ b/lib/authz/permissions.go @@ -359,6 +359,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 } diff --git a/lib/authz/permissions_test.go b/lib/authz/permissions_test.go index f9522323a96e7..98d0755e0ee8c 100644 --- a/lib/authz/permissions_test.go +++ b/lib/authz/permissions_test.go @@ -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 { test := test diff --git a/lib/devicetrust/authz/authz.go b/lib/devicetrust/authz/authz.go index 27a357f643339..77fa24da13d4a 100644 --- a/lib/devicetrust/authz/authz.go +++ b/lib/devicetrust/authz/authz.go @@ -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 } diff --git a/lib/devicetrust/authz/authz_test.go b/lib/devicetrust/authz/authz_test.go index 5fd36e2c4a0ae..1dfa624161d74 100644 --- a/lib/devicetrust/authz/authz_test.go +++ b/lib/devicetrust/authz/authz_test.go @@ -128,25 +128,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) } @@ -167,6 +169,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) }{ { @@ -230,6 +233,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, @@ -239,6 +252,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) { @@ -246,7 +269,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)) }) } } diff --git a/lib/devicetrust/authz/trusted_device_requirement.go b/lib/devicetrust/authz/trusted_device_requirement.go index 2c008cf4d20c9..4bce8d1d19bc5 100644 --- a/lib/devicetrust/authz/trusted_device_requirement.go +++ b/lib/devicetrust/authz/trusted_device_requirement.go @@ -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 } } diff --git a/lib/devicetrust/authz/trusted_device_requirement_test.go b/lib/devicetrust/authz/trusted_device_requirement_test.go index 031ea457eb318..127029c35ed7b 100644 --- a/lib/devicetrust/authz/trusted_device_requirement_test.go +++ b/lib/devicetrust/authz/trusted_device_requirement_test.go @@ -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 { diff --git a/lib/services/role.go b/lib/services/role.go index cf5e6447fc0cb..8902c19445dd7 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -2694,7 +2694,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 @@ -2750,7 +2750,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()), ) @@ -2766,7 +2766,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()), ) @@ -3625,6 +3637,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. @@ -3726,6 +3741,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 } diff --git a/lib/services/role_test.go b/lib/services/role_test.go index aad7393ed7783..d08f531340bf2 100644 --- a/lib/services/role_test.go +++ b/lib/services/role_test.go @@ -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) { diff --git a/lib/srv/db/common/session.go b/lib/srv/db/common/session.go index 575df62c71f53..917b27032167b 100644 --- a/lib/srv/db/common/session.go +++ b/lib/srv/db/common/session.go @@ -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 } diff --git a/lib/srv/db/common/session_test.go b/lib/srv/db/common/session_test.go index d5c42baf78b52..5fb9874b91a5d 100644 --- a/lib/srv/db/common/session_test.go +++ b/lib/srv/db/common/session_test.go @@ -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) { diff --git a/lib/srv/mcp/session.go b/lib/srv/mcp/session.go index f136056fb2a87..be96f31362c21 100644 --- a/lib/srv/mcp/session.go +++ b/lib/srv/mcp/session.go @@ -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 } diff --git a/lib/srv/session_control_test.go b/lib/srv/session_control_test.go index dd50af9bb3c2d..4c3c981bdcda3 100644 --- a/lib/srv/session_control_test.go +++ b/lib/srv/session_control_test.go @@ -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) { diff --git a/lib/sshca/identity.go b/lib/sshca/identity.go index 5881977c4f058..81340ab99c19a 100644 --- a/lib/sshca/identity.go +++ b/lib/sshca/identity.go @@ -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{ diff --git a/lib/tbot/tbot_test.go b/lib/tbot/tbot_test.go index 30bcd06a26a69..04cf3189c6e2b 100644 --- a/lib/tbot/tbot_test.go +++ b/lib/tbot/tbot_test.go @@ -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" @@ -1287,6 +1288,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(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: &config.DestinationMemory{}, + }, + }, + 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) { diff --git a/lib/tlsca/ca.go b/lib/tlsca/ca.go index 0341322c65254..1a9af8ff5a147 100644 --- a/lib/tlsca/ca.go +++ b/lib/tlsca/ca.go @@ -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