Files
Charon/docs/plans/archive/phase3_caddy_integration_completion.md
2026-02-19 16:34:10 +00:00

26 KiB

Phase 3: Caddy Manager Multi-Credential Integration - Completion Plan

Status: 95% Complete - Final Integration Required Created: 2026-01-04 Target Completion: Sprint 11

Executive Summary

The multi-credential infrastructure is complete (models, services, API, helpers, tests). The remaining 5% is integrating the credential resolution logic into the Caddy Manager's config generation flow.

Completion Checklist

  • DNSProviderCredential model created
  • CredentialService with zone matching
  • API handlers (7 endpoints)
  • Helper functions (extractBaseDomain, matchesZoneFilter, getCredentialForDomain)
  • Helper function tests
  • ApplyConfig credential resolution loop ← THIS STEP
  • buildDNSChallengeIssuer integration ← THIS STEP
  • Integration tests
  • Backward compatibility validation

Part 1: Understanding Current Flow

Current Architecture (Single Credential)

File: backend/internal/caddy/manager.go Method: ApplyConfig() (Lines 80-140)

// Current flow:
1. Load proxy hosts from DB
2. Load DNS providers from DB
3. Decrypt DNS provider credentials (single set per provider)
4. Build dnsProviderConfigs []DNSProviderConfig
5. Pass to GenerateConfig()

File: backend/internal/caddy/config.go Method: GenerateConfig() (Lines 18-130) Submethods: DNS policy generation (Lines 131-220)

// Current flow:
1. Group hosts by DNS provider
2. For each provider: Build DNS challenge issuer with provider.Credentials
3. Create TLS automation policy with DNS challenge

New Architecture (Multi-Credential)

ApplyConfig()
  ↓
  For each proxy host with DNS challenge:
    ↓
    getCredentialForDomain(providerID, baseDomain, provider)
      ↓
      Returns zone-specific credentials (or provider default)
  ↓
  Store credentials in map[baseDomain]map[string]string
  ↓
  Pass map to GenerateConfig()
  ↓
  buildDNSChallengeIssuer() uses per-domain credentials

Part 2: Code Changes Required

Change 1: Add Fields to DNSProviderConfig

File: backend/internal/caddy/manager.go Location: Lines 38-44 (DNSProviderConfig struct)

Before:

// DNSProviderConfig contains a DNS provider with its decrypted credentials
// for use in Caddy DNS challenge configuration generation
type DNSProviderConfig struct {
 ID                 uint
 ProviderType       string
 PropagationTimeout int
 Credentials        map[string]string
}

After:

// DNSProviderConfig contains a DNS provider with its decrypted credentials
// for use in Caddy DNS challenge configuration generation
type DNSProviderConfig struct {
 ID                 uint
 ProviderType       string
 PropagationTimeout int

 // Single-credential mode: Use these credentials for all domains
 Credentials        map[string]string

 // Multi-credential mode: Use zone-specific credentials
 UseMultiCredentials bool
 ZoneCredentials     map[string]map[string]string // map[baseDomain]credentials
}

Why:

  • Backwards compatible: Existing Credentials field still works for single-cred mode
  • New ZoneCredentials field stores per-domain credentials
  • UseMultiCredentials flag determines which field to use

Change 2: Credential Resolution in ApplyConfig

File: backend/internal/caddy/manager.go Method: ApplyConfig() Location: Lines 80-140 (between provider decryption and GenerateConfig call)

Context (Lines 93-125):

 // Decrypt DNS provider credentials for config generation
 // We need an encryption service to decrypt the credentials
 var dnsProviderConfigs []DNSProviderConfig
 if len(dnsProviders) > 0 {
  // Try to get encryption key from environment
  encryptionKey := os.Getenv("CHARON_ENCRYPTION_KEY")
  if encryptionKey == "" {
   // Try alternative env vars
   for _, key := range []string{"ENCRYPTION_KEY", "CERBERUS_ENCRYPTION_KEY"} {
    if val := os.Getenv(key); val != "" {
     encryptionKey = val
     break
    }
   }
  }

  if encryptionKey != "" {
   // Import crypto package for inline decryption
   encryptor, err := crypto.NewEncryptionService(encryptionKey)
   if err != nil {
    logger.Log().WithError(err).Warn("failed to initialize encryption service for DNS provider credentials")
   } else {
    // Decrypt each DNS provider's credentials
    for _, provider := range dnsProviders {
     if provider.CredentialsEncrypted == "" {
      continue
     }

     decryptedData, err := encryptor.Decrypt(provider.CredentialsEncrypted)
     if err != nil {
      logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to decrypt DNS provider credentials")
      continue
     }

     var credentials map[string]string
     if err := json.Unmarshal(decryptedData, &credentials); err != nil {
      logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to parse DNS provider credentials")
      continue
     }

     dnsProviderConfigs = append(dnsProviderConfigs, DNSProviderConfig{
      ID:                 provider.ID,
      ProviderType:       provider.ProviderType,
      PropagationTimeout: provider.PropagationTimeout,
      Credentials:        credentials,
     })
    }
   }
  } else {
   logger.Log().Warn("CHARON_ENCRYPTION_KEY not set, DNS challenge configuration will be skipped")
  }
 }

Insert After Line 125 (after dnsProviderConfigs built, before acmeEmail fetch):

 // Phase 2: Resolve zone-specific credentials for multi-credential providers
 // For each provider with UseMultiCredentials=true, build a map of domain->credentials
 // by iterating through all proxy hosts that use DNS challenge
 for i := range dnsProviderConfigs {
  cfg := &dnsProviderConfigs[i]

  // Find the provider in the dnsProviders slice to check UseMultiCredentials
  var provider *models.DNSProvider
  for j := range dnsProviders {
   if dnsProviders[j].ID == cfg.ID {
    provider = &dnsProviders[j]
    break
   }
  }

  // Skip if not multi-credential mode or provider not found
  if provider == nil || !provider.UseMultiCredentials {
   continue
  }

  // Enable multi-credential mode for this provider config
  cfg.UseMultiCredentials = true
  cfg.ZoneCredentials = make(map[string]map[string]string)

  // Preload credentials for this provider (eager loading for better logging)
  if err := m.db.Preload("Credentials").First(provider, provider.ID).Error; err != nil {
   logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to preload credentials for provider")
   continue
  }

  // Iterate through proxy hosts to find domains that use this provider
  for _, host := range hosts {
   if !host.Enabled || host.DNSProviderID == nil || *host.DNSProviderID != provider.ID {
    continue
   }

   // Extract base domain from host's domain names
   baseDomain := extractBaseDomain(host.DomainNames)
   if baseDomain == "" {
    continue
   }

   // Skip if we already resolved credentials for this domain
   if _, exists := cfg.ZoneCredentials[baseDomain]; exists {
    continue
   }

   // Resolve the appropriate credential for this domain
   credentials, err := m.getCredentialForDomain(provider.ID, baseDomain, provider)
   if err != nil {
    logger.Log().
     WithError(err).
     WithField("provider_id", provider.ID).
     WithField("domain", baseDomain).
     Warn("failed to resolve credential for domain, DNS challenge will be skipped for this domain")
    continue
   }

   // Store resolved credentials for this domain
   cfg.ZoneCredentials[baseDomain] = credentials

   logger.Log().WithFields(map[string]any{
    "provider_id":   provider.ID,
    "provider_type": provider.ProviderType,
    "domain":        baseDomain,
   }).Debug("resolved credential for domain")
  }

  // Log summary of credential resolution for audit trail
  logger.Log().WithFields(map[string]any{
   "provider_id":      provider.ID,
   "provider_type":    provider.ProviderType,
   "domains_resolved": len(cfg.ZoneCredentials),
  }).Info("multi-credential DNS provider resolution complete")
 }

Why This Works:

  1. Non-invasive: Only adds logic for providers with UseMultiCredentials=true
  2. Backward compatible: Single-cred providers skip this entire block
  3. Efficient: Pre-resolves credentials once, before config generation
  4. Auditable: Logs credential selection for security compliance
  5. Error-resilient: Failed credential resolution logs warning, doesn't block entire config

Change 3: Use Resolved Credentials in Config Generation

File: backend/internal/caddy/config.go Method: GenerateConfig() Location: Lines 131-220 (DNS challenge policy generation)

Context (Lines 131-140):

 // Group hosts by DNS provider for TLS automation policies
 // We need separate policies for:
 // 1. Wildcard domains with DNS challenge (per DNS provider)
 // 2. Regular domains with HTTP challenge (default policy)
 var tlsPolicies []*AutomationPolicy

 // Build a map of DNS provider ID to DNS provider config for quick lookup
 dnsProviderMap := make(map[uint]DNSProviderConfig)
 for _, cfg := range dnsProviderConfigs {
  dnsProviderMap[cfg.ID] = cfg
 }

Find the section that builds DNS challenge issuer (Lines 180-230):

  // Create DNS challenge policies for each DNS provider
  for providerID, domains := range dnsProviderDomains {
   // Find the DNS provider config
   dnsConfig, ok := dnsProviderMap[providerID]
   if !ok {
    logger.Log().WithField("provider_id", providerID).Warn("DNS provider not found in decrypted configs")
    continue
   }

   // Build provider config for Caddy with decrypted credentials
   providerConfig := map[string]any{
    "name": dnsConfig.ProviderType,
   }

   // Add all credential fields to the provider config
   for key, value := range dnsConfig.Credentials {
    providerConfig[key] = value
   }

Replace Lines 190-198 (credential assembly) with multi-credential logic:

  // Create DNS challenge policies for each DNS provider
  for providerID, domains := range dnsProviderDomains {
   // Find the DNS provider config
   dnsConfig, ok := dnsProviderMap[providerID]
   if !ok {
    logger.Log().WithField("provider_id", providerID).Warn("DNS provider not found in decrypted configs")
    continue
   }

   // **CHANGED: Multi-credential support**
   // If provider uses multi-credentials, create separate policies per domain
   if dnsConfig.UseMultiCredentials && len(dnsConfig.ZoneCredentials) > 0 {
    // Create a separate TLS automation policy for each domain with its own credentials
    for baseDomain, credentials := range dnsConfig.ZoneCredentials {
     // Find all domains that match this base domain
     var matchingDomains []string
     for _, domain := range domains {
      if extractBaseDomain(domain) == baseDomain {
       matchingDomains = append(matchingDomains, domain)
      }
     }

     if len(matchingDomains) == 0 {
      continue // No domains for this credential
     }

     // Build provider config with zone-specific credentials
     providerConfig := map[string]any{
      "name": dnsConfig.ProviderType,
     }
     for key, value := range credentials {
      providerConfig[key] = value
     }

     // Build issuer config with these credentials
     var issuers []any
     switch sslProvider {
     case "letsencrypt":
      acmeIssuer := map[string]any{
       "module": "acme",
       "email":  acmeEmail,
       "challenges": map[string]any{
        "dns": map[string]any{
         "provider":            providerConfig,
         "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
        },
       },
      }
      if acmeStaging {
       acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
      }
      issuers = append(issuers, acmeIssuer)
     case "zerossl":
      issuers = append(issuers, map[string]any{
       "module": "zerossl",
       "challenges": map[string]any{
        "dns": map[string]any{
         "provider":            providerConfig,
         "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
        },
       },
      })
     default: // "both" or empty
      acmeIssuer := map[string]any{
       "module": "acme",
       "email":  acmeEmail,
       "challenges": map[string]any{
        "dns": map[string]any{
         "provider":            providerConfig,
         "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
        },
       },
      }
      if acmeStaging {
       acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
      }
      issuers = append(issuers, acmeIssuer)
      issuers = append(issuers, map[string]any{
       "module": "zerossl",
       "challenges": map[string]any{
        "dns": map[string]any{
         "provider":            providerConfig,
         "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
        },
       },
      })
     }

     // Create TLS automation policy for this domain with zone-specific credentials
     tlsPolicies = append(tlsPolicies, &AutomationPolicy{
      Subjects:   dedupeDomains(matchingDomains),
      IssuersRaw: issuers,
     })

     logger.Log().WithFields(map[string]any{
      "provider_id":    providerID,
      "base_domain":    baseDomain,
      "domain_count":   len(matchingDomains),
      "credential_used": true,
     }).Debug("created DNS challenge policy with zone-specific credential")
    }

    // Skip the original single-credential logic below
    continue
   }

   // **ORIGINAL: Single-credential mode (backward compatible)**
   // Build provider config for Caddy with decrypted credentials
   providerConfig := map[string]any{
    "name": dnsConfig.ProviderType,
   }

   // Add all credential fields to the provider config
   for key, value := range dnsConfig.Credentials {
    providerConfig[key] = value
   }

   // [KEEP EXISTING CODE FROM HERE - Lines 201-235 for single-credential issuer creation]

Why This Works:

  1. Conditional branching: Checks UseMultiCredentials flag
  2. Per-domain policies: Creates separate TLS automation policies per domain
  3. Credential isolation: Each domain gets its own credential set
  4. Backward compatible: Falls back to original logic for single-cred mode
  5. Auditable: Logs which credential is used for each domain

Part 3: Testing Strategy

Test 1: Backward Compatibility (Single Credential)

File: backend/internal/caddy/manager_test.go

func TestApplyConfig_SingleCredential_BackwardCompatibility(t *testing.T) {
 // Setup: Create provider with UseMultiCredentials=false
 provider := models.DNSProvider{
  ProviderType:        "cloudflare",
  UseMultiCredentials: false,
  CredentialsEncrypted: encryptJSON(t, map[string]string{
   "api_token": "test-token",
  }),
 }

 // Setup: Create proxy host with wildcard domain
 host := models.ProxyHost{
  DomainNames:    "*.example.com",
  DNSProviderID:  &provider.ID,
  ForwardHost:    "localhost",
  ForwardPort:    8080,
  Enabled:        true,
 }

 // Act: Apply config
 err := manager.ApplyConfig(ctx)

 // Assert: No errors
 require.NoError(t, err)

 // Assert: Generated config uses provider credentials
 config, err := manager.GetCurrentConfig(ctx)
 require.NoError(t, err)

 // Assert: TLS policy has DNS challenge with correct credentials
 assertDNSChallengePolicy(t, config, "example.com", "cloudflare", "test-token")
}

Test 2: Multi-Credential Zone Matching

File: backend/internal/caddy/manager_multicred_integration_test.go (new file)

func TestApplyConfig_MultiCredential_ZoneMatching(t *testing.T) {
 // Setup: Create provider with UseMultiCredentials=true
 provider := models.DNSProvider{
  ProviderType:        "cloudflare",
  UseMultiCredentials: true,
  Credentials: []models.DNSProviderCredential{
   {
    Label:      "Example.com Credential",
    ZoneFilter: "example.com",
    CredentialsEncrypted: encryptJSON(t, map[string]string{
     "api_token": "token-example-com",
    }),
    Enabled: true,
   },
   {
    Label:      "Example.org Credential",
    ZoneFilter: "example.org",
    CredentialsEncrypted: encryptJSON(t, map[string]string{
     "api_token": "token-example-org",
    }),
    Enabled: true,
   },
  },
 }

 // Setup: Create proxy hosts for different domains
 hosts := []models.ProxyHost{
  {
   DomainNames:    "*.example.com",
   DNSProviderID:  &provider.ID,
   ForwardHost:    "localhost",
   ForwardPort:    8080,
   Enabled:        true,
  },
  {
   DomainNames:    "*.example.org",
   DNSProviderID:  &provider.ID,
   ForwardHost:    "localhost",
   ForwardPort:    8081,
   Enabled:        true,
  },
 }

 // Act: Apply config
 err := manager.ApplyConfig(ctx)
 require.NoError(t, err)

 // Assert: Generated config has separate policies with correct credentials
 config, err := manager.GetCurrentConfig(ctx)
 require.NoError(t, err)

 assertDNSChallengePolicy(t, config, "example.com", "cloudflare", "token-example-com")
 assertDNSChallengePolicy(t, config, "example.org", "cloudflare", "token-example-org")
}

Test 3: Wildcard and Catch-All Matching

File: backend/internal/caddy/manager_multicred_integration_test.go

func TestApplyConfig_MultiCredential_WildcardAndCatchAll(t *testing.T) {
 // Setup: Provider with wildcard and catch-all credentials
 provider := models.DNSProvider{
  ProviderType:        "cloudflare",
  UseMultiCredentials: true,
  Credentials: []models.DNSProviderCredential{
   {
    Label:      "Example.com Specific",
    ZoneFilter: "example.com",
    CredentialsEncrypted: encryptJSON(t, map[string]string{"api_token": "specific"}),
    Enabled: true,
   },
   {
    Label:      "Example.org Wildcard",
    ZoneFilter: "*.example.org",
    CredentialsEncrypted: encryptJSON(t, map[string]string{"api_token": "wildcard"}),
    Enabled: true,
   },
   {
    Label:      "Catch-All",
    ZoneFilter: "",
    CredentialsEncrypted: encryptJSON(t, map[string]string{"api_token": "catch-all"}),
    Enabled: true,
   },
  },
 }

 // Test exact match beats catch-all
 assertCredentialSelection(t, manager, provider.ID, "example.com", "specific")

 // Test wildcard match beats catch-all
 assertCredentialSelection(t, manager, provider.ID, "app.example.org", "wildcard")

 // Test catch-all for unmatched domain
 assertCredentialSelection(t, manager, provider.ID, "random.net", "catch-all")
}

Test 4: Error Handling

File: backend/internal/caddy/manager_multicred_integration_test.go

func TestApplyConfig_MultiCredential_ErrorHandling(t *testing.T) {
 tests := []struct {
  name          string
  setup         func(*models.DNSProvider)
  expectError   bool
  expectWarning string
 }{
  {
   name: "no matching credential",
   setup: func(p *models.DNSProvider) {
    p.Credentials = []models.DNSProviderCredential{
     {
      ZoneFilter: "example.com",
      Enabled:    true,
     },
    }
   },
   expectWarning: "failed to resolve credential for domain",
  },
  {
   name: "all credentials disabled",
   setup: func(p *models.DNSProvider) {
    p.Credentials = []models.DNSProviderCredential{
     {
      ZoneFilter: "example.com",
      Enabled:    false,
     },
    }
   },
   expectWarning: "no matching credential found",
  },
  {
   name: "decryption failure",
   setup: func(p *models.DNSProvider) {
    p.Credentials = []models.DNSProviderCredential{
     {
      ZoneFilter:           "example.com",
      CredentialsEncrypted: "invalid-encrypted-data",
      Enabled:              true,
     },
    }
   },
   expectWarning: "failed to decrypt credential",
  },
 }

 for _, tt := range tests {
  t.Run(tt.name, func(t *testing.T) {
   // Setup and run test
   // Assert warning is logged
  })
 }
}

Part 4: Integration Sequence

To avoid breaking intermediate states, apply changes in this order:

Step 1: Add Struct Fields

  • Modify DNSProviderConfig struct in manager.go
  • Add UseMultiCredentials and ZoneCredentials fields
  • Validation: Run go test ./internal/caddy -run TestApplyConfig - should still pass

Step 2: Add Credential Resolution Loop

  • Insert credential resolution code in ApplyConfig() after provider decryption
  • Validation: Run go test ./internal/caddy -run TestApplyConfig - should still pass
  • Validation: Check logs for "multi-credential DNS provider resolution complete"

Step 3: Update Config Generation

  • Modify GenerateConfig() to check UseMultiCredentials flag
  • Add per-domain policy creation logic
  • Keep fallback to original logic
  • Validation: Run go test ./internal/caddy/... - all tests should pass

Step 4: Add Integration Tests

  • Create manager_multicred_integration_test.go
  • Add 4 test scenarios above
  • Validation: All new tests pass

Step 5: Manual Validation

  • Start Charon with multi-credential provider
  • Create proxy hosts for different domains
  • Apply config and check generated Caddy config JSON
  • Verify separate TLS automation policies per domain

Part 5: Backward Compatibility Checklist

  • Single-credential providers (UseMultiCredentials=false) work unchanged
  • Existing proxy hosts with DNS challenge still get certificates
  • No breaking changes to DNSProviderConfig API (only additions)
  • Existing tests still pass without modification
  • New fields are optional (zero values = backward compatible behavior)
  • Error handling is non-fatal (warnings logged, doesn't block config)

Part 6: Performance Considerations

Optimization 1: Lazy Loading vs Eager Loading

Decision: Use eager loading in credential resolution loop Rationale:

  • Small dataset (typically <10 credentials per provider)
  • Better logging and debugging
  • Simpler error handling
  • Minimal performance impact

Optimization 2: Credential Caching

Decision: Pre-resolve credentials once in ApplyConfig, cache in ZoneCredentials map Rationale:

  • Avoids repeated DB queries during config generation
  • Credentials don't change during config generation
  • Simpler code flow

Optimization 3: Domain Deduplication

Decision: Skip already-resolved domains in credential resolution loop Rationale:

  • Multiple proxy hosts may use same base domain
  • Avoid redundant credential resolution
  • Slight performance gain

Part 7: Security Considerations

Audit Logging

  • Log credential selection for each domain (provider_id, domain, credential_uuid)
  • Log credential resolution summary (provider_id, domains_resolved)
  • Log credential selection in debug mode for troubleshooting

Error Handling

  • Failed credential resolution logs warning, doesn't block entire config
  • Decryption failures are non-fatal for individual credentials
  • No credentials in error messages (use UUIDs only)

Credential Isolation

  • Each domain gets its own credential set in Caddy config
  • No credential leakage between domains
  • Caddy enforces per-policy credential usage

Part 8: Rollback Plan

If issues arise after deployment:

  1. Immediate: Set UseMultiCredentials=false on all providers via API
  2. Short-term: Revert to previous Charon version
  3. Investigation: Check logs for credential resolution warnings
  4. Fix: Address specific credential matching or decryption issues

Part 9: Success Criteria

  • All existing tests pass
  • 4 new integration tests pass
  • Manual testing with 2+ domains per provider works
  • Backward compatibility validated with single-credential provider
  • No performance regression (config generation <2s for 100 hosts)
  • Audit logs show credential selection for all domains
  • Documentation updated (API docs, admin guide)

Part 10: Documentation Updates Required

  1. API Documentation: Add multi-credential endpoints to OpenAPI spec
  2. Admin Guide: Add section on multi-credential configuration
  3. Migration Guide: Document single→multi credential migration
  4. Troubleshooting Guide: Add credential resolution debugging section
  5. Changelog: Document multi-credential support in v0.3.0 release notes

Appendix A: Helper Function Reference

Already implemented in backend/internal/caddy/manager_helpers.go:

extractBaseDomain(domainNames string) string

  • Extracts base domain from comma-separated list
  • Strips wildcard prefix (*.example.com → example.com)
  • Returns lowercase domain

matchesZoneFilter(zoneFilter, domain string, exactOnly bool) bool

  • Checks if domain matches zone filter pattern
  • Supports exact match and wildcard match
  • Returns false for empty filter (handled separately as catch-all)

(m Manager) getCredentialForDomain(providerID uint, domain string, providermodels.DNSProvider) (map[string]string, error)

  • Resolves appropriate credential for domain
  • Priority: exact match → wildcard match → catch-all
  • Returns decrypted credentials map
  • Logs credential selection for audit trail

Appendix B: Testing Helpers

Create these in manager_multicred_integration_test.go:

func encryptJSON(t *testing.T, data map[string]string) string {
 // Encrypt JSON for test fixtures
}

func assertDNSChallengePolicy(t *testing.T, config *Config, domain, provider, token string) {
 // Assert TLS automation policy exists with correct credentials
}

func assertCredentialSelection(t *testing.T, manager *Manager, providerID uint, domain, expectedToken string) {
 // Assert getCredentialForDomain returns expected credential
}

Appendix C: Error Scenarios

Scenario Behavior User Impact
No matching credential Log warning, skip domain Certificate not issued for that domain
Decryption failure Log warning, skip credential Fallback to catch-all or skip domain
Empty ZoneCredentials Fall back to single-cred mode Backward compatible behavior
Disabled credential Skip credential Next priority credential used
No encryption key Skip DNS challenge HTTP challenge used (if applicable)

End of Plan

Next Action: Implement changes in sequence (Steps 1-5) Review Required: Code review after Step 3 (before integration tests) Deployment: Sprint 11 release (after all success criteria met)