diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index 94383711..1a307b24 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -5,6 +5,7 @@ - Add missing endpoints from monitoring to v2 - Add missing endpoints from administration to v2 - Add missing endpoints from cluster to v2 +- Add missing endpoints from security to v2 ## [2.1.5](https://github.com/arangodb/go-driver/tree/v2.1.5) (2025-08-31) - Add tasks endpoints to v2 diff --git a/v2/arangodb/client_admin.go b/v2/arangodb/client_admin.go index c75cac74..5e817ea9 100644 --- a/v2/arangodb/client_admin.go +++ b/v2/arangodb/client_admin.go @@ -71,6 +71,20 @@ type ClientAdmin interface { // by compacting the entire database system data. // The endpoint requires superuser access. CompactDatabases(ctx context.Context, opts *CompactOpts) (map[string]interface{}, error) + + // GetTLSData returns information about the server's TLS configuration. + // This call requires authentication. + GetTLSData(ctx context.Context, dbName string) (TLSDataResponse, error) + + // ReloadTLSData triggers a reload of all TLS data (server key, client-auth CA) + // and returns the updated TLS configuration summary. + // Requires superuser rights. + ReloadTLSData(ctx context.Context) (TLSDataResponse, error) + + // RotateEncryptionAtRestKey reloads the user-supplied encryption key from + // the --rocksdb.encryption-keyfolder and re-encrypts the internal encryption key. + // Requires superuser rights and is not available on Coordinators. + RotateEncryptionAtRestKey(ctx context.Context) ([]EncryptionKey, error) } type ClientAdminLog interface { @@ -541,3 +555,36 @@ type CompactOpts struct { // Whether or not to compact the bottommost level of data. CompactBottomMostLevel *bool `json:"compactBottomMostLevel,omitempty"` } + +// ServerName represents the hostname used in SNI configuration. +type ServerName string + +// TLSConfigObject describes the details of a TLS keyfile or CA file. +type TLSDataObject struct { + // SHA-256 hash of the whole input file (certificate or CA file). + Sha256 *string `json:"sha256,omitempty"` + // Public certificates in the chain, in PEM format. + Certificates []string `json:"certificates,omitempty"` + // SHA-256 hash of the private key (only present for keyfile). + PrivateKeySha256 *string `json:"privateKeySha256,omitempty"` +} + +// TLSConfigResponse represents the response of the TLS configuration endpoint. +type TLSDataResponse struct { + // Information about the server TLS keyfile (certificate + private key). + Keyfile *TLSDataObject `json:"keyfile,omitempty"` + // Information about the CA certificates used for client verification. + ClientCA *TLSDataObject `json:"clientCA,omitempty"` + // Optional mapping of server names (via SNI) to their respective TLS configurations. + SNI map[ServerName]TLSDataObject `json:"sni,omitempty"` +} + +// EncryptionKey represents metadata about an encryption key used for +// RocksDB encryption-at-rest in ArangoDB. +// The server exposes only the SHA-256 hash of the key for identification. +// The actual key material is never returned for security reasons. +type EncryptionKey struct { + // SHA256 is the SHA-256 hash of the encryption key, encoded as a hex string. + // This is used to uniquely identify which key is active/available. + SHA256 *string `json:"sha256,omitempty"` +} diff --git a/v2/arangodb/client_admin_impl.go b/v2/arangodb/client_admin_impl.go index fee4f083..225452d9 100644 --- a/v2/arangodb/client_admin_impl.go +++ b/v2/arangodb/client_admin_impl.go @@ -364,3 +364,75 @@ func (c *clientAdmin) CompactDatabases(ctx context.Context, opts *CompactOpts) ( return nil, (&shared.ResponseStruct{}).AsArangoErrorWithCode(code) } } + +// GetTLSData returns information about the server's TLS configuration. +// This call requires authentication. +func (c *clientAdmin) GetTLSData(ctx context.Context, dbName string) (TLSDataResponse, error) { + url := connection.NewUrl("_db", url.PathEscape(dbName), "_admin", "server", "tls") + + var response struct { + shared.ResponseStruct `json:",inline"` + Result TLSDataResponse `json:"result,omitempty"` + } + + resp, err := connection.CallGet(ctx, c.client.connection, url, &response) + if err != nil { + return TLSDataResponse{}, errors.WithStack(err) + } + + switch code := resp.Code(); code { + case http.StatusOK: + return response.Result, nil + default: + return TLSDataResponse{}, response.AsArangoErrorWithCode(code) + } +} + +// ReloadTLSData triggers a reload of all TLS data (server key, client-auth CA) +// and returns the updated TLS configuration summary. +// Requires superuser rights. +func (c *clientAdmin) ReloadTLSData(ctx context.Context) (TLSDataResponse, error) { + url := connection.NewUrl("_admin", "server", "tls") + + var response struct { + shared.ResponseStruct `json:",inline"` + Result TLSDataResponse `json:"result,omitempty"` + } + + // POST request, no body required + resp, err := connection.CallPost(ctx, c.client.connection, url, &response, nil) + if err != nil { + return TLSDataResponse{}, errors.WithStack(err) + } + switch code := resp.Code(); code { + case http.StatusOK: + return response.Result, nil + // Requires superuser rights, otherwise returns 403 Forbidden + default: + return TLSDataResponse{}, response.AsArangoErrorWithCode(code) + } +} + +// RotateEncryptionAtRestKey reloads the user-supplied encryption key from +// the --rocksdb.encryption-keyfolder and re-encrypts the internal encryption key. +// Requires superuser rights and is not available on Coordinators. +func (c *clientAdmin) RotateEncryptionAtRestKey(ctx context.Context) ([]EncryptionKey, error) { + url := connection.NewUrl("_admin", "server", "encryption") + + var response struct { + shared.ResponseStruct `json:",inline"` + Result []EncryptionKey `json:"result,omitempty"` + } + + // POST request, no body required + resp, err := connection.CallPost(ctx, c.client.connection, url, &response, nil) + if err != nil { + return nil, errors.WithStack(err) + } + switch code := resp.Code(); code { + case http.StatusOK: + return response.Result, nil + default: + return nil, response.AsArangoErrorWithCode(code) + } +} diff --git a/v2/tests/admin_test.go b/v2/tests/admin_test.go index 84d5a7a6..64f91b99 100644 --- a/v2/tests/admin_test.go +++ b/v2/tests/admin_test.go @@ -23,6 +23,7 @@ import ( "context" "errors" "net/http" + "strings" "testing" "time" @@ -309,3 +310,161 @@ func Test_CompactDatabases(t *testing.T) { }) }) } + +// Test_GetTLSData checks that TLS configuration data is available and valid, skipping if not configured. +func Test_GetTLSData(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + withContextT(t, time.Minute, func(ctx context.Context, t testing.TB) { + db, err := client.GetDatabase(ctx, "_system", nil) + require.NoError(t, err) + + // Get TLS data using the client (which embeds ClientAdmin) + tlsResp, err := client.GetTLSData(ctx, db.Name()) + if err != nil { + var arangoErr shared.ArangoError + if errors.As(err, &arangoErr) { + t.Logf("GetTLSData failed with ArangoDB error code: %d", arangoErr.Code) + switch arangoErr.Code { + case 403: + t.Skip("Skipping TLS get test - authentication/permission denied (HTTP 403)") + default: + t.Logf("Unexpected ArangoDB error code: %d, message: %s", arangoErr.Code, arangoErr.ErrorMessage) + } + return + } + // Skip for any other error (TLS not configured, network issues, etc.) + t.Logf("GetTLSData failed: %v", err) + t.Skip("Skipping TLS get test - likely TLS not configured or other server issue") + } + + // Success! Validate response structure + t.Logf("TLS data retrieved successfully") + + // Validate TLS response data + validateTLSResponse(t, tlsResp, "Retrieved") + }) + }) +} + +// validateTLSResponse is a helper function to validate TLS response data +func validateTLSResponse(t testing.TB, tlsResp arangodb.TLSDataResponse, operation string) { + // Basic validation - at least one field should be populated + hasData := false + if tlsResp.Keyfile != nil { + if tlsResp.Keyfile.Sha256 != nil && *tlsResp.Keyfile.Sha256 != "" { + t.Logf("%s keyfile SHA256: %s", operation, *tlsResp.Keyfile.Sha256) + hasData = true + } + if len(tlsResp.Keyfile.Certificates) > 0 { + t.Logf("%s keyfile contains %d certificates", operation, len(tlsResp.Keyfile.Certificates)) + hasData = true + + // Validate certificate content (basic PEM format check) + for i, cert := range tlsResp.Keyfile.Certificates { + require.NotEmpty(t, cert, "Certificate %d should not be empty", i) + // Basic PEM format validation + if !strings.Contains(cert, "-----BEGIN CERTIFICATE-----") { + t.Logf("Warning: Certificate %d may not be in PEM format", i) + } else { + t.Logf("Certificate %d appears to be valid PEM format", i) + } + } + } + if tlsResp.Keyfile.PrivateKeySha256 != nil && *tlsResp.Keyfile.PrivateKeySha256 != "" { + t.Logf("%s keyfile private key SHA256: %s", operation, *tlsResp.Keyfile.PrivateKeySha256) + hasData = true + } + } + if tlsResp.ClientCA != nil && tlsResp.ClientCA.Sha256 != nil && *tlsResp.ClientCA.Sha256 != "" { + t.Logf("%s client CA SHA256: %s", operation, *tlsResp.ClientCA.Sha256) + hasData = true + } + if len(tlsResp.SNI) > 0 { + t.Logf("%s SNI configurations found: %d", operation, len(tlsResp.SNI)) + hasData = true + } + if hasData { + t.Logf("TLS configuration data validated successfully") + } else { + t.Logf("TLS endpoint accessible but no TLS data returned - server may not have TLS configured") + } +} + +// Test_ReloadTLSData tests TLS certificate reload functionality, skipping if superuser rights unavailable. +func Test_ReloadTLSData(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + withContextT(t, time.Minute, func(ctx context.Context, t testing.TB) { + // Reload TLS data - requires superuser rights + tlsResp, err := client.ReloadTLSData(ctx) + if err != nil { + var arangoErr shared.ArangoError + if errors.As(err, &arangoErr) { + t.Logf("ReloadTLSData failed with ArangoDB error code: %d", arangoErr.Code) + switch arangoErr.Code { + case 403: + t.Skip("Skipping TLS reload test - superuser rights required (HTTP 403)") + default: + t.Logf("Unexpected ArangoDB error code: %d, message: %s", arangoErr.Code, arangoErr.ErrorMessage) + } + return + } + // Skip for any other error (TLS not configured, network issues, etc.) + t.Logf("ReloadTLSData failed: %v", err) + t.Skip("Skipping TLS reload test - likely TLS not configured or other server issue") + } + + // Success! Validate response structure + t.Logf("TLS data reloaded successfully") + + // Validate TLS response data + validateTLSResponse(t, tlsResp, "Reloaded") + }) + }) +} + +// Test_RotateEncryptionAtRestKey verifies that the encryption key rotation endpoint works as expected. +// The test is skipped if superuser rights are missing or the feature is disabled/not configured. +func Test_RotateEncryptionAtRestKey(t *testing.T) { + Wrap(t, func(t *testing.T, client arangodb.Client) { + withContextT(t, time.Minute, func(ctx context.Context, t testing.TB) { + + // Attempt to rotate encryption at rest key - requires superuser rights + resp, err := client.RotateEncryptionAtRestKey(ctx) + if err != nil { + var arangoErr shared.ArangoError + if errors.As(err, &arangoErr) { + t.Logf("RotateEncryptionAtRestKey failed with ArangoDB error code: %d", arangoErr.Code) + switch arangoErr.Code { + case 403: + t.Skip("Skipping RotateEncryptionAtRestKey test - superuser rights required (HTTP 403)") + case 404: + t.Skip("Skipping RotateEncryptionAtRestKey test - encryption key rotation disabled (HTTP 404)") + default: + t.Logf("Unexpected ArangoDB error code: %d, message: %s", arangoErr.Code, arangoErr.ErrorMessage) + t.FailNow() + } + } else { + t.Fatalf("RotateEncryptionAtRestKey failed with unexpected error: %v", err) + } + return + } + + // Convert response to JSON for logging + encryptionRespJson, err := utils.ToJSONString(resp) + require.NoError(t, err) + t.Logf("RotateEncryptionAtRestKey response: %s", encryptionRespJson) + + // Validate the response is not nil + require.NotNil(t, resp, "Expected non-nil response") + t.Logf("RotateEncryptionAtRestKey succeeded with %d encryption keys", len(resp)) + + // Validate each encryption key + for i, key := range resp { + // Explicit nil check for pointer + require.NotNil(t, key.SHA256, "Expected encryption key %d SHA256 not to be nil", i) + require.NotEmpty(t, *key.SHA256, "Expected encryption key %d SHA256 not to be empty", i) + t.Logf("Encryption key %d SHA256: %s", i, *key.SHA256) + } + }) + }) +}