mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
EnrollPairingService: Add methods for approving and denying (#69459)
This commit is contained in:
@@ -42,6 +42,17 @@ type EnrollPairing interface {
|
||||
// retry gating, and returns the updated pairing.
|
||||
// Returns CompareFailed if the pairing is no longer awaiting a device.
|
||||
RequestEnrollPairingApproval(ctx context.Context, pairing *devicepb.EnrollPairing, device *devicepb.EnrollPairingDevice) (*devicepb.EnrollPairing, error)
|
||||
|
||||
// ApproveEnrollPairing transitions pairing from AWAITING_APPROVAL to APPROVED
|
||||
// and returns the updated pairing.
|
||||
// Returns CompareFailed if the pairing is no longer awaiting approval.
|
||||
ApproveEnrollPairing(ctx context.Context, pairing *devicepb.EnrollPairing) (*devicepb.EnrollPairing, error)
|
||||
|
||||
// DeleteEnrollPairing removes pairing along with its token index. It backs
|
||||
// both denial by the user and the single-use consumption of a pairing when
|
||||
// the enrollment token is issued.
|
||||
// Returns CompareFailed if the pairing has changed since it was read.
|
||||
DeleteEnrollPairing(ctx context.Context, pairing *devicepb.EnrollPairing) error
|
||||
}
|
||||
|
||||
// MarshalEnrollPairing marshals an EnrollPairing resource to JSON.
|
||||
|
||||
@@ -225,6 +225,58 @@ func (s *EnrollPairingService) RequestEnrollPairingApproval(ctx context.Context,
|
||||
return updated, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// ApproveEnrollPairing transitions pairing from AWAITING_APPROVAL to APPROVED
|
||||
// and returns the updated pairing.
|
||||
func (s *EnrollPairingService) ApproveEnrollPairing(ctx context.Context, pairing *devicepb.EnrollPairing) (*devicepb.EnrollPairing, error) {
|
||||
if pairing == nil {
|
||||
return nil, trace.BadParameter("pairing required")
|
||||
}
|
||||
|
||||
const errNotAwaitingApproval = "enroll pairing is not awaiting approval"
|
||||
if pairing.GetStatus().GetState() != devicepb.EnrollPairingState_ENROLL_PAIRING_STATE_AWAITING_APPROVAL {
|
||||
return nil, trace.CompareFailed(errNotAwaitingApproval)
|
||||
}
|
||||
|
||||
pairing.GetStatus().SetState(devicepb.EnrollPairingState_ENROLL_PAIRING_STATE_APPROVED)
|
||||
|
||||
updated, err := s.service.ConditionalUpdateResource(ctx, pairing)
|
||||
if trace.IsCompareFailed(err) {
|
||||
// Lost the CAS to a concurrent approval — same outcome as the state check above.
|
||||
return nil, trace.CompareFailed(errNotAwaitingApproval)
|
||||
}
|
||||
return updated, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// DeleteEnrollPairing removes pairing along with the token index written by
|
||||
// [EnrollPairingService.CreateEnrollPairing].
|
||||
func (s *EnrollPairingService) DeleteEnrollPairing(ctx context.Context, pairing *devicepb.EnrollPairing) error {
|
||||
if pairing == nil {
|
||||
return trace.BadParameter("pairing required")
|
||||
}
|
||||
|
||||
name := pairing.GetMetadata().GetName()
|
||||
token := pairing.GetStatus().GetToken()
|
||||
|
||||
// The pairing's revision gates the whole write and AtomicWrite is
|
||||
// all-or-nothing, so the index needs no condition of its own.
|
||||
_, err := s.backend.AtomicWrite(ctx, []backend.ConditionalAction{
|
||||
{
|
||||
Key: s.service.BackendKey(name),
|
||||
Condition: backend.Revision(pairing.GetMetadata().GetRevision()),
|
||||
Action: backend.Delete(),
|
||||
},
|
||||
{
|
||||
Key: enrollPairingByTokenKey(token),
|
||||
Condition: backend.Whatever(),
|
||||
Action: backend.Delete(),
|
||||
},
|
||||
})
|
||||
if errors.Is(err, backend.ErrConditionFailed) {
|
||||
return trace.CompareFailed("enroll pairing has changed")
|
||||
}
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
func enrollPairingByTokenKey(token string) backend.Key {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return backend.NewKey("devices", "enroll_pairing_by_token", hex.EncodeToString(hash[:]))
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package local_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
@@ -281,6 +282,152 @@ func TestEnrollPairingService_RequestEnrollPairingApproval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollPairingService_ApproveEnrollPairing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newEnrollPairingService(t)
|
||||
|
||||
t.Run("transitions to approved", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
pairing := claimed(t, ctx, s, "approve-ok")
|
||||
|
||||
updated, err := s.ApproveEnrollPairing(ctx, pairing)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
devicepb.EnrollPairingState_ENROLL_PAIRING_STATE_APPROVED,
|
||||
updated.GetStatus().GetState())
|
||||
// Verify that approval doesn't clobber the device.
|
||||
assert.Empty(t, cmp.Diff(makeDevice(), updated.GetStatus().GetDevice(), protocmp.Transform()))
|
||||
|
||||
got, err := s.GetEnrollPairingByToken(ctx, pairing.GetStatus().GetToken())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cmp.Diff(updated, got, protocmp.Transform()))
|
||||
})
|
||||
|
||||
t.Run("rejects a pairing that is not awaiting approval", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
created, err := s.CreateEnrollPairing(ctx, "approve-too-early")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Still AWAITING_DEVICE, no device has claimed it yet.
|
||||
_, err = s.ApproveEnrollPairing(ctx, created)
|
||||
assert.ErrorAs(t, err, new(*trace.CompareFailedError))
|
||||
assert.ErrorContains(t, err, "not awaiting approval")
|
||||
})
|
||||
|
||||
t.Run("rejects a stale pairing that lost the compare-and-swap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
pairing := claimed(t, ctx, s, "approve-stale")
|
||||
|
||||
// Advance the stored revision.
|
||||
fresh, err := s.GetEnrollPairingByToken(ctx, pairing.GetStatus().GetToken())
|
||||
require.NoError(t, err)
|
||||
_, err = s.ApproveEnrollPairing(ctx, fresh)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.ApproveEnrollPairing(ctx, pairing)
|
||||
assert.ErrorAs(t, err, new(*trace.CompareFailedError))
|
||||
assert.ErrorContains(t, err, "not awaiting approval")
|
||||
})
|
||||
|
||||
t.Run("rejects a nil pairing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := s.ApproveEnrollPairing(t.Context(), nil)
|
||||
assert.ErrorAs(t, err, new(*trace.BadParameterError))
|
||||
assert.ErrorContains(t, err, "pairing required")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnrollPairingService_DeleteEnrollPairing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newEnrollPairingService(t)
|
||||
|
||||
t.Run("removes the pairing and its token index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
created, err := s.CreateEnrollPairing(ctx, "delete-ok")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.DeleteEnrollPairing(ctx, created))
|
||||
|
||||
_, err = s.GetCurrentEnrollPairing(ctx, "delete-ok")
|
||||
assert.ErrorAs(t, err, new(*trace.NotFoundError))
|
||||
_, err = s.GetEnrollPairingByToken(ctx, created.GetStatus().GetToken())
|
||||
assert.ErrorAs(t, err, new(*trace.NotFoundError))
|
||||
|
||||
// The name is free again, so the user can start a new pairing.
|
||||
_, err = s.CreateEnrollPairing(ctx, "delete-ok")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
// The enrollment token is issued off an approved pairing, so the single-use
|
||||
// consumption path deletes in that state rather than AWAITING_DEVICE.
|
||||
t.Run("removes an approved pairing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
approved, err := s.ApproveEnrollPairing(ctx, claimed(t, ctx, s, "delete-approved"))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.DeleteEnrollPairing(ctx, approved))
|
||||
|
||||
_, err = s.GetCurrentEnrollPairing(ctx, "delete-approved")
|
||||
assert.ErrorAs(t, err, new(*trace.NotFoundError))
|
||||
_, err = s.GetEnrollPairingByToken(ctx, approved.GetStatus().GetToken())
|
||||
assert.ErrorAs(t, err, new(*trace.NotFoundError))
|
||||
})
|
||||
|
||||
t.Run("rejects a stale pairing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
created, err := s.CreateEnrollPairing(ctx, "delete-stale")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Advance the stored revision.
|
||||
fresh, err := s.GetEnrollPairingByToken(ctx, created.GetStatus().GetToken())
|
||||
require.NoError(t, err)
|
||||
_, err = s.RequestEnrollPairingApproval(ctx, fresh, makeDevice())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.DeleteEnrollPairing(ctx, created)
|
||||
assert.ErrorAs(t, err, new(*trace.CompareFailedError))
|
||||
assert.ErrorContains(t, err, "enroll pairing has changed")
|
||||
})
|
||||
|
||||
t.Run("rejects a second delete of the same pairing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
created, err := s.CreateEnrollPairing(ctx, "delete-twice")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.DeleteEnrollPairing(ctx, created))
|
||||
err = s.DeleteEnrollPairing(ctx, created)
|
||||
assert.ErrorAs(t, err, new(*trace.CompareFailedError))
|
||||
assert.ErrorContains(t, err, "enroll pairing has changed")
|
||||
})
|
||||
|
||||
t.Run("rejects a nil pairing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := s.DeleteEnrollPairing(t.Context(), nil)
|
||||
assert.ErrorAs(t, err, new(*trace.BadParameterError))
|
||||
assert.ErrorContains(t, err, "pairing required")
|
||||
})
|
||||
}
|
||||
|
||||
// claimed returns a pairing for user in AWAITING_APPROVAL, the only state
|
||||
// from which it can be approved.
|
||||
func claimed(t *testing.T, ctx context.Context, s *local.EnrollPairingService, user string) *devicepb.EnrollPairing {
|
||||
t.Helper()
|
||||
created, err := s.CreateEnrollPairing(ctx, user)
|
||||
require.NoError(t, err)
|
||||
pairing, err := s.RequestEnrollPairingApproval(ctx, created, makeDevice())
|
||||
require.NoError(t, err)
|
||||
return pairing
|
||||
}
|
||||
|
||||
func makeDevice() *devicepb.EnrollPairingDevice {
|
||||
return devicepb.EnrollPairingDevice_builder{
|
||||
OsType: devicepb.OSType_OS_TYPE_IOS,
|
||||
|
||||
Reference in New Issue
Block a user