Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions v2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions v2/arangodb/client_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"`
}
72 changes: 72 additions & 0 deletions v2/arangodb/client_admin_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
159 changes: 159 additions & 0 deletions v2/tests/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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)
}
Comment on lines +366 to +370
Copy link

Copilot AI Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PEM format validation is overly simplistic and only checks for the BEGIN marker. Consider also checking for the corresponding END marker or use proper PEM parsing from the crypto/x509 package for more robust validation.

Copilot uses AI. Check for mistakes.
}
}
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)
}
})
})
}