diff --git a/run_tests.sh b/run_tests.sh index 137a20b..edb3c37 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -7,7 +7,7 @@ set -e case "$1" in unit) echo "๐Ÿงช Running unit tests..." - go test -v ./sdk + go test -v ./sdk/... ;; integration) echo "๐Ÿ”— Running integration tests..." @@ -17,7 +17,7 @@ case "$1" in echo "๐Ÿš€ Running all tests..." echo "" echo "1๏ธโƒฃ Unit tests..." - go test -v ./sdk + go test -v ./sdk/... echo "" echo "2๏ธโƒฃ Integration tests..." go test -v ./integration_tests diff --git a/sdk/builders/fluent/query_test.go b/sdk/builders/fluent/query_test.go index 87c3921..84499fb 100644 --- a/sdk/builders/fluent/query_test.go +++ b/sdk/builders/fluent/query_test.go @@ -13,11 +13,12 @@ import ( func TestQueryBuilder_BasicChaining(t *testing.T) { qb := newTestQueryBuilder(utils.Configuration{ - Token: "test-token", - OrgID: "default-org", + Token: "test-token", + OrgID: "default-org", + DataDockID: "test-datadock", }, func(req *http.Request) (*http.Response, error) { // Verify the URL was constructed correctly - expectedPath := "/default-org/openapi/test-catalog/test-schema/test-table" + expectedPath := "/test-datadock/openapi/test-catalog/test-schema/test-table" if !strings.Contains(req.URL.Path, expectedPath) { t.Errorf("Expected path to contain '%s', got '%s'", expectedPath, req.URL.Path) } diff --git a/sdk/client.go b/sdk/client.go index 296656e..9308180 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -2,6 +2,7 @@ package sdk import ( "context" + "fmt" "net/http" "github.com/nudibranches-tech/bifrost-hyperfluid-sdk-dev/sdk/builders/fluent" @@ -15,7 +16,7 @@ type Client struct { httpClient *http.Client } -// NewClient creates a new Bifrost client. +// NewClient creates a new Bifrost client with the provided configuration. func NewClient(config utils.Configuration) *Client { // Create a copy of the configuration to avoid side effects cfg := config @@ -28,6 +29,80 @@ func NewClient(config utils.Configuration) *Client { } } +// NewClientFromServiceAccount creates a new Bifrost client using a ServiceAccount. +// This is the recommended way to create a client for service-to-service authentication. +// +// Example: +// +// // Load service account from file (e.g., Kubernetes mounted secret) +// sa, err := sdk.LoadServiceAccount("/var/run/secrets/hyperfluid/service_account.json") +// if err != nil { +// log.Fatalf("Failed to load service account: %v", err) +// } +// +// // Create client +// client, err := sdk.NewClientFromServiceAccount(sa, sdk.ServiceAccountOptions{ +// BaseURL: "https://api.hyperfluid.cloud", +// OrgID: "my-org-id", +// }) +// if err != nil { +// log.Fatalf("Failed to create client: %v", err) +// } +func NewClientFromServiceAccount(sa *ServiceAccount, opts ServiceAccountOptions) (*Client, error) { + if sa == nil { + return nil, fmt.Errorf("service account is nil") + } + + if opts.BaseURL == "" { + return nil, fmt.Errorf("BaseURL is required in ServiceAccountOptions") + } + + cfg, err := sa.ToConfiguration(opts) + if err != nil { + return nil, fmt.Errorf("failed to create configuration from service account: %w", err) + } + + return NewClient(cfg), nil +} + +// NewClientFromServiceAccountFile creates a new Bifrost client by loading a ServiceAccount +// from a JSON file. This is a convenience function that combines LoadServiceAccount and +// NewClientFromServiceAccount. +// +// This is ideal for Kubernetes deployments where secrets are mounted as files: +// +// client, err := sdk.NewClientFromServiceAccountFile( +// "/var/run/secrets/hyperfluid/service_account.json", +// sdk.ServiceAccountOptions{ +// BaseURL: "https://api.hyperfluid.cloud", +// }, +// ) +func NewClientFromServiceAccountFile(path string, opts ServiceAccountOptions) (*Client, error) { + sa, err := LoadServiceAccount(path) + if err != nil { + return nil, err + } + return NewClientFromServiceAccount(sa, opts) +} + +// NewClientFromServiceAccountJSON creates a new Bifrost client by parsing a ServiceAccount +// from a JSON string. This is useful when the service account is provided via environment +// variables. +// +// Example: +// +// saJSON := os.Getenv("HYPERFLUID_SERVICE_ACCOUNT") +// client, err := sdk.NewClientFromServiceAccountJSON(saJSON, sdk.ServiceAccountOptions{ +// BaseURL: os.Getenv("HYPERFLUID_API_URL"), +// }) +func NewClientFromServiceAccountJSON(jsonStr string, opts ServiceAccountOptions) (*Client, error) { + sa, err := LoadServiceAccountFromJSON(jsonStr) + if err != nil { + return nil, err + } + return NewClientFromServiceAccount(sa, opts) +} + // Do executes an HTTP request (implements the interface needed by builders) func (c *Client) Do(ctx context.Context, method, endpoint string, body []byte) (*utils.Response, error) { return c.do(ctx, method, endpoint, body) diff --git a/sdk/service_account.go b/sdk/service_account.go new file mode 100644 index 0000000..6d36b7d --- /dev/null +++ b/sdk/service_account.go @@ -0,0 +1,233 @@ +package sdk + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "strings" + + "github.com/nudibranches-tech/bifrost-hyperfluid-sdk-dev/sdk/utils" +) + +// ServiceAccount represents the Hyperfluid service account credentials. +// This is the standard format distributed by Hyperfluid for service-to-service authentication. +// +// Example JSON file: +// +// { +// "client_id": "hf-org-sa-9e4132be-9498-4e75-8d8b-9699c92c4673", +// "client_secret": "8ek2Muno5b5sHeLUV8yk6pUYoPHPk6oZ", +// "issuer": "https://auth.hyperfluid.cloud/realms/nudibranches-tech", +// "auth_uri": "https://auth.hyperfluid.cloud/realms/nudibranches-tech/protocol/openid-connect/auth", +// "token_uri": "https://auth.hyperfluid.cloud/realms/nudibranches-tech/protocol/openid-connect/token" +// } +type ServiceAccount struct { + // ClientID is the OAuth2 client identifier for the service account. + ClientID string `json:"client_id"` + + // ClientSecret is the OAuth2 client secret for authentication. + ClientSecret string `json:"client_secret"` + + // Issuer is the OIDC issuer URL (e.g., "https://auth.hyperfluid.cloud/realms/my-org"). + // Used to derive the Keycloak base URL and realm. + Issuer string `json:"issuer"` + + // AuthURI is the OAuth2 authorization endpoint (typically not used for service accounts). + AuthURI string `json:"auth_uri"` + + // TokenURI is the OAuth2 token endpoint used to obtain access tokens. + TokenURI string `json:"token_uri"` +} + +// LoadServiceAccount loads a ServiceAccount from a JSON file at the given path. +// This is the recommended way to load credentials in production environments, +// especially when using Kubernetes secrets mounted as files. +// +// Example: +// +// // Load from a mounted Kubernetes secret +// sa, err := sdk.LoadServiceAccount("/var/run/secrets/hyperfluid/service_account.json") +// if err != nil { +// log.Fatalf("Failed to load service account: %v", err) +// } +// +// // Create client with additional options +// client, err := sdk.NewClientFromServiceAccount(sa, sdk.ServiceAccountOptions{ +// BaseURL: "https://api.hyperfluid.cloud", +// }) +func LoadServiceAccount(path string) (*ServiceAccount, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open service account file: %w", err) + } + defer func() { _ = file.Close() }() + + return LoadServiceAccountFromReader(file) +} + +// LoadServiceAccountFromJSON loads a ServiceAccount from a JSON string. +// This is useful when the service account is provided via environment variables. +// +// Example: +// +// // Load from environment variable +// saJSON := os.Getenv("HYPERFLUID_SERVICE_ACCOUNT") +// sa, err := sdk.LoadServiceAccountFromJSON(saJSON) +// if err != nil { +// log.Fatalf("Failed to parse service account: %v", err) +// } +func LoadServiceAccountFromJSON(jsonStr string) (*ServiceAccount, error) { + return LoadServiceAccountFromReader(strings.NewReader(jsonStr)) +} + +// LoadServiceAccountFromReader loads a ServiceAccount from an io.Reader. +// This provides maximum flexibility for loading from various sources. +// +// Example: +// +// // Load from an embedded file or any io.Reader +// sa, err := sdk.LoadServiceAccountFromReader(myReader) +func LoadServiceAccountFromReader(r io.Reader) (*ServiceAccount, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("failed to read service account data: %w", err) + } + + var sa ServiceAccount + if err := json.Unmarshal(data, &sa); err != nil { + return nil, fmt.Errorf("failed to parse service account JSON: %w", err) + } + + if err := sa.Validate(); err != nil { + return nil, fmt.Errorf("invalid service account: %w", err) + } + + return &sa, nil +} + +// Validate checks that the ServiceAccount has all required fields populated. +func (sa *ServiceAccount) Validate() error { + if sa.ClientID == "" { + return fmt.Errorf("client_id is required") + } + if sa.ClientSecret == "" { + return fmt.Errorf("client_secret is required") + } + if sa.Issuer == "" && sa.TokenURI == "" { + return fmt.Errorf("either issuer or token_uri is required") + } + return nil +} + +// ParseIssuer extracts the Keycloak base URL and realm from the issuer URL. +// The issuer URL format is: https:///realms/ +// +// Returns: +// - baseURL: The Keycloak server URL (e.g., "https://auth.hyperfluid.cloud") +// - realm: The Keycloak realm name (e.g., "nudibranches-tech") +// - error: If the issuer URL cannot be parsed +func (sa *ServiceAccount) ParseIssuer() (baseURL, realm string, err error) { + if sa.Issuer == "" { + // Try to extract from token_uri as fallback + if sa.TokenURI != "" { + return parseKeycloakURL(sa.TokenURI) + } + return "", "", fmt.Errorf("issuer is empty and no token_uri available") + } + return parseKeycloakURL(sa.Issuer) +} + +// parseKeycloakURL extracts base URL and realm from a Keycloak URL. +// Supports both issuer format (https://host/realms/realm) and +// token URL format (https://host/realms/realm/protocol/openid-connect/token). +func parseKeycloakURL(rawURL string) (baseURL, realm string, err error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", "", fmt.Errorf("failed to parse URL: %w", err) + } + + // Validate scheme is present and valid + if parsed.Scheme == "" { + return "", "", fmt.Errorf("URL missing scheme (http/https): %s", rawURL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", "", fmt.Errorf("URL has invalid scheme %q, expected http or https: %s", parsed.Scheme, rawURL) + } + + // Path format: /realms/ or /realms//protocol/... + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) < 2 || parts[0] != "realms" { + return "", "", fmt.Errorf("URL does not contain /realms/ pattern: %s", rawURL) + } + + realm = parts[1] + baseURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) + + return baseURL, realm, nil +} + +// ServiceAccountOptions provides additional configuration when creating a client +// from a service account. These options supplement the authentication credentials +// from the service account file. +type ServiceAccountOptions struct { + // BaseURL is the Hyperfluid API base URL (required). + // Example: "https://api.hyperfluid.cloud" + BaseURL string + + // OrgID is the default organization ID for API requests (optional). + // If set, this will be used as the default for operations requiring an org ID. + OrgID string + + // DataDockID is the default DataDock ID for query operations (optional). + // If set, this will be used as the default for query operations. + DataDockID string + + // SkipTLSVerify disables TLS certificate verification (optional). + // WARNING: Only use this for development/testing. Never in production. + SkipTLSVerify bool + + // RequestTimeout specifies the timeout for HTTP requests (optional). + // Defaults to 30 seconds if not specified. + RequestTimeout int + + // MaxRetries specifies the maximum number of retry attempts for failed requests (optional). + // Defaults to 3 if not specified. + MaxRetries int +} + +// ToConfiguration converts the ServiceAccount to a utils.Configuration. +// This is used internally when creating a client from a service account. +func (sa *ServiceAccount) ToConfiguration(opts ServiceAccountOptions) (utils.Configuration, error) { + baseURL, realm, err := sa.ParseIssuer() + if err != nil { + return utils.Configuration{}, fmt.Errorf("failed to parse issuer: %w", err) + } + + cfg := utils.Configuration{ + BaseURL: opts.BaseURL, + OrgID: opts.OrgID, + DataDockID: opts.DataDockID, + SkipTLSVerify: opts.SkipTLSVerify, + KeycloakBaseURL: baseURL, + KeycloakRealm: realm, + KeycloakClientID: sa.ClientID, + KeycloakClientSecret: sa.ClientSecret, + } + + // Apply defaults for optional fields + if opts.RequestTimeout > 0 { + cfg.RequestTimeout = utils.SecondsToDuration(opts.RequestTimeout) + } else { + cfg.RequestTimeout = utils.DefaultRequestTimeout + } + + if opts.MaxRetries > 0 { + cfg.MaxRetries = opts.MaxRetries + } else { + cfg.MaxRetries = utils.DefaultMaxRetries + } + + return cfg, nil +} diff --git a/sdk/service_account_test.go b/sdk/service_account_test.go new file mode 100644 index 0000000..21729f9 --- /dev/null +++ b/sdk/service_account_test.go @@ -0,0 +1,364 @@ +package sdk + +import ( + "strings" + "testing" +) + +func TestLoadServiceAccountFromJSON(t *testing.T) { + tests := []struct { + name string + json string + wantErr bool + errContains string + checkFunc func(t *testing.T, sa *ServiceAccount) + }{ + { + name: "valid service account", + json: `{ + "client_id": "hf-org-sa-12345", + "client_secret": "secret123", + "issuer": "https://auth.hyperfluid.cloud/realms/my-org", + "auth_uri": "https://auth.hyperfluid.cloud/realms/my-org/protocol/openid-connect/auth", + "token_uri": "https://auth.hyperfluid.cloud/realms/my-org/protocol/openid-connect/token" + }`, + wantErr: false, + checkFunc: func(t *testing.T, sa *ServiceAccount) { + if sa.ClientID != "hf-org-sa-12345" { + t.Errorf("ClientID = %q, want %q", sa.ClientID, "hf-org-sa-12345") + } + if sa.ClientSecret != "secret123" { + t.Errorf("ClientSecret = %q, want %q", sa.ClientSecret, "secret123") + } + if sa.Issuer != "https://auth.hyperfluid.cloud/realms/my-org" { + t.Errorf("Issuer = %q, want %q", sa.Issuer, "https://auth.hyperfluid.cloud/realms/my-org") + } + }, + }, + { + name: "valid with token_uri only (no issuer)", + json: `{ + "client_id": "hf-org-sa-12345", + "client_secret": "secret123", + "token_uri": "https://auth.hyperfluid.cloud/realms/my-org/protocol/openid-connect/token" + }`, + wantErr: false, + }, + { + name: "missing client_id", + json: `{"client_secret": "secret123", "issuer": "https://auth.hyperfluid.cloud/realms/my-org"}`, + wantErr: true, + errContains: "client_id is required", + }, + { + name: "missing client_secret", + json: `{"client_id": "hf-org-sa-12345", "issuer": "https://auth.hyperfluid.cloud/realms/my-org"}`, + wantErr: true, + errContains: "client_secret is required", + }, + { + name: "missing both issuer and token_uri", + json: `{"client_id": "hf-org-sa-12345", "client_secret": "secret123"}`, + wantErr: true, + errContains: "either issuer or token_uri is required", + }, + { + name: "invalid json", + json: `{"client_id": "hf-org-sa-12345"`, + wantErr: true, + errContains: "failed to parse service account JSON", + }, + { + name: "empty json", + json: `{}`, + wantErr: true, + errContains: "client_id is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sa, err := LoadServiceAccountFromJSON(tt.json) + if tt.wantErr { + if err == nil { + t.Errorf("LoadServiceAccountFromJSON() error = nil, want error containing %q", tt.errContains) + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("LoadServiceAccountFromJSON() error = %q, want error containing %q", err.Error(), tt.errContains) + } + return + } + if err != nil { + t.Errorf("LoadServiceAccountFromJSON() unexpected error = %v", err) + return + } + if tt.checkFunc != nil { + tt.checkFunc(t, sa) + } + }) + } +} + +func TestServiceAccount_ParseIssuer(t *testing.T) { + tests := []struct { + name string + issuer string + tokenURI string + wantBaseURL string + wantRealm string + wantErr bool + errContains string + }{ + { + name: "standard issuer URL", + issuer: "https://auth.hyperfluid.cloud/realms/my-org", + wantBaseURL: "https://auth.hyperfluid.cloud", + wantRealm: "my-org", + wantErr: false, + }, + { + name: "issuer with trailing slash", + issuer: "https://auth.hyperfluid.cloud/realms/my-org/", + wantBaseURL: "https://auth.hyperfluid.cloud", + wantRealm: "my-org", + wantErr: false, + }, + { + name: "issuer with complex realm name", + issuer: "https://auth.hyperfluid.cloud/realms/nudibranches-tech", + wantBaseURL: "https://auth.hyperfluid.cloud", + wantRealm: "nudibranches-tech", + wantErr: false, + }, + { + name: "fallback to token_uri when issuer empty", + issuer: "", + tokenURI: "https://auth.hyperfluid.cloud/realms/fallback-org/protocol/openid-connect/token", + wantBaseURL: "https://auth.hyperfluid.cloud", + wantRealm: "fallback-org", + wantErr: false, + }, + { + name: "invalid issuer URL - no realms path", + issuer: "https://auth.hyperfluid.cloud/other/path", + wantErr: true, + errContains: "does not contain /realms/ pattern", + }, + { + name: "both empty", + issuer: "", + tokenURI: "", + wantErr: true, + errContains: "issuer is empty and no token_uri available", + }, + { + name: "missing scheme - protocol relative URL", + issuer: "//auth.hyperfluid.cloud/realms/my-org", + wantErr: true, + errContains: "URL missing scheme (http/https)", + }, + { + name: "missing scheme - no slashes", + issuer: "auth.hyperfluid.cloud/realms/my-org", + wantErr: true, + errContains: "URL missing scheme (http/https)", + }, + { + name: "invalid scheme - ftp", + issuer: "ftp://auth.hyperfluid.cloud/realms/my-org", + wantErr: true, + errContains: "URL has invalid scheme \"ftp\"", + }, + { + name: "invalid scheme - file", + issuer: "file:///etc/passwd/realms/my-org", + wantErr: true, + errContains: "URL has invalid scheme \"file\"", + }, + { + name: "http scheme is valid", + issuer: "http://localhost:8080/realms/dev", + wantBaseURL: "http://localhost:8080", + wantRealm: "dev", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sa := &ServiceAccount{ + ClientID: "test-client", + ClientSecret: "test-secret", + Issuer: tt.issuer, + TokenURI: tt.tokenURI, + } + baseURL, realm, err := sa.ParseIssuer() + if tt.wantErr { + if err == nil { + t.Errorf("ParseIssuer() error = nil, want error") + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("ParseIssuer() error = %q, want error containing %q", err.Error(), tt.errContains) + } + return + } + if err != nil { + t.Errorf("ParseIssuer() unexpected error = %v", err) + return + } + if baseURL != tt.wantBaseURL { + t.Errorf("ParseIssuer() baseURL = %q, want %q", baseURL, tt.wantBaseURL) + } + if realm != tt.wantRealm { + t.Errorf("ParseIssuer() realm = %q, want %q", realm, tt.wantRealm) + } + }) + } +} + +func TestServiceAccount_ToConfiguration(t *testing.T) { + sa := &ServiceAccount{ + ClientID: "hf-org-sa-12345", + ClientSecret: "secret123", + Issuer: "https://auth.hyperfluid.cloud/realms/my-org", + AuthURI: "https://auth.hyperfluid.cloud/realms/my-org/protocol/openid-connect/auth", + TokenURI: "https://auth.hyperfluid.cloud/realms/my-org/protocol/openid-connect/token", + } + + opts := ServiceAccountOptions{ + BaseURL: "https://api.hyperfluid.cloud", + OrgID: "org-123", + DataDockID: "dd-456", + SkipTLSVerify: false, + RequestTimeout: 60, + MaxRetries: 5, + } + + cfg, err := sa.ToConfiguration(opts) + if err != nil { + t.Fatalf("ToConfiguration() unexpected error = %v", err) + } + + // Verify all fields are correctly mapped + if cfg.BaseURL != "https://api.hyperfluid.cloud" { + t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://api.hyperfluid.cloud") + } + if cfg.OrgID != "org-123" { + t.Errorf("OrgID = %q, want %q", cfg.OrgID, "org-123") + } + if cfg.DataDockID != "dd-456" { + t.Errorf("DataDockID = %q, want %q", cfg.DataDockID, "dd-456") + } + if cfg.KeycloakBaseURL != "https://auth.hyperfluid.cloud" { + t.Errorf("KeycloakBaseURL = %q, want %q", cfg.KeycloakBaseURL, "https://auth.hyperfluid.cloud") + } + if cfg.KeycloakRealm != "my-org" { + t.Errorf("KeycloakRealm = %q, want %q", cfg.KeycloakRealm, "my-org") + } + if cfg.KeycloakClientID != "hf-org-sa-12345" { + t.Errorf("KeycloakClientID = %q, want %q", cfg.KeycloakClientID, "hf-org-sa-12345") + } + if cfg.KeycloakClientSecret != "secret123" { + t.Errorf("KeycloakClientSecret = %q, want %q", cfg.KeycloakClientSecret, "secret123") + } + if cfg.MaxRetries != 5 { + t.Errorf("MaxRetries = %d, want %d", cfg.MaxRetries, 5) + } +} + +func TestNewClientFromServiceAccount(t *testing.T) { + tests := []struct { + name string + sa *ServiceAccount + opts ServiceAccountOptions + wantErr bool + errContains string + }{ + { + name: "valid service account", + sa: &ServiceAccount{ + ClientID: "hf-org-sa-12345", + ClientSecret: "secret123", + Issuer: "https://auth.hyperfluid.cloud/realms/my-org", + }, + opts: ServiceAccountOptions{ + BaseURL: "https://api.hyperfluid.cloud", + }, + wantErr: false, + }, + { + name: "nil service account", + sa: nil, + opts: ServiceAccountOptions{BaseURL: "https://api.hyperfluid.cloud"}, + wantErr: true, + errContains: "service account is nil", + }, + { + name: "missing BaseURL", + sa: &ServiceAccount{ + ClientID: "hf-org-sa-12345", + ClientSecret: "secret123", + Issuer: "https://auth.hyperfluid.cloud/realms/my-org", + }, + opts: ServiceAccountOptions{}, + wantErr: true, + errContains: "BaseURL is required", + }, + { + name: "invalid issuer", + sa: &ServiceAccount{ + ClientID: "hf-org-sa-12345", + ClientSecret: "secret123", + Issuer: "https://invalid-url.com/no-realms", + }, + opts: ServiceAccountOptions{ + BaseURL: "https://api.hyperfluid.cloud", + }, + wantErr: true, + errContains: "failed to create configuration", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClientFromServiceAccount(tt.sa, tt.opts) + if tt.wantErr { + if err == nil { + t.Errorf("NewClientFromServiceAccount() error = nil, want error") + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("NewClientFromServiceAccount() error = %q, want error containing %q", err.Error(), tt.errContains) + } + return + } + if err != nil { + t.Errorf("NewClientFromServiceAccount() unexpected error = %v", err) + return + } + if client == nil { + t.Error("NewClientFromServiceAccount() returned nil client") + } + }) + } +} + +func TestLoadServiceAccountFromReader(t *testing.T) { + json := `{ + "client_id": "hf-org-sa-reader-test", + "client_secret": "reader-secret", + "issuer": "https://auth.hyperfluid.cloud/realms/reader-test" + }` + + reader := strings.NewReader(json) + sa, err := LoadServiceAccountFromReader(reader) + if err != nil { + t.Fatalf("LoadServiceAccountFromReader() unexpected error = %v", err) + } + + if sa.ClientID != "hf-org-sa-reader-test" { + t.Errorf("ClientID = %q, want %q", sa.ClientID, "hf-org-sa-reader-test") + } +} diff --git a/sdk/utils/helpers.go b/sdk/utils/helpers.go index 808333c..af64ce7 100644 --- a/sdk/utils/helpers.go +++ b/sdk/utils/helpers.go @@ -10,6 +10,20 @@ import ( "time" ) +// Default configuration values +const ( + // DefaultRequestTimeout is the default HTTP request timeout (30 seconds). + DefaultRequestTimeout = 30 * time.Second + + // DefaultMaxRetries is the default number of retry attempts for failed requests. + DefaultMaxRetries = 3 +) + +// SecondsToDuration converts an integer number of seconds to time.Duration. +func SecondsToDuration(seconds int) time.Duration { + return time.Duration(seconds) * time.Second +} + // Environment variables handling func GetEnvironmentVariable(key string, fallback string) string { value := os.Getenv(key) diff --git a/usage_examples/service_account_examples.go b/usage_examples/service_account_examples.go new file mode 100644 index 0000000..07239ec --- /dev/null +++ b/usage_examples/service_account_examples.go @@ -0,0 +1,307 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/nudibranches-tech/bifrost-hyperfluid-sdk-dev/sdk" + "github.com/nudibranches-tech/bifrost-hyperfluid-sdk-dev/sdk/utils" +) + +// This file demonstrates how to use Hyperfluid service accounts with the Bifrost SDK. +// Service accounts are the recommended way for service-to-service authentication. +// +// Service Account JSON Format: +// +// { +// "client_id": "hf-org-sa-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", +// "client_secret": "your-client-secret", +// "issuer": "https://auth.hyperfluid.cloud/realms/your-org", +// "auth_uri": "https://auth.hyperfluid.cloud/realms/your-org/protocol/openid-connect/auth", +// "token_uri": "https://auth.hyperfluid.cloud/realms/your-org/protocol/openid-connect/token" +// } + +// runServiceAccountFromFileExample demonstrates loading a service account from a file. +// This is the recommended approach for Kubernetes deployments using mounted secrets. +// +// Kubernetes Secret Example: +// +// apiVersion: v1 +// kind: Secret +// metadata: +// name: hyperfluid-service-account +// type: Opaque +// stringData: +// service_account.json: | +// { +// "client_id": "hf-org-sa-...", +// "client_secret": "...", +// "issuer": "https://auth.hyperfluid.cloud/realms/my-org", +// "auth_uri": "...", +// "token_uri": "..." +// } +// +// Pod Volume Mount: +// +// volumes: +// - name: hyperfluid-credentials +// secret: +// secretName: hyperfluid-service-account +// containers: +// - name: app +// volumeMounts: +// - name: hyperfluid-credentials +// mountPath: /var/run/secrets/hyperfluid +// readOnly: true +func runServiceAccountFromFileExample() { + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + fmt.Println("๐Ÿ” Service Account Example 1: Load from File") + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + + // Get the service account file path from environment or use default + saPath := getEnv("HYPERFLUID_SERVICE_ACCOUNT_FILE", "/var/run/secrets/hyperfluid/service_account.json") + apiURL := getEnv("HYPERFLUID_API_URL", "") + + if apiURL == "" { + fmt.Println("โš ๏ธ Skipping: HYPERFLUID_API_URL not set") + fmt.Println() + return + } + + fmt.Printf("๐Ÿ“ Service account file: %s\n", saPath) + fmt.Printf("๐ŸŒ API URL: %s\n", apiURL) + fmt.Println() + + // Method 1: One-liner using NewClientFromServiceAccountFile + client, err := sdk.NewClientFromServiceAccountFile(saPath, sdk.ServiceAccountOptions{ + BaseURL: apiURL, + OrgID: getEnv("HYPERFLUID_ORG_ID", ""), + DataDockID: getEnv("HYPERFLUID_DATADOCK_ID", ""), + RequestTimeout: 30, + MaxRetries: 3, + }) + if err != nil { + fmt.Printf("โŒ Failed to create client: %v\n", err) + fmt.Println() + return + } + + fmt.Println("โœ… Client created successfully from service account file!") + runSampleQuery(client) + fmt.Println() +} + +// runServiceAccountFromJSONExample demonstrates loading a service account from a JSON string. +// This is useful when the service account is provided via environment variables. +// +// Kubernetes ConfigMap/Secret as Environment Variable: +// +// env: +// - name: HYPERFLUID_SERVICE_ACCOUNT +// valueFrom: +// secretKeyRef: +// name: hyperfluid-service-account +// key: service_account.json +func runServiceAccountFromJSONExample() { + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + fmt.Println("๐Ÿ” Service Account Example 2: Load from JSON String") + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + + // Get the service account JSON from environment variable + saJSON := os.Getenv("HYPERFLUID_SERVICE_ACCOUNT") + apiURL := getEnv("HYPERFLUID_API_URL", "") + + if saJSON == "" { + fmt.Println("โš ๏ธ Skipping: HYPERFLUID_SERVICE_ACCOUNT env var not set") + fmt.Println() + return + } + + if apiURL == "" { + fmt.Println("โš ๏ธ Skipping: HYPERFLUID_API_URL not set") + fmt.Println() + return + } + + fmt.Println("๐Ÿ“‹ Service account loaded from HYPERFLUID_SERVICE_ACCOUNT env var") + fmt.Printf("๐ŸŒ API URL: %s\n", apiURL) + fmt.Println() + + // Create client from JSON string + client, err := sdk.NewClientFromServiceAccountJSON(saJSON, sdk.ServiceAccountOptions{ + BaseURL: apiURL, + OrgID: getEnv("HYPERFLUID_ORG_ID", ""), + DataDockID: getEnv("HYPERFLUID_DATADOCK_ID", ""), + RequestTimeout: 30, + MaxRetries: 3, + }) + if err != nil { + fmt.Printf("โŒ Failed to create client: %v\n", err) + fmt.Println() + return + } + + fmt.Println("โœ… Client created successfully from JSON string!") + runSampleQuery(client) + fmt.Println() +} + +// runServiceAccountManualExample demonstrates the two-step approach: +// 1. Load the service account +// 2. Create the client +// +// This is useful when you need to inspect or modify the service account before use. +func runServiceAccountManualExample() { + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + fmt.Println("๐Ÿ” Service Account Example 3: Two-Step Approach") + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + + saPath := getEnv("HYPERFLUID_SERVICE_ACCOUNT_FILE", "") + apiURL := getEnv("HYPERFLUID_API_URL", "") + + if saPath == "" || apiURL == "" { + fmt.Println("โš ๏ธ Skipping: Required environment variables not set") + fmt.Println() + return + } + + // Step 1: Load the service account + sa, err := sdk.LoadServiceAccount(saPath) + if err != nil { + fmt.Printf("โŒ Failed to load service account: %v\n", err) + fmt.Println() + return + } + + // Inspect the service account (e.g., for logging/debugging) + fmt.Printf("๐Ÿ“‹ Service Account Details:\n") + fmt.Printf(" Client ID: %s\n", sa.ClientID) + fmt.Printf(" Issuer: %s\n", sa.Issuer) + fmt.Println() + + // Parse the issuer to see what realm we're connecting to + baseURL, realm, err := sa.ParseIssuer() + if err != nil { + fmt.Printf("โŒ Failed to parse issuer: %v\n", err) + fmt.Println() + return + } + fmt.Printf(" Auth Server: %s\n", baseURL) + fmt.Printf(" Realm: %s\n", realm) + fmt.Println() + + // Step 2: Create the client + client, err := sdk.NewClientFromServiceAccount(sa, sdk.ServiceAccountOptions{ + BaseURL: apiURL, + OrgID: getEnv("HYPERFLUID_ORG_ID", ""), + DataDockID: getEnv("HYPERFLUID_DATADOCK_ID", ""), + RequestTimeout: 30, + MaxRetries: 3, + }) + if err != nil { + fmt.Printf("โŒ Failed to create client: %v\n", err) + fmt.Println() + return + } + + fmt.Println("โœ… Client created successfully!") + runSampleQuery(client) + fmt.Println() +} + +// runServiceAccountWithSkipTLSExample demonstrates using SkipTLSVerify for development. +// WARNING: Never use SkipTLSVerify in production! +func runServiceAccountWithSkipTLSExample() { + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + fmt.Println("๐Ÿ” Service Account Example 4: Development (Skip TLS)") + fmt.Println("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + + saPath := getEnv("HYPERFLUID_SERVICE_ACCOUNT_FILE", "") + apiURL := getEnv("HYPERFLUID_API_URL", "") + + if saPath == "" || apiURL == "" { + fmt.Println("โš ๏ธ Skipping: Required environment variables not set") + fmt.Println() + return + } + + fmt.Println("โš ๏ธ WARNING: SkipTLSVerify is enabled - for development only!") + fmt.Println() + + client, err := sdk.NewClientFromServiceAccountFile(saPath, sdk.ServiceAccountOptions{ + BaseURL: apiURL, + SkipTLSVerify: true, // Only for development! + }) + if err != nil { + fmt.Printf("โŒ Failed to create client: %v\n", err) + fmt.Println() + return + } + + fmt.Println("โœ… Client created with TLS verification disabled") + runSampleQuery(client) + fmt.Println() +} + +// runSampleQuery executes a simple query to verify the client is working. +func runSampleQuery(client *sdk.Client) { + testCatalog := getEnv("BIFROST_TEST_CATALOG", "") + testSchema := getEnv("BIFROST_TEST_SCHEMA", "") + testTable := getEnv("BIFROST_TEST_TABLE", "") + dataDockID := getEnv("HYPERFLUID_DATADOCK_ID", "") + + if testCatalog == "" || testSchema == "" || testTable == "" { + fmt.Println("โ„น๏ธ Skipping sample query: test table not configured") + return + } + + fmt.Printf("๐Ÿ“Š Running sample query on %s.%s.%s...\n", testCatalog, testSchema, testTable) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client. + DataDock(dataDockID). + Catalog(testCatalog). + Schema(testSchema). + Table(testTable). + Limit(5). + Get(ctx) + + handleServiceAccountResponse(resp, err) +} + +func handleServiceAccountResponse(resp *utils.Response, err error) { + if err != nil { + fmt.Printf("โŒ Query error: %v\n", err) + if resp != nil && resp.Error != "" { + fmt.Printf(" Server response: %s (HTTP %d)\n", resp.Error, resp.HTTPCode) + } + return + } + if resp.Status != utils.StatusOK { + fmt.Printf("โŒ Query failed: %s (HTTP %d)\n", resp.Error, resp.HTTPCode) + return + } + fmt.Println("โœ… Query successful!") + if dataSlice, ok := resp.Data.([]interface{}); ok { + fmt.Printf("๐Ÿ“ฆ Retrieved %d records\n", len(dataSlice)) + } +} + +// RunServiceAccountExamples runs all service account examples. +// Call this from main.go to include these examples in the demo. +func RunServiceAccountExamples() { + fmt.Println() + fmt.Println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + fmt.Println("๐Ÿ” SERVICE ACCOUNT AUTHENTICATION EXAMPLES") + fmt.Println("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•") + fmt.Println() + + runServiceAccountFromFileExample() + runServiceAccountFromJSONExample() + runServiceAccountManualExample() + runServiceAccountWithSkipTLSExample() +}