rdev/cmd/rdev-api/config.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

249 lines
9.9 KiB
Go

package main
import (
"context"
"log/slog"
"os"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/envutil"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/worker"
)
// Config holds application configuration.
type Config struct {
Port int
DBHost string
DBPort int
DBUser string
DBPassword string
DBName string
DBSSLMode string
AdminKey string
// Credential store encryption key (required for storing secrets in DB)
CredentialEncryptionKey string
// OpenCode configuration (optional - enables OpenCode as alternative code agent)
OpenCodeURL string // e.g., "http://opencode:4096"
OpenCodeUsername string // Basic auth username (default: "opencode")
OpenCodePassword string // Basic auth password
// Internal API token for service-to-service callbacks
InternalToken string
// Citadel logging integration
CitadelURL string // e.g., "https://citadel-staging.orchard9.ai"
CitadelAPIKey string // API key for Citadel (starts with ck_live_ or ck_dev_)
CitadelPlatformTenantID string // Tenant ID for the rdev-platform environment
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
GiteaURL string
GiteaToken string
GiteaDefaultOrg string
CloudflareToken string
CloudflareZoneID string
DefaultDomain string
DeployNamespace string
DeployTLSIssuer string
ClusterIP string
RegistryURL string
WoodpeckerURL string
WoodpeckerAPIToken string
WoodpeckerWebhookSecret string
}
// InfraConfig holds infrastructure adapter configuration.
// Loaded from credential store with env var fallback.
type InfraConfig struct {
GiteaURL string
GiteaToken string
GiteaDefaultOrg string
CloudflareToken string
CloudflareZoneID string
DefaultDomain string
DeployNamespace string
DeployTLSIssuer string
ClusterIP string
RegistryURL string
WoodpeckerURL string
WoodpeckerAPIToken string
WoodpeckerWebhookSecret string
// CockroachDB provisioner (for project databases)
CRDBHost string // e.g., "cockroachdb-public.databases.svc.cluster.local"
CRDBPort int // e.g., 26257
CRDBUser string // e.g., "root" (insecure mode)
CRDBSSLMode string // e.g., "disable" (insecure) or "verify-full" (production)
// Redis provisioner (for project cache)
RedisHost string // e.g., "redis.databases.svc.cluster.local"
RedisPort int // e.g., 6379
RedisPassword string // admin password for ACL management
// GCS provisioner (for project storage)
GCSProjectID string // e.g., "threesix-prod"
GCSCredentialsPath string // Path to service account JSON (empty = ADC)
GCSLocation string // Bucket location (default: "US")
// Notify provisioner (for per-project email delivery)
NotifyURL string // e.g., "https://notify.orchard9.ai"
NotifyAdminKey string // notify_admin_... admin API key
ResendAPIKey string // re_... Resend API key for per-project domain provisioning
}
func loadConfig() Config {
return Config{
Port: envutil.GetEnvInt("PORT", 8080),
DBHost: envutil.GetEnv("DB_HOST", "postgres.databases.svc.cluster.local"),
DBPort: envutil.GetEnvInt("DB_PORT", 5432),
DBUser: envutil.GetEnv("DB_USER", "appuser"),
DBPassword: os.Getenv("DB_PASSWORD"),
DBName: envutil.GetEnv("DB_NAME", "rdev"),
DBSSLMode: envutil.GetEnv("DB_SSL_MODE", "disable"),
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
// Encryption key for credential store (generate with: openssl rand -base64 32)
// REQUIRED in production - no default to prevent insecure deployments
CredentialEncryptionKey: os.Getenv("CREDENTIAL_ENCRYPTION_KEY"),
// OpenCode (optional alternative code agent)
OpenCodeURL: os.Getenv("OPENCODE_URL"), // e.g., "http://opencode:4096"
OpenCodeUsername: envutil.GetEnv("OPENCODE_USERNAME", "opencode"),
OpenCodePassword: os.Getenv("OPENCODE_PASSWORD"),
// Internal API token for service-to-service callbacks (e.g., SDLC callbacks)
InternalToken: os.Getenv("INTERNAL_TOKEN"),
// Citadel logging integration
CitadelURL: os.Getenv("CITADEL_URL"), // e.g., "https://citadel-staging.orchard9.ai"
CitadelAPIKey: os.Getenv("CITADEL_API_KEY"), // API key for Citadel
CitadelPlatformTenantID: os.Getenv("CITADEL_PLATFORM_TENANT_ID"), // rdev-platform tenant ID
// Infrastructure adapters (fallback if not in credential store)
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaToken: os.Getenv("GITEA_TOKEN"),
GiteaDefaultOrg: envutil.GetEnv("GITEA_DEFAULT_ORG", "jordan"),
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
DefaultDomain: envutil.GetEnv("DEFAULT_DOMAIN", "threesix.ai"),
DeployNamespace: envutil.GetEnv("DEPLOY_NAMESPACE", "projects"),
DeployTLSIssuer: envutil.GetEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
ClusterIP: envutil.GetEnv("CLUSTER_IP", "208.122.204.172"),
RegistryURL: envutil.GetEnv("REGISTRY_URL", "registry.threesix.ai"),
WoodpeckerURL: envutil.GetEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
}
}
// loadInfraConfig loads infrastructure configuration from credential store,
// falling back to environment variables if not found in the store.
func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig {
// Try to load from credential store
creds, err := store.GetMultiple(ctx, []string{
domain.CredKeyGiteaToken,
domain.CredKeyGiteaURL,
domain.CredKeyCloudflareAPIToken,
domain.CredKeyCloudflareZoneID,
domain.CredKeyWoodpeckerURL,
domain.CredKeyWoodpeckerAPIToken,
domain.CredKeyWoodpeckerWebhookSecret,
domain.CredKeyRegistryURL,
domain.CredKeyNotifyURL,
domain.CredKeyNotifyAdminKey,
domain.CredKeyResendAPIKey,
})
if err != nil {
logger.Warn("failed to load credentials from store, using env vars", "error", err)
creds = make(map[string]string)
}
// Helper to get from store or fall back to env var
getOrFallback := func(key, envFallback string) string {
if v, ok := creds[key]; ok && v != "" {
return v
}
return envFallback
}
infraCfg := InfraConfig{
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
GiteaDefaultOrg: cfg.GiteaDefaultOrg, // Not a secret, use env
CloudflareToken: getOrFallback(domain.CredKeyCloudflareAPIToken, cfg.CloudflareToken),
CloudflareZoneID: getOrFallback(domain.CredKeyCloudflareZoneID, cfg.CloudflareZoneID),
DefaultDomain: cfg.DefaultDomain, // Not a secret, use env
DeployNamespace: cfg.DeployNamespace, // Not a secret, use env
DeployTLSIssuer: cfg.DeployTLSIssuer, // Not a secret, use env
ClusterIP: cfg.ClusterIP, // Not a secret, use env
RegistryURL: getOrFallback(domain.CredKeyRegistryURL, cfg.RegistryURL),
WoodpeckerURL: getOrFallback(domain.CredKeyWoodpeckerURL, cfg.WoodpeckerURL),
WoodpeckerAPIToken: getOrFallback(domain.CredKeyWoodpeckerAPIToken, cfg.WoodpeckerAPIToken),
WoodpeckerWebhookSecret: getOrFallback(domain.CredKeyWoodpeckerWebhookSecret, cfg.WoodpeckerWebhookSecret),
// CockroachDB and Redis provisioners (env-only for now)
CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc.cluster.local"
CRDBPort: envutil.GetEnvInt("CRDB_PORT", 26257),
CRDBUser: envutil.GetEnv("CRDB_USER", "root"),
CRDBSSLMode: envutil.GetEnv("CRDB_SSL_MODE", "disable"),
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.databases.svc.cluster.local"
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
// GCS provisioner (env-only)
GCSProjectID: os.Getenv("GCS_PROJECT_ID"),
GCSCredentialsPath: os.Getenv("GCS_CREDENTIALS_PATH"),
GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"),
// Notify provisioner (credential store with env fallback)
NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")),
NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")),
ResendAPIKey: getOrFallback(domain.CredKeyResendAPIKey, os.Getenv("RESEND_API_KEY")),
}
// Log which credentials were loaded from store vs env
fromStore := 0
for k := range creds {
if creds[k] != "" {
fromStore++
}
}
if fromStore > 0 {
logger.Info("loaded credentials from store", "count", fromStore)
}
return infraCfg
}
// closeProvisioner attempts to close a provisioner that implements io.Closer.
func closeProvisioner(p any, name string, logger *slog.Logger) {
if p == nil {
return
}
if closer, ok := p.(interface{ Close() error }); ok {
if err := closer.Close(); err != nil {
logger.Warn("failed to close "+name+" provisioner", "error", err)
}
}
}
// podGitCommitterAdapter wraps worker.PodGitOperations to satisfy
// service.PodGitCommitter without creating an import cycle.
type podGitCommitterAdapter struct {
podGitOps *worker.PodGitOperations
}
func (a *podGitCommitterAdapter) CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *service.GitCommitResult {
result := a.podGitOps.CommitAndPush(ctx, podName, workDir, message, push)
return &service.GitCommitResult{
HasChanges: result.HasChanges,
CommitSHA: result.CommitSHA,
FilesChanged: result.FilesChanged,
Pushed: result.Pushed,
Error: result.Error,
}
}