From 87c9b9470bbd290441a6009e4225433c545c7fc0 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Mon, 19 Jan 2026 09:22:04 -0700 Subject: [PATCH] VAULT-41681: SSH certificate observations (#11811) (#11834) * ssh observations and tests * remove unnecessary comments * add metadata in comments * add more assertions, fix test * fix test Co-authored-by: miagilepner --- builtin/logical/ssh/backend_test.go | 134 +++++++++++++++--- builtin/logical/ssh/observation_consts.go | 24 ++++ .../ssh/path_cleanup_dynamic_host_keys.go | 4 + builtin/logical/ssh/path_config_ca.go | 13 ++ builtin/logical/ssh/path_issue.go | 12 +- builtin/logical/ssh/path_issue_sign.go | 33 +++-- builtin/logical/ssh/path_sign.go | 20 ++- 7 files changed, 202 insertions(+), 38 deletions(-) diff --git a/builtin/logical/ssh/backend_test.go b/builtin/logical/ssh/backend_test.go index 510287bed7..a5b89b2ab9 100644 --- a/builtin/logical/ssh/backend_test.go +++ b/builtin/logical/ssh/backend_test.go @@ -135,6 +135,14 @@ SjOQL/GkH1nkRcDS9++aAAAAAmNhAQID dockerImageTagSupportsNoRSA1 = "8.4_p1-r3-ls48" ) +var caObservationFields = []string{ + "ttl", "max_ttl", "allow_user_certificates", "allow_host_certificates", + "allow_bare_domains", "allow_subdomains", "allow_user_key_ids", + "allowed_users_template", "allowed_domains_template", "default_user_template", + "default_extensions_template", "algorithm_signer", "not_before_duration", + "allow_empty_principals", +} + var ctx = context.Background() func prepareTestContainer(t *testing.T, tag, caPublicKeyPEM string) (func(), string) { @@ -773,10 +781,11 @@ func TestSSHBackend_VerifyEcho(t *testing.T) { expectedData := map[string]interface{}{ "message": api.VerifyEchoResponse, } + obsRecorder := observations.NewTestObservationRecorder() logicaltest.Test(t, logicaltest.TestCase{ - LogicalFactory: newTestingFactory(t, nil), + LogicalFactory: newTestingFactory(t, obsRecorder), Steps: []logicaltest.TestStep{ - testVerifyWrite(t, verifyData, expectedData), + testVerifyWrite(t, verifyData, expectedData, obsRecorder), }, }) } @@ -1007,7 +1016,7 @@ cKumubUxOfFdy1ZvAAAAEm5jY0BtYnAudWJudC5sb2NhbA== testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(caPublicKey, caPrivateKey), + configCaStep(caPublicKey, caPrivateKey, obsRecorder), testRoleWrite(t, "testcarole", roleOptions, obsRecorder), { Operation: logical.UpdateOperation, @@ -1050,19 +1059,27 @@ cKumubUxOfFdy1ZvAAAAEm5jY0BtYnAudWJudC5sb2NhbA== if !expectError && err != nil { return err } + + obs := obsRecorder.LastObservationOfType(ObservationTypeSSHSign) + if obs == nil { + return errors.New("no SSH sign observation recorded") + } + if obs.Data["role_name"] != "testcarole" { + return fmt.Errorf("expected role_name %q, got %q", "testcarole", obs.Data["role_name"]) + } return nil }, }, - testIssueCert("testcarole", "ec", testUserName, sshAddress, expectError), - testIssueCert("testcarole", "ed25519", testUserName, sshAddress, expectError), - testIssueCert("testcarole", "rsa", testUserName, sshAddress, expectError), + testIssueCert("testcarole", "ec", testUserName, sshAddress, expectError, obsRecorder), + testIssueCert("testcarole", "ed25519", testUserName, sshAddress, expectError, obsRecorder), + testIssueCert("testcarole", "rsa", testUserName, sshAddress, expectError, obsRecorder), }, } logicaltest.Test(t, testCase) } -func testIssueCert(role string, keyType string, testUserName string, sshAddress string, expectError bool) logicaltest.TestStep { +func testIssueCert(role string, keyType string, testUserName string, sshAddress string, expectError bool, obsRecorder *observations.TestObservationRecorder) logicaltest.TestStep { return logicaltest.TestStep{ Operation: logical.UpdateOperation, Path: "issue/" + role, @@ -1105,6 +1122,32 @@ func testIssueCert(role string, keyType string, testUserName string, sshAddress return err } + if obsRecorder == nil { + return nil + } + obs := obsRecorder.LastObservationOfType(ObservationTypeSSHIssue) + if obs == nil { + return errors.New("no SSH issue observation recorded") + } + if obs.Data["role_name"] != role { + return fmt.Errorf("expected role_name %q, got %q", role, obs.Data["role_name"]) + } + if obs.Data["key_type"] == nil { + return fmt.Errorf("missing key_type in observation metadata") + } + if obs.Data["certificate_type"] == nil { + return fmt.Errorf("missing certificate_type in observation metadata") + } + if obs.Data["serial_number"] == nil { + return fmt.Errorf("missing serial_number in observation metadata") + } + if obs.Data["key_id"] == nil { + return fmt.Errorf("missing key_id in observation metadata") + } + if _, exists := obs.Data["ttl"]; !exists { + return fmt.Errorf("missing ttl in observation metadata") + } + return nil }, } @@ -1186,7 +1229,7 @@ cKumubUxOfFdy1ZvAAAAEm5jY0BtYnAudWJudC5sb2NhbA== testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, obsRecorder), testRoleWrite(t, "testcarole", roleOptionsOldEntry, obsRecorder), testRoleWrite(t, "testcarole", roleOptionsUpgradedEntry, obsRecorder), { @@ -1243,7 +1286,7 @@ func TestBackend_AbleToRetrievePublicKey(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), { Operation: logical.ReadOperation, @@ -1325,7 +1368,7 @@ func TestBackend_ValidPrincipalsValidatedForHostCertificates(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("testing", map[string]interface{}{ "key_type": "ca", @@ -1368,7 +1411,7 @@ func TestBackend_OptionsOverrideDefaults(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("testing", map[string]interface{}{ "key_type": "ca", @@ -1415,7 +1458,7 @@ func TestBackend_EmptyPrincipals(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("no_user_principals", map[string]interface{}{ "key_type": "ca", "allow_user_certificates": true, @@ -1489,7 +1532,7 @@ func TestBackend_AllowedUserKeyLengths(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("weakkey", map[string]interface{}{ "key_type": "ca", "allow_user_certificates": true, @@ -1662,7 +1705,7 @@ func TestBackend_CustomKeyIDFormat(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("customrole", map[string]interface{}{ "key_type": "ca", @@ -1711,7 +1754,7 @@ func TestBackend_DisallowUserProvidedKeyIDs(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("testing", map[string]interface{}{ "key_type": "ca", @@ -1976,7 +2019,7 @@ func TestSSHBackend_ValidateNotBeforeDuration(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("testing", map[string]interface{}{ "key_type": "ca", @@ -2071,7 +2114,7 @@ func TestSSHBackend_IssueSign(t *testing.T) { testCase := logicaltest.TestCase{ LogicalBackend: b, Steps: []logicaltest.TestStep{ - configCaStep(testCAPublicKey, testCAPrivateKey), + configCaStep(testCAPublicKey, testCAPrivateKey, nil), createRoleStep("testing", map[string]interface{}{ "key_type": "otp", @@ -2304,7 +2347,7 @@ func testAllowedUsersTemplate(t *testing.T, testAllowedUsersTemplate string, ) } -func configCaStep(caPublicKey, caPrivateKey string) logicaltest.TestStep { +func configCaStep(caPublicKey, caPrivateKey string, obsRecorder *observations.TestObservationRecorder) logicaltest.TestStep { return logicaltest.TestStep{ Operation: logical.UpdateOperation, Path: "config/ca", @@ -2312,6 +2355,17 @@ func configCaStep(caPublicKey, caPrivateKey string) logicaltest.TestStep { "public_key": caPublicKey, "private_key": caPrivateKey, }, + Check: func(r *logical.Response) error { + if obsRecorder == nil { + return nil + } + obs := obsRecorder.LastObservationOfType(ObservationTypeSSHConfigCAWrite) + if obs == nil { + return errors.New("no SSH config CA write observation recorded") + } + + return nil + }, } } @@ -2540,7 +2594,7 @@ func testConfigZeroAddressRead(t *testing.T, expected map[string]interface{}, ob } } -func testVerifyWrite(t *testing.T, data map[string]interface{}, expected map[string]interface{}) logicaltest.TestStep { +func testVerifyWrite(t *testing.T, data map[string]interface{}, expected map[string]interface{}, obsRecorder *observations.TestObservationRecorder) logicaltest.TestStep { return logicaltest.TestStep{ Operation: logical.UpdateOperation, Path: fmt.Sprintf("verify"), @@ -2558,6 +2612,17 @@ func testVerifyWrite(t *testing.T, data map[string]interface{}, expected map[str if !reflect.DeepEqual(ac, ex) { return fmt.Errorf("invalid response") } + + if obsRecorder != nil && data["otp"] != api.VerifyEchoRequest { + lastObservation := obsRecorder.LastObservationOfType(ObservationTypeSSHOTPVerify) + if lastObservation == nil { + return fmt.Errorf("missing OTP verify observation") + } + if lastObservation.Data["role_name"] == nil { + return fmt.Errorf("missing role_name in OTP verify observation metadata") + } + } + return nil }, } @@ -2610,6 +2675,15 @@ func testRoleWrite(t *testing.T, name string, data map[string]interface{}, obsRe if lastObservation.Data["key_type"] != data["key_type"] { return fmt.Errorf("invalid observation data: \nactual:%#v\nexpected:%#v", lastObservation.Data["key_type"], data["key_type"]) } + + if data["key_type"] == KeyTypeCA { + for _, field := range caObservationFields { + if _, exists := lastObservation.Data[field]; !exists { + return fmt.Errorf("missing CA-specific field %q in observation metadata for CA role", field) + } + } + } + return nil }, } @@ -2670,6 +2744,15 @@ func testRoleRead(t *testing.T, roleName string, expected map[string]interface{} if lastObservation.Data["key_type"] != d.KeyType { return fmt.Errorf("invalid observation data: \nactual:%#v\nexpected:%#v", lastObservation.Data["key_type"], d.KeyType) } + + if d.KeyType == KeyTypeCA { + for _, field := range caObservationFields { + if _, exists := lastObservation.Data[field]; !exists { + return fmt.Errorf("missing CA-specific field %q in observation metadata for CA role", field) + } + } + } + return nil }, } @@ -2754,12 +2837,14 @@ func testCredsWrite(t *testing.T, roleName string, data map[string]interface{}, if lastObservation == nil { return fmt.Errorf("missing observation") } + if lastObservation.Data["role_name"] != roleName { return fmt.Errorf("invalid observation data: \nactual:%#v\nexpected:%#v", lastObservation.Data["role_name"], roleName) } if lastObservation.Data["key_type"] != KeyTypeOTP { return fmt.Errorf("invalid observation data: \nactual:%#v\nexpected:%#v", lastObservation.Data["key_type"], KeyTypeOTP) } + return nil }, } @@ -2768,6 +2853,8 @@ func testCredsWrite(t *testing.T, roleName string, data map[string]interface{}, func TestBackend_CleanupDynamicHostKeys(t *testing.T) { config := logical.TestBackendConfig() config.StorageView = &logical.InmemStorage{} + obsRecorder := observations.NewTestObservationRecorder() + config.ObservationRecorder = obsRecorder b, err := Backend(config) if err != nil { t.Fatal(err) @@ -2790,6 +2877,9 @@ func TestBackend_CleanupDynamicHostKeys(t *testing.T) { require.NotNil(t, resp.Data) require.NotNil(t, resp.Data["message"]) require.Contains(t, resp.Data["message"], "0 of 0") + obs := obsRecorder.LastObservationOfType(ObservationTypeSSHTidyDynamicKeys) + require.NotNil(t, obs) + require.Equal(t, 0, obs.Data["keys_deleted"]) // Write a bunch of bogus entries. for i := 0; i < 15; i++ { data := map[string]interface{}{ @@ -2809,6 +2899,9 @@ func TestBackend_CleanupDynamicHostKeys(t *testing.T) { require.NotNil(t, resp.Data) require.NotNil(t, resp.Data["message"]) require.Contains(t, resp.Data["message"], "15 of 15") + obs = obsRecorder.LastObservationOfType(ObservationTypeSSHTidyDynamicKeys) + require.NotNil(t, obs) + require.Equal(t, 15, obs.Data["keys_deleted"]) // Should have none left. resp, err = b.HandleRequest(context.Background(), cleanRequest) @@ -2817,6 +2910,9 @@ func TestBackend_CleanupDynamicHostKeys(t *testing.T) { require.NotNil(t, resp.Data) require.NotNil(t, resp.Data["message"]) require.Contains(t, resp.Data["message"], "0 of 0") + obs = obsRecorder.LastObservationOfType(ObservationTypeSSHTidyDynamicKeys) + require.NotNil(t, obs) + require.Equal(t, 0, obs.Data["keys_deleted"]) } type pathAuthCheckerFunc func(t *testing.T, client *api.Client, path string, token string) diff --git a/builtin/logical/ssh/observation_consts.go b/builtin/logical/ssh/observation_consts.go index fc162a23ed..b65a2a5e25 100644 --- a/builtin/logical/ssh/observation_consts.go +++ b/builtin/logical/ssh/observation_consts.go @@ -36,4 +36,28 @@ const ( // ObservationTypeSSHLookup - Metadata: role_names ([]string) ObservationTypeSSHLookup = "ssh/lookup" + + // ObservationTypeSSHConfigCARead - Metadata: none + ObservationTypeSSHConfigCARead = "ssh/config/ca/read" + // ObservationTypeSSHConfigCAWrite - Metadata: conditionally: + // managed_key_name, managed_key_id (if using managed key), or key_type, key_bits (if generating) + ObservationTypeSSHConfigCAWrite = "ssh/config/ca/write" + // ObservationTypeSSHConfigCADelete - Metadata: none + ObservationTypeSSHConfigCADelete = "ssh/config/ca/delete" + + // ObservationTypeSSHSign - Metadata: role_name, key_type, certificate_type, ttl, serial_number, + // key_id, and for CA roles: max_ttl, allow_user_certificates, allow_host_certificates, + // allow_bare_domains, allow_subdomains, allow_user_key_ids, allowed_users_template, + // allowed_domains_template, default_user_template, default_extensions_template, + // algorithm_signer, not_before_duration, allow_empty_principals + ObservationTypeSSHSign = "ssh/certificate/sign" + // ObservationTypeSSHIssue - Metadata: role_name, key_type (from keySpecs), key_bits, + // certificate_type, ttl, serial_number, key_id, and for CA roles: max_ttl, + // allow_user_certificates, allow_host_certificates, allow_bare_domains, allow_subdomains, + // allow_user_key_ids, allowed_users_template, allowed_domains_template, default_user_template, + // default_extensions_template, algorithm_signer, not_before_duration, allow_empty_principals + ObservationTypeSSHIssue = "ssh/certificate/issue" + + // ObservationTypeSSHTidyDynamicKeys - Metadata: keys_deleted (int) + ObservationTypeSSHTidyDynamicKeys = "ssh/tidy/dynamic-keys" ) diff --git a/builtin/logical/ssh/path_cleanup_dynamic_host_keys.go b/builtin/logical/ssh/path_cleanup_dynamic_host_keys.go index 3055cfae4a..317e476f2d 100644 --- a/builtin/logical/ssh/path_cleanup_dynamic_host_keys.go +++ b/builtin/logical/ssh/path_cleanup_dynamic_host_keys.go @@ -42,6 +42,10 @@ func (b *backend) handleCleanupKeys(ctx context.Context, req *logical.Request, d } } + b.Backend.TryRecordObservationWithRequest(ctx, req, ObservationTypeSSHTidyDynamicKeys, map[string]interface{}{ + "keys_deleted": len(names), + }) + return &logical.Response{ Data: map[string]interface{}{ "message": fmt.Sprintf("Removed %v of %v host keys.", len(names), len(names)), diff --git a/builtin/logical/ssh/path_config_ca.go b/builtin/logical/ssh/path_config_ca.go index 64bd6c841a..585172b8dc 100644 --- a/builtin/logical/ssh/path_config_ca.go +++ b/builtin/logical/ssh/path_config_ca.go @@ -134,6 +134,8 @@ func (b *backend) pathConfigCARead(ctx context.Context, req *logical.Request, da return logical.ErrorResponse("keys haven't been configured yet"), nil } + b.Backend.TryRecordObservationWithRequest(ctx, req, ObservationTypeSSHConfigCARead, nil) + response := &logical.Response{ Data: map[string]interface{}{ "public_key": publicKey, @@ -159,6 +161,9 @@ func (b *backend) pathConfigCADelete(ctx context.Context, req *logical.Request, if err := req.Storage.Delete(ctx, caManagedKeyStoragePath); err != nil { return nil, err } + + b.Backend.TryRecordObservationWithRequest(ctx, req, ObservationTypeSSHConfigCADelete, nil) + return nil, nil } @@ -247,12 +252,16 @@ func (b *backend) pathConfigCAUpdate(ctx context.Context, req *logical.Request, generateSigningKey := data.Get("generate_signing_key").(bool) + metadata := make(map[string]interface{}) + if useManagedKey { generateSigningKey = false err = b.createManagedKey(ctx, req.Storage, managedKeyName, managedKeyID) if err != nil { return nil, err } + metadata["managed_key_name"] = managedKeyName + metadata["managed_key_id"] = managedKeyID } else { if publicKey != "" && privateKey != "" { _, err := ssh.ParsePrivateKey([]byte(privateKey)) @@ -272,6 +281,8 @@ func (b *backend) pathConfigCAUpdate(ctx context.Context, req *logical.Request, if err != nil { return nil, err } + metadata["key_type"] = keyType + metadata["key_bits"] = keyBits } else { return logical.ErrorResponse("if generate_signing_key is false, either both public_key and private_key or a managed key must be provided"), nil } @@ -282,6 +293,8 @@ func (b *backend) pathConfigCAUpdate(ctx context.Context, req *logical.Request, } } + b.Backend.TryRecordObservationWithRequest(ctx, req, ObservationTypeSSHConfigCAWrite, metadata) + if generateSigningKey { response := &logical.Response{ Data: map[string]interface{}{ diff --git a/builtin/logical/ssh/path_issue.go b/builtin/logical/ssh/path_issue.go index 9df8e71470..26a2dbfb62 100644 --- a/builtin/logical/ssh/path_issue.go +++ b/builtin/logical/ssh/path_issue.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "errors" "fmt" + "maps" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" @@ -105,10 +106,10 @@ func (b *backend) pathIssue(ctx context.Context, req *logical.Request, data *fra } // Issue certificate - return b.pathIssueCertificate(ctx, req, data, role, keySpecs) + return b.pathIssueCertificate(ctx, req, data, role, keySpecs, roleName) } -func (b *backend) pathIssueCertificate(ctx context.Context, req *logical.Request, data *framework.FieldData, role *sshRole, keySpecs *keySpecs) (*logical.Response, error) { +func (b *backend) pathIssueCertificate(ctx context.Context, req *logical.Request, data *framework.FieldData, role *sshRole, keySpecs *keySpecs, roleName string) (*logical.Response, error) { publicKey, privateKey, err := generateSSHKeyPair(rand.Reader, keySpecs.Type, keySpecs.Bits) if err != nil { return nil, err @@ -120,7 +121,7 @@ func (b *backend) pathIssueCertificate(ctx context.Context, req *logical.Request return logical.ErrorResponse(fmt.Sprintf("failed to parse public_key as SSH key: %s", err)), nil } - response, err := b.pathSignIssueCertificateHelper(ctx, req, data, role, userPublicKey) + response, certMetadata, err := b.pathSignIssueCertificateHelper(ctx, req, data, role, userPublicKey) if err != nil { return nil, err } @@ -132,6 +133,11 @@ func (b *backend) pathIssueCertificate(ctx context.Context, req *logical.Request response.Data["private_key"] = privateKey response.Data["private_key_type"] = keySpecs.Type + metadata := role.observationMetadata(roleName) + metadata["key_type"] = keySpecs.Type + metadata["key_bits"] = keySpecs.Bits + maps.Copy(metadata, certMetadata) + b.TryRecordObservationWithRequest(ctx, req, ObservationTypeSSHIssue, metadata) return response, nil } diff --git a/builtin/logical/ssh/path_issue_sign.go b/builtin/logical/ssh/path_issue_sign.go index 2f15a7adda..f0a87e3dbf 100644 --- a/builtin/logical/ssh/path_issue_sign.go +++ b/builtin/logical/ssh/path_issue_sign.go @@ -54,57 +54,57 @@ type creationBundle struct { Extensions map[string]string } -func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logical.Request, data *framework.FieldData, role *sshRole, publicKey ssh.PublicKey) (*logical.Response, error) { +func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logical.Request, data *framework.FieldData, role *sshRole, publicKey ssh.PublicKey) (*logical.Response, map[string]interface{}, error) { // Note that these various functions always return "user errors" so we pass // them as 4xx values keyID, err := b.calculateKeyID(data, req, role, publicKey) if err != nil { - return logical.ErrorResponse(err.Error()), nil + return logical.ErrorResponse(err.Error()), nil, nil } certificateType, err := b.calculateCertificateType(data, role) if err != nil { - return logical.ErrorResponse(err.Error()), nil + return logical.ErrorResponse(err.Error()), nil, nil } var parsedPrincipals []string if certificateType == ssh.HostCert { parsedPrincipals, err = b.calculateValidPrincipals(data, req, role, "", role.AllowedDomains, role.AllowedDomainsTemplate, validateValidPrincipalForHosts(role)) if err != nil { - return logical.ErrorResponse(err.Error()), nil + return logical.ErrorResponse(err.Error()), nil, nil } } else { defaultPrincipal := role.DefaultUser if role.DefaultUserTemplate { defaultPrincipal, err = b.renderPrincipal(role.DefaultUser, req) if err != nil { - return nil, err + return nil, nil, err } } parsedPrincipals, err = b.calculateValidPrincipals(data, req, role, defaultPrincipal, role.AllowedUsers, role.AllowedUsersTemplate, strutil.StrListContains) if err != nil { - return logical.ErrorResponse(err.Error()), nil + return logical.ErrorResponse(err.Error()), nil, nil } } ttl, err := b.calculateTTL(data, role) if err != nil { - return logical.ErrorResponse(err.Error()), nil + return logical.ErrorResponse(err.Error()), nil, nil } criticalOptions, err := b.calculateCriticalOptions(data, role) if err != nil { - return logical.ErrorResponse(err.Error()), nil + return logical.ErrorResponse(err.Error()), nil, nil } extensions, addExtTemplatingWarning, err := b.calculateExtensions(data, req, role) if err != nil { - return logical.ErrorResponse(err.Error()), nil + return logical.ErrorResponse(err.Error()), nil, nil } signer, err := b.getCASigner(ctx, req.Storage) if err != nil { - return nil, fmt.Errorf("error creating signer: %w", err) + return nil, nil, fmt.Errorf("error creating signer: %w", err) } cBundle := creationBundle{ @@ -121,12 +121,12 @@ func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logic certificate, err := cBundle.sign() if err != nil { - return nil, err + return nil, nil, err } signedSSHCertificate := ssh.MarshalAuthorizedKey(certificate) if len(signedSSHCertificate) == 0 { - return nil, errors.New("error marshaling signed certificate") + return nil, nil, errors.New("error marshaling signed certificate") } response := &logical.Response{ @@ -140,7 +140,14 @@ func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logic response.AddWarning("default_extension templating enabled with at least one extension requiring identity templating. However, this request lacked identity entity information, causing one or more extensions to be skipped from the generated certificate.") } - return response, nil + metadata := map[string]interface{}{ + "certificate_type": certificateType, + "ttl": ttl.String(), + "serial_number": strconv.FormatUint(certificate.Serial, 16), + "key_id": keyID, + } + + return response, metadata, nil } func (b *backend) renderPrincipal(principal string, req *logical.Request) (string, error) { diff --git a/builtin/logical/ssh/path_sign.go b/builtin/logical/ssh/path_sign.go index f9b85d8e49..282056d0c4 100644 --- a/builtin/logical/ssh/path_sign.go +++ b/builtin/logical/ssh/path_sign.go @@ -6,6 +6,7 @@ package ssh import ( "context" "fmt" + "maps" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" @@ -82,10 +83,10 @@ func (b *backend) pathSign(ctx context.Context, req *logical.Request, data *fram return logical.ErrorResponse(fmt.Sprintf("Unknown role: %s", roleName)), nil } - return b.pathSignCertificate(ctx, req, data, role) + return b.pathSignCertificate(ctx, req, data, role, roleName) } -func (b *backend) pathSignCertificate(ctx context.Context, req *logical.Request, data *framework.FieldData, role *sshRole) (*logical.Response, error) { +func (b *backend) pathSignCertificate(ctx context.Context, req *logical.Request, data *framework.FieldData, role *sshRole, roleName string) (*logical.Response, error) { publicKey := data.Get("public_key").(string) if publicKey == "" { return logical.ErrorResponse("missing public_key"), nil @@ -101,5 +102,18 @@ func (b *backend) pathSignCertificate(ctx context.Context, req *logical.Request, return logical.ErrorResponse(fmt.Sprintf("public_key failed to meet the key requirements: %s", err)), nil } - return b.pathSignIssueCertificateHelper(ctx, req, data, role, userPublicKey) + response, certMetadata, err := b.pathSignIssueCertificateHelper(ctx, req, data, role, userPublicKey) + if err != nil { + return nil, err + } + + if response.IsError() { + return response, nil + } + + metadata := role.observationMetadata(roleName) + maps.Copy(metadata, certMetadata) + b.TryRecordObservationWithRequest(ctx, req, ObservationTypeSSHSign, metadata) + + return response, nil }