Skip to content

Commit da2a787

Browse files
msanftclaude
andcommitted
coordinator: support insecure manifests behind opt-in
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 93490aa commit da2a787

11 files changed

Lines changed: 468 additions & 2 deletions

File tree

coordinator/internal/stateguard/stateguard.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ var (
5252
// ErrConcurrentUpdate is returned by state-modifying operations if the input oldState is not
5353
// the current state. This usually happens when a concurrent operation succeeded.
5454
ErrConcurrentUpdate = errors.New("coordinator state was updated concurrently")
55+
56+
// ErrInsecureNotAllowed is returned when a manifest contains insecure platforms but the
57+
// coordinator was not started with the allow-insecure flag.
58+
ErrInsecureNotAllowed = errors.New("manifest contains insecure platforms, but the coordinator is not configured to allow them")
59+
60+
// ErrMixedManifestNotAllowed is returned when a manifest mixes secure and insecure platforms.
61+
// Manifests must be either all-secure or all-insecure.
62+
ErrMixedManifestNotAllowed = errors.New("manifest must not mix secure and insecure platforms")
63+
64+
// ErrInsecureToSecureTransition is returned when a Coordinator with an insecure manifest tries
65+
// to transition back to a secure manifest.
66+
ErrInsecureToSecureTransition = errors.New("coordinator must not transition from an insecure manifest to a secure manifest")
5567
)
5668

5769
// Guard manages the manifest state of Contrast.
@@ -65,6 +77,10 @@ type Guard struct {
6577
logger *slog.Logger
6678
metrics metrics
6779

80+
// allowInsecure controls whether manifests with insecure platforms are accepted.
81+
// It defaults to false and can be enabled via MakeInsecure.
82+
allowInsecure bool
83+
6884
clock clock.Clock
6985
}
7086

@@ -73,6 +89,9 @@ type metrics struct {
7389
}
7490

7591
// New creates a new state Guard instance.
92+
//
93+
// By default, the Guard rejects manifests that contain insecure platforms. Call MakeInsecure to
94+
// allow them.
7695
func New(hist *history.History, reg *prometheus.Registry, log *slog.Logger) *Guard {
7796
manifestGeneration := promauto.With(reg).NewGauge(prometheus.GaugeOpts{
7897
Subsystem: "contrast_coordinator",
@@ -91,6 +110,14 @@ func New(hist *history.History, reg *prometheus.Registry, log *slog.Logger) *Gua
91110
}
92111
}
93112

113+
// MakeInsecure configures the Guard to accept manifests that contain insecure platforms.
114+
//
115+
// This voids the security guarantees of the deployment and must only be used for testing or
116+
// benchmarking. It should be called before the Guard starts serving requests.
117+
func (g *Guard) MakeInsecure() {
118+
g.allowInsecure = true
119+
}
120+
94121
// WatchHistory monitors the history for manifest updates and sets the state stale if necessary.
95122
//
96123
// This function blocks and keeps watching until the context expires.
@@ -188,6 +215,9 @@ func (g *Guard) ResetState(ctx context.Context, oldState *State, authorizer Secr
188215
if err := json.Unmarshal(manifestBytes, mnfst); err != nil {
189216
return nil, fmt.Errorf("parsing manifest: %w", err)
190217
}
218+
if err := g.validateManifestSecurity(mnfst); err != nil {
219+
return nil, err
220+
}
191221

192222
se, meshCAKey, err := authorizer.AuthorizeByManifest(ctx, mnfst)
193223
if err != nil {
@@ -271,6 +301,12 @@ func (g *Guard) UpdateState(_ context.Context, oldState *State, se *seedengine.S
271301
if err := json.Unmarshal(manifestBytes, &mnfst); err != nil {
272302
return nil, fmt.Errorf("unmarshaling manifest: %w", err)
273303
}
304+
if err := g.validateManifestSecurity(&mnfst); err != nil {
305+
return nil, err
306+
}
307+
if oldState != nil && oldState.Manifest().HasInsecurePlatforms() && !mnfst.HasInsecurePlatforms() {
308+
return nil, ErrInsecureToSecureTransition
309+
}
274310
policyMap := make(map[[history.HashSize]byte][]byte)
275311
for _, policy := range policies {
276312
policyHash, err := g.hist.SetPolicy(policy)
@@ -351,6 +387,19 @@ func (g *Guard) UpdateState(_ context.Context, oldState *State, se *seedengine.S
351387
return nextState, nil
352388
}
353389

390+
func (g *Guard) validateManifestSecurity(mnfst *manifest.Manifest) error {
391+
if !mnfst.HasInsecurePlatforms() {
392+
return nil
393+
}
394+
if !g.allowInsecure {
395+
return ErrInsecureNotAllowed
396+
}
397+
if mnfst.HasSecurePlatforms() {
398+
return ErrMixedManifestNotAllowed
399+
}
400+
return nil
401+
}
402+
354403
// GetHistory returns a list of manifests, the current manifest being last, and the policies
355404
// referenced in at least one of the manifests.
356405
func (g *Guard) GetHistory(ctx context.Context) ([][]byte, map[manifest.HexString][]byte, error) {

coordinator/internal/stateguard/stateguard_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,125 @@ func TestResetState(t *testing.T) {
192192
require.ErrorIs(err, assert.AnError)
193193
}
194194

195+
func TestResetStateInsecureRequiresOptIn(t *testing.T) {
196+
ctx := t.Context()
197+
require := require.New(t)
198+
199+
store := aferostore.New(&afero.Afero{Fs: afero.NewMemMapFs()})
200+
hist := history.NewWithStore(slog.Default(), store)
201+
se := newSeedEngine(t)
202+
_, manifestBytes, policies := newInsecureManifest(t)
203+
204+
originalGuard := New(hist, prometheus.NewRegistry(), slog.Default())
205+
originalGuard.MakeInsecure()
206+
state, err := originalGuard.UpdateState(ctx, nil, se, manifestBytes, policies)
207+
require.NoError(err)
208+
require.NotNil(state)
209+
210+
restartedGuard := New(hist, prometheus.NewRegistry(), slog.Default())
211+
state, err = restartedGuard.ResetState(ctx, nil, &stubAuthorizer{err: assert.AnError})
212+
require.ErrorIs(err, ErrInsecureNotAllowed)
213+
require.Nil(state)
214+
215+
restartedGuard.MakeInsecure()
216+
authz := &stubAuthorizer{
217+
se: se,
218+
pk: testkeys.ECDSA(t),
219+
}
220+
state, err = restartedGuard.ResetState(ctx, nil, authz)
221+
require.NoError(err)
222+
require.NotNil(state)
223+
}
224+
225+
func TestUpdateStateInsecure(t *testing.T) {
226+
ctx := t.Context()
227+
228+
_, insecureManifestBytes, policies := newInsecureManifest(t)
229+
se := newSeedEngine(t)
230+
231+
t.Run("rejected when allowInsecure is false", func(t *testing.T) {
232+
require := require.New(t)
233+
234+
store := aferostore.New(&afero.Afero{Fs: afero.NewMemMapFs()})
235+
hist := history.NewWithStore(slog.Default(), store)
236+
g := New(hist, prometheus.NewRegistry(), slog.Default())
237+
238+
state, err := g.UpdateState(ctx, nil, se, insecureManifestBytes, policies)
239+
require.ErrorIs(err, ErrInsecureNotAllowed)
240+
require.Nil(state)
241+
})
242+
243+
t.Run("accepted when allowInsecure is true", func(t *testing.T) {
244+
require := require.New(t)
245+
246+
store := aferostore.New(&afero.Afero{Fs: afero.NewMemMapFs()})
247+
hist := history.NewWithStore(slog.Default(), store)
248+
g := New(hist, prometheus.NewRegistry(), slog.Default())
249+
g.MakeInsecure()
250+
251+
state, err := g.UpdateState(ctx, nil, se, insecureManifestBytes, policies)
252+
require.NoError(err)
253+
require.NotNil(state)
254+
})
255+
256+
t.Run("mixed manifest rejected even when allowInsecure is true", func(t *testing.T) {
257+
require := require.New(t)
258+
259+
_, mixedManifestBytes, mixedPolicies := newMixedManifest(t)
260+
261+
store := aferostore.New(&afero.Afero{Fs: afero.NewMemMapFs()})
262+
hist := history.NewWithStore(slog.Default(), store)
263+
g := New(hist, prometheus.NewRegistry(), slog.Default())
264+
g.MakeInsecure()
265+
266+
state, err := g.UpdateState(ctx, nil, se, mixedManifestBytes, mixedPolicies)
267+
require.ErrorIs(err, ErrMixedManifestNotAllowed)
268+
require.Nil(state)
269+
})
270+
}
271+
272+
func TestUpdateStateSecurityTransitions(t *testing.T) {
273+
type manifestFixture struct {
274+
name string
275+
build func(*testing.T) (*manifest.Manifest, []byte, [][]byte)
276+
}
277+
fixtures := []manifestFixture{
278+
{name: "secure", build: newManifest},
279+
{name: "insecure", build: newInsecureManifest},
280+
}
281+
282+
for _, from := range fixtures {
283+
for _, to := range fixtures {
284+
t.Run(from.name+" to "+to.name, func(t *testing.T) {
285+
require := require.New(t)
286+
g, _ := newTestGuard(t)
287+
g.MakeInsecure()
288+
se := newSeedEngine(t)
289+
290+
_, fromManifestBytes, fromPolicies := from.build(t)
291+
oldState, err := g.UpdateState(t.Context(), nil, se, fromManifestBytes, fromPolicies)
292+
require.NoError(err)
293+
require.NotNil(oldState)
294+
295+
_, toManifestBytes, toPolicies := to.build(t)
296+
nextState, err := g.UpdateState(t.Context(), oldState, se, toManifestBytes, toPolicies)
297+
if from.name == "insecure" && to.name == "secure" {
298+
require.ErrorIs(err, ErrInsecureToSecureTransition)
299+
require.Nil(nextState)
300+
301+
currentState, getErr := g.GetState(t.Context())
302+
require.NoError(getErr)
303+
require.Same(oldState, currentState)
304+
return
305+
}
306+
307+
require.NoError(err)
308+
require.NotNil(nextState)
309+
})
310+
}
311+
}
312+
}
313+
195314
func TestConcurrentUpdateState(t *testing.T) {
196315
ctx := t.Context()
197316
assert := assert.New(t)
@@ -543,6 +662,51 @@ func newManifest(t *testing.T) (*manifest.Manifest, []byte, [][]byte) {
543662
return mnfst, mnfstBytes, [][]byte{policy}
544663
}
545664

665+
func newInsecureManifest(t *testing.T) (*manifest.Manifest, []byte, [][]byte) {
666+
t.Helper()
667+
policy := []byte("=== SOME REGO HERE ===")
668+
policyHash := sha256.Sum256(policy)
669+
policyHashHex := manifest.NewHexString(policyHash[:])
670+
671+
mnfst := &manifest.Manifest{}
672+
mnfst.Policies = map[manifest.HexString]manifest.PolicyEntry{
673+
policyHashHex: {
674+
SANs: []string{"test"},
675+
WorkloadSecretID: "test2",
676+
Role: manifest.RoleCoordinator,
677+
},
678+
}
679+
mnfst.ReferenceValues.SNP = []manifest.SNPReferenceValues{
680+
{Platform: "Metal-QEMU-Insecure"},
681+
}
682+
mnfstBytes, err := json.Marshal(mnfst)
683+
require.NoError(t, err)
684+
return mnfst, mnfstBytes, [][]byte{policy}
685+
}
686+
687+
func newMixedManifest(t *testing.T) (*manifest.Manifest, []byte, [][]byte) {
688+
t.Helper()
689+
policy := []byte("=== SOME REGO HERE ===")
690+
policyHash := sha256.Sum256(policy)
691+
policyHashHex := manifest.NewHexString(policyHash[:])
692+
693+
mnfst := &manifest.Manifest{}
694+
mnfst.Policies = map[manifest.HexString]manifest.PolicyEntry{
695+
policyHashHex: {
696+
SANs: []string{"test"},
697+
WorkloadSecretID: "test2",
698+
Role: manifest.RoleCoordinator,
699+
},
700+
}
701+
mnfst.ReferenceValues.SNP = []manifest.SNPReferenceValues{
702+
{Platform: "Metal-QEMU-Insecure"},
703+
{Platform: "Metal-QEMU-SNP"},
704+
}
705+
mnfstBytes, err := json.Marshal(mnfst)
706+
require.NoError(t, err)
707+
return mnfst, mnfstBytes, [][]byte{policy}
708+
}
709+
546710
func newSeedEngine(t *testing.T) *seedengine.SeedEngine {
547711
t.Helper()
548712
data := make([]byte, 32)

coordinator/internal/userapi/userapi.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,15 @@ func (s *Server) SetManifest(ctx context.Context, req *userapi.SetManifestReques
151151
state, err := s.guard.UpdateState(ctx, oldState, se, req.GetManifest(), req.GetPolicies())
152152
if err != nil {
153153
code := codes.Internal
154-
if errors.Is(err, stateguard.ErrConcurrentUpdate) {
154+
switch {
155+
case errors.Is(err, stateguard.ErrConcurrentUpdate):
155156
code = codes.FailedPrecondition
157+
case errors.Is(err, stateguard.ErrInsecureNotAllowed):
158+
code = codes.InvalidArgument
159+
case errors.Is(err, stateguard.ErrMixedManifestNotAllowed):
160+
code = codes.InvalidArgument
161+
case errors.Is(err, stateguard.ErrInsecureToSecureTransition):
162+
code = codes.InvalidArgument
156163
}
157164
return nil, status.Errorf(code, "updating Coordinator state: %v", err)
158165
}

coordinator/internal/userapi/userapi_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,48 @@ func TestSetManifest(t *testing.T) {
232232
require.Equal(codes.InvalidArgument, status.Code(err))
233233
})
234234

235+
t.Run("insecure manifest rejected", func(t *testing.T) {
236+
require := require.New(t)
237+
238+
// Default coordinator does not allow insecure manifests.
239+
coordinator := newCoordinator()
240+
m := newInsecureManifest(t)
241+
manifestBytes, err := json.Marshal(m)
242+
require.NoError(err)
243+
req := &userapi.SetManifestRequest{Manifest: manifestBytes}
244+
_, err = coordinator.SetManifest(t.Context(), req)
245+
require.Error(err)
246+
require.Equal(codes.InvalidArgument, status.Code(err))
247+
require.ErrorContains(err, "insecure")
248+
})
249+
250+
t.Run("insecure manifest accepted when allowed", func(t *testing.T) {
251+
require := require.New(t)
252+
253+
coordinator := newCoordinatorAllowInsecure()
254+
m := newInsecureManifest(t)
255+
manifestBytes, err := json.Marshal(m)
256+
require.NoError(err)
257+
req := &userapi.SetManifestRequest{Manifest: manifestBytes}
258+
resp, err := coordinator.SetManifest(t.Context(), req)
259+
require.NoError(err)
260+
require.NotNil(resp)
261+
})
262+
263+
t.Run("mixed manifest rejected even when insecure allowed", func(t *testing.T) {
264+
require := require.New(t)
265+
266+
coordinator := newCoordinatorAllowInsecure()
267+
m := newMixedManifest(t)
268+
manifestBytes, err := json.Marshal(m)
269+
require.NoError(err)
270+
req := &userapi.SetManifestRequest{Manifest: manifestBytes}
271+
_, err = coordinator.SetManifest(t.Context(), req)
272+
require.Error(err)
273+
require.Equal(codes.InvalidArgument, status.Code(err))
274+
require.ErrorContains(err, "mix")
275+
})
276+
235277
t.Run("atomic manifest update", func(t *testing.T) {
236278
require := require.New(t)
237279

@@ -857,6 +899,35 @@ func newCoordinatorWithRegistry(reg *prometheus.Registry) *Server {
857899
return New(logger, auth, &stubDiscovery{})
858900
}
859901

902+
func newCoordinatorAllowInsecure() *Server {
903+
logger := slog.Default()
904+
fs := afero.NewMemMapFs()
905+
store := aferostore.New(&afero.Afero{Fs: fs})
906+
hist := history.NewWithStore(slog.Default(), store)
907+
auth := stateguard.New(hist, prometheus.NewRegistry(), logger)
908+
auth.MakeInsecure()
909+
return New(logger, auth, &stubDiscovery{})
910+
}
911+
912+
func newInsecureManifest(t *testing.T) *manifest.Manifest {
913+
t.Helper()
914+
mnfst := &manifest.Manifest{}
915+
mnfst.ReferenceValues.SNP = []manifest.SNPReferenceValues{
916+
{Platform: "Metal-QEMU-Insecure"},
917+
}
918+
return mnfst
919+
}
920+
921+
func newMixedManifest(t *testing.T) *manifest.Manifest {
922+
t.Helper()
923+
mnfst := &manifest.Manifest{}
924+
mnfst.ReferenceValues.SNP = []manifest.SNPReferenceValues{
925+
{Platform: "Metal-QEMU-Insecure"},
926+
{Platform: "Metal-QEMU-SNP"},
927+
}
928+
return mnfst
929+
}
930+
860931
func newCoordinatorWithWatcher(t *testing.T, hist *history.History) *Server {
861932
t.Helper()
862933
logger := slog.Default()

0 commit comments

Comments
 (0)