mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 06:58:58 +00:00
feat(M50): cloud secret manager discovery — AWS SM, Azure KV, GCP SM
Extend certificate discovery from filesystem + network to cloud secret managers. Three pluggable DiscoverySource connectors feed into the existing discovery pipeline via sentinel agent pattern, with a 9th scheduler loop for periodic cloud scanning. - AWS Secrets Manager: aws-sdk-go-v2, tag/prefix filtering, 10 tests - Azure Key Vault: stdlib HTTP + OAuth2, base64 DER/PEM, 16 tests - GCP Secret Manager: stdlib HTTP + JWT OAuth2, label filter, 14 tests - CloudDiscoveryService orchestrator with 9 tests - 9th scheduler loop (6h default, atomic.Bool idempotency) - Discovery page: color-coded source type badges - 14 new env vars across CloudDiscoveryConfig structs - Docs: connectors.md, architecture.md, features.md, README updated 49 new tests. All CI checks pass (go vet, race, lint, coverage). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,611 @@
|
||||
// Package gcpsm implements the domain.DiscoverySource interface for GCP Secret Manager.
|
||||
//
|
||||
// GCP Secret Manager is a Google Cloud service for securely storing and managing secrets,
|
||||
// including certificates. This discovery source scans Secret Manager for certificates stored
|
||||
// as secrets, filters by configured tags, and reports discovered certificate metadata
|
||||
// back to the control plane for triage and management.
|
||||
//
|
||||
// Discovery approach:
|
||||
// 1. Authenticate using service account JSON credentials (JWT → OAuth2 token exchange)
|
||||
// 2. List all secrets in the configured GCP project
|
||||
// 3. Filter by label "type=certificate"
|
||||
// 4. For each secret, retrieve the latest version's data
|
||||
// 5. Base64-decode the secret value, then attempt PEM or DER parsing
|
||||
// 6. Extract certificate metadata (CN, SANs, serial, validity, key algorithm, etc.)
|
||||
// 7. Report findings with sentinel agent ID "cloud-gcp-sm" and source path "gcp-sm://{project}/{secret-name}"
|
||||
//
|
||||
// Authentication: OAuth2 service account via JWT assertion. The service account
|
||||
// credentials must be provided in a JSON file. The connector loads the private key,
|
||||
// builds a JWT, exchanges it for an access token, then uses Bearer token auth for
|
||||
// all subsequent Secret Manager API calls.
|
||||
//
|
||||
// GCP Secret Manager API operations used:
|
||||
//
|
||||
// GET /v1/projects/{project}/secrets - List secrets with filtering
|
||||
// GET /v1/projects/{project}/secrets/{name}/versions/latest:access - Access secret data
|
||||
package gcpsm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// serviceAccountKey represents the relevant fields from a Google service account JSON file.
|
||||
type serviceAccountKey struct {
|
||||
Type string `json:"type"`
|
||||
ProjectID string `json:"project_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
ClientEmail string `json:"client_email"`
|
||||
TokenURI string `json:"token_uri"`
|
||||
}
|
||||
|
||||
// cachedToken holds an OAuth2 access token and its expiry.
|
||||
type cachedToken struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// SMClient defines the interface for interacting with GCP Secret Manager.
|
||||
// This allows for dependency injection and testing with mock clients.
|
||||
type SMClient interface {
|
||||
// ListSecrets lists secrets in the project, filtered by the "type=certificate" label.
|
||||
ListSecrets(ctx context.Context, project string) ([]SecretEntry, error)
|
||||
|
||||
// AccessSecretVersion retrieves the latest version data for a secret.
|
||||
AccessSecretVersion(ctx context.Context, project, secretName string) ([]byte, error)
|
||||
}
|
||||
|
||||
// SecretEntry represents metadata about a secret from ListSecrets.
|
||||
type SecretEntry struct {
|
||||
Name string // Full resource name: projects/{project}/secrets/{name}
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// Source represents a GCP Secret Manager discovery source.
|
||||
type Source struct {
|
||||
cfg *config.GCPSecretMgrDiscoveryConfig
|
||||
|
||||
// For real HTTP client
|
||||
httpClient *http.Client
|
||||
|
||||
// For test injection
|
||||
client SMClient
|
||||
|
||||
logger *slog.Logger
|
||||
|
||||
// OAuth2 token caching
|
||||
mu sync.Mutex
|
||||
tokenCache *cachedToken
|
||||
saKey *serviceAccountKey
|
||||
rsaKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// New creates a new GCP Secret Manager discovery source with the given configuration.
|
||||
// It uses the real HTTP client for authenticating with GCP.
|
||||
func New(cfg *config.GCPSecretMgrDiscoveryConfig, logger *slog.Logger) *Source {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &config.GCPSecretMgrDiscoveryConfig{}
|
||||
}
|
||||
|
||||
return &Source{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithClient creates a new GCP Secret Manager discovery source with an injected client.
|
||||
// This is primarily for testing.
|
||||
func NewWithClient(cfg *config.GCPSecretMgrDiscoveryConfig, client SMClient, logger *slog.Logger) *Source {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &config.GCPSecretMgrDiscoveryConfig{}
|
||||
}
|
||||
|
||||
return &Source{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns a human-readable name for this discovery source.
|
||||
func (s *Source) Name() string {
|
||||
return "GCP Secret Manager"
|
||||
}
|
||||
|
||||
// Type returns the short type identifier for this discovery source.
|
||||
func (s *Source) Type() string {
|
||||
return "gcp-sm"
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the source is properly configured.
|
||||
func (s *Source) ValidateConfig() error {
|
||||
if s.cfg == nil {
|
||||
return fmt.Errorf("gcp secret manager discovery config is nil")
|
||||
}
|
||||
if s.cfg.Project == "" {
|
||||
return fmt.Errorf("gcp secret manager project is required")
|
||||
}
|
||||
if s.cfg.Credentials == "" {
|
||||
return fmt.Errorf("gcp secret manager credentials path is required")
|
||||
}
|
||||
|
||||
// Verify credentials file exists and is valid
|
||||
_, _, err := loadServiceAccountKey(s.cfg.Credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcp secret manager credentials invalid: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Discover scans GCP Secret Manager for certificates and returns a DiscoveryReport.
|
||||
func (s *Source) Discover(ctx context.Context) (*domain.DiscoveryReport, error) {
|
||||
if err := s.ValidateConfig(); err != nil {
|
||||
return nil, fmt.Errorf("invalid gcp secret manager config: %w", err)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
report := &domain.DiscoveryReport{
|
||||
AgentID: "cloud-gcp-sm",
|
||||
Directories: []string{fmt.Sprintf("gcp-sm://%s/", s.cfg.Project)},
|
||||
Certificates: []domain.DiscoveredCertEntry{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
// Get or create client (use injected mock for testing, real client otherwise)
|
||||
var client SMClient
|
||||
if s.client != nil {
|
||||
client = s.client
|
||||
} else {
|
||||
client = &httpSMClient{
|
||||
source: s,
|
||||
logger: s.logger,
|
||||
}
|
||||
}
|
||||
|
||||
// List secrets in GCP Secret Manager
|
||||
s.logger.Debug("listing secrets in gcp secret manager", "project", s.cfg.Project)
|
||||
secrets, err := client.ListSecrets(ctx, s.cfg.Project)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to list secrets: %v", err)
|
||||
report.Errors = append(report.Errors, errMsg)
|
||||
s.logger.Error(errMsg)
|
||||
return report, err
|
||||
}
|
||||
|
||||
s.logger.Debug("found secrets", "count", len(secrets))
|
||||
|
||||
// Process each secret
|
||||
for _, secret := range secrets {
|
||||
// Extract secret name from full resource name: projects/{project}/secrets/{name}
|
||||
parts := strings.Split(secret.Name, "/")
|
||||
if len(parts) < 2 {
|
||||
report.Errors = append(report.Errors, fmt.Sprintf("invalid secret name format: %s", secret.Name))
|
||||
continue
|
||||
}
|
||||
secretName := parts[len(parts)-1]
|
||||
|
||||
// Access the latest version of the secret
|
||||
data, err := client.AccessSecretVersion(ctx, s.cfg.Project, secretName)
|
||||
if err != nil {
|
||||
report.Errors = append(report.Errors, fmt.Sprintf("failed to access secret %s: %v", secretName, err))
|
||||
s.logger.Warn("failed to access secret", "secret", secretName, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to parse the data as a certificate (PEM or DER)
|
||||
cert, err := parseCertificate(data)
|
||||
if err != nil {
|
||||
report.Errors = append(report.Errors, fmt.Sprintf("failed to parse certificate in secret %s: %v", secretName, err))
|
||||
s.logger.Warn("failed to parse certificate", "secret", secretName, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract certificate metadata
|
||||
entry := s.extractCertificateMetadata(cert, secretName)
|
||||
report.Certificates = append(report.Certificates, entry)
|
||||
}
|
||||
|
||||
report.ScanDurationMs = int(time.Since(startTime).Milliseconds())
|
||||
s.logger.Info("gcp secret manager discovery completed",
|
||||
"project", s.cfg.Project,
|
||||
"certificates_found", len(report.Certificates),
|
||||
"errors", len(report.Errors),
|
||||
"duration_ms", report.ScanDurationMs)
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// extractCertificateMetadata extracts certificate metadata from an x509.Certificate.
|
||||
func (s *Source) extractCertificateMetadata(cert *x509.Certificate, secretName string) domain.DiscoveredCertEntry {
|
||||
// Compute SHA-256 fingerprint
|
||||
certDER := cert.Raw
|
||||
hash := sha256.Sum256(certDER)
|
||||
fingerprint := strings.ToUpper(fmt.Sprintf("%x", hash[:]))
|
||||
|
||||
// Extract SANs
|
||||
var sans []string
|
||||
sans = append(sans, cert.DNSNames...)
|
||||
sans = append(sans, cert.EmailAddresses...)
|
||||
for _, ip := range cert.IPAddresses {
|
||||
sans = append(sans, ip.String())
|
||||
}
|
||||
|
||||
// Determine key algorithm and size
|
||||
keyAlgo := "unknown"
|
||||
keySize := 0
|
||||
|
||||
switch pk := cert.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
keyAlgo = "RSA"
|
||||
keySize = pk.N.BitLen()
|
||||
case *ecdsa.PublicKey:
|
||||
keyAlgo = "ECDSA"
|
||||
switch pk.Curve.Params().Name {
|
||||
case "P-256":
|
||||
keySize = 256
|
||||
case "P-384":
|
||||
keySize = 384
|
||||
case "P-521":
|
||||
keySize = 521
|
||||
default:
|
||||
keySize = pk.X.BitLen()
|
||||
}
|
||||
case ed25519.PublicKey:
|
||||
keyAlgo = "Ed25519"
|
||||
keySize = 253
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
notBeforeStr := cert.NotBefore.UTC().Format(time.RFC3339)
|
||||
notAfterStr := cert.NotAfter.UTC().Format(time.RFC3339)
|
||||
|
||||
// Build PEM representation
|
||||
pemData := encodeCertificatePEM(cert)
|
||||
|
||||
// Source path: gcp-sm://{project}/{secret-name}
|
||||
sourcePath := fmt.Sprintf("gcp-sm://%s/%s", s.cfg.Project, secretName)
|
||||
|
||||
return domain.DiscoveredCertEntry{
|
||||
FingerprintSHA256: fingerprint,
|
||||
CommonName: cert.Subject.CommonName,
|
||||
SANs: sans,
|
||||
SerialNumber: fmt.Sprintf("%x", cert.SerialNumber),
|
||||
IssuerDN: cert.Issuer.String(),
|
||||
SubjectDN: cert.Subject.String(),
|
||||
NotBefore: notBeforeStr,
|
||||
NotAfter: notAfterStr,
|
||||
KeyAlgorithm: keyAlgo,
|
||||
KeySize: keySize,
|
||||
IsCA: cert.IsCA,
|
||||
PEMData: pemData,
|
||||
SourcePath: sourcePath,
|
||||
SourceFormat: "PEM",
|
||||
}
|
||||
}
|
||||
|
||||
// parseCertificate parses a certificate from data that may be PEM or base64-encoded DER.
|
||||
func parseCertificate(data []byte) (*x509.Certificate, error) {
|
||||
// First try PEM
|
||||
block, _ := pem.Decode(data)
|
||||
if block != nil && block.Type == "CERTIFICATE" {
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// Try base64-decode and then DER
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(data)))
|
||||
if err == nil {
|
||||
if cert, err := x509.ParseCertificate(decoded); err == nil {
|
||||
return cert, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try raw DER
|
||||
if cert, err := x509.ParseCertificate(data); err == nil {
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to parse certificate from any format (PEM, base64 DER, or DER)")
|
||||
}
|
||||
|
||||
// encodeCertificatePEM encodes an x509.Certificate as PEM.
|
||||
func encodeCertificatePEM(cert *x509.Certificate) string {
|
||||
block := &pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: cert.Raw,
|
||||
}
|
||||
return string(pem.EncodeToMemory(block))
|
||||
}
|
||||
|
||||
// loadServiceAccountKey reads and parses a service account JSON file.
|
||||
func loadServiceAccountKey(path string) (*serviceAccountKey, *rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot read credentials file: %w", err)
|
||||
}
|
||||
|
||||
var saKey serviceAccountKey
|
||||
if err := json.Unmarshal(data, &saKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot parse credentials JSON: %w", err)
|
||||
}
|
||||
|
||||
if saKey.PrivateKey == "" {
|
||||
return &saKey, nil, nil
|
||||
}
|
||||
|
||||
// Parse the RSA private key
|
||||
block, _ := pem.Decode([]byte(saKey.PrivateKey))
|
||||
if block == nil {
|
||||
return nil, nil, fmt.Errorf("cannot decode private key PEM")
|
||||
}
|
||||
|
||||
// Try PKCS#8 first, then PKCS#1
|
||||
var rsaKey *rsa.PrivateKey
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
var ok bool
|
||||
rsaKey, ok = key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("private key is not RSA")
|
||||
}
|
||||
} else if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
rsaKey = key
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("cannot parse private key: not PKCS#8 or PKCS#1")
|
||||
}
|
||||
|
||||
return &saKey, rsaKey, nil
|
||||
}
|
||||
|
||||
// getAccessToken returns a valid OAuth2 access token, refreshing if needed.
|
||||
func (s *Source) getAccessToken(ctx context.Context) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Return cached token if still valid (5 min buffer)
|
||||
if s.tokenCache != nil && time.Now().Add(5*time.Minute).Before(s.tokenCache.expiresAt) {
|
||||
return s.tokenCache.token, nil
|
||||
}
|
||||
|
||||
// Load credentials if not cached
|
||||
if s.saKey == nil || s.rsaKey == nil {
|
||||
saKey, rsaKey, err := loadServiceAccountKey(s.cfg.Credentials)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load credentials: %w", err)
|
||||
}
|
||||
s.saKey = saKey
|
||||
s.rsaKey = rsaKey
|
||||
}
|
||||
|
||||
// Build JWT
|
||||
now := time.Now()
|
||||
header := base64URLEncode([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||
|
||||
claims, err := json.Marshal(map[string]interface{}{
|
||||
"iss": s.saKey.ClientEmail,
|
||||
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||||
"aud": s.saKey.TokenURI,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal JWT claims: %w", err)
|
||||
}
|
||||
payload := base64URLEncode(claims)
|
||||
|
||||
// Sign
|
||||
signingInput := header + "." + payload
|
||||
hash := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, s.rsaKey, crypto.SHA256, hash[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
jwt := signingInput + "." + base64URLEncode(sig)
|
||||
|
||||
// Exchange JWT for access token
|
||||
form := url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
||||
"assertion": {jwt},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.saKey.TokenURI,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token exchange failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token exchange returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return "", fmt.Errorf("empty access token in response")
|
||||
}
|
||||
|
||||
// Cache token
|
||||
s.tokenCache = &cachedToken{
|
||||
token: tokenResp.AccessToken,
|
||||
expiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
|
||||
}
|
||||
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// httpSMClient implements SMClient using the real GCP Secret Manager HTTP API.
|
||||
type httpSMClient struct {
|
||||
source *Source
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// ListSecrets lists all secrets in the project, filtered by "type=certificate" label.
|
||||
func (c *httpSMClient) ListSecrets(ctx context.Context, project string) ([]SecretEntry, error) {
|
||||
token, err := c.source.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
// Build the list request URL with filter
|
||||
// Filter for secrets with label "type=certificate"
|
||||
filter := `labels.type=certificate`
|
||||
listURL := fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets?filter=%s",
|
||||
url.QueryEscape(project), url.QueryEscape(filter))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create list request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := c.source.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list secrets request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read list response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("list secrets returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var listResp struct {
|
||||
Secrets []struct {
|
||||
Name string `json:"name"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
} `json:"secrets"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &listResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse list response: %w", err)
|
||||
}
|
||||
|
||||
var secrets []SecretEntry
|
||||
for _, s := range listResp.Secrets {
|
||||
secrets = append(secrets, SecretEntry{
|
||||
Name: s.Name,
|
||||
Labels: s.Labels,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: handle pagination with nextPageToken if needed for large secret managers
|
||||
// For now, just return the first page results
|
||||
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// AccessSecretVersion retrieves the latest version of a secret's data.
|
||||
func (c *httpSMClient) AccessSecretVersion(ctx context.Context, project, secretName string) ([]byte, error) {
|
||||
token, err := c.source.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
// Build the access request URL
|
||||
accessURL := fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s/versions/latest:access",
|
||||
url.QueryEscape(project), url.QueryEscape(secretName))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, accessURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create access request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := c.source.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("access secret request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read access response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("access secret returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response to extract the payload data field
|
||||
var accessResp struct {
|
||||
Payload struct {
|
||||
Data string `json:"data"` // base64-encoded secret data
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &accessResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse access response: %w", err)
|
||||
}
|
||||
|
||||
// Decode the base64-encoded data
|
||||
data, err := base64.StdEncoding.DecodeString(accessResp.Payload.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to base64-decode secret data: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// base64URLEncode encodes data using base64url without padding.
|
||||
func base64URLEncode(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// Ensure Source implements the domain.DiscoverySource interface.
|
||||
var _ domain.DiscoverySource = (*Source)(nil)
|
||||
@@ -0,0 +1,525 @@
|
||||
package gcpsm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// mockSMClient implements SMClient for testing.
|
||||
type mockSMClient struct {
|
||||
secrets map[string][]byte
|
||||
accessErrors map[string]error
|
||||
listSecretsError error
|
||||
listSecretsHook func(ctx context.Context, project string) ([]SecretEntry, error)
|
||||
}
|
||||
|
||||
func newMockSMClient() *mockSMClient {
|
||||
return &mockSMClient{
|
||||
secrets: make(map[string][]byte),
|
||||
accessErrors: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockSMClient) ListSecrets(ctx context.Context, project string) ([]SecretEntry, error) {
|
||||
if m.listSecretsHook != nil {
|
||||
return m.listSecretsHook(ctx, project)
|
||||
}
|
||||
|
||||
if m.listSecretsError != nil {
|
||||
return nil, m.listSecretsError
|
||||
}
|
||||
|
||||
var entries []SecretEntry
|
||||
for name := range m.secrets {
|
||||
entries = append(entries, SecretEntry{
|
||||
Name: fmt.Sprintf("projects/%s/secrets/%s", project, name),
|
||||
Labels: map[string]string{"type": "certificate"},
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (m *mockSMClient) AccessSecretVersion(ctx context.Context, project, secretName string) ([]byte, error) {
|
||||
if err, ok := m.accessErrors[secretName]; ok {
|
||||
return nil, err
|
||||
}
|
||||
if data, ok := m.secrets[secretName]; ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("secret not found: %s", secretName)
|
||||
}
|
||||
|
||||
// generateTestCertificate generates a self-signed test certificate.
|
||||
func generateTestCertificate(cn string, expire time.Duration) (*x509.Certificate, []byte, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Create a certificate template
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: cn,
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(expire),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
},
|
||||
DNSNames: []string{"example.com", "*.example.com"},
|
||||
EmailAddresses: []string{"test@example.com"},
|
||||
}
|
||||
|
||||
// Self-sign the certificate
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Parse the DER-encoded cert
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Return both the cert object and the PEM-encoded version
|
||||
pemData := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certDER,
|
||||
})
|
||||
|
||||
return cert, pemData, nil
|
||||
}
|
||||
|
||||
// createTempServiceAccountKey creates a temporary service account key file for testing.
|
||||
func createTempServiceAccountKey() (string, error) {
|
||||
tmpfile, err := os.CreateTemp("", "gcpsm-test-*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tmpfile.Close()
|
||||
|
||||
// Generate a minimal RSA key for the test
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert to PKCS#8 PEM format
|
||||
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: privateKeyDER,
|
||||
})
|
||||
|
||||
// Create a minimal service account key JSON
|
||||
keyJSON := fmt.Sprintf(`{
|
||||
"type": "service_account",
|
||||
"project_id": "test-project",
|
||||
"private_key": %q,
|
||||
"client_email": "test@test-project.iam.gserviceaccount.com",
|
||||
"token_uri": "https://oauth2.googleapis.com/token"
|
||||
}`, string(privateKeyPEM))
|
||||
|
||||
_, err = tmpfile.WriteString(keyJSON)
|
||||
if err != nil {
|
||||
os.Remove(tmpfile.Name())
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tmpfile.Name(), nil
|
||||
}
|
||||
|
||||
func TestValidateConfig_Success(t *testing.T) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := New(cfg, slog.Default())
|
||||
if err := source.ValidateConfig(); err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingProject(t *testing.T) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := New(cfg, slog.Default())
|
||||
if err := source.ValidateConfig(); err == nil {
|
||||
t.Error("expected ValidateConfig to fail with missing project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingCredentials(t *testing.T) {
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: "",
|
||||
}
|
||||
|
||||
source := New(cfg, slog.Default())
|
||||
if err := source.ValidateConfig(); err == nil {
|
||||
t.Error("expected ValidateConfig to fail with missing credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidCredentialsFile(t *testing.T) {
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: "/nonexistent/path/to/creds.json",
|
||||
}
|
||||
|
||||
source := New(cfg, slog.Default())
|
||||
if err := source.ValidateConfig(); err == nil {
|
||||
t.Error("expected ValidateConfig to fail with invalid credentials file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_Success(t *testing.T) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
// Generate two test certificates: one valid, one that will cause a parse error
|
||||
validCert, validPEM, err := generateTestCertificate("test.example.com", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test certificate: %v", err)
|
||||
}
|
||||
|
||||
// Create a mock client with both secrets
|
||||
mockClient := newMockSMClient()
|
||||
mockClient.secrets["valid-cert"] = validPEM
|
||||
mockClient.secrets["invalid-data"] = []byte("not a certificate at all")
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := NewWithClient(cfg, mockClient, slog.Default())
|
||||
report, err := source.Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have discovered 1 valid certificate
|
||||
if len(report.Certificates) != 1 {
|
||||
t.Errorf("expected 1 certificate, got %d", len(report.Certificates))
|
||||
}
|
||||
|
||||
// Should have 1 error (invalid-data)
|
||||
if len(report.Errors) != 1 {
|
||||
t.Errorf("expected 1 error, got %d", len(report.Errors))
|
||||
}
|
||||
|
||||
// Verify certificate metadata
|
||||
entry := report.Certificates[0]
|
||||
if entry.CommonName != "test.example.com" {
|
||||
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
|
||||
}
|
||||
if entry.KeyAlgorithm != "RSA" {
|
||||
t.Errorf("expected RSA key algorithm, got %s", entry.KeyAlgorithm)
|
||||
}
|
||||
if entry.KeySize != 2048 {
|
||||
t.Errorf("expected 2048-bit key, got %d", entry.KeySize)
|
||||
}
|
||||
|
||||
// Verify source path
|
||||
if !contains(report.Directories, "gcp-sm://test-project/") {
|
||||
t.Errorf("expected directory 'gcp-sm://test-project/', got %v", report.Directories)
|
||||
}
|
||||
|
||||
// Verify fingerprint calculation
|
||||
if entry.FingerprintSHA256 == "" {
|
||||
t.Error("expected non-empty fingerprint")
|
||||
}
|
||||
|
||||
// Verify SANs
|
||||
if !contains(entry.SANs, "example.com") || !contains(entry.SANs, "*.example.com") {
|
||||
t.Errorf("expected DNS SANs, got %v", entry.SANs)
|
||||
}
|
||||
|
||||
// Verify cert serial number matches
|
||||
if entry.SerialNumber != fmt.Sprintf("%x", validCert.SerialNumber) {
|
||||
t.Errorf("serial number mismatch: expected %x, got %s", validCert.SerialNumber, entry.SerialNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_EmptySecrets(t *testing.T) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
mockClient := newMockSMClient()
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := NewWithClient(cfg, mockClient, slog.Default())
|
||||
report, err := source.Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover failed: %v", err)
|
||||
}
|
||||
|
||||
if len(report.Certificates) != 0 {
|
||||
t.Errorf("expected 0 certificates, got %d", len(report.Certificates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_ListSecretsError(t *testing.T) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
// Create a mock client that fails on ListSecrets
|
||||
mockClient := newMockSMClient()
|
||||
mockClient.listSecretsError = fmt.Errorf("simulated ListSecrets error")
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := NewWithClient(cfg, mockClient, slog.Default())
|
||||
report, err := source.Discover(context.Background())
|
||||
|
||||
// Should return error
|
||||
if err == nil {
|
||||
t.Error("expected Discover to fail when ListSecrets fails")
|
||||
}
|
||||
|
||||
// But should still return a report with the error recorded
|
||||
if report == nil || len(report.Errors) == 0 {
|
||||
t.Error("expected error to be recorded in report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_AccessSecretError(t *testing.T) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
mockClient := newMockSMClient()
|
||||
mockClient.accessErrors["broken-secret"] = fmt.Errorf("simulated AccessSecretVersion error")
|
||||
// Add to list via the hook since we need it listed but access should fail
|
||||
mockClient.listSecretsHook = func(ctx context.Context, project string) ([]SecretEntry, error) {
|
||||
return []SecretEntry{
|
||||
{Name: fmt.Sprintf("projects/%s/secrets/broken-secret", project), Labels: map[string]string{"type": "certificate"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := NewWithClient(cfg, mockClient, slog.Default())
|
||||
report, _ := source.Discover(context.Background())
|
||||
|
||||
// Should record error but not fail the whole operation
|
||||
if len(report.Errors) == 0 {
|
||||
t.Error("expected error to be recorded in report")
|
||||
}
|
||||
if len(report.Certificates) != 0 {
|
||||
t.Errorf("expected 0 certificates, got %d", len(report.Certificates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_AgentIDAndSourcePath(t *testing.T) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
_, certPEM, err := generateTestCertificate("test.example.com", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test certificate: %v", err)
|
||||
}
|
||||
|
||||
mockClient := newMockSMClient()
|
||||
mockClient.secrets["my-cert"] = certPEM
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "my-gcp-project",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := NewWithClient(cfg, mockClient, slog.Default())
|
||||
report, err := source.Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify agent ID
|
||||
if report.AgentID != "cloud-gcp-sm" {
|
||||
t.Errorf("expected agent ID 'cloud-gcp-sm', got '%s'", report.AgentID)
|
||||
}
|
||||
|
||||
// Verify source path format
|
||||
if len(report.Certificates) > 0 {
|
||||
entry := report.Certificates[0]
|
||||
expectedPath := "gcp-sm://my-gcp-project/my-cert"
|
||||
if entry.SourcePath != expectedPath {
|
||||
t.Errorf("expected source path '%s', got '%s'", expectedPath, entry.SourcePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCertificate_PEM(t *testing.T) {
|
||||
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test certificate: %v", err)
|
||||
}
|
||||
|
||||
cert, err := parseCertificate(certPEM)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse PEM certificate: %v", err)
|
||||
}
|
||||
|
||||
if cert.Subject.CommonName != "test.com" {
|
||||
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCertificate_Base64DER(t *testing.T) {
|
||||
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test certificate: %v", err)
|
||||
}
|
||||
|
||||
// Decode PEM and re-encode as base64 DER
|
||||
block, _ := pem.Decode(certPEM)
|
||||
base64DER := []byte(base64.StdEncoding.EncodeToString(block.Bytes))
|
||||
|
||||
cert, err := parseCertificate(base64DER)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse base64 DER certificate: %v", err)
|
||||
}
|
||||
|
||||
if cert.Subject.CommonName != "test.com" {
|
||||
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCertificate_RawDER(t *testing.T) {
|
||||
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test certificate: %v", err)
|
||||
}
|
||||
|
||||
// Decode PEM to get raw DER
|
||||
block, _ := pem.Decode(certPEM)
|
||||
|
||||
cert, err := parseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse raw DER certificate: %v", err)
|
||||
}
|
||||
|
||||
if cert.Subject.CommonName != "test.com" {
|
||||
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCertificate_Invalid(t *testing.T) {
|
||||
invalidData := []byte("not a certificate at all")
|
||||
|
||||
_, err := parseCertificate(invalidData)
|
||||
if err == nil {
|
||||
t.Error("expected parseCertificate to fail on invalid data")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a slice contains a string
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestSourceImplementsInterface ensures Source implements domain.DiscoverySource
|
||||
func TestSourceImplementsInterface(t *testing.T) {
|
||||
var _ domain.DiscoverySource = (*Source)(nil)
|
||||
}
|
||||
|
||||
// BenchmarkDiscover provides basic performance metrics for discovery
|
||||
func BenchmarkDiscover(b *testing.B) {
|
||||
tmpfile, err := createTempServiceAccountKey()
|
||||
if err != nil {
|
||||
b.Fatalf("failed to create temp key file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpfile)
|
||||
|
||||
// Generate 10 test certificates
|
||||
mockClient := newMockSMClient()
|
||||
for i := 0; i < 10; i++ {
|
||||
_, certPEM, err := generateTestCertificate(fmt.Sprintf("test%d.example.com", i), 24*time.Hour)
|
||||
if err != nil {
|
||||
b.Fatalf("failed to generate test certificate: %v", err)
|
||||
}
|
||||
mockClient.secrets[fmt.Sprintf("cert-%d", i)] = certPEM
|
||||
}
|
||||
|
||||
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
||||
Project: "test-project",
|
||||
Credentials: tmpfile,
|
||||
}
|
||||
|
||||
source := NewWithClient(cfg, mockClient, slog.Default())
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := source.Discover(context.Background())
|
||||
if err != nil {
|
||||
b.Fatalf("Discover failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user