diff --git a/doc/spire_agent.md b/doc/spire_agent.md index de5054d607..d02fdf88dd 100644 --- a/doc/spire_agent.md +++ b/doc/spire_agent.md @@ -369,7 +369,31 @@ plugins { ## Delegated Identity API -The Delegated Identity API allows an authorized (i.e. delegated) workload to obtain SVIDs and bundles on behalf of workloads that cannot be attested by SPIRE Agent directly. The authorized workload does so by providing SPIRE Agent the selectors that would normally be obtained during workload attestation. The Delegated Identity API is served over the admin API endpoint. +The Delegated Identity API allows an authorized (i.e. delegated) workload to obtain SVIDs and bundles on behalf of workloads that cannot be attested by SPIRE Agent directly. + +The Delegated Identity API is served over the SPIRE Agent's admin API endpoint. + +Note that this explicitly and by-design grants the authorized delegate workload the ability to impersonate any of the other workloads it can obtain SVIDs for. Any workload authorized to use the +Delegated Identity API becomes a "trusted delegate" of the SPIRE Agent, and may impersonate and act on behalf of all workload SVIDs it obtains from the SPIRE Agent. + +The trusted delegate workload itself is attested by the SPIRE Agent first, and the delegate's SPIFFE ID is checked against an allowlist of authorized delegates. + +Once these requirements are met, the trusted delegate workload can obtain SVIDS for any workloads in the scope of the SPIRE Agent instance it is interacting with. + +There are two ways the trusted delegate workload can request SVIDs for other workloads from the SPIRE Agent: + +1. By attesting the other workload itself, building a set of selectors, and then providing SPIRE Agent those selectors over the Delegated Identity API. + In this approach, the trusted delegate workload is entirely responsible for attesting the other workload and building the attested selectors. + When those selectors are presented to the SPIRE Agent, the SPIRE Agent will simply return SVIDs for any workload registration entries that match the provided selectors. + No other checks or attestations will be performed by the SPIRE Agent. + +1. By obtaining a PID for the other workload, and providing that PID to the SPIRE Agent over the Delegated Identity API. + In this approach, the SPIRE Agent will do attestation for the provided PID, build the attested selectors, and return SVIDs for any workload registration entries that match the selectors the SPIRE Agent attested from that PID. + This differs from the previous approach in that the SPIRE Agent itself (not the trusted delegate) handles the attestation of the other workload. + On most platforms PIDs are not stable identifiers, so the trusted delegate workload **must** ensure that the PID it provides to the SPIRE Agent + via the Delegated Identity API for attestation is not recycled between the time a trusted delegate makes an Delegate Identity API request, and obtains a Delegate Identity API response. + How this is accomplished is platform-dependent and the responsibility of the trusted delegate (e.g. by using pidfds on Linux). + Attestation results obtained via the Delegated Identity API for a PID are valid until the process referred to by the PID terminates, or is re-attested - whichever comes first. To enable the Delegated Identity API, configure the admin API endpoint address and the list of SPIFFE IDs for authorized delegates. For example: diff --git a/go.mod b/go.mod index b3fc8f4527..6e0624cdfb 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,7 @@ require ( github.com/sigstore/sigstore v1.8.8 github.com/sirupsen/logrus v1.9.3 github.com/spiffe/go-spiffe/v2 v2.3.0 - github.com/spiffe/spire-api-sdk v1.2.5-0.20240627195926-b5ac064f580b + github.com/spiffe/spire-api-sdk v1.2.5-0.20240722174251-0116a7186c35 github.com/spiffe/spire-plugin-sdk v1.4.4-0.20230721151831-bf67dde4721d github.com/stretchr/testify v1.9.0 github.com/uber-go/tally/v4 v4.1.16 diff --git a/go.sum b/go.sum index 9229709c1d..3340a17366 100644 --- a/go.sum +++ b/go.sum @@ -1426,8 +1426,8 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+ github.com/spiffe/go-spiffe/v2 v2.1.6/go.mod h1:eVDqm9xFvyqao6C+eQensb9ZPkyNEeaUbqbBpOhBnNk= github.com/spiffe/go-spiffe/v2 v2.3.0 h1:g2jYNb/PDMB8I7mBGL2Zuq/Ur6hUhoroxGQFyD6tTj8= github.com/spiffe/go-spiffe/v2 v2.3.0/go.mod h1:Oxsaio7DBgSNqhAO9i/9tLClaVlfRok7zvJnTV8ZyIY= -github.com/spiffe/spire-api-sdk v1.2.5-0.20240627195926-b5ac064f580b h1:k7ei1fQyt6+FbqDEAd90xaXLg52YuXueM+BRcoHZvEU= -github.com/spiffe/spire-api-sdk v1.2.5-0.20240627195926-b5ac064f580b/go.mod h1:4uuhFlN6KBWjACRP3xXwrOTNnvaLp1zJs8Lribtr4fI= +github.com/spiffe/spire-api-sdk v1.2.5-0.20240722174251-0116a7186c35 h1:Ah7jJvfjw2fYXtSJF69lWokspl5Vhge0yiSi/mFhzhM= +github.com/spiffe/spire-api-sdk v1.2.5-0.20240722174251-0116a7186c35/go.mod h1:4uuhFlN6KBWjACRP3xXwrOTNnvaLp1zJs8Lribtr4fI= github.com/spiffe/spire-plugin-sdk v1.4.4-0.20230721151831-bf67dde4721d h1:LCRQGU6vOqKLfRrG+GJQrwMwDILcAddAEIf4/1PaSVc= github.com/spiffe/spire-plugin-sdk v1.4.4-0.20230721151831-bf67dde4721d/go.mod h1:GA6o2PVLwyJdevT6KKt5ZXCY/ziAPna13y/seGk49Ik= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/pkg/agent/api/delegatedidentity/v1/service.go b/pkg/agent/api/delegatedidentity/v1/service.go index bec937487d..073da39802 100644 --- a/pkg/agent/api/delegatedidentity/v1/service.go +++ b/pkg/agent/api/delegatedidentity/v1/service.go @@ -54,10 +54,11 @@ func New(config Config) *Service { } return &Service{ - manager: config.Manager, - attestor: endpoints.PeerTrackerAttestor{Attestor: config.Attestor}, - metrics: config.Metrics, - authorizedDelegates: AuthorizedDelegates, + manager: config.Manager, + peerAttestor: endpoints.PeerTrackerAttestor{Attestor: config.Attestor}, + delegateWorkloadAttestor: config.Attestor, + metrics: config.Metrics, + authorizedDelegates: AuthorizedDelegates, } } @@ -65,9 +66,10 @@ func New(config Config) *Service { type Service struct { delegatedidentityv1.UnsafeDelegatedIdentityServer - manager manager.Manager - attestor attestor - metrics telemetry.Metrics + manager manager.Manager + peerAttestor attestor + delegateWorkloadAttestor workloadattestor.Attestor + metrics telemetry.Metrics // SPIFFE IDs of delegates that are authorized to use this API authorizedDelegates map[string]bool @@ -79,7 +81,7 @@ func (s *Service) isCallerAuthorized(ctx context.Context, log logrus.FieldLogger callerSelectors := cachedSelectors if callerSelectors == nil { - callerSelectors, err = s.attestor.Attest(ctx) + callerSelectors, err = s.peerAttestor.Attest(ctx) if err != nil { log.WithError(err).Error("Workload attestation failed") return nil, status.Error(codes.Internal, "workload attestation failed") @@ -111,6 +113,53 @@ func (s *Service) isCallerAuthorized(ctx context.Context, log logrus.FieldLogger return nil, status.Error(codes.PermissionDenied, "caller not configured as an authorized delegate") } +func (s *Service) constructValidSelectorsFromReq(ctx context.Context, log logrus.FieldLogger, reqPid int32, reqSelectors []*types.Selector) ([]*common.Selector, error) { + // If you set + // - both pid and selector args + // - neither of them + // it's an error + // NOTE: the default value of int32 is naturally 0 in protobuf, which is also a valid PID. + // However, we will still treat that as an error, as we do not expect to ever be asked to attest + // pid 0. + + if (len(reqSelectors) != 0 && reqPid != 0) || (len(reqSelectors) == 0 && reqPid == 0) { + log.Error("Invalid argument; must provide either selectors or non-zero PID, but not both") + return nil, status.Error(codes.InvalidArgument, "must provide either selectors or non-zero PID, but not both") + } + + var selectors []*common.Selector + var err error + + if len(reqSelectors) != 0 { + // Delegate authorized, if the delegate gives us selectors, we treat them as attested. + selectors, err = api.SelectorsFromProto(reqSelectors) + if err != nil { + log.WithError(err).Error("Invalid argument; could not parse provided selectors") + return nil, status.Error(codes.InvalidArgument, "could not parse provided selectors") + } + } else { + // Delegate authorized, use PID the delegate gave us to try and attest on-behalf-of + selectors, err = s.delegateWorkloadAttestor.Attest(ctx, int(reqPid)) + if err != nil { + return nil, err + } + } + + return selectors, nil +} + +// Attempt to attest and authorize the delegate, and then +// +// - Take a pre-atttested set of selectors from the delegate +// - the PID the delegate gave us and attempt to attest that into a set of selectors +// +// and provide a SVID subscription for those selectors. +// +// NOTE: +// - If supplying a PID, the trusted delegate is responsible for ensuring the PID is valid and not recycled, +// from initiation of this call until the termination of the response stream, and if it is, +// must discard any stream contents provided by this call as invalid. +// - If supplying selectors, the trusted delegate is responsible for ensuring they are correct. func (s *Service) SubscribeToX509SVIDs(req *delegatedidentityv1.SubscribeToX509SVIDsRequest, stream delegatedidentityv1.DelegatedIdentity_SubscribeToX509SVIDsServer) error { latency := adminapi.StartFirstX509SVIDUpdateLatency(s.metrics) ctx := stream.Context() @@ -122,10 +171,9 @@ func (s *Service) SubscribeToX509SVIDs(req *delegatedidentityv1.SubscribeToX509S return err } - selectors, err := api.SelectorsFromProto(req.Selectors) + selectors, err := s.constructValidSelectorsFromReq(ctx, log, req.Pid, req.Selectors) if err != nil { - log.WithError(err).Error("Invalid argument; could not parse provided selectors") - return status.Error(codes.InvalidArgument, "could not parse provided selectors") + return err } log.WithFields(logrus.Fields{ @@ -290,6 +338,18 @@ func (s *Service) SubscribeToX509Bundles(_ *delegatedidentityv1.SubscribeToX509B } } +// Attempt to attest and authorize the delegate, and then +// +// - Take a pre-atttested set of selectors from the delegate +// - the PID the delegate gave us and attempt to attest that into a set of selectors +// +// and provide a JWT SVID for those selectors. +// +// NOTE: +// - If supplying a PID, the trusted delegate is responsible for ensuring the PID is valid and not recycled, +// from initiation of this call until the response is returned, and if it is, +// must discard any response provided by this call as invalid. +// - If supplying selectors, the trusted delegate is responsible for ensuring they are correct. func (s *Service) FetchJWTSVIDs(ctx context.Context, req *delegatedidentityv1.FetchJWTSVIDsRequest) (resp *delegatedidentityv1.FetchJWTSVIDsResponse, err error) { log := rpccontext.Logger(ctx) if len(req.Audience) == 0 { @@ -301,10 +361,9 @@ func (s *Service) FetchJWTSVIDs(ctx context.Context, req *delegatedidentityv1.Fe return nil, err } - selectors, err := api.SelectorsFromProto(req.Selectors) + selectors, err := s.constructValidSelectorsFromReq(ctx, log, req.Pid, req.Selectors) if err != nil { - log.WithError(err).Error("Invalid argument; could not parse provided selectors") - return nil, status.Error(codes.InvalidArgument, "could not parse provided selectors") + return nil, err } resp = new(delegatedidentityv1.FetchJWTSVIDsResponse) diff --git a/pkg/agent/api/delegatedidentity/v1/service_test.go b/pkg/agent/api/delegatedidentity/v1/service_test.go index d1df00596f..ea1d9f68a9 100644 --- a/pkg/agent/api/delegatedidentity/v1/service_test.go +++ b/pkg/agent/api/delegatedidentity/v1/service_test.go @@ -79,6 +79,7 @@ func TestSubscribeToX509SVIDs(t *testing.T) { managerErr error expectMetrics []fakemetrics.MetricItem expectResp *delegatedidentityv1.SubscribeToX509SVIDsResponse + req *delegatedidentityv1.SubscribeToX509SVIDsRequest }{ { testName: "attest error", @@ -86,6 +87,32 @@ func TestSubscribeToX509SVIDs(t *testing.T) { expectCode: codes.Internal, expectMsg: "workload attestation failed", }, + { + testName: "incorrectly populate both pid and selectors", + authSpiffeID: []string{"spiffe://example.org/one"}, + identities: []cache.Identity{ + identities[0], + }, + expectCode: codes.InvalidArgument, + expectMsg: "must provide either selectors or non-zero PID, but not both", + req: &delegatedidentityv1.SubscribeToX509SVIDsRequest{ + Selectors: []*types.Selector{{Type: "sa", Value: "foo"}}, + Pid: 447, + }, + }, + { + testName: "incorrectly populate neither pid or selectors", + authSpiffeID: []string{"spiffe://example.org/one"}, + identities: []cache.Identity{ + identities[0], + }, + expectCode: codes.InvalidArgument, + expectMsg: "must provide either selectors or non-zero PID, but not both", + req: &delegatedidentityv1.SubscribeToX509SVIDsRequest{ + Selectors: []*types.Selector{}, + Pid: 0, + }, + }, { testName: "access to \"privileged\" admin API denied", authSpiffeID: []string{"spiffe://example.org/one/wrong"}, @@ -194,6 +221,40 @@ func TestSubscribeToX509SVIDs(t *testing.T) { expectMsg: "could not serialize response", expectMetrics: generateSubscribeToX509SVIDMetrics(), }, + { + testName: "workload update by PID with identity and federated bundles", + authSpiffeID: []string{"spiffe://example.org/one"}, + req: &delegatedidentityv1.SubscribeToX509SVIDsRequest{ + Pid: 447, + }, + identities: []cache.Identity{ + identities[0], + }, + updates: []*cache.WorkloadUpdate{ + { + Identities: []cache.Identity{ + identities[0], + }, + Bundle: bundle, + FederatedBundles: map[spiffeid.TrustDomain]*spiffebundle.Bundle{ + federatedBundle1.TrustDomain(): federatedBundle1}, + }, + }, + expectResp: &delegatedidentityv1.SubscribeToX509SVIDsResponse{ + X509Svids: []*delegatedidentityv1.X509SVIDWithKey{ + { + X509Svid: &types.X509SVID{ + Id: utilIDProtoFromString(t, x509SVID1.ID.String()), + CertChain: x509util.RawCertsFromCertificates(x509SVID1.Certificates), + ExpiresAt: x509SVID1.Certificates[0].NotAfter.Unix(), + }, + X509SvidKey: pkcs8FromSigner(t, x509SVID1.PrivateKey), + }, + }, + FederatesWith: []string{federatedBundle1.TrustDomain().IDString()}, + }, + expectMetrics: generateSubscribeToX509SVIDMetrics(), + }, { testName: "workload update with identity and federated bundles", authSpiffeID: []string{"spiffe://example.org/one"}, @@ -271,22 +332,24 @@ func TestSubscribeToX509SVIDs(t *testing.T) { ManagerErr: tt.managerErr, Metrics: metrics, } - runTest(t, params, - func(ctx context.Context, client delegatedidentityv1.DelegatedIdentityClient) { - selectors := []*types.Selector{{Type: "sa", Value: "foo"}} - req := &delegatedidentityv1.SubscribeToX509SVIDsRequest{ - Selectors: selectors, - } - - stream, err := client.SubscribeToX509SVIDs(ctx, req) - - require.NoError(t, err) - resp, err := stream.Recv() - - spiretest.RequireGRPCStatus(t, err, tt.expectCode, tt.expectMsg) - spiretest.RequireProtoEqual(t, tt.expectResp, resp) - require.Equal(t, tt.expectMetrics, metrics.AllMetrics()) - }) + runTest(t, params, func(ctx context.Context, client delegatedidentityv1.DelegatedIdentityClient) { + req := &delegatedidentityv1.SubscribeToX509SVIDsRequest{ + Selectors: []*types.Selector{{Type: "sa", Value: "foo"}}, + } + // if test params has a custom request, prefer that + if tt.req != nil { + req = tt.req + } + + stream, err := client.SubscribeToX509SVIDs(ctx, req) + + require.NoError(t, err) + resp, err := stream.Recv() + + spiretest.RequireGRPCStatus(t, err, tt.expectCode, tt.expectMsg) + spiretest.RequireProtoEqual(t, tt.expectResp, resp) + require.Equal(t, tt.expectMetrics, metrics.AllMetrics()) + }) }) } } @@ -409,6 +472,7 @@ func TestFetchJWTSVIDs(t *testing.T) { authSpiffeID []string audience []string selectors []*types.Selector + pid int32 expectCode codes.Code expectMsg string attestErr error @@ -427,6 +491,30 @@ func TestFetchJWTSVIDs(t *testing.T) { expectCode: codes.Internal, expectMsg: "workload attestation failed", }, + { + testName: "incorrectly populate both pid and selectors", + authSpiffeID: []string{"spiffe://example.org/one"}, + selectors: []*types.Selector{{Type: "sa", Value: "foo"}}, + pid: 447, + audience: []string{"AUDIENCE"}, + identities: []cache.Identity{ + identities[0], + }, + expectCode: codes.InvalidArgument, + expectMsg: "must provide either selectors or non-zero PID, but not both", + }, + { + testName: "incorrectly populate neither pid or selectors", + authSpiffeID: []string{"spiffe://example.org/one"}, + selectors: []*types.Selector{}, + pid: 0, + audience: []string{"AUDIENCE"}, + identities: []cache.Identity{ + identities[0], + }, + expectCode: codes.InvalidArgument, + expectMsg: "must provide either selectors or non-zero PID, but not both", + }, { testName: "Access to \"privileged\" admin API denied", authSpiffeID: []string{"spiffe://example.org/one/wrong"}, @@ -509,6 +597,33 @@ func TestFetchJWTSVIDs(t *testing.T) { }, }, }, + { + testName: "success with one identity by PID", + pid: 447, + authSpiffeID: []string{"spiffe://example.org/one"}, + audience: []string{"AUDIENCE"}, + identities: []cache.Identity{ + identities[0], + }, + jwtSVIDsResp: map[spiffeid.ID]*client.JWTSVID{ + id1: { + Token: jwtSVID1Token, + ExpiresAt: time.Unix(1680786600, 0), + IssuedAt: time.Unix(1680783000, 0), + }, + }, + expectResp: &delegatedidentityv1.FetchJWTSVIDsResponse{ + Svids: []*types.JWTSVID{ + { + Token: jwtSVID1Token, + Id: api.ProtoFromID(id1), + Hint: "internal", + ExpiresAt: 1680786600, + IssuedAt: 1680783000, + }, + }, + }, + }, { testName: "success with two identities", authSpiffeID: []string{"spiffe://example.org/one"}, @@ -562,6 +677,7 @@ func TestFetchJWTSVIDs(t *testing.T) { resp, err := client.FetchJWTSVIDs(ctx, &delegatedidentityv1.FetchJWTSVIDsRequest{ Audience: tt.audience, Selectors: tt.selectors, + Pid: tt.pid, }) spiretest.RequireGRPCStatus(t, err, tt.expectCode, tt.expectMsg) @@ -578,6 +694,7 @@ func TestFetchJWTSVIDs(t *testing.T) { }) } } + func TestSubscribeToJWTBundles(t *testing.T) { ca := testca.New(t, trustDomain1) @@ -707,7 +824,11 @@ func runTest(t *testing.T, params testParams, fn func(ctx context.Context, clien AuthorizedDelegates: params.AuthSpiffeID, }) - service.attestor = FakeAttestor{ + service.peerAttestor = FakeAttestor{ + err: params.AttestErr, + } + + service.delegateWorkloadAttestor = FakeWorkloadPIDAttestor{ err: params.AttestErr, } @@ -739,6 +860,15 @@ func (fa FakeAttestor) Attest(context.Context) ([]*common.Selector, error) { return fa.selectors, fa.err } +type FakeWorkloadPIDAttestor struct { + selectors []*common.Selector + err error +} + +func (fa FakeWorkloadPIDAttestor) Attest(_ context.Context, _ int) ([]*common.Selector, error) { + return fa.selectors, fa.err +} + type FakeManager struct { manager.Manager