diff --git a/conf/server/server_full.conf b/conf/server/server_full.conf index 06d57bb52b2..8444c49b4dc 100644 --- a/conf/server/server_full.conf +++ b/conf/server/server_full.conf @@ -8,7 +8,7 @@ server { # domain as the server and need not have a corresponding admin registration # entry with the server. # admin_ids = ["spiffe://example.org/my/admin"] - + # bind_address: IP address or DNS name of the SPIRE server. # Default: 0.0.0.0. bind_address = "127.0.0.1" @@ -145,7 +145,7 @@ server { # default_svid_ttl: The default SVID TTL. Default: 1h. # default_svid_ttl = "1h" - + # omit_x509svid_uid: If true, the subject on X509-SVIDs will not contain # the unique ID attribute. This configurable is deprecated and will be # removed from a future release. @@ -329,10 +329,9 @@ plugins { # # app_secret = "" # # } # # } - # } - - # # } - # # } + # # agent_path_template: A URL path portion format of Agent's SPIFFE ID. + # # Describe in text/template format. + # # agent_path_template = "" # } # } diff --git a/doc/plugin_server_nodeattestor_azure_msi.md b/doc/plugin_server_nodeattestor_azure_msi.md index 90a10785f30..6a98574922a 100644 --- a/doc/plugin_server_nodeattestor_azure_msi.md +++ b/doc/plugin_server_nodeattestor_azure_msi.md @@ -17,20 +17,22 @@ attestation or to resolve selectors. ## Configuration -| Configuration | Required | Description | Default | -| --------------- | ----------- | ----------------------- | -| `tenants` | Required | A map of tenants, keyed by tenant ID, that are authorized for attestation. Tokens for unspecified tenants are rejected. | | +| Configuration | Required | Description | Default | +|-----------------------|----------|-------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| +| `tenants` | Required | A map of tenants, keyed by tenant ID, that are authorized for attestation. Tokens for unspecified tenants are rejected. | | +| `agent_path_template` | Optional | A URL path portion format of Agent's SPIFFE ID. Describe in text/template format. | `"/{{ .PluginName }}/{{ .TenantID }}/{{ .PrincipalID }}"` | + Each tenant in the main configuration supports the following -| Configuration | Required | Description | Default | -| ----------------- | ----------- | ----------------------- | +| Configuration | Required | Description | Default | +|-------------------|--------------------------------------|-----------------------------------------------------------------------------------------------------------|-------------------------------| | `resource_id` | Optional | The resource ID (or audience) for the tenant's MSI token. Tokens for a different resource ID are rejected | https://management.azure.com/ | -| `use_msi` | [Optional](#authenticating-to-azure) | Whether or not to use MSI to authenticate to Azure services for selector resolution. | false | -| `subscription_id` | [Optional](#authenticating-to-azure) | The subscription the tenant resides in | | -| `app_id` | [Optional](#authenticating-to-azure) | The application id | | -| `app_secret` | [Optional](#authenticating-to-azure) | The application secret | | +| `use_msi` | [Optional](#authenticating-to-azure) | Whether or not to use MSI to authenticate to Azure services for selector resolution. | false | +| `subscription_id` | [Optional](#authenticating-to-azure) | The subscription the tenant resides in | | +| `app_id` | [Optional](#authenticating-to-azure) | The application id | | +| `app_secret` | [Optional](#authenticating-to-azure) | The application secret | | It is important to note that the resource ID MUST be for a well known Azure service, or an app ID for a registered app in Azure AD. Azure will not issue an @@ -97,6 +99,18 @@ The plugin produces the following selectors. All of the selectors have the type `azure_msi`. +## Agent Path Template +The agent path template is a way of customizing the format of generated SPIFFE IDs for agents. +The template formatter is using Golang text/template conventions, it can reference values provided by the plugin or in a [MSI access token](https://learn.microsoft.com/en-us/azure/active-directory/develop/access-tokens#payload-claims). + +Some useful values are: + +| Value | Description | +|-----------------------|------------------------------------------------------------| +| .PluginName | The name of the plugin | +| .TenantID | Azure tenant identifier | +| .PrincipalID | A identifier that is unique to a particular application ID | + ## Security Considerations The Azure Managed Service Identity token, which this attestor leverages to prove node identity, is available to any process running on the node by default. As a result, it is possible for non-agent code running on a node to attest to the SPIRE Server, allowing it to obtain any workload identity that the node is authorized to run. diff --git a/doc/plugin_server_nodeattestor_gcp_iit.md b/doc/plugin_server_nodeattestor_gcp_iit.md index 71659b34931..7dad8bc449b 100644 --- a/doc/plugin_server_nodeattestor_gcp_iit.md +++ b/doc/plugin_server_nodeattestor_gcp_iit.md @@ -8,14 +8,15 @@ This plugin requires an allow list of ProjectID from which nodes can be attested ## Configuration -| Configuration | Description | Default | -|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------| -| `projectid_allow_list` | List of ProjectIDs from which nodes can be attested. | | -| `use_instance_metadata` | If true, instance metadata is fetched from the Google Compute Engine API and used to augment the node selectors produced by the plugin. | false | -| `service_account_file` | Path to the service account file used to authenticate with the Google Compute Engine API | | -| `allowed_label_keys` | Instance label keys considered for selectors | | -| `allowed_metadata_keys` | Instance metadata keys considered for selectors | | -| `max_metadata_value_size` | Sets the maximum metadata value size considered by the plugin for selectors | 128 | +| Configuration | Description | Default | +|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| +| `projectid_allow_list` | List of ProjectIDs from which nodes can be attested. | | +| `use_instance_metadata` | If true, instance metadata is fetched from the Google Compute Engine API and used to augment the node selectors produced by the plugin. | false | +| `service_account_file` | Path to the service account file used to authenticate with the Google Compute Engine API | | +| `allowed_label_keys` | Instance label keys considered for selectors | | +| `allowed_metadata_keys` | Instance metadata keys considered for selectors | | +| `max_metadata_value_size` | Sets the maximum metadata value size considered by the plugin for selectors | 128 | +| `agent_path_template` | A URL path portion format of Agent's SPIFFE ID. Describe in text/template format. | `"/{{ .PluginName }}/{{ .ProjectID }}/{{ .InstanceID }}"` | A sample configuration: @@ -31,11 +32,11 @@ A sample configuration: This plugin generates the following selectors based on information contained in the Instance Identity Token: -| Selector | Example | Description | -| -------------------------- | ------------------------------------------------------------ | ----------------------------------------- | -| `gcp_iit:project-id` | `gcp_iit:project-id:big-kahuna-123456` | ID of the project containing the instance | -| `gcp_iit:zone` | `gcp_iit:zone:us-west1-b` | Zone containing the instance | -| `gcp_iit:instance-name` | `gcp_iit:instance-name:blog-server` | Name of the instance | +| Selector | Example | Description | +|-------------------------|----------------------------------------|-------------------------------------------| +| `gcp_iit:project-id` | `gcp_iit:project-id:big-kahuna-123456` | ID of the project containing the instance | +| `gcp_iit:zone` | `gcp_iit:zone:us-west1-b` | Zone containing the instance | +| `gcp_iit:instance-name` | `gcp_iit:instance-name:blog-server` | Name of the instance | If `use_instance_metadata` is true, then the Google Compute Engine API is queried for instance metadata which is used to populate these additional selectors: @@ -67,6 +68,22 @@ The plugin uses the Application Default Credentials to authenticate with the Goo The service account must have IAM permissions and Authorization Scopes granting access to the following APIs: * [compute.instances.get](https://cloud.google.com/compute/docs/reference/rest/v1/instances/get) +## Agent Path Template +The agent path template is a way of customizing the format of generated SPIFFE IDs for agents. +The template formatter is using Golang text/template conventions, it can reference values provided by the plugin or in a [Compute Engine identity token](https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload). + +Some useful values are: + +| Value | Description | +|----------------------------|------------------------------------------------------------------| +| .PluginName | The name of the plugin | +| .ProjectID | The ID for the project where the instance was created | +| .InstanceID | The unique ID for the instance to which this token belongs. | +| .ProjectNumber | The unique number for the project where you created the instance | +| .Zone | The zone where the instance is located | +| .InstanceCreationTimestamp | A Unix timestamp indicating when you created the instance. | + + ## Security Considerations The Instance Identity Token, which this attestor leverages to prove node identity, is available to any process running on the node by default. As a result, it is possible for non-agent code running on a node to attest to the SPIRE Server, allowing it to obtain any workload identity that the node is authorized to run. diff --git a/pkg/common/plugin/azure/msi.go b/pkg/common/plugin/azure/msi.go index 4868b144bbf..201d36815ee 100644 --- a/pkg/common/plugin/azure/msi.go +++ b/pkg/common/plugin/azure/msi.go @@ -5,9 +5,10 @@ import ( "encoding/json" "io" "net/http" - "net/url" - "path" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/agentpathtemplate" + "github.com/spiffe/spire/pkg/common/idutil" "github.com/zeebo/errs" "gopkg.in/square/go-jose.v2/jwt" ) @@ -17,8 +18,12 @@ const ( // audience of the MSI token. The current value is the service ID for the // Resource Manager API. DefaultMSIResourceID = "https://management.azure.com/" + PluginName = "azure_msi" ) +// DefaultAgentPathTemplate is the default text/template +var DefaultAgentPathTemplate = agentpathtemplate.MustParse("/{{ .PluginName }}/{{ .TenantID }}/{{ .PrincipalID }}") + type ComputeMetadata struct { Name string `json:"name"` SubscriptionID string `json:"subscriptionId"` @@ -35,16 +40,8 @@ type MSIAttestationData struct { type MSITokenClaims struct { jwt.Claims - TenantID string `json:"tid,omitempty"` -} - -func (c *MSITokenClaims) AgentID(trustDomain string) string { - u := url.URL{ - Scheme: "spiffe", - Host: trustDomain, - Path: path.Join("spire", "agent", "azure_msi", c.TenantID, c.Subject), - } - return u.String() + TenantID string `json:"tid,omitempty"` + PrincipalID string `json:"sub,omitempty"` } type HTTPClient interface { @@ -125,6 +122,23 @@ func FetchInstanceMetadata(ctx context.Context, cl HTTPClient) (*InstanceMetadat return metadata, nil } +type agentPathTemplateData struct { + MSITokenClaims + PluginName string +} + +func MakeAgentID(td spiffeid.TrustDomain, agentPathTemplate *agentpathtemplate.Template, claims *MSITokenClaims) (spiffeid.ID, error) { + agentPath, err := agentPathTemplate.Execute(agentPathTemplateData{ + MSITokenClaims: *claims, + PluginName: PluginName, + }) + if err != nil { + return spiffeid.ID{}, err + } + + return idutil.AgentID(td, agentPath) +} + func tryRead(r io.Reader) string { b := make([]byte, 1024) n, _ := r.Read(b) diff --git a/pkg/common/plugin/azure/msi_test.go b/pkg/common/plugin/azure/msi_test.go index f134e94b748..c8af4d87e28 100644 --- a/pkg/common/plugin/azure/msi_test.go +++ b/pkg/common/plugin/azure/msi_test.go @@ -2,26 +2,20 @@ package azure import ( "context" + "errors" "fmt" "io" "net/http" "strings" "testing" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/agentpathtemplate" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2/jwt" ) -func TestMSITokenClaims(t *testing.T) { - claims := MSITokenClaims{ - Claims: jwt.Claims{ - Subject: "PRINCIPALID", - }, - TenantID: "TENANTID", - } - require.Equal(t, "spiffe://example.org/spire/agent/azure_msi/TENANTID/PRINCIPALID", claims.AgentID("example.org")) -} - func TestFetchMSIToken(t *testing.T) { ctx := context.Background() @@ -114,6 +108,75 @@ func TestFetchInstanceMetadata(t *testing.T) { require.Equal(t, expected, metadata) } +func TestMakeAgentID(t *testing.T) { + type args struct { + td string + agentPathTemplate string + claims *MSITokenClaims + } + tests := []struct { + name string + args args + want string + errWanted error + }{ + { + name: "successfully applies template", + args: args{ + td: "example.org", + agentPathTemplate: "/{{ .PluginName }}/{{ .TenantID }}/{{ .PrincipalID }}", + claims: &MSITokenClaims{ + Claims: jwt.Claims{}, + TenantID: "TENANTID", + PrincipalID: "PRINCIPALID", + }, + }, + want: "spiffe://example.org/spire/agent/azure_msi/TENANTID/PRINCIPALID", + errWanted: nil, + }, + { + name: "error applying template with non-existent field", + args: args{ + td: "example.org", + agentPathTemplate: "/{{ .PluginName }}/{{ .TenantID }}/{{ .NonExistent }}", + claims: &MSITokenClaims{ + Claims: jwt.Claims{}, + TenantID: "TENANTID", + PrincipalID: "PRINCIPALID", + }, + }, + want: "", + errWanted: errors.New("template: agent-path:1:38: executing \"agent-path\" at <.NonExistent>: can't evaluate field NonExistent in type azure.agentPathTemplateData"), + }, + { + name: "error building agent ID with invalid path", + args: args{ + td: "example.org", + agentPathTemplate: "/{{ .PluginName }}/{{ .TenantID }}/{{ .PrincipalID }}", + claims: &MSITokenClaims{ + Claims: jwt.Claims{}, + }, + }, + want: "", + errWanted: errors.New("invalid agent path suffix \"/azure_msi//\": path cannot contain empty segments"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + td := spiffeid.RequireTrustDomainFromString(test.args.td) + agentPathTemplate, _ := agentpathtemplate.Parse(test.args.agentPathTemplate) + got, err := MakeAgentID(td, agentPathTemplate, test.args.claims) + if test.errWanted != nil { + require.EqualError(t, err, test.errWanted.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, test.want, got.String()) + }) + } +} + func fakeTokenHTTPClient(statusCode int, body string) HTTPClient { return HTTPClientFunc(func(req *http.Request) (*http.Response, error) { // assert the expected request values diff --git a/pkg/server/plugin/nodeattestor/azuremsi/msi.go b/pkg/server/plugin/nodeattestor/azuremsi/msi.go index 0ff13cc3ad8..a4cfbc1da1d 100644 --- a/pkg/server/plugin/nodeattestor/azuremsi/msi.go +++ b/pkg/server/plugin/nodeattestor/azuremsi/msi.go @@ -20,6 +20,7 @@ import ( "github.com/spiffe/go-spiffe/v2/spiffeid" nodeattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/nodeattestor/v1" configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/agentpathtemplate" "github.com/spiffe/spire/pkg/common/catalog" "github.com/spiffe/spire/pkg/common/jwtutil" "github.com/spiffe/spire/pkg/common/plugin/azure" @@ -69,7 +70,8 @@ type TenantConfig struct { } type MSIAttestorConfig struct { - Tenants map[string]*TenantConfig `hcl:"tenants" json:"tenants"` + Tenants map[string]*TenantConfig `hcl:"tenants" json:"tenants"` + AgentPathTemplate string `hcl:"agent_path_template" json:"agent_path_template"` } type tenantConfig struct { @@ -78,8 +80,9 @@ type tenantConfig struct { } type msiAttestorConfig struct { - td spiffeid.TrustDomain - tenants map[string]*tenantConfig + td spiffeid.TrustDomain + tenants map[string]*tenantConfig + idPathTemplate *agentpathtemplate.Template } type MSIAttestorPlugin struct { @@ -168,17 +171,22 @@ func (p *MSIAttestorPlugin) Attest(stream nodeattestorv1.NodeAttestor_AttestServ if err := token.Claims(&keys[0], claims); err != nil { return status.Errorf(codes.InvalidArgument, "unable to verify token: %v", err) } + switch { case claims.TenantID == "": return status.Error(codes.Internal, "token missing tenant ID claim") - case claims.Subject == "": + case claims.PrincipalID == "": return status.Error(codes.Internal, "token missing subject claim") } // Before doing the work to validate the token, ensure that this MSI token // has not already been used to attest an agent. - agentID := claims.AgentID(config.td.String()) - if err := p.AssessTOFU(stream.Context(), agentID, p.log); err != nil { + agentID, err := azure.MakeAgentID(config.td, config.idPathTemplate, claims) + if err != nil { + return status.Errorf(codes.Internal, "unable to make agent ID: %v", err) + } + + if err := p.AssessTOFU(stream.Context(), agentID.String(), p.log); err != nil { return err } @@ -196,7 +204,7 @@ func (p *MSIAttestorPlugin) Attest(stream nodeattestorv1.NodeAttestor_AttestServ var selectorValues []string if tenant.client != nil { - selectorValues, err = p.resolve(stream.Context(), tenant.client, claims.Subject) + selectorValues, err = p.resolve(stream.Context(), tenant.client, claims.PrincipalID) if err != nil { return err } @@ -205,7 +213,7 @@ func (p *MSIAttestorPlugin) Attest(stream nodeattestorv1.NodeAttestor_AttestServ return stream.Send(&nodeattestorv1.AttestResponse{ Response: &nodeattestorv1.AttestResponse_AgentAttributes{ AgentAttributes: &nodeattestorv1.AgentAttributes{ - SpiffeId: agentID, + SpiffeId: agentID.String(), CanReattest: false, SelectorValues: selectorValues, }, @@ -297,9 +305,19 @@ func (p *MSIAttestorPlugin) Configure(ctx context.Context, req *configv1.Configu } } + tmpl := azure.DefaultAgentPathTemplate + if len(hclConfig.AgentPathTemplate) > 0 { + var err error + tmpl, err = agentpathtemplate.Parse(hclConfig.AgentPathTemplate) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse agent path template: %q", hclConfig.AgentPathTemplate) + } + } + p.setConfig(&msiAttestorConfig{ - td: td, - tenants: tenants, + td: td, + tenants: tenants, + idPathTemplate: tmpl, }) return &configv1.ConfigureResponse{}, nil } diff --git a/pkg/server/plugin/nodeattestor/azuremsi/msi_test.go b/pkg/server/plugin/nodeattestor/azuremsi/msi_test.go index 39cc16d0845..2a1b6a00d75 100644 --- a/pkg/server/plugin/nodeattestor/azuremsi/msi_test.go +++ b/pkg/server/plugin/nodeattestor/azuremsi/msi_test.go @@ -235,6 +235,44 @@ func (s *MSIAttestorSuite) TestAttestSuccessWithCustomResourceID() { vmSelectors) } +func (s *MSIAttestorSuite) TestAttestSuccessWithCustomSPIFFEIDTemplate() { + s.setVirtualMachine(&armcompute.VirtualMachine{ + Properties: &armcompute.VirtualMachineProperties{}, + }) + + payload := s.signAttestPayload("KEYID", resourceID, "TENANTID", "PRINCIPALID") + + selectorValues := append([]string{}, vmSelectors...) + sort.Strings(selectorValues) + + var expected []*common.Selector + for _, selectorValue := range selectorValues { + expected = append(expected, &common.Selector{ + Type: "azure_msi", + Value: selectorValue, + }) + } + + attestorWithCustomAgentTemplate := s.loadPluginWithConfig( + ` + tenants = { + "TENANTID" = { + resource_id = "https://example.org/app/" + use_msi = true + } + "TENANTID2" = { + use_msi = true + } + } + agent_path_template = "/{{ .PluginName }}/{{ .TenantID }}" + `) + resp, err := attestorWithCustomAgentTemplate.Attest(context.Background(), payload, expectNoChallenge) + s.Require().NoError(err) + s.Require().NotNil(resp) + s.Require().Equal("spiffe://example.org/spire/agent/azure_msi/TENANTID", resp.AgentID) + s.RequireProtoListEqual(expected, resp.Selectors) +} + func (s *MSIAttestorSuite) TestAttestSuccessWithNoClientCredentials() { s.attestor = s.loadPlugin(plugintest.Configure(` tenants = { @@ -586,6 +624,20 @@ func (s *MSIAttestorSuite) signAttestPayload(keyID, audience, tenantID, principa } func (s *MSIAttestorSuite) loadPlugin(options ...plugintest.Option) nodeattestor.NodeAttestor { + return s.loadPluginWithConfig(` + tenants = { + "TENANTID" = { + resource_id = "https://example.org/app/" + use_msi = true + } + "TENANTID2" = { + use_msi = true + } + } + `, options...) +} + +func (s *MSIAttestorSuite) loadPluginWithConfig(config string, options ...plugintest.Option) nodeattestor.NodeAttestor { attestor := New() attestor.hooks.now = func() time.Time { return s.now @@ -609,17 +661,7 @@ func (s *MSIAttestorSuite) loadPlugin(options ...plugintest.Option) nodeattestor plugintest.CoreConfig(catalog.CoreConfig{ TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), }), - plugintest.Configure(` - tenants = { - "TENANTID" = { - resource_id = "https://example.org/app/" - use_msi = true - } - "TENANTID2" = { - use_msi = true - } - } - `), + plugintest.Configure(config), }, options...)...) return v1 }