mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user