EnrollPairingService: Add methods for approving and denying (#69459)

This commit is contained in:
Maja Cieślak
2026-08-10 09:00:16 +00:00
committed by GitHub
parent c7ccc02280
commit 4aa80c63ec
3 changed files with 210 additions and 0 deletions
+11
View File
@@ -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.
+52
View File
@@ -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[:]))
+147
View File
@@ -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,